Add phlex, roda, and sequel skills
Document the project's HTTP, view, and database conventions as Claude
Code skills, grounded in lib/web.rb, lib/views/, lib/app.rb, and
db/migrate/. Mirrors the existing utopia skill's format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQTgGstuG7U9yzrtK6emxy
diff --git a/.claude/skills/phlex/SKILL.md b/.claude/skills/phlex/SKILL.md
new file mode 100644
index 0000000..5e6f606
--- /dev/null
+++ b/.claude/skills/phlex/SKILL.md
@@ -0,0 +1,130 @@
+---
+name: phlex
+description: "Use this skill when writing or editing Domus's HTML views — the Phlex components in lib/views/ (layout.rb, capture.rb). Triggers: adding a view or partial, rendering markup from a Roda route, emitting Alpine.js attributes, inlining SVG/raw HTML safely, or debugging why output is escaped or missing. Domus uses Phlex 2.x (a Ruby DSL where each HTML element is a method) from the kejadlen/phlex fork pinned in the Gemfile."
+---
+
+# Phlex views
+
+Domus renders HTML with [Phlex](https://www.phlex.fun) 2.4 — views are Ruby
+objects, and every HTML element is a method call. No templates, no ERB.
+
+> The Gemfile pins a **fork** (`github.com/kejadlen/phlex.git`) that silences
+> the method-redefinition warnings Phlex 2.4 emits under `-w`. Treat the API
+> as upstream Phlex 2.x.
+
+## Where views live
+
+- **`lib/views/`** — one class per view (`Layout`, `Capture`).
+- Each subclasses `Phlex::HTML` and defines `#view_template`.
+- A Roda route renders a view by instantiating it and calling `#call`:
+ `Views::Capture.new.call` (see `lib/web.rb`).
+
+## Anatomy of a view
+
+```ruby
+require "phlex"
+
+module Domus
+ module Views
+ class Capture < Phlex::HTML
+ def view_template
+ doctype
+ html(lang: "en") do
+ head { title { "Domus" } }
+ body { render_main }
+ end
+ end
+
+ private
+
+ def render_main
+ main(class: "content") { h2 { plain "Add an image" } }
+ end
+ end
+ end
+end
+```
+
+- **`view_template`** is the entry point — emit the page here.
+- Each tag is a method: `div`, `header`, `a`, `button`, `input`, `img`, …
+- A block becomes the element's children; nest blocks to nest markup.
+- Break a view into `private` helper methods (`render_header`,
+ `render_main`) — that's the house style in `capture.rb`.
+
+## Attributes
+
+Pass attributes as keyword/hash arguments. Use string keys for anything
+that isn't a plain identifier (dashes, `@`, `:`, `x-`):
+
+```ruby
+div(class: "card", id: "main") # symbol keys for simple names
+input(type: "file", name: "file", accept: "image/*")
+a(href: "/", class: "logo") { plain "domus" }
+```
+
+### Alpine.js attributes
+
+Domus drives interactivity with Alpine, so views are full of `x-`, `@`, and
+`:` attributes. These must be **string keys**:
+
+```ruby
+div(
+ "x-data": "captureApp()",
+ "@dragover.prevent": "dragging = true",
+ ":data-drag": "dragging ? 'over' : null"
+) do
+ button(type: "button", "@click": "$refs.fileInput.click()") { plain "Browse" }
+end
+```
+
+## Text and raw HTML
+
+- **`plain "text"`** — emit escaped text content. Use it for any literal
+ string inside an element (`h2 { plain "Add an image" }`).
+- **`raw safe(html)`** — emit a pre-trusted HTML string *without* escaping.
+ Only for strings you control. Domus inlines SVG icons this way:
+
+```ruby
+ICONS = Hash.new { |cache, name| cache[name] = File.read("…/#{name}.svg").freeze }
+
+def icon(name)
+ raw safe(ICONS[name]) # safe() marks it trusted; raw emits it verbatim
+end
+```
+
+Never pass user input to `raw safe` — that's an XSS hole. Everything else
+(`plain`, attribute values) is escaped by Phlex automatically.
+
+## Layouts
+
+`Layout` takes the page content as a block and wraps it in the `<html>`
+shell. Compose by yielding inside `view_template`:
+
+```ruby
+class Layout < Phlex::HTML
+ def initialize(title: "Domus", &content)
+ @title = title
+ @content = content
+ end
+
+ def view_template
+ doctype
+ html(lang: "en") do
+ head { title { @title }; link(rel: "stylesheet", href: "/app.css") }
+ body { yield }
+ end
+ end
+end
+```
+
+## Styling
+
+Views reference the Calm Archive design tokens via CSS classes in
+`public/app.css` — never inline colors or sizes. See `AGENTS.md` and the
+**utopia** skill for the `--step-*` / `--space-*` scales.
+
+## Testing
+
+Views are exercised end-to-end through Roda routes with `rack-test`; assert
+on `last_response.body` (see `test/test_app.rb`). There's no separate view
+unit test — render through the route.
diff --git a/.claude/skills/roda/SKILL.md b/.claude/skills/roda/SKILL.md
new file mode 100644
index 0000000..5b1b465
--- /dev/null
+++ b/.claude/skills/roda/SKILL.md
@@ -0,0 +1,121 @@
+---
+name: roda
+description: "Use this skill when working on Domus's HTTP layer — the Roda app in lib/web.rb and its wiring in config.ru. Triggers: adding or changing a route, handling params/uploads, returning redirects or error statuses, enabling a Roda plugin, injecting the App dependency via opts, or writing rack-test request specs. Domus uses Roda 3.x, a routing-tree web toolkit where requests are matched by walking a block."
+---
+
+# Roda routing
+
+Domus serves HTTP with [Roda](https://roda.jeremyevans.net) 3.103. The whole
+app is one class, `Domus::Web < Roda`, in `lib/web.rb`. Roda routes by
+*walking a tree* — the `route` block matches segments of the path as it
+descends, calling handlers when a branch matches.
+
+## The routing tree
+
+```ruby
+class Web < Roda
+ plugin :public
+ plugin :all_verbs
+ plugin :error_handler do |e|
+ raise e unless e.is_a?(ClientError)
+ response.status = e.status
+ e.message
+ end
+
+ route do |r|
+ r.public # serve files from public/
+
+ r.root do # GET "/"
+ r.get { Views::Capture.new.call }
+ end
+
+ r.on "files" do # path prefix "/files"
+ r.post do # POST "/files"
+ save_file(r.params)
+ r.redirect "/"
+ end
+ end
+ end
+end
+```
+
+Key matchers (the request is the `r` yielded to `route`):
+
+- **`r.root`** — matches `GET /` (combined with `r.get` inside).
+- **`r.on "files"`** — matches the path *prefix* `files` and descends; nest
+ verb matchers inside.
+- **`r.get` / `r.post`** — match the HTTP verb (and optionally remaining path).
+- **`r.params`** — merged GET/POST params.
+- **`r.redirect "/"`** — 302 redirect (halts the route).
+- **`r.public`** — serve a static file from `public/` if one matches.
+
+Return value of the matched block is the response body. A view renders with
+`Views::Capture.new.call` (a Phlex string).
+
+## Plugins in use
+
+- **`:public`** — static files from `public/` via `r.public`.
+- **`:all_verbs`** — adds `r.put`, `r.patch`, `r.delete`, etc.
+- **`:error_handler`** — wraps the route in a rescue; see error handling below.
+
+Add a plugin with `plugin :name` at class level. Check the
+[plugin list](https://roda.jeremyevans.net/documentation.html) before
+hand-rolling behavior — Roda ships most of what you'd want.
+
+## Error handling
+
+Domus defines a `ClientError` carrying an HTTP status:
+
+```ruby
+class ClientError < StandardError
+ attr_reader :status
+ def initialize(message, status: 422) = (super(message); @status = status)
+end
+```
+
+Route code raises it for bad input (`raise ClientError, "Choose a file…"`),
+and the `:error_handler` plugin renders it with the right status. Anything
+that isn't a `ClientError` is re-raised. Prefer raising `ClientError` over
+manually setting `response.status` in handlers.
+
+## Dependency injection via `opts`
+
+The app object (DB + config) is injected through Roda's class-level `opts`
+rather than a global. `config.ru` sets it; the route reads it:
+
+```ruby
+# config.ru
+Domus::Web.opts[:app] = app
+
+# lib/web.rb
+def app = opts.fetch(:app)
+def db = app.db
+```
+
+Use `opts.fetch(:app)` (not `[]`) so a missing wiring fails loudly. Tests set
+the same key against an in-memory app.
+
+## Bootstrapping
+
+`config.ru` builds the `App`, runs pending Sequel migrations, wires
+`opts[:app]`, then `run Domus::Web`. The dev server is `rake dev`
+(port 9292).
+
+## Testing
+
+Request specs use `rack-test` with `Domus::Web` as the app:
+
+```ruby
+class TestApp < Minitest::Test
+ include Rack::Test::Methods
+ def app = Domus::Web
+
+ def test_upload
+ post "/files", "file" => upload("photo.png", "image/png", "bytes")
+ assert_equal 302, last_response.status
+ end
+end
+```
+
+Assert on `last_response.status` and `.body`. See `test/test_app.rb` for the
+upload happy-path and the `422` rejection cases.
diff --git a/.claude/skills/sequel/SKILL.md b/.claude/skills/sequel/SKILL.md
new file mode 100644
index 0000000..97c6d7b
--- /dev/null
+++ b/.claude/skills/sequel/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: sequel
+description: "Use this skill when working on Domus's database layer — the Sequel connection in lib/app.rb, models in lib/models.rb, and migrations in db/migrate/. Triggers: adding or rolling back a migration, defining a model, writing dataset queries/inserts, wrapping work in a transaction, or reasoning about the schema (documents, files, assets, asset_attachments). Domus uses Sequel 5.x against SQLite via the sqlite3 gem."
+---
+
+# Sequel database
+
+Domus persists data with [Sequel](https://sequel.jeremyevans.net) 5.104 on
+SQLite. The connection is opened once in `lib/app.rb` and threaded through the
+Roda app's `opts`.
+
+## Connection
+
+```ruby
+# lib/app.rb
+@db = Sequel.sqlite(config.database_url) # file path, or ":memory:" in tests
+```
+
+`App#db` is the `Sequel::Database`. Routes reach it via `app.db` (see the
+**roda** skill). Tests build an `App` with `database_url: ":memory:"` and run
+migrations into it (`test/test_helper.rb`).
+
+## Schema
+
+Four tables (see `db/migrate/`):
+
+- **`documents`** — `path`, `kind`, `received_at`, `created_at`.
+- **`files`** — `extension`, `created_at`. One row per stored image blob;
+ the blob lives on disk at `App#file_path`.
+- **`assets`** — `name`, `created_at`.
+- **`asset_attachments`** — join table, composite PK `[asset_id, file_id]`,
+ both `foreign_key`s, plus `created_at`.
+
+## Migrations
+
+Plain Sequel migrations, numbered `NNN_description.rb`, in `db/migrate/`. Use
+the **`change`** block (Sequel reverses it automatically for rollback):
+
+```ruby
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ create_table(:assets) do
+ primary_key :id
+ String :name, null: false
+ DateTime :created_at, null: false
+ end
+ end
+end
+```
+
+Column helpers are Ruby type names: `String`, `DateTime`, `Integer`,
+`primary_key`, `foreign_key :asset_id, :assets`. For a composite key use
+`primary_key [:asset_id, :file_id]` (see `004_create_asset_attachments.rb`).
+
+Conventions:
+- Always `null: false` unless a column is genuinely optional.
+- Give every table a `created_at DateTime`.
+- Name the next file with the next zero-padded number.
+
+### Running migrations
+
+- **`rake db:migrate`** — apply pending migrations.
+- **`rake db:rollback`** — revert the last one.
+- `config.ru` also runs `Sequel::Migrator.run(app.db, "db/migrate")` on boot,
+ so deploys self-migrate.
+
+## Datasets
+
+Most queries go through datasets (`db[:table]`), not models. The dataset API
+is chainable and returns plain hashes:
+
+```ruby
+file_id = db[:files].insert(extension: ext, created_at: Time.now)
+rows = db[:files].all
+count = db[:files].count
+db[:assets].delete # truncate (used in test setup)
+db[:schema_migrations].order(Sequel.desc(:filename)).limit(1).get(:filename)
+```
+
+Use `Sequel.desc(:col)` / `Sequel.asc(:col)` for ordering, `.get(:col)` to
+pull a single value, `.where(...)` to filter.
+
+## Transactions
+
+Wrap multi-statement writes so they commit or roll back together:
+
+```ruby
+db.transaction do
+ file_id = db[:files].insert(extension: ext, created_at: Time.now)
+ asset_id = db[:assets].insert(name: name, created_at: now)
+ db[:asset_attachments].insert(asset_id:, file_id:, created_at: now)
+end
+```
+
+The file-upload path in `lib/web.rb` does exactly this — DB rows and the
+on-disk blob are written inside one transaction.
+
+## Models
+
+`Sequel::Model` subclasses live in `lib/models.rb` and infer their table from
+the class name (`Document` → `documents`):
+
+```ruby
+module Domus
+ class Document < Sequel::Model
+ end
+end
+```
+
+Models are sparse today — reach for the dataset API for new query code unless
+you specifically need model behavior (associations, validations, hooks).
+
+## Testing
+
+`test_helper.rb` migrates a fresh `:memory:` database; `test_app.rb` clears
+tables in `setup` (`db[:assets].delete`) and asserts against dataset reads
+(`db[:files].all`, `.count`). Follow that pattern — assert on the dataset,
+not on raw SQL.