Manual Getting Started Conventions

Conventions

elements man start/conventions Read as markdown

Where 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 under app/pages/ are labels, not URLs.
  • A scaffolded page has index.ts (route only) and template.html (template, event handlers, inline rpc). As a page grows, move its @rpc functions and shared interfaces into a hand-authored services.ts sibling. An @rpc never belongs in a page's index.ts.
  • Cross-page services and templates live under app/shared/.
  • Import app source with the #app/... map (#app/pages/..., #app/shared/...), never ../../. #config imports 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 and html is a reserved name. Other top-level tags are named templates.
  • e:for keys by the id field. Provide one or the runtime warns and falls back to object identity.
  • The directives are e:for, e:if/e:elseif/e:else, and e:switch/e:case/e:default.
  • Read and write data, not the DOM. el.value = "..." fights the runtime; bind with value={form.x} instead. Use {raw(html)} for unescaped html rather than el.innerHTML.
  • Mutating an object's fields in a handler is live. Rebinding is not: a function cannot reassign its caller's variable, so errors = serverErrors inside 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, and onremove. Each receives an event object with event.target set to the element.
  • Event handlers are named functions in the same .html file. The inline arrow passes template attributes into one.
  • <form onsubmit={...}> calls e.preventDefault() for you.
  • Navigate with redirect(url), not window.location. The same call works in a template handler, a route, middleware, and an @rpc. Do not render a session.isLoggedIn() view on a signin page: login is an @rpc, so the session flips and repaints before redirect() runs. Bounce a signed-in visitor from the route instead.

Rpc and errors

  • @rpc is the boundary. Once across it, anything downstream can call sql(), tx(), and session freely.
  • One form object per @rpc, File[] fields included. Splitting the files out into a second parameter breaks the upload.
  • Internal errors are wrapped in ServerError before they reach the client. Throw an AppError subclass for a message the client should see.
  • ValidationError(message) covers a single rule and ValidationError(fieldErrors) a per-field map. In a template, direct-assign the result: errors = err.errors, not Object.assign.
  • There is no validate() in @elements/app. Throw ValidationError.

Database

  • sql(), tx(), session.login(), and session.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 a SqlResult<T>. .all() gives the array, .first() gives T | undefined, and .firstOrThrow(msg) gives a required row.
  • Nesting tx() joins the transaction already in progress, and inner sql() 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: false for 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 as createdAt: new Date(). Omit a displayed createdAt and the cell renders Invalid Date until the broadcast lands.
  • Read rows with find, filter, map, at, and length on 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 sync insert(), or use a plain @rpc.

Sync-style

  • Elements primitives are written sync-style, and the compiler propagates async/await up 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 function async and await those calls directly. Your own async/await is left alone.
  • The xAsync variants (sqlAsync, txAsync, insertAsync) are for genuine promise control such as Promise.all over 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") or session.getOrThrow("userId") and type them through the SessionData interface. There is no getUserId().
  • 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 in SendWelcomeJob.

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 with class="button is-primary".
  • Setting padding, border, radius, or background on something the design system already styles means rebuilding what exists. Read elements man style and 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})
`);