v0.7.3
What's new
- Snippets beside generators.
cargo offrails snippetslists small, tested recipes (an Atom feed, slugs, Markdown, pagination, a theme toggle, backdated seeds, a public page by slug, a CRUD resource);snippet showprints one to read or copy,snippet addcopies and wires it,snippet removetakes it back. Both snippets and generators are optional starting points. - Generated code is small files with clear insert and remove points. A resource is a folder per layer (controllers per action pair, the model's entity, params and queries, a routes file, tests by concern), and every line it adds to a shared file is marked with the feature's name, so
destroyor a person can find and remove it. Path parameters work ing controllerandg page. - Polish from one file and a component kit.
brand.tomlwithcargo offrails brandsets the colors (contrast-checked), fonts, radius, logo, favicon set and social card; five layouts (top nav, sidebar, marketing, auth, docs); a kit of Askama components (forms with errors, tables, pagination, cards, modals, a confirm dialog, toasts) shown in every state at/dev/components. Generated and snippet pages are built from the kit, in a layout you pick with--layout. cargo offrails prove. With the app running, it screenshots every page at desktop and phone width in light and dark, checks console errors, CSP, accessibility (axe), links and sideways scrolling, and writesdocs/proof/PROOF.md, which ties each acceptance criterion to its passing tests and the pages they touch.- Invite-only sign-up is a setting.
DEFAULT_SIGNUPinsrc/config.rs(orAPP_AUTH_SIGNUP=open|invite) closes sign-up for every sign-in method, andapp task auth_invite <email>lets people in;cargo offrails new … --invitestarts an app that way. - Security review fixes (template, tooling and guides): an unset
APP_ENVnow refuses to boot instead of defaulting to development;APP_TRUSTED_PROXY_HOPSpicks whichX-Forwarded-Forhop is the client behind several proxies;/staticmarks a URL immutable only when its?v=is the file's current digest;Cache::delete_prefixis one escapedLIKEdelete;cargo offrails verifynames every skipped step in its verdict and never probes a local Postgres;--pitchis validated like the title; the Claude Code hook guards canonicalize paths and matchpkillbehind paths, quotes and subshells, and fail closed without jq. Guides corrected: sea-orm-migration takes no lock, so exactly one process migrates per deploy; every proxy needsAPP_TRUSTED_PROXY=true; blob downloads areno-store; API tokens die with a password reset and expire by default; webhook routes verify signatures; search escapesLIKEwildcards; the realtime guide says plainly that its signals/streams design is a spec the parts do not implement yet. - Fixes from real builds and upgrades: a huge
?page=no longer crashes generated lists, an upgraded app no longer runs the previous release'scargo offrails,verify --fullaccepts a separate coverage toolchain, the browser smokes find any password sign-in form, the Dockerfile stops compiling dependencies twice, andoffrails-checkwarns abouthx-triggerfilters that the CSP setup silently disables.
Breaking changes
- Generators and
snippet addneed two 0.7.3 template changes first, and refuse (naming what's missing) without them: the feature routes anchors insrc/routes.rs(ROUTESbecomesAPP_ROUTES+TABLES; steps 3–4 below), and the component kit (templates/components/ui.htmland its helpers; step 6). - The generated file layout changed. New generations write small files per feature;
destroyof aresource,model,controller,pageorapigenerated before 0.7.3 refuses and lists its files (delete them by hand, thendestroy … --forget). - The
ACCOUNTSheader has new words: sign-in methods joined by+, theninvite(signup→password,invite→password invite,magic-link-invite→magic-link invite).add-partreads the old ones; rewrite the line (step 8). - Build the new release's offrails-gen in its own target directory (
target/offrails-gen-<version>) when upgrading, nottarget/offrails-gen. APP_ENVis required.app …fails withAPP_ENV is not setwhen neither the environment nor.envsets it. Every server already setsAPP_ENV=production(the Dockerfile does); locallybin/setupwritesAPP_ENV=developmentto.env. CI scripts that ran the binary without it must set it.Configgainstrusted_proxy_hops: u8(default 1);Config::boot_warningsno longer warns about an unsetAPP_ENV, andenv_defaultedis alwaysfalse.- No MSRV or toolchain change.
Upgrading
Follow the numbered steps below in order (each names the entry whose App migration has the details), and upgrade for the whole procedure (/offrails:upgrade runs it).
Upgrading an app to 0.7.3, in order (each step's Detect/Converge/Prove is in the entry it names below; upgrade is the whole procedure). $FW is the new framework. Build the new release's offrails-gen in its own target directory, --target-dir "target/offrails-gen-$(cat "$FW/VERSION")", never target/offrails-gen (From a real app's upgrade, below).
- Parts first, before re-vendoring, as in 0.7.2:
cargo offrails update-part <each installed part> --framework "$FW" --dry-run, then without it. The parts are synced from samples2bc93ad(Parts below: invite-only as a switch,paths::after_sign_in()/account()in their tests, the mailbox, phone widths). An app on 0.7.1 or older runs the new release'soffrails-genfor this one step (upgrade step 6). - Re-vendor:
cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir "target/offrails-gen-$(cat "$FW/VERSION")" -- vendor --framework "$FW".cargo offrails snippets,snippet …, path params,--layoutandnew --invitenow work. - Feature routes files (Generators → App migration):
src/routes.rsgetsroute modsandroute tablesanchors andROUTESbecomesAPP_ROUTES+TABLES. Required before the firstgenerate resource|controller|page|apiorsnippet add(they refuse and name the anchor without it). - The route sweeps fill any
{param}, and the mounted-route check readssrc/routes/(same entry): required with the step above. samples: app jsat the end ofstatic/app.js(Snippets → App migration): required beforesnippet add theme-toggle.- The component kit (Polish and proof → App migration, Layouts and components, and Generators and snippets on the kit → App migration): required before
generate resource|controller|page(they refuse without it, naming the files) and beforesnippet add resource|pagination|public-show-by-slug.brand.toml, the other layouts andprovestay optional. - Template fixes (Template → App migration, and From a real app's upgrade → App migration): the
?page=clamp (do this one: every generated index answers a huge page with a 500 otherwise), the pending-migration and seed tests, the AGENTS.md placeholder,COPY .cargo .cargoin the Dockerfile's dependency layer, the.scrollbox instatic/kernel/admin.css. Each is independent. - The
ACCOUNTSheader (Accounts → App migration): rewrite it in the new grammar (signup→password,invite→password invite,magic-link-invite→magic-link invite); an invite-only app setsDEFAULT_SIGNUP = Signup::Invite(after step 1 brought auth-core's switch). - Generations recorded before 0.7.3 keep working as code;
destroyof aresource/model/controller/page/apifrom before refuses and lists its files (Generators → App migration). - Optional polish (Polish and proof → App migration):
brand.toml(cargo offrails brand --init), the layouts, andcargo offrails prove, whichverify --fullruns whenBASEis set. - Security review (Security review fixes, above): Add
trusted_proxy_hops: 1to any hand-builtConfigliteral (Config::test()has it). SetAPP_TRUSTED_PROXY_HOPSwhere more than one proxy appends toX-Forwarded-For. Replace thean_unset_app_env_warns_at_bootunit test withan_unset_app_env_refuses_to_bootintests/app/kernel_test.rs(config.md shows it). - Prove it:
cargo offrails verify --fullis green on SQLite and Postgres,sh .offrails/tools/offrails-checkreports 0 errors,BASE=… cargo offrails provesays proven, anddocs/decisions.mdhasYYYY-MM-DD: upgraded to framework 0.7.3.
Full list of changes
-
Snippets: small, tested code to copy (snippets). The human, after a timed blog build on 0.7.2 (43 min, most of it friction, ~2,240 hand-written lines): "make generators kinda optional, i guess i still prefer snippets to generators... or generators that have good insertion or removal points. ideally multiple smaller files." The blog had hand-written (or fought) a feed, slugs, Markdown, the pager, a theme button, backdated seeds and a public page by slug. Each is now a snippet in
template/snippets/<name>/: a README (what, when, the files, the anchors it wires into, how to remove it), asnippet.tomlin a part'ssample.tomlformat (plusas, the model--asrenames, andsnippets, the snippets it builds on) andfiles/at app paths.- The eight:
resource(htmx CRUD fornotes, public: exactly the filesg resource notes title:string body:text --publicwrites; a test in offrails-gen keeps the two identical),atom-feed(GET /feed.xml, escaped by Askama, RFC 3339 dates, newest 20,<link rel="alternate">on every page),slug(slugify, accents folded, 80 chars;unique(base, query):-2,-3…, per owner or global),markdown(pulldown-cmark + ammonia; tests for<script>,on*=,javascript:, iframes, styles),pagination(Pagerandshared/_pager.html,perkept, the?pageclamp),theme-toggle(an app-side partial and one delegated handler instatic/app.js; no-JS POST to/theme; a test that the script and the server agree on the cookie),seed-backdated(backdate::<E>(db, id, days_ago(30))after the model'screate; it needs no resource: its test makes a table of a generated table's shape, so it adds to an app whose resources are generated and owner-scoped, which refused before because it required the resource snippet, found by the 0.7.3 blog build),public-show-by-slug(GET /read/notes/{slug}: a route with a path param end to end, a nullable uniqueslugcolumn, slugs that never move; it builds on the resource snippet's public notes, and its README says how to do the same for a generated owner-scoped resource, which it doesn't take). - The CLI:
cargo offrails snippets(list, with what each needs and whether it's in),snippet show <name>(the README, every file, every wire: to read or copy by hand),snippet add <name> [--as <model>] [--dry-run],snippet remove <name>.addis a recorded generation (kindsnippetindocs/generated.toml): checked first (anchors, files in the way, the parts and snippets it needs), re-adding is a no-op,removedeletes the unchanged files and the marked lines.--as articlerenames on word boundaries (note/notes/Note/Notes:NoteParams,/notes/{id}, "Note was saved"), one-word names. - CI:
tools/ci/test-template.sh snippetsadds every snippet (resource twice: as notes and--as article) into a bare app and into an app with every part, runs fmt, clippy, the suite on SQLite and Postgres and offrails-check, re-adds each (no-op), removes them all in reverse and checks the app is byte-for-byte the baseline; it refuses a shipped snippet missing from its list. - App migration: Detect:
grep -c "samples: app js" static/app.jsis 0. Converge: add// samples: app jsas the last line ofstatic/app.js(its comment words must passpublic_scripts_have_no_comment_words_zap_flags: the template's line above it reads "More behaviors (snippets, your own) go above this line, each one delegated listener."). The other snippets need only the 0.7.3 routes anchors (Generators below). Prove it:cargo offrails snippet add theme-toggle --dry-runlists its wires and refuses nothing.
- The eight:
-
Generators: optional, small files per feature, clean insertion and removal points (generators). AGENTS.md prime directive 2 was "Generate before you hand-write"; it is now "Start app code from a snippet or a generator (both tested, both yours once copied); hand-write the rest." Neither is mandatory. The same message in CHEATSHEET, workflow (the loop's PLAN and START steps), new step 8, the app AGENTS.md blueprint, the
feature,generate,scaffoldandnewskills, RAILS_MAP, template, cli; no doc says "generate before you hand-write" any more.- The split, and why this one. A resource used to be one 400–900-line controller, a 300–500-line model, one 250-line request test, and rows inserted into the big
src/routes.rs. Now (g resource posts …):src/controllers/posts/{mod,index,show,new,edit,destroy}.rs(one file per action pair:new.rsis new + create andedit.rsis edit + update, because each pair shares its form, its 422 re-render and its imports, and splitting them would duplicate both);src/models/post/{mod,params,queries}.rs(the entity and its relations; the untrusted input and its validation with its unit tests; the queries, which are what a reviewer audits for scoping);src/routes/posts.rs(its router,paths::helpers andROUTESrows);src/views/posts.rs(one file: the page structs are small);templates/posts/*;tests/app/posts/{mod,read_test,write_test,access_test}.rs(read, write, and who-may: the access tests are the security review's);tests/app/factories/post.rs. Each file is under ~150 lines for a typical model (a 12-field model'sparams.rsis longer: validation is per field). Rejected: one file per action (seven files for one form, imports repeated seven times), and per-feature crates (one crate is one compile unit; a workspace per feature would multiply link times). Callers see one module either way (post::find_for,post::PostParams,factories::post). - Routes per feature.
ROUTESis nowAPP_ROUTES(the kernel's and the parts' rows, as before) followed by each feature's table, listed inTABLESand concatenated at compile time by twoconst fns, so it stays one&[Route]that every test,app routes, docs/routes.md and the parts' own tests iterate unchanged. Generators (and snippets) wire a feature with one line at each ofsamples: route mods,routes,pathsandroute tables.g controller,g pageandg api(the API kernel's routes in a sharedsrc/routes/api.rs, each resource insrc/routes/api_v1_<p>.rs) do the same.g pageis one set of files per page (src/controllers/pages/<page>.rs,src/views/pages/<page>.rs,tests/app/pages/<page>_test.rs) instead of functions appended to shared files. - Every wiring line names its owner. A one-line statement or declaration ends with
// posts({# posts #}in HTML); a list element, field, match arm or block gets a// postsline above it (rustfmt aligns a comment that follows a trailing one in a list, and drags it onto the anchor). Declarations (pub mod x;,pub use …;) are inserted in sorted position, because rustfmt reordering a marked last line moves its comment onto another line (found by the CI:pub mod recurring; // notify_post_published).destroy, and a human, find a feature's lines by name; adding touches a shared file only through these one-line inserts (two for a factory, a few in the tenancy suite and the parent model'shas_many). Golden files and the byte-identical-after-destroy CI keep both guarantees. - Path params for
g controllerandg page:g controller blog index show:/blog/{slug}(or--path /blog/{slug}for one action),g page profile --path /people/{handle}. The handler takesPath(slug): Path<String>({id}and{…_id}arei64; several: a tuple), the page struct gets the field (escaped by Askama), the helper ispaths::blog_show(slug: &str), the test requestspaths::blog_show("example")and finds it on the page. A page with a parameter gets no sitemap entry; a signed-in page with one is listed in the tenancy suite'sNOT_TENANT_IDS.--navrefuses a first action with parameters. - The generated HTML is in two files,
tools/offrails-gen/src/generate/files/html/resource.htmlandpages.html(one section per piece: the index, its header and cells, show and its rows, new, edit, the form, each control, the nav link, the controller and page templates), with__NAME__tokens, instead of format strings inside the generators. Swapping the markup is editing those two files; the golden files show the result. The split itself was byte-identical (the goldens didn't move); the kit came next (Generators and snippets on the kit, below). - Other generator fixes: a generated boolean checkbox has its
dark:partner (accent-indigo-600 dark:accent-indigo-400); every generated index has a test that a huge?page=answers 200; the next steps say to commit right after the run. - App migration:
- Feature routes files. Detect:
grep -c "samples: route mods" src/routes.rsis 0. Converge: from.offrails/template/app/src/routes.rs, copy the// samples: route modsblock (after theuselines),pub type Route,pub const ROUTES(theconcatline),TABLESwith// samples: route tables, and the twoconst fns at the end; rename the app's existing tablepub const ROUTES: &[(&str, &str, &str, Access)] = &[toconst APP_ROUTES: &[Route] = &[(its rows and its// samples: routes tablestay). Prove it:cargo run -q -- routeslists the same rows as before, andcargo nextest run security_test smoke_testpasses. - Route sweeps. Detect:
grep -c "src/routes" tests/app/security_test.rsis 0. Converge: takeconcrete()(any other{param}becomesexample, or1for an id) andevery_mounted_route_is_in_the_routes_table(it also reads everysrc/routes/*.rs) from the template'stests/app/security_test.rs, and makesmoke_test.rs'severy_listed_route_is_routable_in_developmentcallcrate::security_test::concrete(path). Prove it: the two tests pass; after ag controller x show:/x/{slug}, both still pass. - Generations recorded before 0.7.3. Detect: entries in
docs/generated.tomlof kindresource,model,controller,pageorapiwithoutlayout = "2". Converge: nothing, if you keep them (the code is yours; it compiles as it is). To remove one,destroyrefuses with its file list: delete those files and its lines in the shared files by hand, thencargo offrails destroy <kind> <name> --forgetdrops the record. Migrations, jobs, mailers, channels and tests from before destroy as they did. Prove it:cargo offrails g resource …of a new feature writes the split layout, anddestroyof it leavesgit statusclean. - Agents. Log in
docs/decisions.md:YYYY-MM-DD: snippets and generators are optional starting points (framework 0.7.3); features are small files per feature with owner-marked wiring.
- Feature routes files. Detect:
- Changed code:
tools/offrails-gen(generate/feature.rsroutes files and path params,generate/snippet.rs,plan.rsmarkers and sorted inserts,model.rs/resource.rs/controller.rs/page.rs/api.rsthe split,routes_md.rsreading every routes file, golden files for path params and a test that the resource snippet is the generator's output).
- The split, and why this one. A resource used to be one 400–900-line controller, a 300–500-line model, one 250-line request test, and rows inserted into the big
-
The blog build's friction, fixed in the framework (the list from the 0.7.2 rebuild; the samples-repo items, invite-only auth, the parts' hard-coded
/account, the dev mailbox's "CODE 2026" and the AI briefing card, came with the parts sync below):- Template:
Pageclamps?page=to 1..=Page::MAX_PAGE(100 000):?page=18446744073709551615overflowed(page − 1) × per, a panic and a 500 on every generated index (unit testa_huge_page_is_clamped_not_overflowed; generateda_page_past_the_end_is_empty). The production pending-migration test requests/robots.txt, not/(an app's home that reads a generated table broke it).tasks_run_by_name_and_refuse_unknown_onesrunsseedunder a development config (seeds may refuse outside development).static/app.jsends with thesamples: app jsanchor. new: the generated AGENTS.md no longer shows the raw{{TS modes only…}}placeholder;--pitchalso replaces the home page's sentence, the sentence forms (description, home) are capitalized with their period, and the tagline is one value, brand.toml'stagline(the pitch capitalized, without its period), whichconfig::APP_TAGLINEnow is (pub const APP_TAGLINE: &str = crate::brand::TAGLINE;), so the home page title, the social card and a nav that shows the tagline agree (--pitchwrote the pitch as typed, often lowercase, intoAPP_TAGLINEbeside brand.toml's capitalized sentence, which nothing but the social card read; found by the 0.7.3 blog build; unit tests check the template still has every token andAPP_TAGLINEis the brand's); a pitch with quotes, braces or angle brackets is refused; on macOS it writestarget/.metadata_never_indexso Spotlight doesn't index build output.- Browser smokes:
smoke-dev-tools.mjsaccepts the dev mailbox's test button landing on the message it sent (dev-mailbox 0.7.2+) as well as on the list;smoke-dev-tools.mjsjudges Clear all, Back and Forward by the cleared messages' ids, not by an empty list (inverify --fullwith an auth part, the other smokes' sign-ins send "New sign-in" emails at the same moment, and Back/Forward failed on them; found verifying a 0.7.3 app with every auth part);run.shcopies each run's scripts into its own directory (parallel smokes overwrote a sharedlib.mjswhile another read it) and exits 77 (skip) when Node is older than 22.12;verifyreports the kernel-page smoke and the a11y smoke as skipped on 77, never failed or passed. The a11y smoke finds axe-core the way Node findspuppeteer-core, from the nearestnode_modulesup (the cache):lib.mjsread./node_modules/axe-corenext to itself, which the per-run copy doesn't have, soverify --full's "browser a11y" failed with ENOENT on every app (found by the 0.7.3 blog build);test-template.sh provenow runsrun.sh a11ywith a scenario, asverifydoes. The puppeteer smokes (run.sh dev-tools | live-tabs | login | a11y) find Chrome the same way as the CDP smokes (cdp.mjs:CHROME_PATH/CHROME, else the usual places on macOS, Linux and Windows) instead of defaulting to the macOS path, and skip (exit 77) when there is none. - Coverage with Homebrew's rust:
verify --fullpoints cargo-llvm-cov at Homebrew'sllvm(LLVM_COV,LLVM_PROFDATA) when the activerustc's sysroot has nollvm-tools(rustc --print sysroot, thenlib/rustlib/<host>/bin/llvm-cov) and they aren't set (testing). Deciding by whether rustup exists, as first written, missed the common case found by the 0.7.3 blog build: rustup installed but Homebrew'srustcfirst on PATH, where llvm-cov failed until both were set by hand (unit testllvm_tools_are_looked_for_in_the_active_sysroot). - Flaky tests show why:
verifyruns nextest at--status-level retry, so a test that fails and then passes on retry keeps its failed attempt's output intarget/verify/tests-*.log(before, only theFLAKYline), and the template CI prints that output when a flaky test is not a known one. - Docs:
SMOKE_EMAIL/SMOKE_PASSWORD(andSMOKE_PATHS) in the Definition of Done table; theassert_textapostrophe gotcha (it escapes like Askama, so literal template text needsassert_contains; testing, CHEATSHEET 26); one span rule everywhere (skip_allon actions and public model fns,level = "debug", skip_allon per-row helpers; errors-and-logging, CHEATSHEET 27); commit after each generator or snippet run (workflow, generators); how a sub-agent hands a running dev server back (detached withnohup, its PID file and log, cli); upgrade step 6's 0.7.1 caveat forupdate-part; authentication rewritten around the auth parts (what they do, what's left: invite-only, roles, teams), and new (steps 2–3 and Tests) and auth point at the parts too: they still saidauthentication.mdbuilds the guard and tenancy suites (the first owner-scopedg resourcewrites them) and "generates" accounts (found by the 0.7.3 blog build); routing, structure, models, testing and controller for the new layout. - A framework checkout is vendored from HEAD, and says so. new §1 told agents to export "the tag matching
VERSION", which on a release branch before tagging is the previous release (the 0.7.3 blog build vendoredgit archive 044d7ebby hand and logged why).new,vendorandupdate-partnow handle a--framework/OFFRAILS_FW(or the generator's own root) that is a git checkout themselves: they use HEAD's committed files (git archive HEAD | tar -x, astools/release/release.shbuilds the tarball), never the working tree (uncommitted changes are named and left out), andnew/vendorwrite the commit as.offrails/VERSION's third line and in thenewlog line:from git v0.7.3 (<sha>)at the tag,from git <sha> (unreleased: HEAD is not the v0.7.3 tag; VERSION names the last release)past it.VERSION's first line (and theFRAMEWORKheader) stay the version, sooffrails-checkandupgrade --checkread it as before; release installs (the plugin,~/.offrails/current, a tag export: no.git) are copied as before, with nothing added.--worktreeuses the working tree as it is, recorded as such:test-template.shpasses it, since CI tests uncommitted changes (unit testa_framework_checkout_is_vendored_from_head_with_its_commit; upgrade). - CI:
test-template.shtakesOFFRAILS_CI_TAG(a suffix for the apps' crate names, so two checkouts' runs on one Postgres server never share test databases) andOFFRAILS_CI_PORT(the smokes' first port). - App migration:
- The
?pageclamp. Detect:grep -c MAX_PAGE src/models/mod.rsis 0. Converge: inPage, addpub const MAX_PAGE: u64 = 100_000;and makenumber()self.page.unwrap_or(1).clamp(1, Self::MAX_PAGE); copy the template'sa_huge_page_is_clamped_not_overflowedtest. Prove it:curl -s -o /dev/null -w '%{http_code}' "$URL/<a list>?page=18446744073709551615"prints 200. - Tests that assume the home page and the seed. Detect:
grep -n 'app.get("/").await.assert_ok()' tests/app/kernel_web_test.rsinsideproduction_with_a_pending_migration_is_unhealthy_but_serves, andTestApp::new()intasks_run_by_name_and_refuse_unknown_ones. Converge:/robots.txtthere;TestApp::with_config(Config { env: AppEnv::Development, ..Config::test() })here. Prove it: both pass. - AGENTS.md. Detect:
grep -c "TS modes only" AGENTS.md. Converge: delete{{TS modes only: ", or :5173 via Vite"; htmx: nothing}}(htmx), or replace it with, or :5173 via Vite(inertia, spa). Prove it:grep -c "{{" AGENTS.mdis 0. - The smokes,
verifyand the docs arrive with re-vendoring; nothing to converge.
- The
- Template:
-
Polish and proof: one file rebrands the app, five layouts, a component kit, and
cargo offrails prove(views, testing). The human: "make it quick and easy to polish an app ... layout, branding ... components ... FOR AI. how to make agents move fast and precisely, and test, take screenshots, build a guide proving what they build works."brand.toml+cargo offrails brand(std-only, inoffrails-gen;newruns it). Name, tagline,primary/accent/neutralcolors, optionalon_primary, font stacks, radius, and the logo (a 1–2 character mark or a square SVG). It rendersassets/brand.css(Tailwind@theme:primary-*andaccent-*scales built in OKLCH with 600 exactly the brand color,gray-*= the neutral,indigo-*= primary so parts and older templates follow,on-primary,primary-hover, fonts, radius; semantic--brand-*tokens for light and dark),src/brand.rs(NAMEis nowconfig::APP_NAME;THEME_COLORfeeds the theme-color meta;brand::email::*colors, and the mailer part's header is pointed at them),static/favicon.svg, and the social cardassets/brand/og.svg. Before writing anything it checks 16 text/surface pairs the components use (body, muted, links, badges, button text and its hover; light and dark) against WCAG AA 4.5:1 and refuses with the pair, its ratio and a color that passes; the scales are built so that the shades holding text pass for any base color (a yellow brand gets near-black button text). PNGs (favicon.ico,apple-touch-icon.png,icon-512.png,og.png) are rasterized by the installed Chrome/Chromium/Edge (headless) orrsvg-convert; with neither the SVGs are current and it says the PNGs aren't (no image crate: a text mark needs a font rasterizer).--checkfails when a generated file is stale (verify --fullruns it).tools/brand/render.shis superseded (kept for apps without abrand.toml;assets/brand/og.htmlleft the template). 24 unit tests (color math against WCAG reference values, scales, contrast refusals and fixes, the TOML subset, outputs, the ICO container, rustfmt-stablebrand.rs).- Layouts (
templates/layouts/):application.htmlis now the shell (head, skip link, icon sprite, overlay slots, dev footer) whosebodyblock is the top-nav layout;sidebar.html(folds behind Menu on a phone),marketing.html(hero, nav, CTA, footer),auth.html(centered card),docs.html(table of contents beside the article). One<head>for all of them (boosted links), a skip link to<main id="main">, landmarks, dark mode, no sideways scroll at 360–390 px. Pages pick one with{% extends %}. - Component kit (
templates/components/ui.html, one import): button (variants, sizes, icon, disabled), link_button, icon_button, confirm_button, icon; field (text/email/password/…), textarea, select, checkbox, radios (labels, hints, errors witharia-invalid/aria-describedby, values kept on a 422, passwords never echoed), form_actions; alert, flash, toast/toast_oob; card, badge, stat, table (caption, sortable header links witharia-sort, empty state), pagination (helpers::Pager), empty_state, avatar; breadcrumbs, page_header, nav_tabs, tabs/tab_panel, dropdown/menu_item; modal (native<dialog>: inert page, focus kept inside, Esc, focus returned), modal_button, modal_link (htmx-loaded, opens itself, leaves when closed), modal_close, and the layout-wide confirm dialog for everydata-confirmandhx-confirm. Styles are component classes inassets/app.css(brand tokens, light + dark); behaviors arestatic/components.js(delegated; thedata-confirmhandler moved there fromapp.js, which now keepsaria-expandedin step fordata-toggle). Every component in every state at/dev/components(?theme=light|dark), each layout at/dev/layouts/<name>(development only,Access::Dev); guides/views.md has the signatures as one table. - Tests:
theme_testrenders the gallery and the layout demos and checks every class list for itsdark:partner and its rule in the built CSS;components_test(skip link, landmarks and one<head>in every layout, the 422 keeps values, sort and pagination, development only);tools/browser-smoke/smoke-components.mjsdrives the modal (Tab, Esc, focus return), the htmx modal, the confirm dialog (Cancel and Confirm) and the 422 on a full load and after a boosted link; the boosted smoke covers the gallery's dropdowns, tabs, toggles and dismiss buttons. offrails-check [classes](a warning, never an error): a<button>,<input>,<select>,<textarea>or button-like<a>with more than eight utilities (CLASS_LIST_MAX) in an app template outsidetemplates/components,kernel,mailers;{# offrails-check: classes ok #}on the line silences it. A line an installed part ships as it is (in its copy of the same file in.offrails/template/parts/<part>/files/, crate names aside: the baseupdate-partmerges from, or in a part's wiring insample.toml, since parts wire into each other's templates, e.g.shared/_auth_nav.html) is skipped and counted (skipped N line(s) the installed parts ship): the auth parts' ownpassword_sessions/_confirm.html,_form.html,passwords/_new_password.html,registrations/verify.htmlandsecurity/not_me.htmlwarned in every app that installed them (found by the 0.7.3 blog build); moving them onto the kit is a samples change. A line the app wrote or edited is checked. Intools/ci/test-offrails-check.sh(a part's unmodified line, an edited one, and a wired one).cargo offrails prove(offrails-genlaunchestools/browser-smoke/prove.mjs: Node ≥ 18 and the installed Chrome over the DevTools Protocol, the driver and browser discovery every smoke shares (tools/browser-smoke/cdp.mjs, below), no npm install; exit 77 without them). It starts nothing: withBASEset it visits every GET page ofdocs/routes.md(plusSMOKE_PATHS, minusSMOKE_SKIP) as the visitor who can see it (signed in as the demo account when a page sends you to sign in), takes full-page screenshots at desktop 1280 and mobile 390 in light and dark, and fails on console errors, uncaught exceptions, CSP violations, axe-core violations (WCAG 2.2 AA, light and dark; the a11y smoke's pinned axe from the smokes' cache, installed there once if missing), broken same-site links and#anchors, and sideways scroll at 390 px (naming the elements that stick out; on kernel pages, which apps don't own, a warning unless--strict: its first run on the all-parts app found the dev dashboard's requests and lab pages and/admin/jobs,/admin/jobs/cronscrolling sideways on a phone, a samples fix to make upstream). Each screenshot is diffed against the previous run's (PNG decode/encode withnode:zlib, a per-pixel threshold): reported with a diff image, failing only with--strict. It writesdocs/proof/PROOF.md(result, checks, pages with their four screenshots and changes, and every acceptance criterion → the tests citing it with their result from the lastverify→ the pages they request with screenshots) andproof.json; PNGs are git-ignored unless--commit-screenshots.verify --fullrunsbrand --check, the component smoke andprovewhenBASEis set (a separatepolish_stepsblock); workflow.md's Definition of Done has the proof row and the final message links PROOF.md.- Measured (M2, 8 cores, other agents building on the machine): the bare app, 9 pages, 36 screenshots, axe on 18 loads: 13 s at load ~15, 20 s (first run) and 24 s (second run, with the diffs) at load ~50; the app with every part, 63 pages (57 signed out, 6 signed in as the demo account), 252 screenshots: 165 s and 216 s at load 50–70. Its second run reported 82 changed screenshots out of 252 (the dev dashboard's live charts, timestamps, uptime): reported, not failed, as designed.
- CI:
tools/ci/test-template.sh prove(its apps' crate names takeOFFRAILS_CI_TAGand its two servers the ports after the smokes',OFFRAILS_CI_PORT+ 2 and + 3, like the other targets) generates the bare app and the app with every part, adds a fixture feature whose two criteria cite request tests, runsverify(SQLite + Postgres) and offrails-check, serves each in development, runs the component smoke andprovetwice, and checks that PROOF.md says PROVEN, has four screenshots per page, tracesAC-home-1to its passing test and to/, that the second run compared screenshots, and that the PNGs are git-ignored. Its fixture also has a helper whose doc comment citesAC-home-2, which PROOF.md must not list:provecounted the firstfnafter any AC id as a citing test, so handlers and helpers whose comments name a criterion (session_deleteinsrc/routes.rs, queries, module docs, string literals in the dev dashboard's tests) showed up as its tests (found by the 0.7.3 blog build). In Rust it now counts only test functions: a test attribute (#[test],#[tokio::test],#[sqlx::test],#[rstest], …) among the citation's attributes and comments, only those between it and thefn, and insrc/a#[cfg(test)]module (testing). Its apps use a sticky-footer layout (<body>a flex column,<main>w-full flex-1, as apps often make it), and/dev/componentsand every/dev/layouts/*page must be ok in PROOF.md (no sideways scroll at 390 px):/dev/components' own<main>hadmx-autowithoutw-full, so in such a layout it shrank to its content and the table's min-content made it 396 px wide at 390 px, whichprovecounts as a failure (a boosted page; found by the 0.7.3 blog build). It hasw-fullnow.
App migration (an app on 0.7.2; optional, but
verify --fullruns the new steps once re-vendored):- Brand. Detect: no
brand.tomlat the app root. Converge: re-vendor, thencargo offrails brand --init(writesbrand.tomlwith the name fromsrc/config.rsand the description fromsrc/views/mod.rs, without its period, as the tagline); put the app's tagline in brand.toml'stagline(the one inAPP_TAGLINE, capitalized); set the colors you use (the template default primary is#4f46e5); copy@import "./brand.css";(after@import "tailwindcss";) and@source "../static/components.js";intoassets/app.css; addpub mod brand;tosrc/lib.rs; setpub const APP_NAME: &str = crate::brand::NAME;andpub const APP_TAGLINE: &str = crate::brand::TAGLINE;insrc/config.rs(a smoke test that asserts the home page title then sees the capitalized tagline); replace the layout's<meta name="theme-color" content="#…">with{{ crate::brand::THEME_COLOR }}; runcargo offrails brand. Prove:cargo offrails brand --checkprints "is current" andcargo nextest run theme_testpasses. - Layouts and components. Detect: no
templates/components/ui.html. Converge: copy from.offrails/template/app/:templates/components/,templates/layouts/{sidebar,marketing,auth,docs}.html,templates/shared/{_brand,_notices,_sidebar}.html,static/components.js, the component layer ofassets/app.css(keep your own classes),src/views/helpers.rs's component helpers (same,tone_icon,initials,PageNumber,Pager), thecomponents_jsasset (src/kernel/assets.rs: the field and the"components.js"key in the same position), and the gallery (src/controllers/gallery.rs,src/views/gallery.rs,templates/dev/, its routes incontrollers/dev.rs,ROUTESandpaths); then maketemplates/layouts/application.htmlthe shell: wrap its nav-and-main in{% block body %}…{% endblock %}, give<main>id="main", and add the skip link,{% include "components/_icons.html" %},{% include "components/_overlays.html" %}and thecomponents.jsscript as the template's does (keep thesamples:anchors). Delete thedata-confirmlistener fromstatic/app.js(components.js has it). Copytests/app/components_test.rsand the gallery test intests/app/theme_test.rs. Prove:cargo offrails verify, then withbin/devrunningBASE=… node .offrails/tools/browser-smoke/smoke-components.mjs. - Proof. Detect:
cargo offrails prove --helpis unknown. Converge: re-vendor. Prove:BASE=<bin/dev's URL> cargo offrails proveprintsprovenand writesdocs/proof/PROOF.md; link it in your final message from now on. LogYYYY-MM-DD: upgraded to framework 0.7.3 (brand.toml, layouts, component kit, prove)indocs/decisions.md.
-
Generators and snippets build their pages from the component kit, in a layout you pick (generators, views). The two branches met here: the generators' markup had just moved into
files/html/{resource,pages}.html, and the kit had just landed. Every generated page now calls the kit, so it followsbrand.toml, both themes and the kit's accessibility, andoffrails-check [classes]finds nothing in it (0 warnings on an app with a resource of every field type, a references chain, pages and controllers; was 0 before too, but only because the old markup was hand-checked).- Index:
ui::page_headerwith aui::link_button("New post",plusicon);ui::table(caption, the list's columns; the actions column is gone: Edit is on the show page) or, empty,ui::empty_statewith its call to action;ui::paginationwithpages()= this page + 1 when the query found more (the generatedallreturnsmore, no COUNT) andpage_base(). Before → after (templates/posts/index.html, abridged):<div class="flex flex-wrap items-end justify-between gap-4"> <h1 class="text-3xl font-semibold tracking-tight">Posts</h1> <a href="{{ paths::new_post() }}" class="btn">New post</a> </div> {% if posts.is_empty() %}<p class="mt-10 rounded-xl border border-dashed … p-10 text-center …">No posts yet. …</p>{% else %} <div class="mt-8 overflow-x-auto rounded-xl border …"><table class="min-w-full divide-y …">…</table></div>{% endif %} {% if newer().is_some() || older().is_some() %}<nav …>← Newer … Older →</nav>{% endif %}{% import "components/ui.html" as ui %} {% call ui::page_header("Posts") %}{% call ui::link_button("New post", paths::new_post(), icon = "plus") %}{% endcall %}{% endcall %} {% if posts.is_empty() %}{% call ui::empty_state("No posts yet", "Create the first one.", "New post", paths::new_post()) %}{% endcall %} {% else %}{% call ui::table("Posts, newest first", [("", "Name"), ("", "Title")]) %}<tr id="post_{{ post.id }}"><td>…</td></tr>{% endcall %}{% endif %} {% call ui::pagination(page, pages(), page_base()) %}{% endcall %} - Show:
ui::page_headerwith Edit (link_button) and Delete (ui::confirm_button, the layout's confirm dialog); the fields in aui::card. New/edit:ui::page_headerand the form in aui::card. Form:ui::field(text, number withstep/inputmode, decimal, money with a0.00placeholder,datetime-local, date, a monospace uuid),ui::textarea(text; json monospace),ui::checkbox(boolean),ui::select(an optional boolean: Not set/Yes/No; a reference: its choices), each with its label, the required mark,aria-invalid/aria-describedbyand its errors;ui::form_actions(Save, Cancel).g controller/g page:ui::page_headerand the placeholder paragraph. The flash is the layout's:shared/_flash.htmlis now the kit'sui::flash(dismissible). --layout application|sidebar|marketing|auth|docsong resource,g controllerandg page(defaultapplication, the top nav; recorded aspage_layoutindocs/generated.toml, so a re-run anddestroysee the same pages). A layout the app doesn't have is refused.- The kit, for generated forms:
fieldtakesmaxlength,inputmode,step,mono(monospace, no autocorrect) andkey(the errors' key when it isn't the input's name: abook_idselect reports onbook);textareatakesmonoandkey;selecttakeskey, and its option values may be ids (helpers::samecompares anyDisplay, soVec<(i64, String)>choices work as they are);page_header's title wraps (break-words).helpers::error_keyis new, with unit tests. - Snippets:
resourceis re-exported from the generator (the unit test that it equalsg resource notes title:string body:text --publicstill passes);paginationisviews::pager::PageLinks { number, pages, base }(renamed fromPager, which is the kit's helper) andshared/_pager.htmlis oneui::paginationcall (?per=kept inbase, tests for the links,per, the clamp and the partial);public-show-by-slug's page usesui::page_header;theme-toggle's button is the kit'sbtn-ghost btn-sm(the kit has no theme switch of its own; it had 11 utilities, anoffrails-check [classes]warning). - Proof: the goldens (
resource_items,page_about,page_profile_param,controller_reports,controller_blog_params) show every page; an app with a resource of every field type, a references chain, a docs-layout page and a sidebar-layout controller: 194 tests green,theme_testincluded, 0[classes]warnings, the boosted smoke and the component smoke green,proveproven (24 pages, 96 screenshots, show and edit pages throughSMOKE_PATHS);test-template.sh generatorsandsnippetskeep the byte-identical-after-destroy/removechecks. - App migration: Detect:
grep -c error_key templates/components/ui.html src/views/helpers.rsis 0 (or no kit). Converge: copytemplates/components/ui.htmlandsrc/views/helpers.rs'ssameanderror_key(and their test lines) from.offrails/template/app/, andtemplates/shared/_flash.html. Pages generated before keep their markup (they're yours);g resourceof a new feature writes the kit's. Prove it:cargo offrails g resource probes name:string --public --dry-runrefuses nothing, and after a real run andcargo offrails verify,destroy resource probesleavesgit statusclean.
- Index:
-
One browser driver for the zero-dependency smokes (
tools/browser-smoke/cdp.mjs).smoke-boosted.mjsandprove.mjseach carried their own copy of the Chrome discovery, the DevTools Protocol pipe and the tab (console errors, CSP,eval,goto, settle), andsmoke-components.mjsimported prove's. Now all three importfindChrome,requireChrome(skip: no Chrome, exit 77),launch(a throwaway profile, headless, Linux CI's--no-sandbox, closed and deleted afterwards),Cdp,Tab(viewport,screenshot, anignorepattern, the boosted smoke'sphaseprefix) andsignInPostfrom it.selftest-boosted.mjs(4/4) andselftest-prove.mjspass; the three smokes andproveare green against a bare app and one with every part. -
From a real app's 0.7.1 → 0.7.2 upgrade (1,530 tests, green after it; six things it tripped on):
- Release fixes: a stale
cargo offrailsafter an upgrade. Every file in the release tarball had mtime 2000-01-01, older than an app's cached offrails-gen binary, so Cargo reused the previous release's (update-part: "unknown command" untiltarget/offrails-genwas deleted).tools/release/release.shnow stamps the files with the release commit's time (still deterministic),install.shunpacks withtar -m(the files get the time they were unpacked), offrails-gen'sCargo.tomlversion followsVERSION(it's in Cargo's fingerprint;release.shrefuses a mismatch; the README's release steps say so), and upgrade builds the new release's offrails-gen intarget/offrails-gen-<version>. verify --fulland a coverage toolchain. WithRUSTUP_TOOLCHAINset for cargo-llvm-cov (a toolchain withllvm-tools, often without rustfmt or clippy), fmt and clippy failed. That toolchain (orOFFRAILS_COVERAGE_TOOLCHAIN) now goes to the coverage step only; every other step runs without it, andverifysays so (unit testthe_coverage_toolchain_is_for_the_coverage_step_only; testing).- The smokes find the sign-in form.
smoke-boosted.mjsandprovesigned in only whendocs/routes.mdhadPOST /session/password; that app signs in atPOST /session. They now try when it has/session/password,/session,/sessions,/login,/sign_inor/users/sign_in, orSMOKE_SIGNIN_PATHnames the route, and submit the password form found onSMOKE_LOGIN, whatever its action (testing). run.sh dev-toolsneeded a visible/devlink on/. It opens/devdirectly, in a desktop window; each "Send test email" must land on/dev/mailbox/<id>with the flash "Sent a test email to your mailbox." and the list must show at least 3 rows (the mailbox's current markup,#mbx-list); the jobs steps run only when/devhas a test-job button (no part has one now: it says skip) (dev-tools).- The template Dockerfile compiled every dependency twice: its dependency layer had no
.cargo/, so it and the final build used different rustflags. It now copies.cargobefore building the dependencies. hx-triggerfilters never fire.keyup[key=='Enter']needs eval, which the template'shtmx-configturns off for the CSP (allowEval:false), so the trigger silently does nothing. A gotcha in frontend-htmx and CHEATSHEET 28, andoffrails-check [htmx]warns on a filter intemplates/(a fixture case intest-offrails-check.sh).- App migration: Dockerfile. Detect:
grep -c 'COPY .cargo .cargo' Dockerfileis 0. Converge: addCOPY .cargo .cargoafterCOPY Cargo.toml Cargo.lock ./(before the dependencycargo build). Prove it:docker build .twice after a code change: the second build's dependency layer is cached and its final step compiles only the app. The others arrive with re-vendoring; nothing to converge (an upgrade from 0.7.2 builds the new offrails-gen intarget/offrails-gen-0.7.3once).
- Release fixes: a stale
-
Accounts: invite-only is a switch; the
ACCOUNTSgrammar (auth, authentication). With the parts synced below, auth-core has one switch for every sign-in method:DEFAULT_SIGNUPinsrc/config.rs(Signup::OpenorSignup::Invite), overridden per deploy byAPP_AUTH_SIGNUP=open|invite; invite-only, the sign-up pages answer 404 and their links hide (ctx.signup_open), magic links go only to existing accounts, andapp task auth_invite <email>lets people in. The framework side:- The header names methods, then
invite:ACCOUNTS: password,magic-link,password+magic-link,magic-link invite,password+totp invite. The old words map:signup=password,invite=password invite,magic-link-invite=magic-link invite. The blueprint, the interview, the auth variants table (old anchors kept for the parts' links), authentication (invite-only is a switch now, not a list of edits), template and api use it.add-part auth-password/auth-magic-linkwrites it, adding a method to the ones there and normalizing old words (unit testthe_accounts_header_grammar_and_its_old_words). new --invite: setsDEFAULT_SIGNUP = Signup::InviteandACCOUNTS: … invite; refused withoutauth-passwordorauth-magic-linkamong the parts.- App migration: Detect:
grep -E '^ACCOUNTS: +(signup|invite|magic-link-invite)\b' AGENTS.md. Converge: rewrite the words as above; an invite-only app also setsDEFAULT_SIGNUP = Signup::Inviteinsrc/config.rsand drops any hand-made invite-only edits the old authentication told it to make (restored/registration*routes and links are hidden by the switch). Prove it:sh .offrails/tools/offrails-checkpasses[header]; invite-only:curl -s -o /dev/null -w '%{http_code}' $URL/registration/newprints 404.
- The header names methods, then
-
Parts synced from samples
2bc93ad(tools/sync-parts.sh): two auth-magic-link tests create their account first, so they pass in invite-only apps too (samples CI now reruns the auth suites withDEFAULT_SIGNUP = Signup::Invite); dev-dashboard'sjobs_are_traced_with_their_sqlpicks its trace by request id and prints every trace it held if it fails (reported flaky once in CI, not reproduced); invite-only as the switch above (auth_invitetask,Signup,SignupOpen,ctx.signup_open); part tests that usepaths::after_sign_in()/paths::account()instead of a hard-coded/account; the dev mailbox's code detection (no more "CODE 2026"); the dev dashboard's AI briefing summarized on load; phone widths: a.scrollbox contains its table's visually-hidden labels indashboard.cssandjobs.css, and every dashboard tab, the jobs pages,/devand the mailbox fit 390 px. The same containment for the template's ownstatic/kernel/admin.css(.scroll { position: relative; max-width: 100% }):proveon the app with every part reports 0 kernel pages with warnings (it listed/admin/jobs,/admin/jobs/cronand the dashboard's requests and lab pages before). App migration (admin.css): Detect:grep -n '^\.scroll' static/kernel/admin.cssshows noposition: relative. Converge:.scroll { position: relative; max-width: 100%; overflow-x: auto; }. Prove it:provereports no sideways scroll at 390 px. -
Dev-dashboard flakes fixed at their cause; parts synced from samples
393408b. Three dev-dashboard tests failed once and passed on retry in this CI (a_trace_expands_inline_with_repeats_folded,jobs_are_traced_with_their_sql,the_checkout_fan_out_traces_four_upstreams; reproduced locally under parallel load, about 1 run in 300), and a fourth under the same load locally (every_tool_answers_within_its_cap:cap()kept 60 characters for its 61-character cut note, so a trace that filled the room came out one over; fixed, with a test at the boundary). Not shared on-disk state: other threads hold clones of a span, and a span closes at its last clone's drop. The SQLite driver's worker thread keeps the span each query ran in until its next command; with the tests' thread-local subscriber (set_default), a span dropped last there closed its parent through that thread's (empty) default, so the handler and the methods above the last query never closed and the waterfall had no handler row. hyper spawns the connection a request opens with the current span and pools it, so an outbound call that opened a connection lasted as long as the connection and overlapped the next calls; and the sequential-calls check wanted under 5 ms of wall clock between two calls, which a loaded machine exceeds with nothing in between. Fixed in the part: the tests'traced()installs the subscriber as the process's global default (asmaindoes); a request trace records the spans still open when its response is done (trace::flush_open; a counter per span says whether to look);kernel::outboundsends from a task of its own (aborted with the call), so the pooled connection holds no span; calls are sequential when nothing of the handler's (a query, a method, a template) ran between them (SEQUENTIAL_GAP_USis gone); the job test pings the database (the SQLite worker answers only after it let go of the job span) before it looks.test-template.sh'sKNOWN_FLAKYis empty: its two entries (run_history_is_capped_redacted_and_reopened,bench_drives_a_running_server) sharedstorage/dashboard/, which the part already keeps per test process, and neither has flaked in CI since; the gate (one retry, any flaky test not listed fails the run) stays. App migration (apps with the dev-dashboard part): Detect:grep -c flush_open src/kernel/dashboard/trace.rsis 0. Converge:cargo offrails update-part dev-dashboard. Prove it:cargo offrails verify --fast. -
Docs, one story across both branches: start from a snippet or a generator (both optional), build pages from the component kit in a layout, prove with
cargo offrails prove. AGENTS.md (prime directive 2 and the load table), CHEATSHEET (the kit, the layouts,brand), workflow (BUILD and VERIFY), template, README, generators, scaffold, controller, views (Layout & partials and Forms rewritten on the kit; the stale layout listing, its per-page head scripts and_pagination.htmlare gone), testing, models.tools/ci/check-links.pycounts<a id="…"></a>anchors (kept for renamed headings): 0 broken links; every shellcheck line in CI is clean.