Rewrite phlex/roda/sequel skills from research; refine utopia
Ground each skill in authoritative, version-specific facts (phlex 2.x,
roda 3.x, sequel 5.x) alongside Domus's actual usage:
- phlex: call out the v1->v2 renames (template->view_template,
text->plain, Phlex::View->Phlex::HTML), symbol vs string attribute
keys, and the raw(safe(...)) escape boundary.
- roda: explain r.on (prefix) vs r.is (terminal), matcher captures,
the plugin-first design, and freezing opts in production.
- sequel: note SQLite foreign keys are off by default, IntegerMigrator
vs change-block limits, frozen/chainable datasets, injection safety,
and Sequel::Rollback.
- utopia: add the rem/WCAG accessibility guidance, the 2.5x zoom
danger zone, one-up pairs, and current cqi/vi tooling notes.
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
index 5e6f606..3371794 100644
--- a/.claude/skills/phlex/SKILL.md
+++ b/.claude/skills/phlex/SKILL.md
@@ -6,18 +6,32 @@ description: "Use this skill when writing or editing Domus's HTML views — the
# 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.
+objects and every HTML element is a method call. No templates, no ERB. Output
+is escaped by default, which is the whole point: Phlex is built to make XSS
+structurally hard.
-> 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.
+> The Gemfile pins a **fork** (`github.com/kejadlen/phlex.git`) that only
+> silences the method-redefinition warnings Phlex 2.4 emits under `-w`. The
+> API is upstream Phlex 2.x — treat the official docs as authoritative.
+
+## Phlex 2 vs 1 — don't trust v1 examples
+
+Phlex 2 renamed core API. Most blog posts and LLM memory describe v1; the
+renames below will silently misbehave if you copy v1 code:
+
+| v1 | v2 | gotcha if you use the old name |
+|---|---|---|
+| `def template` | **`def view_template`** | `template` now emits a `<template>` element |
+| `text "x"` | **`plain "x"`** | `text` is gone (it clashed with SVG `<text>`) |
+| `Phlex::View` | **`Phlex::HTML`** / `Phlex::SVG` | `Phlex::View` was removed |
## 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`).
+- Each subclasses `Phlex::HTML` and defines **`#view_template`**.
+- A Roda route renders a view by instantiating and calling it:
+ `Views::Capture.new.call` (see `lib/web.rb`). `#call` returns the HTML
+ string.
## Anatomy of a view
@@ -28,7 +42,7 @@ module Domus
module Views
class Capture < Phlex::HTML
def view_template
- doctype
+ doctype # => <!DOCTYPE html>
html(lang: "en") do
head { title { "Domus" } }
body { render_main }
@@ -46,26 +60,41 @@ end
```
- **`view_template`** is the entry point — emit the page here.
-- Each tag is a method: `div`, `header`, `a`, `button`, `input`, `img`, …
+- Every 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`.
+- Split a view into `private` helper methods (`render_header`, `render_main`)
+ and call them plainly — that's the house style in `capture.rb`. (To render
+ *another component*, use `render OtherView.new(...)`, not a method call.)
+
+## Text and content
+
+- **`plain "text"`** — escaped text content. Use it for every literal string
+ inside an element: `h2 { plain "Add an image" }`. A bare string inside a
+ block is *not* emitted — you must call `plain`.
+- **`whitespace`** — emit a single space (to let inline elements wrap).
+- **`comment { "…" }`** — an HTML comment.
## Attributes
-Pass attributes as keyword/hash arguments. Use string keys for anything
-that isn't a plain identifier (dashes, `@`, `:`, `x-`):
+Pass attributes as keyword/hash arguments. Two key behaviors to know:
+
+- **Symbol keys** convert underscores to dashes: `data_id: 1` → `data-id="1"`.
+ There's also a nested shorthand: `data: { controller: "x" }` →
+ `data-controller="x"`.
+- **String keys** are emitted verbatim — required for names with `@`, `:`, or
+ `.`, and the safe choice for anything non-trivial.
+- **Booleans**: `disabled: true` emits `disabled`; `disabled: false` omits it.
+- All attribute **values are escaped** automatically.
```ruby
-div(class: "card", id: "main") # symbol keys for simple names
-input(type: "file", name: "file", accept: "image/*")
+input(type: "file", name: "file", accept: "image/*") # symbol keys, simple
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**:
+Domus drives interactivity with Alpine, so views carry `x-`, `@`, and `:`
+attributes. These are not valid Ruby symbol names, so use **string keys**:
```ruby
div(
@@ -77,28 +106,35 @@ div(
end
```
-## Text and raw HTML
+## Raw HTML and the escape boundary
-- **`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:
+Phlex escapes `plain` text and all attribute values. To emit HTML *verbatim*
+you must opt out — and Phlex makes you do it in two deliberate steps:
+
+```ruby
+raw(safe(trusted_html))
+```
+
+- **`safe(str)`** wraps the string in a `Phlex::SGML::SafeObject`, asserting
+ you trust it. `raw` only accepts a safe object (or it raises), so you can't
+ emit unescaped HTML by accident.
+- Domus uses this to inline pre-read SVG icon files:
```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
+ raw safe(ICONS[name]) # trusted, on-disk SVG — never user input
end
```
-Never pass user input to `raw safe` — that's an XSS hole. Everything else
-(`plain`, attribute values) is escaped by Phlex automatically.
+**Never** pass user input through `raw safe` — that reopens the XSS hole Phlex
+closes. Keep it to static assets and HTML you generated yourself.
## Layouts
`Layout` takes the page content as a block and wraps it in the `<html>`
-shell. Compose by yielding inside `view_template`:
+shell. Yield the block inside `view_template`:
```ruby
class Layout < Phlex::HTML
@@ -117,6 +153,9 @@ class Layout < Phlex::HTML
end
```
+Store constructor args in instance variables (`@title`) and read them in
+`view_template` — Phlex 2 is explicit, nothing is auto-copied in.
+
## Styling
Views reference the Calm Archive design tokens via CSS classes in
@@ -125,6 +164,6 @@ Views reference the Calm Archive design tokens via CSS classes in
## 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.
+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
index 5b1b465..b665d45 100644
--- a/.claude/skills/roda/SKILL.md
+++ b/.claude/skills/roda/SKILL.md
@@ -6,9 +6,14 @@ description: "Use this skill when working on Domus's HTTP layer — the Roda app
# 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.
+app is one class, `Domus::Web < Roda`, in `lib/web.rb`. Roda is a *routing
+tree*: the `route` block runs fresh per request and the request object `r`
+walks the path one segment at a time, executing the first branch that matches.
+
+Roda's core is tiny — almost every feature is a **plugin** you opt into. When
+you reach for behavior, check the
+[plugin list](https://roda.jeremyevans.net/documentation.html) before
+hand-rolling it.
## The routing tree
@@ -23,13 +28,13 @@ class Web < Roda
end
route do |r|
- r.public # serve files from public/
+ r.public # serve a matching file from public/
- r.root do # GET "/"
+ r.root do # GET "/" (only the exact root)
r.get { Views::Capture.new.call }
end
- r.on "files" do # path prefix "/files"
+ r.on "files" do # path PREFIX "files" — keeps descending
r.post do # POST "/files"
save_file(r.params)
r.redirect "/"
@@ -39,28 +44,45 @@ class Web < Roda
end
```
-Key matchers (the request is the `r` yielded to `route`):
+### `r.on` vs `r.is` — the distinction that bites
-- **`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.
+- **`r.on(matcher)`** matches a path *prefix* and keeps walking the tree. Use
+ it to branch: `r.on "files" do … end` handles `/files`, `/files/123`, etc.
+- **`r.is(matcher)`** matches only when the path is *fully consumed* after the
+ matcher. Use it for a terminal/leaf route.
+- **`r.root`** is sugar for `GET /` exactly.
+- **`r.get` / `r.post`** (and, via `:all_verbs`, `r.put`/`r.patch`/`r.delete`)
+ match the HTTP verb. Inside `r.on`, pair them with the verb to pin a route.
-Return value of the matched block is the response body. A view renders with
-`Views::Capture.new.call` (a Phlex string).
+Domus only branches on `r.on "files"` today; if you add `/files/:id` actions,
+prefer `r.on "files" do … r.is Integer do |id| … end … end` so the bare
+`/files` collection and the `/files/123` member don't collide.
-## Plugins in use
+### Matchers and captures
-- **`: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.
+- **String** — `r.on "files"` matches that segment.
+- **Class** — `r.is Integer do |id|` matches a numeric segment and yields it;
+ `String` matches any non-empty segment.
+- **Array** — `r.on %w[new edit]` matches either, yields the match.
+- **Regexp** — yields capture groups as block args.
-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.
+Captured segments arrive as block parameters: `r.is("user", Integer) { |id| }`.
+
+### What a matched block returns
+
+The return value of the matched block becomes the response body (a string).
+That's why `r.get { Views::Capture.new.call }` works — the Phlex string is the
+body. Default status is 200 with a body, 404 with none.
+
+## Request and response
+
+- **`r.params`** — merged GET/POST params (the upload `Hash` for a multipart
+ file lives here as `params["file"]`).
+- **`r.redirect "/"`** — 302 (pass a status for others); halts the route.
+- **`response.status = 422`**, `response["Header"] = "…"` — set on the
+ response object directly.
+- **`r.halt`** — short-circuit with a full Rack response (needs the `:halt`
+ plugin; not currently loaded).
## Error handling
@@ -73,10 +95,23 @@ class ClientError < StandardError
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.
+Route code raises it for bad input (`raise ClientError, "Choose a file…"`).
+The **`:error_handler`** plugin wraps the whole route in a rescue: it renders a
+`ClientError` with its status, and re-raises anything else (so real bugs still
+surface as 500s). Prefer raising `ClientError` over poking `response.status`
+in handlers — it keeps the status next to the reason.
+
+## Plugins in use
+
+- **`:public`** — serve static files from `public/` via `r.public` (GET only,
+ guards against directory traversal).
+- **`:all_verbs`** — adds `r.put`, `r.patch`, `r.delete`, … matchers.
+- **`:error_handler`** — the rescue wrapper described above.
+
+Worth knowing for later: `:render` (Tilt templates — Domus uses Phlex
+instead), `:json`, `:head`, `:not_found`, `:sessions`, and **`:route_csrf`**
+(request-specific CSRF tokens; reach for it before adding any browser-facing
+state-changing form beyond the current trusted-header setup).
## Dependency injection via `opts`
@@ -92,14 +127,14 @@ 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.
+Use `opts.fetch(:app)` (not `[]`) so missing wiring fails loudly. Tests set
+the same key against an in-memory app. In production you can `Web.freeze` to
+lock `opts` and catch accidental runtime mutation / thread-safety bugs.
## 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).
+`opts[:app]`, then `run Domus::Web`. The dev server is `rake dev` (port 9292).
## Testing
diff --git a/.claude/skills/sequel/SKILL.md b/.claude/skills/sequel/SKILL.md
index 97c6d7b..32602d1 100644
--- a/.claude/skills/sequel/SKILL.md
+++ b/.claude/skills/sequel/SKILL.md
@@ -18,23 +18,32 @@ Roda app's `opts`.
`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`).
+migrations into it (`test/test_helper.rb`). A `:memory:` database is private to
+its single connection — fine for the test suite's one-connection use.
+
+> **SQLite foreign keys are OFF by default.** Sequel doesn't enable them unless
+> you pass `foreign_keys: true` to `Sequel.sqlite`. So the `foreign_key`
+> columns in the schema document intent and create indexes, but SQLite is not
+> currently enforcing referential integrity at runtime. Keep that in mind
+> before relying on cascade/restrict behavior.
## 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`.
+- **`files`** — `extension`, `created_at`. One row per stored image blob; the
+ blob itself 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):
+Plain Sequel migrations, numbered `NNN_description.rb`, in `db/migrate/`. The
+leading integers mean Domus uses the **IntegerMigrator** (sequential, no
+duplicates) — not timestamped migrations. Use the **`change`** block; Sequel
+reverses it automatically on rollback:
```ruby
# frozen_string_literal: true
@@ -50,37 +59,59 @@ Sequel.migration do
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`).
+Column DSL (Ruby type names map to SQL types):
+
+- `primary_key :id` — auto-increment integer PK.
+- `String :name` (→ varchar), `DateTime :created_at`, `Integer :n`.
+- `foreign_key :asset_id, :assets` — FK column referencing `assets(id)`.
+- `primary_key [:asset_id, :file_id]` — composite PK (see migration 004).
+- Modifiers: `null: false`, `default:`, `unique: true`, `index: true`.
+
+Conventions in this repo: every column is `null: false` unless genuinely
+optional, and every table carries a `created_at DateTime`. Name the next file
+with the next zero-padded number.
-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.
+**`change` can't auto-reverse everything.** It reverses `create_table`,
+`add_column`, `add_index`, `rename_*`, etc. For data backfills or raw SQL,
+write explicit `up`/`down` blocks instead — `change` will raise if it can't
+figure out the inverse. Also: don't reference `Sequel::Model` classes in
+migrations; use the dataset API so a migration keeps working as models evolve.
### 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.
+- **`rake db:rollback`** — revert the last one (it looks up the latest row in
+ `schema_migrations` and migrates back one step).
+- `config.ru` 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:
+Most queries go through datasets (`db[:table]`), not models. Datasets are
+**frozen and chainable** — each method returns a new dataset, so they're
+reusable and thread-safe; nothing executes until a terminal method is called.
```ruby
-file_id = db[:files].insert(extension: ext, created_at: Time.now)
-rows = db[:files].all
+# building (lazy, returns datasets)
+recent = db[:files].where { created_at > Time.now - 86400 }.order(Sequel.desc(:created_at))
+
+# terminal (executes, returns data)
+file_id = db[:files].insert(extension: ext, created_at: Time.now) # => new id
+rows = db[:files].all # => [Hash, …]
+one = db[:files].first # => Hash | nil
count = db[:files].count
-db[:assets].delete # truncate (used in test setup)
-db[:schema_migrations].order(Sequel.desc(:filename)).limit(1).get(:filename)
+db[:assets].delete # truncate (test setup)
+latest = 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.
+Helpers: `where` / `exclude` (Hash, block, or `Sequel.lit`), `select`,
+`order` with `Sequel.desc`/`Sequel.asc`, `limit`, `.get(:col)` for one value,
+`.update(col: val)`, `.delete`.
+
+**SQL-injection safety:** pass conditions as hashes/blocks — Sequel
+parameterizes them. If you must drop to literal SQL, use placeholders
+(`db[:t].where(Sequel.lit("x = ?", input))`), never string interpolation, and
+never feed user input to `Sequel.lit` as part of the SQL text.
## Transactions
@@ -94,8 +125,11 @@ db.transaction do
end
```
-The file-upload path in `lib/web.rb` does exactly this — DB rows and the
-on-disk blob are written inside one transaction.
+Any exception rolls the transaction back and re-raises; raise
+`Sequel::Rollback` to abort *silently* (no exception escapes). The file-upload
+path in `lib/web.rb` does exactly this — DB rows and the on-disk blob are
+written inside one transaction. Nested `db.transaction` calls join the outer
+transaction by default; pass `savepoint: true` for a real savepoint.
## Models
@@ -109,12 +143,16 @@ module Domus
end
```
-Models are sparse today — reach for the dataset API for new query code unless
-you specifically need model behavior (associations, validations, hooks).
+Models add associations (`many_to_one`, `one_to_many`, `many_to_many`),
+validations (override `#validate` or use the `validation_helpers` plugin),
+lifecycle hooks, and reusable scopes via `dataset_module`. Domus's models are
+bare today — reach for the dataset API for new query code unless you
+specifically need that model behavior, and remember the model's table must
+already exist (it reflects on the schema at load time).
## 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.
+(`db[:files].all`, `.count`). Follow that pattern — assert on the dataset, not
+on raw SQL.
diff --git a/.claude/skills/utopia/SKILL.md b/.claude/skills/utopia/SKILL.md
index 920621c..a7c65ab 100644
--- a/.claude/skills/utopia/SKILL.md
+++ b/.claude/skills/utopia/SKILL.md
@@ -8,8 +8,12 @@ description: "Use this skill when working with Domus's fluid type and space scal
Domus scales type and spacing with [Utopia](https://utopia.fyi): each step is
one `clamp()` that interpolates between a **min** size (at the min viewport)
and a **max** size (at the max viewport). Below the min viewport the value
-pins to the min; above the max viewport it pins to the max. Everything is in
-`rem`, so it also respects the user's browser font-size preference.
+pins to the min; above the max viewport it pins to the max — so there are no
+breakpoints to manage.
+
+Everything is in `rem`, never `px`. That isn't cosmetic: a `px`-bounded
+`clamp()` can't be zoomed, which fails WCAG 1.4.4 (resize text). Keep the
+bounds in `rem` so the scale tracks the user's browser font-size and zoom.
## Where the tokens live
@@ -60,8 +64,14 @@ interceptPx = 18 - 0.0021739 * 320 = 17.30435px → 1.0815rem
Type steps are derived by multiplying/dividing the body by the ratio
(`step n = body * ratio^n`), using the **min ratio** for min sizes and the
-**max ratio** for max sizes. Space steps are the base size times the
-multiples above.
+**max ratio** for max sizes. Negative steps (`--step--1`, `--step--2`) divide
+instead of multiply; Utopia keeps these shallow because deep negatives become
+illegible. Space steps are the base size times the multiples above.
+
+> **Accessibility check.** A step where `max` is more than ~2.5× its `min` can
+> fail the 200%-zoom requirement. Utopia's calculators now flag these; keep
+> type bounds within that ratio. (For Domus this is comfortably satisfied —
+> body only goes 18→20px.)
## Generating tokens
@@ -76,6 +86,15 @@ emitted `--step-*` / `--space-*` clamps. Verify a new value against an
existing one in `docs/design/domus-tokens.css` (e.g. step 0 must stay
`clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem)`) so the scale matches.
+The space calculator can also emit **one-up pairs** like `--space-s-m` —
+a single `clamp()` whose min is step `s` and max is step `m`, useful for a gap
+that grows by a full step across the viewport range. Add them only if a layout
+needs them; don't bulk-generate pairs.
+
+> Newer Utopia tooling can target `cqi`/`cqw` (container queries) or `vi`
+> (logical inline axis) instead of `vw`. Domus's tokens are `vw`-based — stay
+> on `vw` unless you're deliberately introducing container-relative scaling.
+
## Using the tokens
Reference the custom properties; never hard-code sizes.