build: add treefmt and pre-commit
This commit is contained in:
parent
6493c6495a
commit
31dc620b0a
6 changed files with 396 additions and 209 deletions
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -1,10 +1,21 @@
|
||||||
|
# Build artifacts
|
||||||
target/
|
target/
|
||||||
result*
|
result*
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
TODO.md
|
|
||||||
backup.json
|
# Runtime configuration and secrets
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
# Generated by TailwindCSS CLI as part of `cargo build`
|
||||||
static/css/styles.css
|
static/css/styles.css
|
||||||
|
|
||||||
|
# This is generated by the nix shell hook
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
|
# Backup of database
|
||||||
|
backup.json
|
||||||
|
|
|
||||||
412
CLAUDE.md
412
CLAUDE.md
|
|
@ -11,11 +11,26 @@ Brewlog is a self-hosted coffee logging platform built in Rust. It provides:
|
||||||
|
|
||||||
## Build & Test Commands
|
## Build & Test Commands
|
||||||
|
|
||||||
|
Use `prek` to run the lints, tests and formatters all-in-one:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build # Build the project
|
prek run -av
|
||||||
cargo test # Run all tests
|
```
|
||||||
|
|
||||||
|
Individual checks can be run with `prek` if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prek run clippy -av
|
||||||
|
prek run cargo-test -av
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the individual commands themselves:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # Build the project
|
||||||
|
cargo test # Run all tests
|
||||||
cargo clippy --allow-dirty --fix # Lint and auto-fix
|
cargo clippy --allow-dirty --fix # Lint and auto-fix
|
||||||
cargo fmt # Format code
|
nix fmt # Format code
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Migrations
|
### Database Migrations
|
||||||
|
|
@ -101,13 +116,15 @@ src/
|
||||||
|
|
||||||
These are non-obvious footguns that will cause bugs if missed.
|
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**:
|
**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
|
```html
|
||||||
<form data-on:submit="$_extracting = true; @post(...)"
|
<form
|
||||||
|
data-on:submit="$_extracting = true; @post(...)"
|
||||||
data-on:datastar-fetch="if (!$_extracting) return;
|
data-on:datastar-fetch="if (!$_extracting) return;
|
||||||
if (evt.detail.type === 'finished') { $_extracting = false }
|
if (evt.detail.type === 'finished') { $_extracting = false }
|
||||||
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }">
|
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }"
|
||||||
|
></form>
|
||||||
```
|
```
|
||||||
|
|
||||||
Only reset state on `finished` or `error`, never unconditionally.
|
Only reset state on `finished` or `error`, never unconditionally.
|
||||||
|
|
@ -140,14 +157,14 @@ Only reset state on `finished` or `error`, never unconditionally.
|
||||||
|
|
||||||
`infrastructure/database.rs` configures SQLite pragmas at connection time:
|
`infrastructure/database.rs` configures SQLite pragmas at connection time:
|
||||||
|
|
||||||
| Pragma | Value | Purpose |
|
| Pragma | Value | Purpose |
|
||||||
|--------|-------|---------|
|
| -------------- | -------- | -------------------------------- |
|
||||||
| `foreign_keys` | `ON` | Enforce FK constraints |
|
| `foreign_keys` | `ON` | Enforce FK constraints |
|
||||||
| `journal_mode` | `WAL` | Concurrent reads during writes |
|
| `journal_mode` | `WAL` | Concurrent reads during writes |
|
||||||
| `synchronous` | `NORMAL` | Faster writes (safe with WAL) |
|
| `synchronous` | `NORMAL` | Faster writes (safe with WAL) |
|
||||||
| `cache_size` | `-8000` | 8 MB page cache |
|
| `cache_size` | `-8000` | 8 MB page cache |
|
||||||
| `temp_store` | `MEMORY` | Temp tables in RAM |
|
| `temp_store` | `MEMORY` | Temp tables in RAM |
|
||||||
| `busy_timeout` | `5000` | Wait up to 5s on lock contention |
|
| `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.
|
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.
|
||||||
|
|
||||||
|
|
@ -164,6 +181,7 @@ Social media preview cards are powered by Open Graph and Twitter Card meta tags
|
||||||
**`og:image`** — a static 1200×630 PNG (`static/og-image.png`) served at `/static/og-image.png` via `include_bytes!()`. All detail pages (bag, brew, cafe, cup, gear, roast, roaster), plus home and stats, include it via `{% block head %}`.
|
**`og:image`** — a static 1200×630 PNG (`static/og-image.png`) served at `/static/og-image.png` via `include_bytes!()`. All detail pages (bag, brew, cafe, cup, gear, roast, roaster), plus home and stats, include it via `{% block head %}`.
|
||||||
|
|
||||||
**Adding OG tags to a new page:**
|
**Adding OG tags to a new page:**
|
||||||
|
|
||||||
1. Add `pub base_url: &'static str` to the template struct
|
1. Add `pub base_url: &'static str` to the template struct
|
||||||
2. Set `base_url: crate::base_url()` in the handler
|
2. Set `base_url: crate::base_url()` in the handler
|
||||||
3. Override `{% block og_title %}`, `{% block og_description %}`, and add `<meta property="og:image" content="{{ base_url }}/static/og-image.png" />` in `{% block head %}`
|
3. Override `{% block og_title %}`, `{% block og_description %}`, and add `<meta property="og:image" content="{{ base_url }}/static/og-image.png" />` in `{% block head %}`
|
||||||
|
|
@ -177,6 +195,7 @@ All data access goes through trait-based repositories defined in `domain/reposit
|
||||||
Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation.
|
Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation.
|
||||||
|
|
||||||
**When to use services vs repos:**
|
**When to use services vs repos:**
|
||||||
|
|
||||||
- **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert.
|
- **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.
|
- **Repos** — for `get()`, `list()`, `update()`, `delete()`. No side effects needed.
|
||||||
|
|
||||||
|
|
@ -186,12 +205,12 @@ The `define_simple_service!` macro in `services/mod.rs` generates services for e
|
||||||
|
|
||||||
Entities needing enrichment are hand-written:
|
Entities needing enrichment are hand-written:
|
||||||
|
|
||||||
| Service | Extra repos | Why |
|
| Service | Extra repos | Why |
|
||||||
|---------|-------------|-----|
|
| -------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||||
| `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline |
|
| `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline |
|
||||||
| `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline |
|
| `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline |
|
||||||
| `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response |
|
| `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response |
|
||||||
| `CupService` | — | `create()` enriches via `get_with_details()` for timeline |
|
| `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:
|
Timeline events are display-only (not data integrity), so they use fire-and-forget error handling:
|
||||||
|
|
||||||
|
|
@ -226,41 +245,42 @@ if is_datastar_request(&headers) {
|
||||||
|
|
||||||
Seven detail pages share layout via extracted template macros and Rust helpers:
|
Seven detail pages share layout via extracted template macros and Rust helpers:
|
||||||
|
|
||||||
| Page | Route | Shared macros used |
|
| Page | Route | Shared macros used |
|
||||||
|------|-------|--------------------|
|
| ------- | ---------------------------------------------- | -------------------------------------------- |
|
||||||
| Bag | `/bags/{id}` | coffee_card, roaster_card, map_with_legend_2 |
|
| Bag | `/bags/{id}` | coffee_card, roaster_card, map_with_legend_2 |
|
||||||
| Brew | `/brews/{id}` | coffee_card, roaster_card, map_with_legend_2 |
|
| Brew | `/brews/{id}` | coffee_card, roaster_card, map_with_legend_2 |
|
||||||
| Cafe | `/cafes/{slug}` | map_with_legend_1 |
|
| Cafe | `/cafes/{slug}` | map_with_legend_1 |
|
||||||
| Cup | `/cups/{id}` | coffee_card, roaster_card, map_with_legend_3 |
|
| Cup | `/cups/{id}` | coffee_card, roaster_card, map_with_legend_3 |
|
||||||
| Gear | `/gear/{id}` | (standalone layout) |
|
| Gear | `/gear/{id}` | (standalone layout) |
|
||||||
| Roast | `/roasters/{roaster_slug}/roasts/{roast_slug}` | coffee_card, roaster_card, map_with_legend_2 |
|
| Roast | `/roasters/{roaster_slug}/roasts/{roast_slug}` | coffee_card, roaster_card, map_with_legend_2 |
|
||||||
| Roaster | `/roasters/{slug}` | map_with_legend_1 |
|
| Roaster | `/roasters/{slug}` | map_with_legend_1 |
|
||||||
|
|
||||||
**Template macros** — `templates/partials/detail_cards.html` provides:
|
**Template macros** — `templates/partials/detail_cards.html` provides:
|
||||||
|
|
||||||
| Macro | Parameters | Used by |
|
| Macro | Parameters | Used by |
|
||||||
|-------|-----------|---------|
|
| ------------------------ | --------------------------------------------------------------------------------------- | --------------------------- |
|
||||||
| `share_button()` | — | (defined, currently unused) |
|
| `share_button()` | — | (defined, currently unused) |
|
||||||
| `share_script()` | — | (defined, currently unused) |
|
| `share_script()` | — | (defined, currently unused) |
|
||||||
| `coffee_card(...)` | roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes | Bag, Brew, Cup, Roast |
|
| `coffee_card(...)` | roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes | Bag, Brew, Cup, Roast |
|
||||||
| `roaster_card(...)` | name, country, country_flag, city, homepage | Bag, Brew, Cup, Roast |
|
| `roaster_card(...)` | name, country, country_flag, city, homepage | Bag, Brew, Cup, Roast |
|
||||||
| `map_with_legend_1(...)` | map_countries, map_max, label1, opacity1 | Cafe, Roaster |
|
| `map_with_legend_1(...)` | map_countries, map_max, label1, opacity1 | Cafe, Roaster |
|
||||||
| `map_with_legend_2(...)` | map_countries, map_max, label1, opacity1, label2, opacity2 | Bag, Brew, Roast |
|
| `map_with_legend_2(...)` | map_countries, map_max, label1, opacity1, label2, opacity2 | Bag, Brew, Roast |
|
||||||
| `map_with_legend_3(...)` | map_countries, map_max, label1-3, opacity1-3 | Cup |
|
| `map_with_legend_3(...)` | map_countries, map_max, label1-3, opacity1-3 | Cup |
|
||||||
|
|
||||||
Detail page templates import with `{% import "partials/detail_cards.html" as detail %}` and call macros as `{{ detail::coffee_card(...) }}`.
|
Detail page templates import with `{% import "partials/detail_cards.html" as detail %}` and call macros as `{{ detail::coffee_card(...) }}`.
|
||||||
|
|
||||||
**View model helpers** — `presentation/web/views/mod.rs` provides:
|
**View model helpers** — `presentation/web/views/mod.rs` provides:
|
||||||
|
|
||||||
| Helper | Input | Purpose |
|
| Helper | Input | Purpose |
|
||||||
|--------|-------|---------|
|
| ----------------------------- | ---------------- | --------------------------------------------------------------- |
|
||||||
| `build_coffee_info(roast)` | `&Roast` | Extracts origin, flag, region, producer, process, tasting notes |
|
| `build_coffee_info(roast)` | `&Roast` | Extracts origin, flag, region, producer, process, tasting notes |
|
||||||
| `build_roaster_info(roaster)` | `&Roaster` | Extracts country, flag, city, homepage |
|
| `build_roaster_info(roaster)` | `&Roaster` | Extracts country, flag, city, homepage |
|
||||||
| `build_map_data(entries)` | `&[(&str, u32)]` | Builds `data-countries` + `data-max` for `<world-map>` |
|
| `build_map_data(entries)` | `&[(&str, u32)]` | Builds `data-countries` + `data-max` for `<world-map>` |
|
||||||
|
|
||||||
Each `*DetailView::from_parts()` calls these helpers and flattens the results into its own struct (Askama needs direct field access).
|
Each `*DetailView::from_parts()` calls these helpers and flattens the results into its own struct (Askama needs direct field access).
|
||||||
|
|
||||||
**Detail page layout** — pages using the shared macros (Bag, Brew, Cup, Roast) follow the same grid structure:
|
**Detail page layout** — pages using the shared macros (Bag, Brew, Cup, Roast) follow the same grid structure:
|
||||||
|
|
||||||
1. Header: page title + subtitle
|
1. Header: page title + subtitle
|
||||||
2. Row 1 (2-col): Coffee card + Map with legend
|
2. Row 1 (2-col): Coffee card + Map with legend
|
||||||
3. Row 2 (2-col): Roaster card + page-specific card (Gear/Recipe for brew, Cafe for cup, Bag Info for bag)
|
3. Row 2 (2-col): Roaster card + page-specific card (Gear/Recipe for brew, Cafe for cup, Bag Info for bag)
|
||||||
|
|
@ -274,16 +294,16 @@ Cafe, Gear, and Roaster detail pages have simpler standalone layouts.
|
||||||
|
|
||||||
All macros have doc comments with usage examples. Check the source files for full documentation.
|
All macros have doc comments with usage examples. Check the source files for full documentation.
|
||||||
|
|
||||||
| Macro | Location | Purpose |
|
| Macro | Location | Purpose |
|
||||||
|-------|----------|---------|
|
| -------------------------------- | --------------------------------------- | -------------------------------------------------- |
|
||||||
| `define_simple_service!` | `application/services/mod.rs` | Generate service struct with `create()` + timeline |
|
| `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_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_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_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_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_get_command!` | `presentation/cli/macros.rs` | CLI get-entity command |
|
||||||
| `define_delete_command!` | `presentation/cli/macros.rs` | CLI delete-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` |
|
| `push_update_field!` | `infrastructure/repositories/macros.rs` | Build dynamic UPDATE queries with `QueryBuilder` |
|
||||||
|
|
||||||
### SQL & Queries
|
### SQL & Queries
|
||||||
|
|
||||||
|
|
@ -293,10 +313,10 @@ Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (se
|
||||||
|
|
||||||
`domain/formatting.rs` contains shared formatting helpers with unit tests. Always use these instead of ad-hoc `format!()` calls:
|
`domain/formatting.rs` contains shared formatting helpers with unit tests. Always use these instead of ad-hoc `format!()` calls:
|
||||||
|
|
||||||
| Function | Signature | Output examples |
|
| Function | Signature | Output examples |
|
||||||
|----------|-----------|-----------------|
|
| ---------------------- | --------------------------------------------------- | ----------------------------------------------------- |
|
||||||
| `format_relative_time` | `(dt: DateTime<Utc>, now: DateTime<Utc>) -> String` | "Just now", "5m ago", "Yesterday", "2w ago", "Mar 15" |
|
| `format_relative_time` | `(dt: DateTime<Utc>, now: DateTime<Utc>) -> String` | "Just now", "5m ago", "Yesterday", "2w ago", "Mar 15" |
|
||||||
| `format_weight` | `(grams: f64) -> String` | "15g", "15.5g", "250g", "1.0kg", "2.3kg" |
|
| `format_weight` | `(grams: f64) -> String` | "15g", "15.5g", "250g", "1.0kg", "2.3kg" |
|
||||||
|
|
||||||
**`format_relative_time`** — accepts an explicit `now` parameter for testability. Callers pass `Utc::now()` at the call site. Covers seconds through absolute dates, with title case ("Just now", "Yesterday").
|
**`format_relative_time`** — accepts an explicit `now` parameter for testability. Callers pass `Utc::now()` at the call site. Covers seconds through absolute dates, with title case ("Just now", "Yesterday").
|
||||||
|
|
||||||
|
|
@ -315,6 +335,7 @@ Statistics are pre-computed and stored as a single JSON row in `stats_cache`. A
|
||||||
**Database reset** clears `stats_cache` along with all other coffee data (see `infrastructure/backup.rs`).
|
**Database reset** clears `stats_cache` along with all other coffee data (see `infrastructure/backup.rs`).
|
||||||
|
|
||||||
**Adding new stats:**
|
**Adding new stats:**
|
||||||
|
|
||||||
1. Add the field to the relevant domain struct (`RoastSummaryStats`, `ConsumptionStats`, `BrewingSummaryStats`, or `GeoStats`)
|
1. Add the field to the relevant domain struct (`RoastSummaryStats`, `ConsumptionStats`, `BrewingSummaryStats`, or `GeoStats`)
|
||||||
2. Add the query in `SqlStatsRepository`
|
2. Add the query in `SqlStatsRepository`
|
||||||
3. The `CachedStats` struct inherits the change via serde
|
3. The `CachedStats` struct inherits the change via serde
|
||||||
|
|
@ -351,16 +372,16 @@ The web UI uses [Datastar](https://data-star.dev/) for reactive updates without
|
||||||
|
|
||||||
Key attributes:
|
Key attributes:
|
||||||
|
|
||||||
| Attribute | Purpose | Example |
|
| Attribute | Purpose | Example |
|
||||||
|-----------|---------|---------|
|
| ---------------------------- | ------------------------------------------------------ | -------------------------------------------------- |
|
||||||
| `data-signals:_name="value"` | Declare local signal (underscore = not sent to server) | `data-signals:_show-form="false"` |
|
| `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-show="$_signal"` | Conditional visibility | `data-show="$_showForm"` |
|
||||||
| `data-bind:_signal-name` | Two-way binding to input value | `data-bind:_roaster-name` |
|
| `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-on:event="expr"` | Event handler | `data-on:submit="$_submitting = true; @post(...)"` |
|
||||||
| `data-ref="_name"` | DOM element reference | `data-ref="_form"` |
|
| `data-ref="_name"` | DOM element reference | `data-ref="_form"` |
|
||||||
| `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` |
|
| `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` |
|
||||||
| `data-attr:attr="$_signal"` | Set attribute from signal | `data-attr:value="$_roastId"` |
|
| `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'})` |
|
| `@get/@post/@put/@delete` | HTTP actions with Datastar headers | `@post('/api/v1/roasters', {contentType: 'form'})` |
|
||||||
|
|
||||||
### Signal Naming
|
### Signal Naming
|
||||||
|
|
||||||
|
|
@ -372,12 +393,12 @@ Signal names use **kebab-case** in HTML attributes and auto-convert to **camelCa
|
||||||
|
|
||||||
**Naming conventions for common signals:**
|
**Naming conventions for common signals:**
|
||||||
|
|
||||||
| Signal | Purpose |
|
| Signal | Purpose |
|
||||||
|--------|---------|
|
| --------------------------- | ---------------------------- |
|
||||||
| `_extracting` | AI extraction in progress |
|
| `_extracting` | AI extraction in progress |
|
||||||
| `_submitting` | Form save/create in progress |
|
| `_submitting` | Form save/create in progress |
|
||||||
| `_extract-error` / `_error` | Error message |
|
| `_extract-error` / `_error` | Error message |
|
||||||
| `_show-{thing}` | Boolean visibility toggle |
|
| `_show-{thing}` | Boolean visibility toggle |
|
||||||
|
|
||||||
### Response Types
|
### Response Types
|
||||||
|
|
||||||
|
|
@ -390,6 +411,7 @@ Two response formats exist for Datastar:
|
||||||
### Datastar vs JavaScript
|
### Datastar vs JavaScript
|
||||||
|
|
||||||
**Use Datastar for:**
|
**Use Datastar for:**
|
||||||
|
|
||||||
- Visibility toggling (`data-show` + signals)
|
- Visibility toggling (`data-show` + signals)
|
||||||
- List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render
|
- List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render
|
||||||
- Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides`
|
- Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides`
|
||||||
|
|
@ -398,6 +420,7 @@ Two response formats exist for Datastar:
|
||||||
- Searchable selection lists — `<searchable-select>` with `data-on:change`
|
- Searchable selection lists — `<searchable-select>` with `data-on:change`
|
||||||
|
|
||||||
**Use JavaScript for:**
|
**Use JavaScript for:**
|
||||||
|
|
||||||
- Browser APIs: WebAuthn, clipboard, geolocation, FileReader
|
- Browser APIs: WebAuthn, clipboard, geolocation, FileReader
|
||||||
- Infinite scroll (`IntersectionObserver` in `base.html`)
|
- Infinite scroll (`IntersectionObserver` in `base.html`)
|
||||||
- Theme toggle — must run in `<head>` before DOM renders
|
- Theme toggle — must run in `<head>` before DOM renders
|
||||||
|
|
@ -410,13 +433,19 @@ Pages with AI-powered form filling use a Datastar-native pattern. Extraction end
|
||||||
Template structure:
|
Template structure:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<section data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false">
|
<section
|
||||||
|
data-signals:_extracting="false"
|
||||||
|
data-signals:_extract-error="''"
|
||||||
|
data-signals:_submitting="false"
|
||||||
|
>
|
||||||
<!-- Extraction form -->
|
<!-- Extraction form -->
|
||||||
<form id="{id}-extract-form"
|
<form
|
||||||
|
id="{id}-extract-form"
|
||||||
data-on:submit="$_extracting = true; $_extractError = ''; @post('{endpoint}', {contentType: 'form'})"
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('{endpoint}', {contentType: 'form'})"
|
||||||
data-on:datastar-fetch="if (!$_extracting) return;
|
data-on:datastar-fetch="if (!$_extracting) return;
|
||||||
if (evt.detail.type === 'finished') { $_extracting = false }
|
if (evt.detail.type === 'finished') { $_extracting = false }
|
||||||
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }">
|
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }"
|
||||||
|
>
|
||||||
<input type="hidden" name="image" id="{id}-image" />
|
<input type="hidden" name="image" id="{id}-image" />
|
||||||
<div data-show="!$_extracting">
|
<div data-show="!$_extracting">
|
||||||
<brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="...">
|
<brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="...">
|
||||||
|
|
@ -426,7 +455,12 @@ Template structure:
|
||||||
<button type="submit">Go</button>
|
<button type="submit">Go</button>
|
||||||
</div>
|
</div>
|
||||||
<div data-show="$_extracting" style="display:none"><!-- spinner --></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>
|
<p
|
||||||
|
data-show="$_extractError"
|
||||||
|
data-text="$_extractError"
|
||||||
|
style="display:none"
|
||||||
|
class="text-sm text-red-600"
|
||||||
|
></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Main form with data-bind fields populated by extraction -->
|
<!-- Main form with data-bind fields populated by extraction -->
|
||||||
|
|
@ -450,24 +484,24 @@ render_signals_json(&signals)
|
||||||
|
|
||||||
**`<brew-photo-capture>`** (`static/js/components/photo-capture.js`):
|
**`<brew-photo-capture>`** (`static/js/components/photo-capture.js`):
|
||||||
|
|
||||||
| Attribute | Purpose |
|
| Attribute | Purpose |
|
||||||
|-----------|---------|
|
| -------------- | ------------------------------------------------- |
|
||||||
| `target-input` | ID of hidden `<input>` that receives the data URL |
|
| `target-input` | ID of hidden `<input>` that receives the data URL |
|
||||||
| `target-form` | ID of `<form>` to submit after reading the photo |
|
| `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.
|
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`):
|
**`<searchable-select>`** (`static/js/components/searchable-select.js`):
|
||||||
|
|
||||||
| Attribute | Purpose |
|
| Attribute | Purpose |
|
||||||
|-----------|---------|
|
| ------------- | ------------------------------------------------------- |
|
||||||
| `name` | Name for hidden `<input>` in form submission |
|
| `name` | Name for hidden `<input>` in form submission |
|
||||||
| `placeholder` | Search input placeholder (default: "Type to search...") |
|
| `placeholder` | Search input placeholder (default: "Type to search...") |
|
||||||
|
|
||||||
| Event | Detail |
|
| Event | Detail |
|
||||||
|-------|--------|
|
| -------- | ----------------------------------------------- |
|
||||||
| `change` | `{ value, display, data }` — fires on selection |
|
| `change` | `{ value, display, data }` — fires on selection |
|
||||||
| `clear` | Fires when selection is cleared |
|
| `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.
|
Place `<button>` children with `value` and `data-display` attributes. The component handles search filtering, hidden input, and selected-value display internally.
|
||||||
|
|
||||||
|
|
@ -477,11 +511,11 @@ Wraps a horizontal scroll container with floating left/right chevron buttons on
|
||||||
|
|
||||||
Required child structure:
|
Required child structure:
|
||||||
|
|
||||||
| Selector | Purpose |
|
| Selector | Purpose |
|
||||||
|----------|---------|
|
| --------------------- | ------------------------ |
|
||||||
| `[data-chip-scroll]` | The scrollable container |
|
| `[data-chip-scroll]` | The scrollable container |
|
||||||
| `[data-scroll-left]` | Left chevron button |
|
| `[data-scroll-left]` | Left chevron button |
|
||||||
| `[data-scroll-right]` | Right chevron button |
|
| `[data-scroll-right]` | Right chevron button |
|
||||||
|
|
||||||
Scroll distance and button sizing are set in the template `onclick` handler, not the component — use `scrollBy({left: ±200})` for chips, `±220` for cards. For chips use `p-1` + `h-4 w-4` icons at `left-0`/`right-0`. For cards use `p-1.5` + `h-5 w-5` icons at `-left-4`/`-right-4` (half-overlapping the card edges).
|
Scroll distance and button sizing are set in the template `onclick` handler, not the component — use `scrollBy({left: ±200})` for chips, `±220` for cards. For chips use `p-1` + `h-4 w-4` icons at `left-0`/`right-0`. For cards use `p-1.5` + `h-5 w-5` icons at `-left-4`/`-right-4` (half-overlapping the card edges).
|
||||||
|
|
||||||
|
|
@ -489,11 +523,11 @@ Scroll distance and button sizing are set in the template `onclick` handler, not
|
||||||
|
|
||||||
Renders an inline SVG choropleth world map colored by country counts.
|
Renders an inline SVG choropleth world map colored by country counts.
|
||||||
|
|
||||||
| Attribute | Purpose |
|
| Attribute | Purpose |
|
||||||
|-----------|---------|
|
| ---------------- | ----------------------------------------------------- |
|
||||||
| `data-countries` | Comma-separated `ISO:count` pairs (e.g. `ET:5,CO:3`) |
|
| `data-countries` | Comma-separated `ISO:count` pairs (e.g. `ET:5,CO:3`) |
|
||||||
| `data-max` | Maximum count value (for color scaling) |
|
| `data-max` | Maximum count value (for color scaling) |
|
||||||
| `data-selected` | ISO code of the currently selected country (optional) |
|
| `data-selected` | ISO code of the currently selected country (optional) |
|
||||||
|
|
||||||
Uses `requestAnimationFrame` to defer rendering until after Datastar morphing completes.
|
Uses `requestAnimationFrame` to defer rendering until after Datastar morphing completes.
|
||||||
|
|
||||||
|
|
@ -501,10 +535,10 @@ Uses `requestAnimationFrame` to defer rendering until after Datastar morphing co
|
||||||
|
|
||||||
Renders an SVG donut chart with a legend, colored proportionally using the `--highlight-rgb` CSS custom property.
|
Renders an SVG donut chart with a legend, colored proportionally using the `--highlight-rgb` CSS custom property.
|
||||||
|
|
||||||
| Attribute | Purpose |
|
| Attribute | Purpose |
|
||||||
|-----------|---------|
|
| ------------ | --------------------------------------------------------------- |
|
||||||
| `data-items` | Pipe-separated `label:count` pairs (e.g. `V60:15\|Aeropress:8`) |
|
| `data-items` | Pipe-separated `label:count` pairs (e.g. `V60:15\|Aeropress:8`) |
|
||||||
| `data-icon` | Icon key displayed in the center (`"beaker"` or `"grinder"`) |
|
| `data-icon` | Icon key displayed in the center (`"beaker"` or `"grinder"`) |
|
||||||
|
|
||||||
Re-renders on theme change via `MutationObserver` on the `data-theme` attribute. Uses `requestAnimationFrame` to defer rendering.
|
Re-renders on theme change via `MutationObserver` on the `data-theme` attribute. Uses `requestAnimationFrame` to defer rendering.
|
||||||
|
|
||||||
|
|
@ -522,19 +556,19 @@ Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fiel
|
||||||
|
|
||||||
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.
|
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 |
|
| Token | Tailwind classes |
|
||||||
|-------|-----------------|
|
| ------------------ | ------------------------------------------ |
|
||||||
| `--page` | `bg-page` |
|
| `--page` | `bg-page` |
|
||||||
| `--surface` | `bg-surface` |
|
| `--surface` | `bg-surface` |
|
||||||
| `--surface-alt` | `bg-surface-alt` |
|
| `--surface-alt` | `bg-surface-alt` |
|
||||||
| `--border` | default `border` / `divide-y` |
|
| `--border` | default `border` / `divide-y` |
|
||||||
| `--accent` | `bg-accent`, `text-accent` |
|
| `--accent` | `bg-accent`, `text-accent` |
|
||||||
| `--accent-hover` | `bg-accent-hover`, `hover:bg-accent-hover` |
|
| `--accent-hover` | `bg-accent-hover`, `hover:bg-accent-hover` |
|
||||||
| `--accent-subtle` | `bg-accent-subtle` |
|
| `--accent-subtle` | `bg-accent-subtle` |
|
||||||
| `--accent-text` | `text-accent-text` |
|
| `--accent-text` | `text-accent-text` |
|
||||||
| `--text` | `text-text` |
|
| `--text` | `text-text` |
|
||||||
| `--text-secondary` | `text-text-secondary` |
|
| `--text-secondary` | `text-text-secondary` |
|
||||||
| `--text-muted` | `text-text-muted` |
|
| `--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.
|
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.
|
||||||
|
|
||||||
|
|
@ -542,27 +576,27 @@ Dark mode uses `[data-theme="dark"]` on `<html>`, set via a `<script>` in `<head
|
||||||
|
|
||||||
Defined in `input.css` — use these instead of ad-hoc utility combinations:
|
Defined in `input.css` — use these instead of ad-hoc utility combinations:
|
||||||
|
|
||||||
| Class | Use for |
|
| Class | Use for |
|
||||||
|-------|---------|
|
| ------------------------------------ | ------------------------------------------------- |
|
||||||
| `.input-field` | All text/number/select inputs |
|
| `.input-field` | All text/number/select inputs |
|
||||||
| `.btn-adjust` | +/- stepper buttons flanking a numeric input |
|
| `.btn-adjust` | +/- stepper buttons flanking a numeric input |
|
||||||
| `.sticky-submit` | Mobile-fixed / desktop-static submit bar |
|
| `.sticky-submit` | Mobile-fixed / desktop-static submit bar |
|
||||||
| `.pill` + variant | Tags, status badges (always include `.pill` base) |
|
| `.pill` + variant | Tags, status badges (always include `.pill` base) |
|
||||||
| `.tab` / `.tab-active` | Desktop tab buttons |
|
| `.tab` / `.tab-active` | Desktop tab buttons |
|
||||||
| `.tab-mobile` / `.tab-mobile-active` | Mobile tab dropdown items |
|
| `.tab-mobile` / `.tab-mobile-active` | Mobile tab dropdown items |
|
||||||
| `.responsive-table` | Tables that become card layout on mobile |
|
| `.responsive-table` | Tables that become card layout on mobile |
|
||||||
| `.scrollbar-hide` | Hide scrollbar on horizontal scroll containers |
|
| `.scrollbar-hide` | Hide scrollbar on horizontal scroll containers |
|
||||||
| `.timeline-*` / `.tl-card` | Timeline page layout (line, node, heading, card) |
|
| `.timeline-*` / `.tl-card` | Timeline page layout (line, node, heading, card) |
|
||||||
| `.text-2xs` | Micro text (0.65rem) — available for fine print |
|
| `.text-2xs` | Micro text (0.65rem) — available for fine print |
|
||||||
| `.small-caps` | Font-variant small-caps — table headers, footer |
|
| `.small-caps` | Font-variant small-caps — table headers, footer |
|
||||||
|
|
||||||
**Pill variants** (always pair with `.pill` base class):
|
**Pill variants** (always pair with `.pill` base class):
|
||||||
|
|
||||||
| Variant | Use |
|
| Variant | Use |
|
||||||
|---------|-----|
|
| -------------------------------------- | ------------------------------ |
|
||||||
| `.pill-muted` | Categories, neutral status |
|
| `.pill-muted` | Categories, neutral status |
|
||||||
| `.pill-success` | Positive status ("Open") |
|
| `.pill-success` | Positive status ("Open") |
|
||||||
| `.pill-warning` | Warning status |
|
| `.pill-warning` | Warning status |
|
||||||
| `.pill-floral` through `.pill-vegetal` | Tasting note SCA wheel colours |
|
| `.pill-floral` through `.pill-vegetal` | Tasting note SCA wheel colours |
|
||||||
|
|
||||||
Do not override pill colour/border/padding with utilities.
|
Do not override pill colour/border/padding with utilities.
|
||||||
|
|
@ -574,6 +608,7 @@ Do not override pill colour/border/padding with utilities.
|
||||||
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`.
|
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:
|
Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by context:
|
||||||
|
|
||||||
- `p-4` — compact cards (brew cards, bag cards, stat cards)
|
- `p-4` — compact cards (brew cards, bag cards, stat cards)
|
||||||
- `p-5` — form sections, admin sections, timeline cards
|
- `p-5` — form sections, admin sections, timeline cards
|
||||||
- `p-6` — auth pages (login, register)
|
- `p-6` — auth pages (login, register)
|
||||||
|
|
@ -582,21 +617,22 @@ Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by conte
|
||||||
|
|
||||||
#### Typography
|
#### Typography
|
||||||
|
|
||||||
| Level | Classes | Use |
|
| Level | Classes | Use |
|
||||||
|-------|---------|-----|
|
| ---------------- | --------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
| Page title | `text-3xl font-semibold` | `<h1>` on each page |
|
| Page title | `text-3xl font-semibold` | `<h1>` on each page |
|
||||||
| Auth title | `text-2xl font-semibold text-accent` | Login/register headers |
|
| Auth title | `text-2xl font-semibold text-accent` | Login/register headers |
|
||||||
| Section title | `text-lg font-semibold text-text` | `<h2>` for page sections |
|
| Section title | `text-lg font-semibold text-text` | `<h2>` for page sections |
|
||||||
| Subsection title | `text-base font-semibold text-text` | `<h3>` within cards |
|
| 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 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 |
|
| 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 |
|
| Body | `text-sm text-text-secondary` | Description text |
|
||||||
| Muted | `text-xs text-text-muted` | Metadata, subtext |
|
| Muted | `text-xs text-text-muted` | Metadata, subtext |
|
||||||
| Micro | `text-2xs text-text-muted` | Fine print (available utility, currently unused) |
|
| 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">`.
|
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 weight rules:**
|
||||||
|
|
||||||
- `font-bold` — stat values only (`text-lg font-bold text-text`)
|
- `font-bold` — stat values only (`text-lg font-bold text-text`)
|
||||||
- `font-semibold` — headings and primary action buttons
|
- `font-semibold` — headings and primary action buttons
|
||||||
- `font-medium` — card action buttons, link-style actions, tab labels, table content, outlined/secondary buttons
|
- `font-medium` — card action buttons, link-style actions, tab labels, table content, outlined/secondary buttons
|
||||||
|
|
@ -607,45 +643,47 @@ Page headers: `<header class="flex flex-col gap-2">` with `<h1>` + `<p class="ma
|
||||||
|
|
||||||
Entity-type icon mapping (consistent across all pages):
|
Entity-type icon mapping (consistent across all pages):
|
||||||
|
|
||||||
| Entity | Icon macro |
|
| Entity | Icon macro |
|
||||||
|--------|-----------|
|
| ------- | -------------------- |
|
||||||
| brew | `icons::beaker` |
|
| brew | `icons::beaker` |
|
||||||
| roast | `icons::coffee_bean` |
|
| roast | `icons::coffee_bean` |
|
||||||
| roaster | `icons::fire` |
|
| roaster | `icons::fire` |
|
||||||
| bag | `icons::bag` |
|
| bag | `icons::bag` |
|
||||||
| cup | `icons::cup` |
|
| cup | `icons::cup` |
|
||||||
| cafe | `icons::location` |
|
| cafe | `icons::location` |
|
||||||
| gear | `icons::grinder` |
|
| gear | `icons::grinder` |
|
||||||
|
|
||||||
**Size by context:**
|
**Size by context:**
|
||||||
|
|
||||||
| Size | Context |
|
| Size | Context |
|
||||||
|------|---------|
|
| --------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `h-3 w-3` | Timeline card category labels (inline with `text-xs`) |
|
| `h-3 w-3` | Timeline card category labels (inline with `text-xs`) |
|
||||||
| `h-4 w-4` | Buttons with text, tab buttons, admin page actions, list action links, form indicator icons |
|
| `h-4 w-4` | Buttons with text, tab buttons, admin 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-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 |
|
| `h-6 w-6` | Stat cards on homepage |
|
||||||
|
|
||||||
Always include `shrink-0` on icons inside flex containers to prevent shrinking.
|
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:
|
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` — compact inline labels (timeline categories)
|
||||||
- `gap-1.5` — card action buttons, tab buttons (mobile selected label)
|
- `gap-1.5` — card action buttons, tab buttons (mobile selected label)
|
||||||
- `gap-2` — standard buttons with icons
|
- `gap-2` — standard buttons with icons
|
||||||
|
|
||||||
#### Buttons
|
#### Buttons
|
||||||
|
|
||||||
| Variant | Classes | Use |
|
| 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 |
|
| 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) |
|
| 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 text-accent transition hover:text-accent-hover hover:bg-surface-alt` | Compact inline card buttons (e.g., Brew on bag card) |
|
| Card action | `inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium text-accent transition hover:text-accent-hover hover:bg-surface-alt` | Compact inline card buttons (e.g., Brew on bag card) |
|
||||||
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Adjustment | `.btn-adjust` CSS class | +/- steppers |
|
||||||
|
|
||||||
**Sizing rules:**
|
**Sizing rules:**
|
||||||
|
|
||||||
- `w-full` for full-width CTAs (form submits)
|
- `w-full` for full-width CTAs (form submits)
|
||||||
- `py-3` for larger touch targets (login, register, check-in submit)
|
- `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
|
- `disabled:opacity-50 disabled:cursor-not-allowed` with `data-attr:disabled` for loading states
|
||||||
|
|
@ -669,13 +707,13 @@ Checkbox: `<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
|
||||||
#### Feedback States
|
#### Feedback States
|
||||||
|
|
||||||
| Variant | Classes |
|
| Variant | Classes |
|
||||||
|---------|---------|
|
| --------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| Error text | `text-sm text-red-600` with `data-show`/`data-text` bound to error signal |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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…` |
|
| 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.
|
Always pair loading spinners with `data-show` bound to an in-progress signal.
|
||||||
|
|
@ -712,16 +750,16 @@ Tab keys differ between pages: singular on `/add` (brew, roast, bag) vs plural o
|
||||||
|
|
||||||
#### Spacing
|
#### Spacing
|
||||||
|
|
||||||
| Context | Gap |
|
| Context | Gap |
|
||||||
|---------|-----|
|
| --------------------- | -------------------- |
|
||||||
| Page sections (main) | `gap-8` |
|
| Page sections (main) | `gap-8` |
|
||||||
| Major form sections | `gap-6` |
|
| Major form sections | `gap-6` |
|
||||||
| Related field groups | `gap-4` |
|
| Related field groups | `gap-4` |
|
||||||
| Button groups | `gap-2` or `gap-3` |
|
| Button groups | `gap-2` or `gap-3` |
|
||||||
| Icon + text (buttons) | `gap-2` |
|
| Icon + text (buttons) | `gap-2` |
|
||||||
| Icon + text (compact) | `gap-1` or `gap-1.5` |
|
| Icon + text (compact) | `gap-1` or `gap-1.5` |
|
||||||
| Label to input | `gap-1` |
|
| Label to input | `gap-1` |
|
||||||
| Navigation links | `gap-6` |
|
| Navigation links | `gap-6` |
|
||||||
|
|
||||||
## Tables & Lists
|
## Tables & Lists
|
||||||
|
|
||||||
|
|
@ -737,8 +775,7 @@ Each list page has form and list as **separate siblings** under `<main>` (which
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% include "partials/lists/{entity}_list.html" %}
|
{% include "partials/lists/{entity}_list.html" %} {% endblock %}
|
||||||
{% endblock %}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### List Partial Structure
|
### List Partial Structure
|
||||||
|
|
@ -756,12 +793,13 @@ List partials live in `templates/partials/lists/`. Each follows:
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="rounded-lg border bg-surface" ...>
|
<section class="rounded-lg border bg-surface" ...>
|
||||||
{% call table::search_header(navigator, "#{entity}-list") %}
|
{% call table::search_header(navigator, "#{entity}-list") %}
|
||||||
<table class="responsive-table ...">...</table>
|
<table class="responsive-table ...">
|
||||||
|
...
|
||||||
|
</table>
|
||||||
{% if items.is_empty() %}
|
{% if items.is_empty() %}
|
||||||
<div class="p-8 text-center text-text-muted">No {entities} match your search.</div>
|
<div class="p-8 text-center text-text-muted">No {entities} match your search.</div>
|
||||||
{% endif %}
|
{% endif %} {% call table::pagination_header(items, navigator, "#{entity}-list") %} {% if
|
||||||
{% call table::pagination_header(items, navigator, "#{entity}-list") %}
|
items.has_next() %}
|
||||||
{% if items.has_next() %}
|
|
||||||
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -803,9 +841,7 @@ Tables use the `responsive-table` CSS class (card layout on mobile, standard tab
|
||||||
**Mobile** — separate `<td>` for each sub-field:
|
**Mobile** — separate `<td>` for each sub-field:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">
|
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">{{ brew.roaster_name }}</td>
|
||||||
{{ brew.roaster_name }}
|
|
||||||
</td>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pagination & Infinite Scroll
|
### Pagination & Infinite Scroll
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -78,15 +78,15 @@ directory is loaded automatically via [dotenvy](https://crates.io/crates/dotenvy
|
||||||
|
|
||||||
### Server (`brewlog serve`)
|
### Server (`brewlog serve`)
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
| Variable | Purpose | Default |
|
||||||
| ------------------------ | ------------------------------------------------------ | --------------------- |
|
| -------------------------- | --------------------------------------------------------------------- | --------------------- |
|
||||||
| `BREWLOG_RP_ID` | WebAuthn Relying Party ID (server domain) | **required** |
|
| `BREWLOG_RP_ID` | WebAuthn Relying Party ID (server domain) | **required** |
|
||||||
| `BREWLOG_RP_ORIGIN` | WebAuthn Relying Party origin (full URL) | **required** |
|
| `BREWLOG_RP_ORIGIN` | WebAuthn Relying Party origin (full URL) | **required** |
|
||||||
| `BREWLOG_DATABASE_URL` | Database connection string | `sqlite://brewlog.db` |
|
| `BREWLOG_DATABASE_URL` | Database connection string | `sqlite://brewlog.db` |
|
||||||
| `BREWLOG_BIND_ADDRESS` | Server bind address | `127.0.0.1:3000` |
|
| `BREWLOG_BIND_ADDRESS` | Server bind address | `127.0.0.1:3000` |
|
||||||
| `BREWLOG_INSECURE_COOKIES` | Disable the `Secure` cookie flag (set `true` for local dev over HTTP) | `false` |
|
| `BREWLOG_INSECURE_COOKIES` | Disable the `Secure` cookie flag (set `true` for local dev over HTTP) | `false` |
|
||||||
| `RUST_LOG` | Log level filter | `info` |
|
| `RUST_LOG` | Log level filter | `info` |
|
||||||
| `RUST_LOG_FORMAT` | Set to `json` for structured log output | — |
|
| `RUST_LOG_FORMAT` | Set to `json` for structured log output | — |
|
||||||
|
|
||||||
### CLI Client
|
### CLI Client
|
||||||
|
|
||||||
|
|
|
||||||
95
flake.lock
95
flake.lock
|
|
@ -1,5 +1,21 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
|
@ -18,13 +34,56 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1769939035,
|
||||||
|
"narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "a8ca480175326551d6c4121498316261cbb5b260",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770197578,
|
"lastModified": 1770562336,
|
||||||
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -52,8 +111,10 @@
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay",
|
||||||
|
"treefmt-nix": "treefmt-nix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
|
|
@ -63,11 +124,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770347142,
|
"lastModified": 1770606655,
|
||||||
"narHash": "sha256-uz+ZSqXpXEPtdRPYwvgsum/CfNq7AUQ/0gZHqTigiPM=",
|
"narHash": "sha256-rpJf+kxvLWv32ivcgu8d+JeJooog3boJCT8J3joJvvM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "2859683cd9ef7858d324c5399b0d8d6652bf4044",
|
"rev": "11a396520bf911e4ed01e78e11633d3fc63b350e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -75,6 +136,26 @@
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"treefmt-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770228511,
|
||||||
|
"narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "337a4fe074be1042a35086f15481d763b8ddc0e7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|
|
||||||
61
flake.nix
61
flake.nix
|
|
@ -6,6 +6,12 @@
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
|
||||||
|
treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||||
|
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|
||||||
|
git-hooks.url = "github:cachix/git-hooks.nix";
|
||||||
|
git-hooks.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
|
|
@ -14,6 +20,7 @@
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
flake-parts,
|
flake-parts,
|
||||||
|
...
|
||||||
}@inputs:
|
}@inputs:
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
systems = [
|
systems = [
|
||||||
|
|
@ -21,6 +28,11 @@
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
imports = [
|
||||||
|
inputs.treefmt-nix.flakeModule
|
||||||
|
inputs.git-hooks.flakeModule
|
||||||
|
];
|
||||||
|
|
||||||
perSystem =
|
perSystem =
|
||||||
{ config, system, ... }:
|
{ config, system, ... }:
|
||||||
let
|
let
|
||||||
|
|
@ -110,6 +122,10 @@
|
||||||
NIX_CONFIG = "experimental-features = nix-command flakes";
|
NIX_CONFIG = "experimental-features = nix-command flakes";
|
||||||
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
|
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
${config.pre-commit.shellHook}
|
||||||
|
'';
|
||||||
|
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
rust
|
rust
|
||||||
]
|
]
|
||||||
|
|
@ -125,7 +141,50 @@
|
||||||
sqlite
|
sqlite
|
||||||
sqlx-cli
|
sqlx-cli
|
||||||
tailwindcss_4
|
tailwindcss_4
|
||||||
]);
|
])
|
||||||
|
++ config.pre-commit.settings.enabledPackages;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
treefmt = {
|
||||||
|
projectRootFile = "flake.nix";
|
||||||
|
programs = {
|
||||||
|
deadnix.enable = true;
|
||||||
|
nixfmt.enable = true;
|
||||||
|
rustfmt.enable = true;
|
||||||
|
shfmt.enable = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
pre-commit = {
|
||||||
|
check.enable = false;
|
||||||
|
settings = {
|
||||||
|
package = pkgs.prek;
|
||||||
|
hooks = {
|
||||||
|
treefmt = {
|
||||||
|
enable = true;
|
||||||
|
package = config.treefmt.build.wrapper;
|
||||||
|
pass_filenames = false;
|
||||||
|
stages = [ "pre-commit" ];
|
||||||
|
};
|
||||||
|
clippy = {
|
||||||
|
enable = true;
|
||||||
|
package = rust;
|
||||||
|
packageOverrides = {
|
||||||
|
cargo = rust;
|
||||||
|
clippy = rust;
|
||||||
|
};
|
||||||
|
settings.extraArgs = "--allow-dirty --fix";
|
||||||
|
};
|
||||||
|
cargo-test = {
|
||||||
|
enable = true;
|
||||||
|
files = "\\.(rs|toml)$";
|
||||||
|
entry = "cargo test";
|
||||||
|
pass_filenames = false;
|
||||||
|
stages = [ "pre-commit" ];
|
||||||
|
};
|
||||||
|
shellcheck.enable = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ cargo build
|
||||||
# 2. Register at the URL printed on first start
|
# 2. Register at the URL printed on first start
|
||||||
# 3. Create a token: ./target/debug/brewlog token create --name "bootstrap-token"
|
# 3. Create a token: ./target/debug/brewlog token create --name "bootstrap-token"
|
||||||
# 4. Export the token: export BREWLOG_TOKEN=<token>
|
# 4. Export the token: export BREWLOG_TOKEN=<token>
|
||||||
if [[ -z "${BREWLOG_TOKEN:-}" ]]; then
|
if [[ -z ${BREWLOG_TOKEN:-} ]]; then
|
||||||
echo "Error: BREWLOG_TOKEN environment variable is not set."
|
echo "Error: BREWLOG_TOKEN environment variable is not set."
|
||||||
echo "Create a token first: ./target/debug/brewlog token create --name bootstrap-token"
|
echo "Create a token first: ./target/debug/brewlog token create --name bootstrap-token"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue