Conventions
elements man start/conventions Read as markdownWhere Elements differs from what you would guess from the outside, and the conventions its own source follows. Read this once before you write a first page and the rest of the manual reads faster.
Project structure
- Routes are declared in
index.ts, not file-based. Folder names underapp/pages/are labels, not URLs. - A scaffolded page has
index.ts(route only) andtemplate.html(template, event handlers, inline rpc). As a page grows, move its@rpcfunctions and shared interfaces into a hand-authoredservices.tssibling. An@rpcnever belongs in a page'sindex.ts. - Cross-page services and templates live under
app/shared/. - Import app source with the
#app/...map (#app/pages/...,#app/shared/...), never../../.#configimports config. - Import a page's template extensionless:
import html from "./template". - Use kebab-case for directories and filenames.
Html templates
- TypeScript goes outside template declarations or inside
{}expressions. There are no<script>tags. - Attributes live on the root tag only.
<div (form: Form)>inside a template is invalid. <html>is the default-export page andhtmlis a reserved name. Other top-level tags are named templates.e:forkeys by theidfield. Provide one or the runtime warns and falls back to object identity.- The directives are
e:for,e:if/e:elseif/e:else, ande:switch/e:case/e:default. - Read and write data, not the DOM.
el.value = "..."fights the runtime; bind withvalue={form.x}instead. Use{raw(html)}for unescaped html rather thanel.innerHTML. - Mutating an object's fields in a handler is live. Rebinding is not: a function
cannot reassign its caller's variable, so
errors = serverErrorsinside a handler does nothing. Assign in template scope (onsubmit={() => errors = submit(form)}) or wrap a scalar in{ value }. - Class arrays drop falsy entries:
class={["card", active && "active"]}. - The lifecycle hooks are
oninit,oninsert, andonremove. Each receives an event object withevent.targetset to the element. - Event handlers are named functions in the same
.htmlfile. The inline arrow passes template attributes into one. <form onsubmit={...}>callse.preventDefault()for you.- Navigate with
redirect(url), notwindow.location. The same call works in a template handler, a route, middleware, and an@rpc. Do not render asession.isLoggedIn()view on a signin page: login is an@rpc, so the session flips and repaints beforeredirect()runs. Bounce a signed-in visitor from the route instead.
Rpc and errors
@rpcis the boundary. Once across it, anything downstream can callsql(),tx(), andsessionfreely.- One form object per
@rpc,File[]fields included. Splitting the files out into a second parameter breaks the upload. - Internal errors are wrapped in
ServerErrorbefore they reach the client. Throw anAppErrorsubclass for a message the client should see. ValidationError(message)covers a single rule andValidationError(fieldErrors)a per-field map. In a template, direct-assign the result:errors = err.errors, notObject.assign.- There is no
validate()in@elements/app. ThrowValidationError.
Database
sql(),tx(),session.login(), andsession.logout()are server-only. Calling one from browser-reachable code is a compile error that points at the call site.${value}interpolations are parameterized, so SQL injection is impossible by construction.sql<T>(...)returns aSqlResult<T>..all()gives the array,.first()givesT | undefined, and.firstOrThrow(msg)gives a required row.- Nesting
tx()joins the transaction already in progress, and innersql()calls join the outer transaction automatically. - Write camelCase in SQL. Elements translates at the database boundary, and snake_case still works if you prefer it.
- Migrations are hot in development and frozen in production.
LiveTable
- A LiveTable is realtime by default. Pass
realtime: falsefor a snapshot-only table, which drops the listener and lets the response stay edge cacheable. Use'db'when mutations come from outside the app. - An optimistic
insert(...)paints only the object you pass, so include every field the template reads. Server-generated columns count: timestamps, sequence numbers, and trigger-derived values do not exist client-side yet, so pass a placeholder such ascreatedAt: new Date(). Omit a displayedcreatedAtand the cell rendersInvalid Dateuntil the broadcast lands. - Read rows with
find,filter,map,at, andlengthon the table itself. Do not spread it or call.all(). - Never redirect from a mutation's
resetUI. It runs before the write is sent and the row is lost. Redirect after a syncinsert(), or use a plain@rpc.
Sync-style
- Elements primitives are written sync-style, and the compiler propagates
async/awaitup the call stack. Writing them by hand in code that only uses Elements primitives fights the transform. - For third-party libraries (fetch, Stripe,
navigator.clipboard), declare your functionasyncandawaitthose calls directly. Your ownasync/awaitis left alone. - The
xAsyncvariants (sqlAsync,txAsync,insertAsync) are for genuine promise control such asPromise.allover several calls. Reaching for one to fix a call that seems not to work masks a real bug, usually navigating away before a write is sent.
Session
- Read session values with
session.get("userId")orsession.getOrThrow("userId")and type them through theSessionDatainterface. There is nogetUserId(). - A logged-out visitor has no session: no row, no token, no cookie.
- A template that reads a session method updates when the session changes.
Jobs
- A job is declared with the
/** @job */build tag.app.job(...)is not an API. - Suffix job class names with
Job, as inSendWelcomeJob.
Styling
- Bare elements are already styled. A
<button>is a finished button, and so is an<input type="submit">. Inputs, forms, headings, links, tables, and<code>need no classes. - Vary a button with
is-modifiers rather than a new class:is-primary/is-ghost/is-danger,is-sm/is-lg,is-block. Render an<a>as a button withclass="button is-primary". - Setting padding, border, radius, or background on something the design system
already styles means rebuilding what exists. Read
elements man styleand use the class. - Custom CSS is for layout the system does not provide: page composition, positioning, section spacing.
Conventions
- Always use semicolons in TypeScript and JavaScript.
- A comment says why, never what the next line already says. No banner comments grouping code, and no boxed dividers or ascii rules. They add noise and rot as code moves.
- Spell out environment names: development, production, staging. The set is
closed, so a name invented for a site (
demo) produces a config file nothing reads. - Past three or four columns, put one per line in SQL and align the values list under the column list so the mapping reads straight down:
sql(`
insert into signups (name, email, fileName)
values (${name}, ${email}, ${file?.name})
`);