···11-# Arabica - Project Context for AI Agents
11+# CLAUDE.md
2233-Coffee brew tracking application using AT Protocol for decentralized storage.
33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4455-## Work Management
55+## Project Overview
6677-This project uses **cells** for task tracking and coordination. Cells are
88-atomic, dependency-aware units of work designed for coordination between humans
99-and AI agents.
77+Arabica is a coffee brew tracking application built on AT Protocol. User data
88+(beans, brews, cafes, drinks, etc.) lives in each user's Personal Data Server
99+(PDS), not locally. The app authenticates via OAuth, then performs CRUD through
1010+XRPC calls to the user's PDS.
10111111-**For usage instructions, see:** `.cells/AGENTS.md`
1212+## Build & Development Commands
12131313-Quick reference for AI agents:
1414+```bash
1515+# Run development server (debug logging, moderator config, known-dids backfill)
1616+just run
14171515-- `./cells list` - View all active cells
1616-- `./cells list --status open` - Show available work
1717-- `./cells show <cell-id>` - View cell details
1818+# Run tests
1919+go test ./...
18201919-**Note:** Do NOT use `./cells run` - this spawns a new agent session and should
2020-only be used by humans. AI agents should inspect cells using the commands above
2121-and perform work directly.
2121+# Run a single test
2222+go test ./internal/models/... -run TestBeanIsIncomplete
22232323-All work items are tracked as cells. When starting new work, check for existing
2424-cells first.
2424+# After editing .templ files — regenerate Go code
2525+templ generate # all files
2626+templ generate -f <file> # single file
2727+2828+# Rebuild Tailwind CSS (required after CSS/class changes)
2929+just style
3030+3131+# Verify after changes
3232+go vet ./...
3333+go build ./...
3434+```
25352636## Workflow Rules
2737···3040clarifying questions rather than endlessly reading the codebase. When given a
3141specific implementation task, produce code changes in the same session.
32423333-## Dependencies
4343+## Work Management
34443535-When implementing features, prefer standard library solutions over external
3636-dependencies. Only add a third-party dependency if the standard library
3737-genuinely cannot handle the requirement. For Go: check stdlib first (e.g.,
3838-os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm
3939-packages.
4545+This project uses **cells** for task tracking. See `.cells/AGENTS.md` for usage.
40464141-## Task Agents
4242-4343-When spawning task agents, set a hard limit of 3 agents maximum. Each agent must
4444-have a clearly scoped deliverable and file output path. Do not poll agents in a
4545-loop—instead, give each agent its full instructions upfront and collect results
4646-at the end. If agents aren't producing results within 5 minutes, fall back to
4747-doing the work directly.
4747+- `./cells list` / `./cells list --status open` / `./cells show <cell-id>`
4848+- Do NOT use `./cells run` (spawns new agent session, humans only)
48494949-## Testing & Verification
5050+## Dependencies
50515151-For Go projects: always run `go vet ./...` and `go build ./...` after making
5252-changes. For JavaScript/CSS projects: verify template field names match backend
5353-struct fields before considering a task complete. Always test form submissions
5454-to verify content-type handling (JSON vs form-encoded).
5252+Prefer standard library solutions over external dependencies. Only add a
5353+third-party dependency if stdlib genuinely cannot handle the requirement.
55545656-### Using Go Tooling Effectively
5555+## Task Agents
57565858-- To see source files from a dependency, or to answer questions about a
5959- dependency, run `go mod download -json MODULE` and use the returned `Dir` path
6060- to read the files.
6161-- Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages,
6262- types, functions, etc.
6363-- Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to
6464- avoid leaving behind build artifacts.
5757+Hard limit of 3 agents maximum. Each agent must have a clearly scoped
5858+deliverable. Do not poll agents in a loop. If agents aren't producing results
5959+within 5 minutes, fall back to doing the work directly.
65606661## Tech Stack
67626868-- **Language:** Go 1.21+
6969-- **HTTP:** stdlib `net/http` with Go 1.22 routing
7070-- **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry)
6363+- **Language:** Go 1.21+, stdlib `net/http` with Go 1.22 routing
6464+- **Storage:** AT Protocol PDS (user data), BoltDB (sessions), SQLite (firehose index)
7165- **Frontend:** HTMX + Alpine.js + Tailwind CSS
7272-- **Templates:** Templ (type-safe Go templates)
6666+- **Templates:** [Templ](https://templ.guide/) (type-safe Go templates)
7367- **Logging:** zerolog
74687575-## Project Structure
7676-7777-```
7878-cmd/server/main.go # Application entry point
7979-internal/
8080- atproto/ # AT Protocol integration
8181- client.go # Authenticated PDS client (XRPC calls)
8282- oauth.go # OAuth flow with PKCE/DPOP
8383- store.go # database.Store implementation using PDS
8484- cache.go # Per-session in-memory cache
8585- records.go # Model <-> ATProto record conversion
8686- resolver.go # AT-URI parsing and reference resolution
8787- public_client.go # Unauthenticated public API access
8888- nsid.go # Collection NSIDs and AT-URI builders
8989- handlers/
9090- handlers.go # HTTP handlers for all routes
9191- auth.go # OAuth login/logout/callback
9292- web/
9393- bff/
9494- helpers.go # View helpers (formatting, UserProfile)
9595- components/ # Reusable Templ components
9696- layout.templ # Base HTML layout component
9797- header.templ # Navigation header
9898- footer.templ # Site footer
9999- shared.templ # Shared UI components (EmptyState, PageHeader, etc.)
100100- buttons.templ # Button components
101101- forms.templ # Form input components
102102- modal.templ # Modal dialog components
103103- card.templ # Card container components
104104- entity_modals.templ # Entity creation/edit modals
105105- *_templ.go # Generated Go code (auto-generated by templ)
106106- pages/ # Full-page Templ components
107107- home.templ # Home page
108108- about.templ # About page
109109- brew_list.templ # Brew list page
110110- brew_view.templ # Brew detail page
111111- brew_form.templ # Brew creation/edit form
112112- manage.templ # Entity management page
113113- profile.templ # User profile page
114114- feed.templ # Community feed page
115115- *_templ.go # Generated Go code
116116- database/
117117- store.go # Store interface definition
118118- boltstore/ # BoltDB implementation for sessions
119119- feed/
120120- service.go # Community feed aggregation
121121- registry.go # User registration for feed
122122- moderation/
123123- models.go # Moderation types (roles, permissions, reports)
124124- service.go # Role-based moderation service
125125- models/
126126- models.go # Domain models and request types
127127- middleware/
128128- logging.go # Request logging middleware
129129- routing/
130130- routing.go # Router setup and middleware chain
131131-lexicons/ # AT Protocol lexicon definitions (JSON)
132132-config/ # Configuration files (moderators.json.example)
133133-static/ # CSS, JS, manifest
134134-```
135135-136136-## Key Concepts
6969+## Architecture
1377013871### AT Protocol Integration
13972140140-User data stored in their Personal Data Server (PDS), not locally. The app:
7373+1. User authenticates via OAuth (indigo SDK handles PKCE/DPOP)
7474+2. Handler creates `AtprotoStore` scoped to user's DID + session
7575+3. Store methods make XRPC calls to user's PDS
7676+4. Results rendered via Templ components or returned as JSON
14177142142-1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP)
143143-2. Gets access token scoped to user's DID
144144-3. Performs CRUD via XRPC calls to user's PDS
145145-146146-**Collections (NSIDs):**
147147-148148-- `social.arabica.alpha.bean` - Coffee beans
149149-- `social.arabica.alpha.roaster` - Roasters
150150-- `social.arabica.alpha.grinder` - Grinders
151151-- `social.arabica.alpha.brewer` - Brewing devices
152152-- `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer)
7878+**Collections (NSIDs)** — defined in `internal/atproto/nsid.go`:
15379154154-**Record keys:** TID format (timestamp-based identifiers)
8080+- `social.arabica.alpha.bean` — Coffee beans (references roaster)
8181+- `social.arabica.alpha.roaster` — Roasters
8282+- `social.arabica.alpha.grinder` — Grinders
8383+- `social.arabica.alpha.brewer` — Brewing devices
8484+- `social.arabica.alpha.cafe` — Cafes (references roaster)
8585+- `social.arabica.alpha.brew` — Brew sessions (references bean, grinder, brewer, recipe)
8686+- `social.arabica.alpha.drink` — Drinks at cafes (references cafe, bean)
8787+- `social.arabica.alpha.recipe` — Recipes (references brewer)
8888+- `social.arabica.alpha.like` — Likes (strongRef to any record)
8989+- `social.arabica.alpha.comment` — Comments (strongRef to any record, optional parent for threads)
15590156156-**References:** Records reference each other via AT-URIs
157157-(`at://did/collection/rkey`)
9191+Records reference each other via AT-URIs (`at://did/collection/rkey`). Record
9292+keys use TID format (timestamp-based identifiers).
1589315994### Store Interface
16095161161-`internal/database/store.go` defines the `Store` interface. Two implementations:
162162-163163-- `AtprotoStore` - Production, stores in user's PDS
164164-- BoltDB stores only sessions and feed registry (not user data)
165165-166166-All Store methods take `context.Context` as first parameter.
167167-168168-### Request Flow
169169-170170-1. Request hits middleware (logging, auth check)
171171-2. Auth middleware extracts DID + session ID from cookies
172172-3. Handler creates `AtprotoStore` scoped to user
173173-4. Store methods make XRPC calls to user's PDS
174174-5. Results rendered via Templ components or returned as JSON
175175-176176-### Caching
177177-178178-`SessionCache` caches user data in memory (5-minute TTL):
179179-180180-- Avoids repeated PDS calls for same data
181181-- Invalidated on writes
182182-- Background cleanup removes expired entries
183183-184184-### Backfill Strategy
185185-186186-User records are backfilled from their PDS once per DID:
187187-188188-- **On startup**: Backfills registered users + known-dids file
189189-- **On first login**: Backfills the user's historical records
190190-- **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent
191191- redundant fetches
192192-- **Idempotent**: Safe to call multiple times (checks backfill status first)
193193-194194-This prevents excessive PDS requests while ensuring new users' historical data
195195-is indexed.
196196-197197-## Templ Architecture
198198-199199-The application uses [Templ](https://templ.guide/) for type-safe,
200200-component-based HTML templating. Templ generates Go code at compile time,
201201-providing full type safety and IDE support.
202202-203203-### Component Structure
204204-205205-Components are organized into two categories:
206206-207207-**Pages** (`internal/web/pages/`) - Full-page components that compose the layout
208208-with content:
209209-210210-- Each page component accepts `LayoutData` and page-specific props
211211-- Pattern: `PageName(layoutData *components.LayoutData, props PageProps)`
212212-- Examples: `Home()`, `BrewList()`, `BrewView()`, `Profile()`
213213-214214-**Components** (`internal/web/components/`) - Reusable UI building blocks:
215215-216216-- `layout.templ` - Base HTML layout with head, body, header, footer
217217-- `shared.templ` - Common UI elements (EmptyState, PageHeader,
218218- LoadingSkeletonTable, etc.)
219219-- `buttons.templ` - PrimaryButton, SecondaryButton, BackButton
220220-- `forms.templ` - TextInput, NumberInput, TextArea, Select, FormField
221221-- `modal.templ` - Modal dialogs with Alpine.js integration
222222-- `card.templ` - Card containers
223223-- `entity_modals.templ` - Entity creation/edit forms
224224-225225-### LayoutData Pattern
226226-227227-Every page shares a common `LayoutData` struct that contains authentication
228228-state and user information:
229229-230230-```go
231231-type LayoutData struct {
232232- Title string
233233- IsAuthenticated bool
234234- UserDID string
235235- UserProfile *bff.UserProfile
236236- CSPNonce string
237237-}
238238-```
239239-240240-This data flows from handlers to the layout component, ensuring consistent
241241-header/footer rendering across all pages.
242242-243243-### Handler Integration
244244-245245-Handlers follow a consistent pattern:
246246-247247-```go
248248-func (h *Handler) HandlePageName(w http.ResponseWriter, r *http.Request) {
249249- // 1. Get authentication state and user data
250250- store, authenticated := h.getAtprotoStore(r)
251251- userProfile, _ := h.getUserProfile(r, store)
252252-253253- // 2. Fetch page-specific data from store
254254- data, err := store.GetData(r.Context())
255255-256256- // 3. Create LayoutData
257257- layoutData := &components.LayoutData{
258258- Title: "Page Title",
259259- IsAuthenticated: authenticated,
260260- UserDID: userDID,
261261- UserProfile: userProfile,
262262- CSPNonce: nonce,
263263- }
264264-265265- // 4. Create page-specific props
266266- pageProps := pages.PageProps{
267267- Data: data,
268268- // ... other props
269269- }
270270-271271- // 5. Render templ component
272272- if err := pages.PageName(layoutData, pageProps).Render(r.Context(), w); err != nil {
273273- http.Error(w, "Failed to render page", http.StatusInternalServerError)
274274- }
275275-}
276276-```
277277-278278-### The Render(r.Context(), w) Pattern
279279-280280-All templ components implement the `templ.Component` interface, which includes a
281281-`Render(ctx context.Context, w io.Writer)` method. This method:
282282-283283-- Takes the request context for cancellation/timeout support
284284-- Writes directly to the http.ResponseWriter
285285-- Returns an error if rendering fails
286286-- Is type-safe at compile time
287287-288288-Example:
289289-290290-```go
291291-if err := pages.Home(layoutData, homeProps).Render(r.Context(), w); err != nil {
292292- http.Error(w, "Failed to render page", http.StatusInternalServerError)
293293-}
294294-```
295295-296296-### Component Composition
297297-298298-Templ components compose naturally using the `@` syntax:
299299-300300-```templ
301301-// Page component wraps layout
302302-templ Home(layout *components.LayoutData, props HomeProps) {
303303- @components.Layout(layout, HomeContent(props))
304304-}
305305-306306-// Content component uses shared components
307307-templ HomeContent(props HomeProps) {
308308- <div class="max-w-4xl mx-auto">
309309- @components.WelcomeCard(components.WelcomeCardProps{
310310- IsAuthenticated: props.IsAuthenticated,
311311- })
312312- @CommunityFeedSection()
313313- </div>
314314-}
315315-```
316316-317317-### Component Reusability
318318-319319-Shared components accept props structs for configuration:
320320-321321-```templ
322322-// EmptyState component with props
323323-templ EmptyState(props EmptyStateProps) {
324324- <div class="card card-inner text-center">
325325- <p class="text-brown-800 text-lg mb-4 font-medium">{ props.Message }</p>
326326- if props.SubMessage != "" {
327327- <p class="text-sm text-brown-700 mb-4">{ props.SubMessage }</p>
328328- }
329329- if props.ActionURL != "" && props.ActionText != "" {
330330- <a href={ templ.SafeURL(props.ActionURL) } class="btn-primary">
331331- { props.ActionText }
332332- </a>
333333- }
334334- </div>
335335-}
336336-```
337337-338338-Used in pages:
339339-340340-```templ
341341-@components.EmptyState(components.EmptyStateProps{
342342- Message: "No brews yet",
343343- SubMessage: "Start tracking your coffee journey",
344344- ActionURL: "/brews/new",
345345- ActionText: "Log Your First Brew",
346346-})
347347-```
348348-349349-### HTMX Integration
350350-351351-Templ works seamlessly with HTMX for dynamic content loading:
352352-353353-```templ
354354-// Page with HTMX loading
355355-<div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML">
356356- @LoadingSkeletonTable(LoadingSkeletonTableProps{Columns: 5, Rows: 3})
357357-</div>
358358-```
359359-360360-HTMX responses can render partial components:
361361-362362-```go
363363-func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) {
364364- brews, _ := store.ListBrews(r.Context())
365365- if err := components.BrewListTable(brews).Render(r.Context(), w); err != nil {
366366- http.Error(w, "Failed to render", http.StatusInternalServerError)
367367- }
368368-}
369369-```
370370-371371-### Alpine.js Compatibility
372372-373373-Alpine.js directives work natively in templ templates:
374374-375375-```templ
376376-<div x-data="{ open: false }">
377377- <button @click="open = !open">Toggle</button>
378378- <div x-show="open" x-transition>
379379- Content
380380- </div>
381381-</div>
382382-```
383383-384384-Modal components use Alpine.js for state management:
385385-386386-```templ
387387-@Modal(ModalProps{
388388- Show: "showBeanForm",
389389- TitleExpr: "editingBean ? 'Edit Bean' : 'Add Bean'",
390390-}, FormContent())
391391-```
392392-393393-### Type Safety Benefits
394394-395395-Templ provides compile-time type checking:
396396-397397-```go
398398-// Props are strongly typed
399399-type BrewViewProps struct {
400400- Brew *models.Brew
401401- Bean *models.Bean
402402- Grinder *models.Grinder
403403- Brewer *models.Brewer
404404- IsOwner bool
405405-}
406406-407407-// Compiler catches missing/wrong fields
408408-pages.BrewView(layoutData, pages.BrewViewProps{
409409- Brew: brew,
410410- Bean: bean,
411411- IsOwner: isOwner,
412412- // Compile error: missing Grinder, Brewer fields
413413-})
414414-```
415415-416416-## Common Tasks
417417-418418-### Run Development Server
419419-420420-```bash
421421-# Run server (uses firehose mode by default)
422422-go run cmd/server/main.go
423423-424424-# Backfill known DIDs on startup
425425-go run cmd/server/main.go --known-dids known-dids.txt
426426-427427-# Using nix
428428-nix run
429429-```
430430-431431-### Run Tests
432432-433433-```bash
434434-go test ./...
435435-```
436436-437437-### Build
438438-439439-```bash
440440-go build -o arabica cmd/server/main.go
441441-```
442442-443443-### Generate Templ Components
9696+`internal/database/store.go` defines the `Store` interface with CRUD methods for
9797+all entity types. `AtprotoStore` is the production implementation backed by the
9898+user's PDS with witness cache and session cache layers.
44499445445-When you modify `.templ` files, you need to regenerate the Go code:
100100+### Three-Layer Caching
446101447447-```bash
448448-# Generate Go code from .templ files
449449-templ generate
450450-451451-# Or in Nix environment
452452-nix develop -c templ generate
453453-454454-# Watch mode for development (auto-regenerate on changes)
455455-templ generate --watch
456456-```
457457-458458-The generated `*_templ.go` should be regenerated whenever `.templ` files change.
459459-460460-Templ files must use tabs rather than spaces. **Never use spaces for indentation
461461-in `.templ` files** — the templ parser will error with a parse failure. This
462462-applies when writing new components, editing existing ones, and constructing
463463-multi-line template strings. A post-edit hook runs `templ fmt` automatically to
464464-catch any accidental spaces.
102102+1. **SessionCache** (`internal/atproto/cache.go`) — per-user in-memory cache
103103+ (2-min TTL). Copy-on-write pattern, invalidated on writes. Dirty-collection
104104+ tracking skips witness cache after local writes until firehose catches up.
465105466466-## Command-Line Flags
106106+2. **WitnessCache** (`internal/firehose/index.go`) — SQLite-backed local index
107107+ populated by the Jetstream firehose consumer. Provides fast reads without PDS
108108+ calls. Used as fallback when session cache misses.
467109468468-| Flag | Type | Default | Description |
469469-| -------------- | ------ | ------- | -------------------------------------------------- |
470470-| `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) |
471471-| `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) |
110110+3. **PDS fallback** — direct XRPC calls to the user's PDS when both caches miss.
472111473473-**Known DIDs File Format:**
112112+Write path: PDS write -> write-through to witness cache -> invalidate session
113113+cache (mark dirty).
474114475475-- One DID per line (e.g., `did:plc:abc123xyz`)
476476-- Lines starting with `#` are comments
477477-- Empty lines are ignored
478478-- See `known-dids.txt.example` for reference
115115+### Firehose & Feed Pipeline
479116480480-## Environment Variables
117117+`internal/firehose/` subscribes to AT Protocol's Jetstream relay for real-time
118118+events. Records are indexed into the SQLite feed index. The feed pipeline:
481119482482-| Variable | Default | Description |
483483-| ----------------------------- | ------------------------------------ | --------------------------------------------- |
484484-| `PORT` | 18910 | HTTP server port |
485485-| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy |
486486-| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
487487-| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
488488-| `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config |
489489-| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
490490-| `OTEL_EXPORTER_OTLP_ENDPOINT` | localhost:4318 | OTLP HTTP endpoint for traces |
491491-| `METRICS_PORT` | 9101 | Internal metrics server port (localhost only) |
492492-| `SECURE_COOKIES` | false | Set true for HTTPS |
493493-| `LOG_LEVEL` | info | debug/info/warn/error |
494494-| `LOG_FORMAT` | console | console/json |
120120+1. **FeedIndex** (`firehose/index.go`) — SQLite store, `recordToFeedItem()`
121121+ converts indexed records to `FeedItem` structs with resolved references.
122122+2. **FeedIndexAdapter** (`firehose/adapter.go`) — converts firehose `FeedItem`
123123+ to feed `FirehoseFeedItem` to avoid import cycles.
124124+3. **Feed Service** (`feed/service.go`) — converts to `feed.FeedItem`, applies
125125+ moderation filtering, caching, and pagination.
495126496496-## Code Patterns
127127+When adding a new entity type, all three layers need the new fields added.
497128498498-### Creating a Store
129129+### Adding a New Entity Type (Checklist)
499130500500-```go
501501-// In handlers, store is created per-request
502502-store, authenticated := h.getAtprotoStore(r)
503503-if !authenticated {
504504- http.Error(w, "Authentication required", http.StatusUnauthorized)
505505- return
506506-}
131131+The full stack for a new entity requires changes across many files. Follow the
132132+pattern of an existing entity (e.g., roaster for simple entities, brew for
133133+entities with references):
507134508508-// Use store with request context
509509-brews, err := store.ListBrews(r.Context(), userID)
510510-```
135135+1. **Lexicon JSON** in `lexicons/`
136136+2. **NSID constant** in `internal/atproto/nsid.go`
137137+3. **RecordType constant** in `internal/lexicons/record_type.go` (const + ParseRecordType + DisplayName)
138138+4. **Model + request types + validation** in `internal/models/models.go`
139139+5. **Record conversion** (`XToRecord`/`RecordToX`) in `internal/atproto/records.go`
140140+6. **Store interface methods** in `internal/database/store.go`
141141+7. **AtprotoStore implementation** in `internal/atproto/store.go` (CRUD + witness + cache)
142142+8. **Cache fields + Set/Invalidate methods** in `internal/atproto/cache.go`
143143+9. **OAuth scope** in `internal/atproto/oauth.go`
144144+10. **Firehose config** (collection list) in `internal/firehose/config.go`
145145+11. **Firehose FeedItem** fields + `recordToFeedItem` switch case in `internal/firehose/index.go`
146146+12. **Feed adapter** mapping in `internal/firehose/adapter.go`
147147+13. **Feed service** `FeedItem` + `FirehoseFeedItem` fields and both mapper sites in `internal/feed/service.go`
148148+14. **CRUD handlers** in `internal/handlers/entities.go` (also update `HandleManagePartial`, `HandleAPIListAll`, `HandleManageRefresh`)
149149+15. **View + OG image handlers** in `internal/handlers/entity_views.go`
150150+16. **Modal handlers** in `internal/handlers/modals.go`
151151+17. **Routes** in `internal/routing/routing.go` (page views, API CRUD, modals, OG images)
152152+18. **Templ view page** in `internal/web/pages/` (e.g., `cafe_view.templ`)
153153+19. **Templ record content** in `internal/web/components/` (e.g., `record_cafe.templ`)
154154+20. **Entity table component** in `internal/web/components/entity_tables.templ`
155155+21. **Dialog modal** in `internal/web/components/dialog_modals.templ` (+ `getStringValue` cases)
156156+22. **Manage partial** tab in `internal/web/components/manage_partial.templ`
157157+23. **My Coffee tab** in `internal/web/pages/my_coffee.templ`
158158+24. **Feed card** switch cases in `internal/web/pages/feed.templ` (card class, content, ActionText, share URL, title, delete URL)
159159+25. **OG card** function in `internal/ogcard/entities.go` (+ accent color in `brew.go`)
160160+26. **Suggestions** config in `internal/suggestions/suggestions.go` + handler map in `internal/handlers/suggestions.go`
161161+27. **Client-side cache** entity case in `static/js/combo-select.js` `getUserEntities()`
511162512512-### Record Conversion
163163+### Templ Architecture
513164514514-```go
515515-// Model -> ATProto record
516516-record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI)
165165+**Tabs only in `.templ` files** — never use spaces for indentation. A post-edit
166166+hook runs `templ fmt` automatically. After editing `.templ` files, run
167167+`templ generate` to regenerate Go code.
517168518518-// ATProto record -> Model
519519-brew, err := RecordToBrew(record, atURI)
520520-```
169169+Pages (`internal/web/pages/`) accept `*components.LayoutData` + page-specific
170170+props. Components (`internal/web/components/`) are reusable building blocks.
521171522522-### AT-URI Handling
172172+Pattern: `pages.PageName(layoutData, props).Render(r.Context(), w)`
523173524524-```go
525525-// Build AT-URI
526526-uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc
174174+### Combo-Select Component System
527175528528-// Parse AT-URI
529529-components, err := ResolveATURI(uri)
530530-// components.DID, components.Collection, components.RKey
531531-```
176176+Entity selection dropdowns (bean, grinder, brewer, roaster, cafe) use a shared
177177+combo-select pattern with typeahead search, community suggestions, and inline
178178+creation:
532179533533-### Rendering Pages with Templ
180180+- **Go config**: `components.ComboSelectConfig()` in `components/combo_select.templ`
181181+ generates Alpine.js `x-data` with entity-specific label formatting and create
182182+ data mapping.
183183+- **Templ markup**: `components.ComboSelectInput()` renders the shared dropdown UI.
184184+- **JS behavior**: `static/js/combo-select.js` — Alpine.js component that
185185+ searches user records (from client-side cache), community suggestions (from
186186+ `/api/suggestions/{entity}`), and creates new entities inline via POST.
187187+- **Suggestions backend**: `internal/suggestions/suggestions.go` — entity configs
188188+ define searchable fields and dedup keys.
534189535535-```go
536536-// Standard page rendering pattern
537537-func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) {
538538- store, authenticated := h.getAtprotoStore(r)
539539- if !authenticated {
540540- http.Redirect(w, r, "/", http.StatusSeeOther)
541541- return
542542- }
190190+To add a new entity to combo-select: add a case to `ComboSelectConfig`, add to
191191+`getUserEntities()` in `combo-select.js`, add entity config to
192192+`suggestions.go`, and add to the entity-to-NSID map in
193193+`handlers/suggestions.go`.
543194544544- // Fetch data
545545- brews, err := store.ListBrews(r.Context(), userDID)
546546- if err != nil {
547547- http.Error(w, "Failed to load brews", http.StatusInternalServerError)
548548- return
549549- }
195195+### Entity View Handler Pattern
550196551551- // Create layout data
552552- layoutData := &components.LayoutData{
553553- Title: "My Brews",
554554- IsAuthenticated: authenticated,
555555- UserDID: userDID,
556556- UserProfile: userProfile,
557557- CSPNonce: nonce,
558558- }
197197+View handlers (`HandleXView`) support both authenticated (own records) and
198198+public (via `?owner=` parameter) access. They:
559199560560- // Create page props
561561- brewListProps := pages.BrewListProps{
562562- Brews: brews,
563563- }
564564-565565- // Render templ component
566566- if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil {
567567- http.Error(w, "Failed to render page", http.StatusInternalServerError)
568568- }
569569-}
570570-```
571571-572572-### Rendering HTMX Partials with Templ
573573-574574-```go
575575-// Partial component for HTMX responses
576576-func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) {
577577- store, authenticated := h.getAtprotoStore(r)
578578- if !authenticated {
579579- http.Error(w, "Unauthorized", http.StatusUnauthorized)
580580- return
581581- }
582582-583583- brews, err := store.ListBrews(r.Context(), userDID)
584584- if err != nil {
585585- http.Error(w, "Failed to load brews", http.StatusInternalServerError)
586586- return
587587- }
588588-589589- // Render just the table component (no layout)
590590- if err := components.BrewListTable(brews, userDID).Render(r.Context(), w); err != nil {
591591- http.Error(w, "Failed to render", http.StatusInternalServerError)
592592- }
593593-}
594594-```
595595-596596-### Testing Conventions
597597-598598-**IMPORTANT:** All tests in this codebase MUST use
599599-[testify/assert](https://github.com/stretchr/testify) for assertions. Do NOT use
600600-`if` statements with `t.Error()` or `t.Errorf()`.
601601-602602-```go
603603-// CORRECT: Use testify assert
604604-import (
605605- "testing"
606606- "github.com/stretchr/testify/assert"
607607-)
608608-609609-func TestFormatTemp(t *testing.T) {
610610- got := FormatTemp(93.5)
611611- assert.Equal(t, "93.5°C", got)
612612-}
613613-614614-func TestPtrEquals(t *testing.T) {
615615- val := 42
616616- assert.True(t, PtrEquals(&val, 42))
617617- assert.False(t, PtrEquals(&val, 99))
618618-}
619619-620620-func TestRenderedHTML(t *testing.T) {
621621- html := renderComponent()
622622- assert.Contains(t, html, "btn-primary")
623623- assert.NotContains(t, html, "deprecated-class")
624624-}
625625-626626-// WRONG: Don't use if statements for assertions
627627-func TestFormatTemp(t *testing.T) {
628628- got := FormatTemp(93.5)
629629- if got != "93.5°C" { // ❌ Don't do this
630630- t.Errorf("got %q, want %q", got, "93.5°C")
631631- }
632632-}
633633-```
634634-635635-**Common testify assertions:**
636636-637637-- `assert.Equal(t, expected, actual)` - Equality check
638638-- `assert.NotEqual(t, expected, actual)` - Inequality check
639639-- `assert.True(t, value)` - Boolean true
640640-- `assert.False(t, value)` - Boolean false
641641-- `assert.Nil(t, value)` - Nil check
642642-- `assert.NotNil(t, value)` - Not nil check
643643-- `assert.Contains(t, haystack, needle)` - Substring/element check
644644-- `assert.NotContains(t, haystack, needle)` - Negative substring/element check
645645-- `assert.NoError(t, err)` - No error occurred
646646-- `assert.Error(t, err)` - Error occurred
647647-648648-## Future Vision: Social Features
649649-650650-The app currently has a basic community feed. Future plans expand social
651651-interactions leveraging AT Protocol's decentralized nature.
652652-653653-### Planned Lexicons
654654-655655-```
656656-social.arabica.alpha.like - Like a brew (references brew AT-URI)
657657-social.arabica.alpha.comment - Comment on a brew
658658-social.arabica.alpha.follow - Follow another user
659659-social.arabica.alpha.share - Re-share a brew to your feed
660660-```
661661-662662-### Like Record (Planned)
663663-664664-```json
665665-{
666666- "lexicon": 1,
667667- "id": "social.arabica.alpha.like",
668668- "defs": {
669669- "main": {
670670- "type": "record",
671671- "key": "tid",
672672- "record": {
673673- "type": "object",
674674- "required": ["subject", "createdAt"],
675675- "properties": {
676676- "subject": {
677677- "type": "ref",
678678- "ref": "com.atproto.repo.strongRef",
679679- "description": "The brew being liked"
680680- },
681681- "createdAt": { "type": "string", "format": "datetime" }
682682- }
683683- }
684684- }
685685- }
686686-}
687687-```
688688-689689-### Comment Record (Planned)
690690-691691-```json
692692-{
693693- "lexicon": 1,
694694- "id": "social.arabica.alpha.comment",
695695- "defs": {
696696- "main": {
697697- "type": "record",
698698- "key": "tid",
699699- "record": {
700700- "type": "object",
701701- "required": ["subject", "text", "createdAt"],
702702- "properties": {
703703- "subject": {
704704- "type": "ref",
705705- "ref": "com.atproto.repo.strongRef",
706706- "description": "The brew being commented on"
707707- },
708708- "text": {
709709- "type": "string",
710710- "maxLength": 1000,
711711- "maxGraphemes": 300
712712- },
713713- "createdAt": { "type": "string", "format": "datetime" }
714714- }
715715- }
716716- }
717717- }
718718-}
719719-```
720720-721721-### Implementation Approach
722722-723723-**Cross-user interactions:**
724724-725725-- Likes/comments stored in the actor's PDS (not the brew owner's)
726726-- Use `public_client.go` to read other users' brews
727727-- Aggregate likes/comments via relay/firehose or direct PDS queries
728728-729729-**Feed aggregation:**
730730-731731-- Current: Poll registered users' PDS for brews
732732-- Future: Subscribe to firehose for real-time updates
733733-- Index social interactions in local DB for fast queries
734734-735735-**UI patterns:**
736736-737737-- Like button on brew cards in feed
738738-- Comment thread below brew detail view
739739-- Share button to re-post with optional note
740740-- Notification system for interactions on your brews
741741-742742-### Key Design Decisions
743743-744744-1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef`
745745- (URI + CID) to ensure the referenced brew hasn't changed
746746-2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's
747747-3. **Public by default** - Social interactions are public records, readable by
748748- anyone
749749-4. **Portable identity** - Users can switch PDS and keep their social graph
750750-751751-## Deployment Notes
200200+1. Try witness cache first, fall back to PDS
201201+2. Resolve references (e.g., roaster for cafe)
202202+3. Populate OG metadata for social sharing
203203+4. Fetch social data (likes, comments, moderation state)
204204+5. Render the templ page with all props
752205753206### CSS Cache Busting
754207···759212<link rel="stylesheet" href="/static/css/output.css?v=0.1.3" />
760213```
761214762762-Cloudflare caches static assets, so incrementing the version ensures users get
763763-the updated styles.
215215+## Testing Conventions
764216765765-### Templ Code Generation
766766-767767-Templ templates must be compiled to Go code before building:
217217+All tests MUST use [testify/assert](https://github.com/stretchr/testify). Do
218218+NOT use `if` statements with `t.Error()`.
768219769769-```bash
770770-# Generate Go code from .templ files
771771-templ generate
772772-773773-# Or in Nix environment
774774-nix develop -c templ generate
220220+```go
221221+assert.Equal(t, expected, actual)
222222+assert.NoError(t, err)
223223+assert.Contains(t, haystack, needle)
224224+assert.True(t, value)
225225+assert.Nil(t, value)
775226```
776227777777-This is automatically handled by the build process, but you may need to run it
778778-manually during development.
228228+## Using Go Tooling
229229+230230+- `go mod download -json MODULE` — get dependency source path
231231+- `go doc foo.Bar` — read package/type/function docs
232232+- `go run ./cmd/server` instead of `go build` to avoid artifacts