From ba9a9b2a486351046ed6bd1d4959055c09cd9548 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:12:19 +0000 Subject: [PATCH 1/8] docs: changelog backfill, TLS/ACME guide (#1860), a11y dedupe + skills freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md: backfill 21 bullets under the existing [Unreleased] section for work merged since docs batch 3 (#1877) — autumn deploy CLI (#1607), #[lifecycle] state machines (#1675/#1911), SQLite foundation (#1614), a11y primitives + scaffold track (#1706), content negotiation (#1881), nested has_many forms (#1915), security manifest v2 (#1879), owner-scoped codegen (#1882), and assorted CSRF/tenant/multipart/blob/schema fixes. No version bump; no new dated section. - docs/guide/tls.md (NEW): TLS & HTTPS guide — direct in-process TLS, automatic ACME HTTP-01, reverse-proxy termination, certbot + mkcert walkthroughs. Closes #1860. - docs/guide/deployment.md: replace the #1860 placeholder with a live cross-link to the new TLS guide (cross-ref only). - skills/autumn-web/references/api-reference.md: merge two duplicate a11y sections into one canonical section and add a transition_controls entry (#1917). - skills/*: freshness edits for lifecycle, autumn deploy, a11y primitives, SQLite backend, and nested forms. --- CHANGELOG.md | 21 ++ docs/guide/deployment.md | 7 +- docs/guide/tls.md | 256 ++++++++++++++++++ skills/autumn-patterns/SKILL.md | 9 +- skills/autumn-web/SKILL.md | 38 ++- skills/autumn-web/references/api-reference.md | 109 ++++---- skills/doctor/SKILL.md | 22 ++ skills/generate/SKILL.md | 11 + 8 files changed, 411 insertions(+), 62 deletions(-) create mode 100644 docs/guide/tls.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 03aa4b216..978e3431e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1405,6 +1405,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **generate:** `autumn generate scaffold --searchable title,body` makes the named text fields full-text searchable — it adds `#[searchable]` attributes to the model, `searchable` to the `#[repository]`, a migration adding a `search_vector tsvector GENERATED ALWAYS AS (…) STORED` column plus a GIN index, and a search box on the index wired to `GET //search` (an empty `?q` falls back to the plain paginated list). Omitting `--searchable` leaves scaffold output byte-identical; the `--live`/`--live-validation` index variants gate the search box off; naming a non-text/unknown field, a uuid-PK model, or an owner-scoped model fails generation before any files are written (issue #1319). - **generate:** `autumn generate scaffold post avatar:Attachment` now produces working no-JS file uploads end-to-end — the create/update handlers take an `autumn_web::extract::Multipart` extractor, stream each uploaded file to the `BlobStore` via `field.save_to_blob_store(&*store, key)`, and bind the returned `Blob` from a plain `multipart/form-data` submit. `autumn destroy` also no longer strips the `multipart`/`storage` features when a hand-written route still uses them (part of #1236). - **cli:** `autumn new --api` scaffolds a JSON-first project — handlers return `Json<…>`, `autumn-web` is pinned `default-features = false` to a lean API feature set (`db`, `cache-moka`, `http-client`, `reporting`, `flash`) that drops the maud/htmx/tailwind view stack, and no `static/` tree, `input.css`, `tailwind.config.js`, vendored assets, or Tailwind CI/README notes are generated (the first `cargo run` serves JSON). `--api` cannot be combined with `--daemon` or `--bundled-pg` but composes with `--with-i18n` and `--with-seed`. +- **cli:** `autumn deploy` — first-class push-button deployment of an Autumn app to your own Linux host over SSH (issue #1607). `autumn deploy check` reads a `[deploy]` config block, runs a connectivity/secrets/Docker preflight, and prints a dry-run plan without touching the server (#1884); `autumn deploy up` executes a real first deploy over an injectable SSH executor — building/pushing the image, uploading the env, and starting the app (#1912); subsequent deploys perform a **zero-downtime cutover** via kamal-proxy, running pending migrations against the live database *before* flipping the proxy to the new container so a request is never served by an un-migrated build (#1928); and `autumn deploy rollback` — plus automatic rollback when a release fails to come up healthy — restores the previous container and secrets in one step (#1937). Deploy runs in the **production** profile by default, overridable through a `[deploy]` profile knob (#1956), and a push-button walkthrough is documented in the deploy guide (#1950). +- **testing:** an end-to-end `autumn deploy` acceptance harness exercises the real ssh/systemd/kamal-proxy release lifecycle against a throwaway container — first deploy, zero-downtime cutover, and rollback end to end (AC-7 of #1607, #1949). [no-plugin] +- **web:** `#[lifecycle]` typed state machines prove your domain lifecycles sound at build time (issue #1675) — annotate an enum with its allowed transitions and the framework generates a typed transition API that makes an illegal state change a compile error, with `autumn lifecycle check` validating the declared graph and `autumn lifecycle diagram` emitting a Mermaid diagram of it (#1916). The `transition_controls` view helper renders a record's currently-legal transitions as accessible no-JS form buttons (part of #1326, #1917). `autumn generate scaffold`'s `:states(...)` marker wires those transitions into the generated scaffold, emitting guarded transition routes and controls (#1935), and a field-level `#[state_machine(lifecycle = SomeEnum)]` can now reference a `#[lifecycle]` enum so the two mechanisms share one source of truth (issue #1911, #1944). +- **cli:** SQLite backend foundation (issue #1614) — Autumn now detects a SQLite `database.url`, maps generator DDL to SQLite column types, and `autumn doctor` understands the backend; features SQLite cannot support are rejected at generate time rather than failing at runtime, and the capability/support matrix is documented in a new backend-support guide (#1918). +- **a11y:** four more typed accessible form primitives join `TextField` (part of #1706) — `TextArea`, `Select` with `SelectOption`, `Checkbox`, and `FileField` each make the accessible name a compile-time obligation the same way, and every primitive gains a `.hx(name, value)` escape hatch for attaching arbitrary `hx-*` (and other) attributes without dropping back to raw `html!` (#1946). +- **generate:** `autumn generate scaffold` now routes every generated form field through the typed `a11y` primitives instead of hand-rolled `html!` (part of #1706) — text inputs through `a11y::TextField` (with a new `TextField::label_class` for the label wrapper) (#1913), constrained `String`/`Text` fields through `a11y::TextArea` (#1953), and the shared `FieldControl` rendering through the typed primitives (#1954); `--live-validation` fields render through the primitives plus `.hx()` for their inline-validation `hx-*` wiring (closes #1951, #1964). Scaffolds are now accessible by construction and pass `autumn a11y verify`. +- **security:** security posture manifest v2 (part of #1627) — the build-time security manifest now wraps its output in a provenance envelope and adds CSRF and security-headers coverage dimensions alongside the route auth classification, so a CI consumer can prove the shipped build's CSRF protection and header policy, not just its route gating (#1879). +- **web:** content-negotiated responses let one handler serve both HTML and JSON (#1881) — a new `Negotiate` extractor/response inspects the request `Accept` header and renders the matching representation, so an endpoint can back both a browser page and an API client without duplicating the handler. +- **generate:** `autumn generate scaffold` gains authorization variants (#1830) and owner-scoped list/search codegen (#1841) — generated resources can emit policy-authorized handler variants and scope their index/search queries to the current user's own rows (#1882). +- **web:** nested `has_many` form binding for atomic master-detail saves (#1915) — `NestedChangesetForm` with `inputs_for`, `seeded`, and `blank` renders and binds a parent plus its child collection from a single submit and persists them in one atomic save, so a form like an order with its line items round-trips without hand-written child wiring. ### Fixed @@ -1680,6 +1690,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **generate:** `autumn generate auth`'s browser auth pages now render through the shared 4-arg `crate::layout(title, current_path, flash, content)` — the same helper `autumn new`/scaffold emit — instead of a private bare-DOCTYPE stub; the generator preflights for a shared `pub fn layout` in `src/main.rs` and fails with an actionable message pointing at `autumn new` when it is missing (issue #1353). - **generate:** scaffolded resources now route every URL through a generated `autumn_web::paths![index, show, new, create, edit, update, delete]` module (plus `events` under `--live` and one `validate_{field}` per validated field under `--live-validation`) instead of hand-written path strings — all view hrefs, form actions, redirects, the SSE `sse-connect`, and inline-validation `hx-post` attributes call `paths::*`, giving each resource's URLs a single source of truth (issue #1133). - **reliability:** the request-path module set (~25 modules including `form`, `extract`, `idempotency`, session/scheduler/storage/sync, and the middleware stack) is now gated by clippy restriction lints that deny `unwrap`/`expect`/`panic`/`unreachable`/`todo`/`unimplemented`/`indexing_slicing` on non-test builds, and poison-prone locks recover via `unwrap_or_else(PoisonError::into_inner)` rather than panicking — so a poisoned lock or stray unwrap can no longer panic-abort a worker on the request path. A `scripts/check-panic-gate.sh` manifest, run in CI's lint job, keeps the gate from being silently dropped (part of #1611). +- **a11y:** the field DSL now rejects an inline `String`/`Text` length bound greater than `u32::MAX` at parse time (part of #1706, #1913) — an out-of-range `maxlength`/`minlength` constraint that could never be represented in the generated HTML validation attribute is a generate-time error rather than a silently truncated or overflowing value. +- **web:** the htmx form helpers now thread the configured submit-token field name (`[security.submit_token]`) into the hidden field they emit instead of assuming the default `_submit_token`, so a project that renamed the field still gets working one-time-submit protection on helper-rendered forms (#1843, #1883). +- **ci:** CI now discovers Docker-dependent `#[ignore]` database tests dynamically instead of a hard-coded allowlist, so a new testcontainer DB test runs in CI with no workflow edit (#1941). [no-plugin] ### Fixed @@ -1723,6 +1736,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 silently skipping the write, and a loom model-check (`chaos_job_client_loom`) exercises the first-init race across all interleavings. +- **doctor:** `autumn doctor`'s deploy check now fails on a malformed `previous_secrets` block, matching `autumn deploy check` — the two no longer disagree about whether a deploy config is valid (#1904). +- **cli:** `autumn lifecycle diagram` now module-qualifies node ids so two same-named enums in different modules no longer collide into a single node in the emitted diagram (#1929). +- **doctor:** `autumn doctor` now fails a malformed `[[database.shards]]` entry instead of silently treating it as a no-shards configuration, so a typo in a shard block is surfaced rather than passing the SQLite/single-DB path by accident (#1939). +- **security:** CSRF token extraction from a multipart body now scans only a bounded prefix for the token field and streams the remainder rather than buffering the whole body into memory, closing a memory-exhaustion vector on large uploads; the prefix size is tunable via `security.csrf.token_scan_bytes` (#1887). +- **web:** a multipart part with an empty filename is now treated as ordinary non-file input rather than an uploaded file, so a browser submitting an untouched `` (blank filename) no longer produces a spurious empty blob (fixes #1873, #1878). +- **generate:** scaffolded create/update handlers now delete already-uploaded blobs when the handler returns early (e.g. on a validation failure) instead of orphaning them in the blob store (#1888). +- **config:** the startup schema walk no longer aborts when it hits a `statement_timeout`; strict schema enforcement rolls out warn-first, and a new `strict_config_enforce_all` knob opts into failing on every strict finding rather than warning (#1914). +- **security:** tenant-isolation fixes for the repository layer (#1962) — a cross-tenant `update` targeting a foreign or missing id now returns a 404 rather than a 500, and a bulk upsert silently filters out cross-tenant rows for non-versioned records instead of writing across the tenant boundary. ## [0.6.0] - 2026-06-30 diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 582c1f898..2cd80e59d 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -49,9 +49,10 @@ below and are better fits in specific cases: [How the production image works](#how-the-production-image-works)) — a portable OCI image you run on Kubernetes, ECS, Nomad, or any Docker host. -> **HTTPS/TLS.** kamal-proxy fronts your app on the public port. TLS termination -> and ACME certificate issuance are covered in the TLS guide (tracked in -> [#1860](https://github.com/madmax983/autumn/issues/1860)). +> **HTTPS/TLS.** kamal-proxy fronts your app on the public port and terminates +> TLS, so the app itself serves plain HTTP behind the proxy. TLS termination and +> ACME certificate issuance — including serving HTTPS directly from the app +> without a proxy — are covered in the [TLS & HTTPS guide](./tls.md). > **Version.** The `autumn deploy` subcommands (`check` / `plan` / `up` / > `rollback`) are newer than the `autumn-cli 0.5.0` pinned under diff --git a/docs/guide/tls.md b/docs/guide/tls.md new file mode 100644 index 000000000..1c08381f4 --- /dev/null +++ b/docs/guide/tls.md @@ -0,0 +1,256 @@ +# TLS & HTTPS + +This guide covers serving your Autumn app over HTTPS. There are three ways to do +it, and which one you want depends on where TLS is terminated: + +- **Direct in-process TLS** — the app terminates HTTPS itself on its own + host:port using a certificate and key you supply. Best for a **single host + where you already have a certificate** (from `certbot`, a corporate CA, or a + cloud cert vendor). +- **Automatic ACME (Let's Encrypt)** — the app obtains and renews its own + certificate over HTTP-01, with no static cert on disk and no proxy. Best for a + **single host that should get and keep a valid public certificate + automatically**. +- **Reverse-proxy / platform termination** — TLS is terminated in front of the + app (kamal-proxy, nginx, a cloud load balancer) and the app serves plain HTTP + behind it. Best for the **managed `autumn deploy` flow and multi-replica + deployments**, and still the recommendation when a proxy already fronts your + fleet. + +> **Quick decision.** Own cert, single host → [Direct TLS](#direct-in-process-tls-servertls). +> Want auto-issued certs, single host, no proxy → +> [ACME](#automatic-acme-certificates-servertlsacme). Using `autumn deploy`, a +> proxy, or multiple replicas → [Reverse-proxy termination](#terminating-tls-at-a-reverse-proxy). + +Direct TLS and ACME are both **off by default** and each gated behind an +off-by-default cargo feature (`tls` and `acme`), so a default build never links +the TLS stack. The reverse-proxy path needs neither feature — the app just +serves HTTP. + +--- + +## Direct in-process TLS (`[server.tls]`) + +With the `tls` cargo feature enabled and a `[server.tls]` section configured, +the app terminates HTTPS itself on the same host:port — no sidecar proxy. + +Enable the feature in your app's `Cargo.toml`: + +```toml +[features] +tls = ["autumn-web/tls"] +``` + +Then point the app at your PEM certificate and key: + +```toml +[server] +host = "0.0.0.0" +port = 443 + +[server.tls] +cert_path = "/etc/letsencrypt/live/app.example.com/fullchain.pem" +key_path = "/etc/letsencrypt/live/app.example.com/privkey.pem" +# reload_interval_secs = 60 # certs hot-reload by polling file mtimes (default) +# handshake_timeout_secs = 10 # TLS handshake timeout (default) +``` + +Fields: + +| Field | Default | Meaning | +|---|---|---| +| `cert_path` | required | PEM certificate chain (leaf first, then intermediates). | +| `key_path` | required | PEM private key for the leaf. | +| `reload_interval_secs` | `60` | How often the cert/key file mtimes are polled for a hot reload. | +| `handshake_timeout_secs` | `10` | Maximum time allowed for a TLS handshake. | + +Once it is running you should get a valid HTTPS response with no proxy in front: + +```bash +curl https://app.example.com/health # -> {"status":"ok", ...} +``` + +### Fail-fast startup validation + +The certificate is validated at startup, so a broken cert stops the boot instead +of silently serving an unusable listener. Startup fails fast on: + +- a missing or unreadable `cert_path` / `key_path` file, +- an unparseable or empty PEM, +- a private key that does not match the certificate leaf, +- an expired or not-yet-valid leaf or intermediate. + +### Hot reload (renewals without a restart) + +The app polls the cert and key file mtimes every `reload_interval_secs` (default +60s) and swaps in the new material when either file changes. A certificate +renewal — from `certbot`, an ACME client, or any tool that rewrites the PEM +files in place — is picked up **without a restart and without dropping the +site**. + +### The `autumn doctor` TLS check + +`autumn doctor` gains a `tls` check that inspects the configured certificate: it +**fails** on a missing, invalid, or expired certificate and **warns** when the +leaf expires within 30 days — so an approaching expiry surfaces in CI or a +pre-deploy check rather than at the moment the cert lapses. + +### Renewing with certbot + +`certbot` pairs cleanly with direct TLS because it writes renewed certificates +back to the same paths, and the hot-reload picks them up. + +1. Obtain a certificate. The standalone authenticator answers the HTTP-01 + challenge on port 80, so run it while nothing else holds that port: + + ```bash + sudo certbot certonly --standalone -d app.example.com + ``` + + certbot writes the live certificate to + `/etc/letsencrypt/live/app.example.com/fullchain.pem` and the key to + `.../privkey.pem` — exactly the paths used in the `[server.tls]` sample above. + +2. Renewal is automatic. certbot installs a systemd timer (or cron job) that runs + `certbot renew` twice daily and rewrites `fullchain.pem` / `privkey.pem` in + place when a certificate is within its renewal window. Because the app polls + the file mtimes, the renewed certificate is served on the next poll with **no + restart and no dropped requests** — you do not need a `--deploy-hook` to + reload the app. + + ```bash + sudo certbot renew --dry-run # verify the renewal path works + ``` + +If you would rather not run certbot at all, the app can obtain and renew its own +certificate — see [Automatic ACME certificates](#automatic-acme-certificates-servertlsacme) +below. + +### Local development certificates (mkcert) + +For `https://localhost` in development, [`mkcert`](https://github.com/FiloSottile/mkcert) +generates a certificate signed by a locally-trusted CA, so your browser accepts +it without warnings. + +1. Install the local CA once (adds it to your system/browser trust store): + + ```bash + mkcert -install + ``` + +2. Generate a certificate for your dev hostnames: + + ```bash + mkcert localhost 127.0.0.1 ::1 + # writes ./localhost+2.pem and ./localhost+2-key.pem + ``` + +3. Point a dev config (or a `[profile.dev]` override) at the generated files: + + ```toml + [server.tls] + cert_path = "localhost+2.pem" + key_path = "localhost+2-key.pem" + ``` + +You can now load `https://localhost:` with a trusted certificate. Keep the +generated `*.pem` files out of version control — they are per-developer and the +key is a secret. + +--- + +## Automatic ACME certificates (`[server.tls.acme]`) + +With the `acme` cargo feature, the app provisions and renews its own TLS +certificate from an ACME certificate authority (Let's Encrypt by default) over +the HTTP-01 challenge — no static certificate on disk and no reverse proxy. It +builds on the `tls` listener: the issued certificate hot-swaps into the same +reloadable resolver `[server.tls]` uses. + +Enable the feature: + +```toml +[features] +acme = ["autumn-web/acme"] +``` + +The happy path is ≤10 lines of config: + +```toml +[server] +host = "0.0.0.0" +port = 443 + +[server.tls.acme] +domains = ["app.example.com"] +contact_email = "admin@example.com" +directory = "production" # omit for Let's Encrypt STAGING (see below) +``` + +On first boot the app answers the ACME HTTP-01 challenge on `http_challenge_port` +(default `80`), obtains a certificate for `domains`, and starts serving HTTPS. +That same `:80` listener also **redirects plain HTTP to HTTPS**, so visitors who +hit `http://` are upgraded automatically. + +Fields: + +| Field | Default | Meaning | +|---|---|---| +| `domains` | required | One or more non-wildcard domains to issue for. | +| `contact_email` | required | Contact address registered with the ACME account. | +| `directory` | Let's Encrypt **staging** | ACME directory: `production`, or a custom directory URL. | +| `cache_dir` | `config/acme` | Where the account key and issued certificate are cached. | +| `http_challenge_port` | `80` | Port the HTTP-01 challenge (and HTTP→HTTPS redirect) listens on. | +| `renew_before_days` | `30` | Renew this many days before expiry (must be `< 90`). | + +> **Staging is the default — switch to production deliberately.** When +> `directory` is unset the app uses the **Let's Encrypt staging** environment, +> which issues certificates from an untrusted test CA (browsers will warn) but +> has **very generous rate limits**. Staging-first is intentional: Let's +> Encrypt's production environment enforces strict issuance rate limits, and a +> misconfiguration loop (wrong domain, port 80 unreachable, DNS not pointed yet) +> can burn your production quota for a week. Validate end-to-end against staging, +> confirm the challenge succeeds, then set `directory = "production"` to get a +> publicly-trusted certificate. The `renew_before_days` window (default 30) keeps +> renewals well ahead of the 90-day certificate lifetime so a transient failure +> has many days of retries before anything expires. + +### How issuance and renewal work + +- **Provisioning** is over HTTP-01: the `:80` listener serves the challenge + token, the CA validates it, and the issued certificate is cached under + `cache_dir` (default `config/acme`) and swapped into the live resolver. +- **Renewal** runs on a coordinator-**leader-elected** loop that wakes hourly and + renews any certificate within `renew_before_days` of expiry — so in a + clustered deployment exactly one instance renews, and the refreshed certificate + hot-swaps in with no restart. +- **Mutual exclusion.** ACME and static `cert_path` / `key_path` are mutually + exclusive — configure exactly one. Set `[server.tls.acme]` to auto-issue, or + `[server.tls]` with `cert_path` / `key_path` to serve your own certificate. + +### Scope + +This is a **single-host** slice. Wildcard certificates and the DNS-01 challenge +are out of scope (tracked in +[#1620](https://github.com/madmax983/autumn/issues/1620)). For multiple replicas +behind a shared entry point, terminate TLS at the proxy instead — see below. + +--- + +## Terminating TLS at a reverse proxy + +If a reverse proxy or platform already fronts your app, terminate TLS there and +let the app serve plain HTTP behind it. This is the right choice for the managed +`autumn deploy` flow and for multi-replica deployments behind a shared load +balancer. + +The push-button [`autumn deploy`](./deployment.md#push-button-deploy-to-your-own-server-autumn-deploy) +path already works this way: **kamal-proxy fronts your app on the public port and +terminates TLS**, while the app binds a private loopback port and serves HTTP. +In this topology the app needs **neither** the `tls` **nor** the `acme` cargo +feature and **no** `[server.tls]` section — the proxy owns the certificate. + +The same applies to any terminating proxy (nginx, Caddy, a cloud load balancer): +point the proxy's certificate at the public `https://` port and forward to the +app's HTTP port. For the full `autumn deploy` walkthrough, fly.io, and +container-based deployment, see the [deployment guide](./deployment.md). diff --git a/skills/autumn-patterns/SKILL.md b/skills/autumn-patterns/SKILL.md index 6aa3ca7ac..182a561e2 100644 --- a/skills/autumn-patterns/SKILL.md +++ b/skills/autumn-patterns/SKILL.md @@ -22,9 +22,12 @@ check the "Prefer framework idioms over raw Diesel/Axum" table in When a model has a status/phase column with legal transitions, declare them with `#[state_machine(transitions(...))]` on the field and enforce with the generated `transition_{field}_to` in `before_update` — never write your own -`match (old, new)` validation in hooks or handlers. See -`docs/guide/state-machines.md` and the worked example in -`skills/autumn-web/references/examples.md`. +`match (old, new)` validation in hooks or handlers. To reuse one transition +graph across fields/models, define a `#[lifecycle]` enum and reference it with +`#[state_machine(lifecycle = Enum)]` (trunk-dev, #1916); `autumn lifecycle +check` statically verifies soundness and `autumn lifecycle diagram` emits a +DOT/Mermaid state diagram. See `docs/guide/state-machines.md` and the worked +example in `skills/autumn-web/references/examples.md`. ## Testing with TestApp and TestClient diff --git a/skills/autumn-web/SKILL.md b/skills/autumn-web/SKILL.md index 338b43fe0..e9b35af55 100644 --- a/skills/autumn-web/SKILL.md +++ b/skills/autumn-web/SKILL.md @@ -38,12 +38,20 @@ when their details matter: CRUD, production-ish jobs, Redis channels, S3 storage plugins, and signed webhooks. Use this before generating full app code. - `docs/guide/accessibility.md` - accessible-by-construction UI. Prefer the - typed `autumn_web::a11y` primitives (`Img` / `Button` / `Link` / `MenuItem` / - `TextField`) over raw `` / `