Getting started
Account frontend
The browser screens — sign in, account creation, OAuth authorization, connected
applications, two-factor setup and passkeys — are a React application in
assets/, built with Bun and served by Phoenix from priv/static/assets.
Stack#
| Piece | Choice |
|---|---|
| Framework | React 19 with TypeScript, built by Vite |
| Package manager | Bun (assets/bun.lock is the only lockfile) |
| Styling | Tailwind CSS 4 and HeroUI, purple brand, Roboto Mono Variable |
| State | Jotai for screen state, TanStack Query for server reads |
| Forms | React Hook Form with Zod schemas |
| Icons | Tabler |
| Translations | i18next: English, French, Portuguese, Spanish, Italian |
| Tests | Vitest, React Testing Library, MSW |
| Catalogue | Storybook, sharing the MSW handlers |
Commands#
cd assets
bun install
bun run test # Vitest and React Testing Library
bun run check # TypeScript
bun run build # bundle into ../priv/static/assets
bun run storybook # component and screen catalogue on :6006
mix assets.build runs the Bun build, so mix setup, mix precommit and
release builds pick it up without a separate step. Set ATOLL_SKIP_ASSETS=true
to skip it when the bundle was built elsewhere.
How a screen is rendered#
Phoenix owns every route, session and redirect. AtollWeb.Shell renders a small
HTML document containing an empty root element, the screen's data as JSON, and a
no-script fallback form:
<div id="root"></div>
<script type="application/json" id="atoll-bootstrap">{"screen":"login", ...}</script>
React reads that payload and renders the matching screen. Forms post back to the same endpoints as before, so CSRF tokens, 303 redirects and the OAuth callback are unchanged, and the flows still work with JavaScript disabled.
The payload shapes are declared in assets/src/bootstrap.ts and must match what
the controllers send. Server messages travel as codes (invalid_credentials,
authorize_request_invalid, …) and are translated in the browser, so every
language sees the same error in its own words.
Two reads go through TanStack Query against public XRPC endpoints:
com.atproto.server.describeServer for handle domains and policy links, and
com.atproto.identity.resolveHandle for live username availability during
signup.
Content security policy#
Account pages allow script-src 'self' for the bundle and font-src 'self' for
the self-hosted font. style-src also allows 'unsafe-inline', which HeroUI and
its animations require for element style attributes. Inline scripts remain
forbidden, and the bootstrap payload is a non-executable JSON block with its
markup escaped.
Translations#
Locale files live in assets/src/i18n/locales. Every locale carries the same
keys — a test enforces it — so adding a string means adding it to all five. The
language selector persists a choice; otherwise the browser's languages decide.
Nix#
nix/bun-deps.nix is generated from assets/bun.lock by scripts/bun2nix.py.
Bun records an SRI hash for each package, so the Nix build fetches them without
network access at evaluation time and without a second lockfile. Regenerate it
whenever bun.lock changes:
python3 scripts/bun2nix.py
CI fails if the generated file is out of date.