Skip to content
On this page App shell

Getting started

Recipes

Complete route files for the screens an app is made of, from the app shell to a landing page, to copy whole and adapt.

Pick the recipe that matches the screen you are building and copy it whole. Change only the data, the copy and the theme: the structure, the components and the slots are the part that is already right. When you are unsure about a prop, look it up with the get_component tool of the ca-ui-mcp server or in API.md instead of guessing.

Every example is a SvelteKit route file with its imports, its data shape and its state in view. Spacing props on Flex, Stack and Grid take steps of the spacing scale as strings (gap="4"), never pixels.

I am building Recipe Main components
The frame around every signed-in page App shell AppShellLayout (railLabels, railTop, stage, moreSheet), NavModule, CommandPalette, PageShell (aside, width)
A searchable, filterable list of records List page AppList, SimpleTable (density, stickyColumns), TableRow, TableGroupRow, TableSortHeader, UserCell, StatusChip, FilterGroup
A create or edit form Form page AppForm, FormSection, TextField, ChipGroup, Switch, useForm, toast
Workspace or account settings Settings page AppSettings, FormSection, SettingItem, Switch
Sign in, sign up, password reset Auth pages AuthLayout (panelSide, panelBleed, header), AuthCard, TextField (revealable, below), Button
One screen with no shell: a single dashboard, board or map tool Standalone page SkipLink, <main id="main" tabindex="-1">, PageShell
An overview with numbers and a chart Dashboard PageShell, KpiGrid, KpiCard (chart), Sparkline, Card, Grid, BarChart, PieChart, LineChart, SimpleTable
A map the pages float over Map page AppShellLayout stage, MapStage, MapControls, PageFrame, stageInsets, mapColors, rgbaCss
A public marketing page Landing page SiteHeader, NavigationMenu, Drawer, LocaleSwitcher, I18nProvider, Heading, Highlight, ThemeIsland, Container, Section, Grid, Seo
A brand color, fonts and radius Theming ThemeOptions, caThemePlugin, caThemeHandle, setRouteTheme, ThemeIsland

App shell

Use it once, in the layout of a route group that holds every authenticated page, so the rail, the panel and the mobile navigation wrap them all.

Every recipe reads its visible text from src/lib/messages.ts (see App copy): codeatlas-ui lint reports literal text in a template as i18n-literal. The module below is shared by the App shell, List page, Form page, Settings page and Auth recipes. Each recipe shows the keys it reads; put them all in the one file.

// src/lib/messages.ts (the keys the app shell reads)
export const m = {
  brand: "Acme",
  nav: {
    main: "Main",
    dashboard: "Dashboard",
    users: "Users",
    settings: "Settings",
    workspace: "Workspace",
    profile: "My profile",
    search: "Search, command K",
  },
  account: { name: "Ana Lima" },
  notifications: [
    { title: "New user signed up", body: "Hana Park joined the Pro plan.", time: "2 min ago" },
    { title: "Monthly report ready", body: "September revenue is available.", time: "1 hr ago" },
  ],
  sections: "Sections",
  onThisPage: "On this page",
  workspaceSettings: {
    title: "Workspace settings",
    description: "Reading width keeps the form at a comfortable line length.",
    general: "General",
    billing: "Billing",
    name: "Name",
    slug: "Slug",
  },
} as const;
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
  import {
    AppShellLayout,
    Avatar,
    Button,
    CommandPalette,
    NavMoreSheet,
    NotificationBell,
    ThemeToggle,
    Toaster,
    type NavModule,
    type NotificationItem,
  } from "@codeatlasdev/ui";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";
  import { m } from "#lib/messages.ts";
  import { LayoutDashboard, Search, Settings, Users } from "@lucide/svelte";

  let { children } = $props();

  const modules: NavModule[] = [
    {
      id: "main",
      label: m.nav.main,
      icon: LayoutDashboard,
      basePath: "/dashboard",
      pathPrefixes: ["/users"],
      items: [
        { id: "dashboard", label: m.nav.dashboard, icon: LayoutDashboard, path: "/dashboard" },
        { id: "users", label: m.nav.users, icon: Users, path: "/users" },
      ],
    },
    {
      id: "settings",
      label: m.nav.settings,
      icon: Settings,
      basePath: "/settings",
      items: [
        { id: "workspace", label: m.nav.workspace, icon: Settings, path: "/settings" },
        { id: "profile", label: m.nav.profile, icon: Users, path: "/settings/profile" },
      ],
    },
  ];

  let notifications = $state<NotificationItem[]>(
    m.notifications.map((n, i) => ({
      id: String(i + 1),
      ...n,
      read: i > 0,
      variant: i === 0 ? "success" : "info",
    })),
  );

  const markRead = (id: string) =>
    (notifications = notifications.map((n) => (n.id === id ? { ...n, read: true } : n)));
  const markAllRead = () => (notifications = notifications.map((n) => ({ ...n, read: true })));
  const clear = () => (notifications = notifications.filter((n) => !n.read));

  let searchOpen = $state(false);
</script>

<AppShellLayout
  {modules}
  pathname={page.url.pathname}
  onNavigate={(path) => goto(path)}
  railLabels
  collapseShortPanel
  bottomNavPrimary={4}
  moreSheet={NavMoreSheet}
>
  {#snippet brand()}
    <strong>{m.brand}</strong>
  {/snippet}

  {#snippet railTop()}
    <Button variant="ghost" size="sm" aria-label={m.nav.search} onclick={() => (searchOpen = true)}>
      {#snippet leading()}<Search size={16} />{/snippet}
    </Button>
  {/snippet}

  {#snippet railBottom()}
    <ThemeToggle />
    <NotificationBell {notifications} onRead={markRead} onReadAll={markAllRead} onClear={clear} />
    <Avatar alt={m.account.name} size="sm" />
  {/snippet}

  {#snippet bottomNavOverflow()}
    <Button variant="outlined" tone="neutral" fullWidth href="/settings/profile">{m.nav.profile}</Button>
  {/snippet}

  {@render children()}
</AppShellLayout>

<CommandPalette {modules} bind:open={searchOpen} onNavigate={(path) => goto(path)} />

<!-- Mounted once for the whole app: every toast.success(...) in a page renders here. -->
<Toaster />

With a stage (optional)

Skip this block unless the app has a persistent background layer (a map, a canvas, a video). It is not part of the base recipe above: with no stage the shell renders none. Add the two props and the snippet to the same layout:

<!-- src/routes/(app)/+layout.svelte: the same layout, plus a stage -->
<script lang="ts">
  import { AppShellLayout, type NavModule } from "@codeatlasdev/ui";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";

  let { children } = $props();
  // `modules` is the same array as above.
  const modules: NavModule[] = [];
</script>

<AppShellLayout
  {modules}
  pathname={page.url.pathname}
  onNavigate={(path) => goto(path)}
  {stage}
  stageVisible={!page.url.pathname.startsWith("/settings")}
>
  {@render children()}
</AppShellLayout>

<!-- A layer that lives behind every page and is mounted once: a map, a canvas, a video. -->
{#snippet stage({ hidden }: { hidden: boolean })}
  <canvas class="backdrop" class:paused={hidden}></canvas>
{/snippet}

hidden is true while stageVisible is false, so stop rendering then. .backdrop is yours to style: nothing in the package defines it. For a map, use the Map page recipe instead of a hand-made stage.

A page inside the shell. For a form or settings page, width="reading" caps the column at about 46rem, and an aside snippet puts a section index next to it.

<!-- src/routes/(app)/settings/workspace/+page.svelte -->
<script lang="ts">
  import { Card, PageShell, Stack, TextField } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";

  let name = $state("Acme Inc.");
  let slug = $state("acme");
</script>

<!-- Inside the app shell, which owns <main>. With no shell, see Standalone page. -->
<PageShell
  title={m.workspaceSettings.title}
  description={m.workspaceSettings.description}
  width="reading"
  asideLabel={m.sections}
>
  {#snippet aside()}
    <Card full>
      <nav aria-label={m.onThisPage}>
        <a href="#general">{m.workspaceSettings.general}</a>
        <a href="#billing">{m.workspaceSettings.billing}</a>
      </nav>
    </Card>
  {/snippet}

  <Card title={m.workspaceSettings.general} full gap="md" headingLevel={2}>
    <TextField label={m.workspaceSettings.name} bind:value={name} />
    <TextField label={m.workspaceSettings.slug} bind:value={slug} />
  </Card>
</PageShell>

Rules:

  • Put it in src/routes/(app)/+layout.svelte and nowhere else. Never in the root layout: the root layout also wraps the landing page, the sign-in page and the error page, and none of them has a rail.
  • The shell imports no router. Pass pathname={page.url.pathname} from $app/state (never the $app/stores store) and onNavigate={(path) => goto(path)}. Panel items are plain links; onNavigate only fires when the rail or the bottom navigation switches module.
  • NavModule.basePath is where the rail icon goes, items fill the panel, and pathPrefixes claims pages that belong to the module but sit outside its basePath. Icons are @lucide/svelte components, not strings.
  • Pages inside the shell render the content area only. They must not add their own <main>: the shell is the page landmark. Page layouts such as AppList and AppForm fill the area and scroll on their own.
  • The brand snippet is the logo at the top of the rail and in the mobile header. Anything that must stay reachable on a phone (an avatar) goes in railBottom, with mobileHeaderActions for the mobile header.
  • railLabels writes each module’s label under its rail icon. railTop is the slot between the brand and the modules for a search or command-palette trigger: a ghost Button that opens a CommandPalette (which also opens on Cmd+K). collapseShortPanel shows only the rail for a module with 2 destinations or fewer, so a short list does not reserve a column of empty panel.
  • Below 900px the bottom navigation holds bottomNavPrimary tabs (5 by default). With more modules the last tab is More, a dialog with the rest plus the bottomNavOverflow snippet (account, sign out), so no module is unreachable. moreSheet={NavMoreSheet} swaps that dialog for a swipe-to-dismiss Sheet; it is opt-in because it adds about 10 kB gzip.
  • Optional, see With a stage: a {#snippet stage({ hidden })} is a persistent background (a map, canvas or video) rendered once under the pages, so a route change never remounts it and its camera survives. Stop rendering while hidden is true. Mark floating panels with class="ca-stage-panel": the content area lets pointer events through to the stage. stageVisible={false} hides the stage without unmounting it, for a full-page route that covers it. For a whole map page use the map recipe, not a hand-made stage.
  • Toaster is mounted here once, so pages in this group can call toast.success(...). A route outside the group (the auth pages) that toasts needs its own <Toaster />; mount it in the root layout instead when both groups toast.
  • Copy comes from src/lib/messages.ts, never from literals in the template. Copy the ts block above next to the route and edit the text there; see App copy.

See also: AppShell, ThemeToggle, NotificationBell, Avatar, Mobile, App copy.

List page

Use it for any collection that needs search, filters and pages: users, orders, invoices. Pass the whole dataset and AppList runs search, filters, selection and pagination.

// src/lib/messages.ts (the keys the list pages read)
export const m = {
  users: {
    title: "Users",
    export: "Export",
    create: "New user",
    columns: { user: "User", role: "Role", status: "Status" },
    roles: { Admin: "Admin", Editor: "Editor", Viewer: "Viewer" },
    status: { active: "Active", inactive: "Inactive" },
  },
  leads: {
    selectAll: "Select all leads",
    select: (name: string) => `Select ${name}`,
    columns: { name: "Name", company: "Company", value: "Value" },
  },
} as const;
<!-- src/routes/(app)/users/+page.svelte -->
<script lang="ts">
  import {
    AppList,
    Button,
    FilterGroup,
    SimpleTable,
    StatusChip,
    UserCell,
    downloadFile,
    toCsv,
  } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";
  import { Download, Plus } from "@lucide/svelte";

  interface User {
    id: number;
    name: string;
    email: string;
    role: "Admin" | "Editor" | "Viewer";
    status: "active" | "inactive";
  }

  const users: User[] = [
    { id: 1, name: "Ana Lima", email: "[email protected]", role: "Admin", status: "active" },
    { id: 2, name: "Carlos Melo", email: "[email protected]", role: "Editor", status: "active" },
    { id: 3, name: "Diana Kwan", email: "[email protected]", role: "Viewer", status: "inactive" },
    { id: 4, name: "Eduardo Silva", email: "[email protected]", role: "Editor", status: "active" },
  ];

  const roleOptions = (["Admin", "Editor", "Viewer"] as const).map((role) => ({
    value: role,
    label: m.users.roles[role],
  }));

  // Export is a download, not a route: a link to /users/export would 404 on prerender.
  const exportUsers = () => downloadFile("users.csv", toCsv(users), "text/csv");
</script>

<AppList title={m.users.title} data={users} searchKeys={["name", "email"]} perPage={20}>
  {#snippet actions()}
    <Button variant="outlined" tone="neutral" size="sm" onclick={exportUsers}>
      {#snippet leading()}<Download size={14} />{/snippet}
      {m.users.export}
    </Button>
    <Button variant="filled" size="sm" href="/users/new">
      {#snippet leading()}<Plus size={14} />{/snippet}
      {m.users.create}
    </Button>
  {/snippet}

  {#snippet filters(list)}
    <FilterGroup
      options={roleOptions}
      value={list.filters["role"] ?? "all"}
      onchange={(value) => list.setFilter("role", value)}
    />
  {/snippet}

  {#snippet children(list)}
    <SimpleTable>
      <thead>
        <tr>
          <th>{m.users.columns.user}</th>
          <th>{m.users.columns.role}</th>
          <th>{m.users.columns.status}</th>
        </tr>
      </thead>
      <tbody>
        {#each list.rows as user (user.id)}
          <tr>
            <td class="primary"><UserCell name={user.name} email={user.email} /></td>
            <td>{m.users.roles[user.role]}</td>
            <td>
              <StatusChip
                status={user.status === "active" ? "success" : "neutral"}
                label={m.users.status[user.status]}
              />
            </td>
          </tr>
        {/each}
      </tbody>
    </SimpleTable>
  {/snippet}
</AppList>

For a dense, spreadsheet-like grid (hundreds of rows, a pinned name column, groups, a selected row) keep SimpleTable and switch it to density="compact". You own the sort and the selection; the table only draws them.

<!-- src/routes/(app)/leads/+page.svelte -->
<script lang="ts">
  import {
    Checkbox,
    SimpleTable,
    TableGroupRow,
    TableRow,
    TableSortHeader,
  } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";

  interface Lead {
    id: string;
    name: string;
    company: string;
    stage: "New" | "Won";
    value: number;
  }

  const leads: Lead[] = [
    { id: "l1", name: "Ana Souza", company: "Atlas Co", stage: "New", value: 12000 },
    { id: "l2", name: "Bruno Lima", company: "Nimbus", stage: "New", value: 8400 },
    { id: "l3", name: "Carla Dias", company: "Orbit", stage: "Won", value: 22000 },
  ];

  let sort = $state<"none" | "asc" | "desc">("none");
  let selectedId = $state<string | undefined>();
  let opened = $state<Record<string, boolean>>({ New: true, Won: true });

  const sorted = $derived(
    sort === "none"
      ? leads
      : [...leads].sort((a, b) => (sort === "asc" ? 1 : -1) * a.name.localeCompare(b.name)),
  );
  const stages = $derived([...new Set(sorted.map((lead) => lead.stage))]);
  const nextSort = () => (sort = sort === "none" ? "asc" : sort === "asc" ? "desc" : "none");
  const open = (lead: Lead) => (selectedId = lead.id);
</script>

<SimpleTable density="compact" stickyColumns={2} headerCase="sentence">
  <thead>
    <tr>
      <th scope="col"><Checkbox aria-label={m.leads.selectAll} /></th>
      <TableSortHeader {sort} onsort={nextSort}>{m.leads.columns.name}</TableSortHeader>
      <th scope="col">{m.leads.columns.company}</th>
      <th scope="col">{m.leads.columns.value}</th>
    </tr>
  </thead>
  {#each stages as stage (stage)}
    <tbody>
      <TableGroupRow colspan={4} collapsible bind:open={opened[stage]}>
        {#snippet label()}{stage}{/snippet}
      </TableGroupRow>
      {#if opened[stage]}
        {#each sorted.filter((lead) => lead.stage === stage) as lead (lead.id)}
          <TableRow selected={selectedId === lead.id} onactivate={() => open(lead)}>
            <td><Checkbox aria-label={m.leads.select(lead.name)} /></td>
            <td class="primary">{lead.name}</td>
            <td>{lead.company}</td>
            <td>{lead.value.toLocaleString("en-US", { style: "currency", currency: "USD" })}</td>
          </TableRow>
        {/each}
      {/if}
    </tbody>
  {/each}
</SimpleTable>

Rules:

  • Render rows from list.rows (the current page after search and filters), not from your own array. list.filters, list.setFilter(key, value), list.selected and list.toggleRow(id) are on the same list object each snippet receives.
  • list is typed from data: T is inferred, so list.rows are User[] and user.email is checked. To name the type yourself, annotate the parameter: {#snippet children(list: UseListStateReturn<User>)} (UseListStateReturn is exported from @codeatlasdev/ui). Leave the parameter untyped and Svelte infers it from AppList.
  • FilterGroup adds an “all” option itself. Read its value with list.filters[key] ?? "all".
  • AppList has no own docs page: it is documented on the page layouts page. It fills its parent, so inside the app shell it needs no wrapper.
  • Server-side pagination is the manual mode: omit data, render your own toolbar and footer snippets, and drive the rows from the load function. For a heavy grid (sorting, column sizing, thousands of rows) use DataTable from @codeatlasdev/ui/table, which needs @tanstack/svelte-table.
  • Use SimpleTable for plain rows. Pass responsive="cards" to turn rows into cards on a phone. density="compact" keeps rows to 40px, stickyColumns={2} pins the checkbox and the name while the table scrolls sideways, headerCase="sentence" drops the uppercase labels, and frame={false} removes its border inside an already framed panel (scroll="none" when you own the scroller).
  • In a dense table, a sortable header is TableSortHeader (sort, onsort: you own the state and the next direction), a clickable or selectable row is TableRow (selected, onactivate: Tab, Enter and Space work, and controls inside the row do not trigger it) and a group label is TableGroupRow (colspan, collapsible, bind:open, with your own {#if open} for the rows). Do not make a <tr onclick>.
  • An export is a client-side download: downloadFile(name, toCsv(rows), "text/csv") (both from @codeatlasdev/ui, toJson for JSON) from a Button onclick. Never a link to a route you have not written (/users/export): a prerendered page that links to a missing route fails the build.
  • Put the toolbar actions in the actions snippet: the brand action is filled, a secondary one is variant="outlined" tone="neutral" (or ghost), so it carries no brand color beside the primary.
  • Data values that people read (a role, a status, a stage) are copy too: map them through messages.ts (m.users.roles[user.role]) and keep the raw value for filtering and keys. Names, emails and numbers from your data are not copy.

See also: Page layouts (AppList), Table, DataTable, Chip, FilterGroup, App copy.

Form page

Use it for a create or edit screen: a header with a back button, a scrolling body of sections and a footer that stays in view.

// src/lib/messages.ts (the keys the form page reads)
export const m = {
  userForm: {
    title: "New user",
    description: "Fill in the details to invite a user.",
    account: { title: "Account", description: "Basic information about the user." },
    name: "Full name",
    email: "Work email",
    role: "Role",
    roles: { admin: "Admin", editor: "Editor", viewer: "Viewer" },
    notifications: "Notifications",
    welcome: "Send a welcome email",
    cancel: "Cancel",
    submit: "Create user",
    errors: { name: "Enter a name.", email: "Enter a valid email." },
    created: "User created",
    undo: "Undo",
  },
} as const;
<!-- src/routes/(app)/users/new/+page.svelte -->
<script lang="ts">
  import { goto } from "$app/navigation";
  import {
    AppForm,
    Button,
    ChipGroup,
    FormSection,
    Switch,
    TextField,
    toast,
    useForm,
  } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";

  const f = m.userForm;
  const roleOptions = [
    { value: "admin", label: f.roles.admin },
    { value: "editor", label: f.roles.editor },
    { value: "viewer", label: f.roles.viewer },
  ];

  const form = useForm(
    { name: "", email: "", role: "viewer" },
    {
      schema: (data) => ({
        ...(data.name.trim() ? {} : { name: f.errors.name }),
        ...(data.email.includes("@") ? {} : { email: f.errors.email }),
      }),
    },
  );

  let sendWelcome = $state(true);
  let saving = $state(false);

  async function save() {
    if (!form.validate()) return;
    saving = true;
    try {
      const response = await fetch("/api/users", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          name: form.fields.name.value,
          email: form.fields.email.value,
          role: form.fields.role.value,
          sendWelcome,
        }),
      });
      const { id } = await response.json();
      toast.success(f.created, {
        description: form.fields.email.value,
        action: { label: f.undo, onClick: () => fetch(`/api/users/${id}`, { method: "DELETE" }) },
      });
      await goto("/users");
    } finally {
      saving = false;
    }
  }
</script>

<AppForm title={f.title} description={f.description} backHref="/users">
  <FormSection title={f.account.title} description={f.account.description}>
    <TextField
      label={f.name}
      required
      value={form.fields.name.value}
      error={form.fields.name.error}
      oninput={(value) => form.set("name", value)}
      onblur={() => form.touch("name")}
    />
    <TextField
      label={f.email}
      type="email"
      required
      value={form.fields.email.value}
      error={form.fields.email.error}
      oninput={(value) => form.set("email", value)}
      onblur={() => form.touch("email")}
    />
    <ChipGroup
      label={f.role}
      items={roleOptions}
      required
      value={form.fields.role.value}
      error={form.fields.role.error}
      onchange={(value) => form.set("role", value)}
    />
  </FormSection>

  <FormSection title={f.notifications} layout="list">
    <Switch label={f.welcome} bind:checked={sendWelcome} />
  </FormSection>

  {#snippet footer()}
    <Button variant="ghost" href="/users">{f.cancel}</Button>
    <Button variant="filled" loading={saving} onclick={save}>{f.submit}</Button>
  {/snippet}
</AppForm>

Rules:

  • useForm holds every value as a string and shows an error only after a field is touched. Wire each field with value, error, oninput and onblur, and call form.validate() before submitting: it touches every field and returns whether they all pass.
  • The back button comes from backHref. Do not build a header, a back link or a sticky bar by hand: title, description, backHref and the footer snippet are the header and the footer.
  • Lay fields out with FormSection, not a wrapper Stack. layout is grid (default), list (rows of switches) or stack.
  • Show progress with loading on the submit Button, not by swapping its label. Keep Cancel as a ghost button and the primary action as filled, last.
  • A Switch or Checkbox is a boolean, so keep it in $state and bind:checked. Do not put it in useForm, which is strings only.
  • A single choice among a few options is ChipGroup (a radiogroup of chips that wraps on a phone), not a Select: items are { value, label }, label is the group name, error shows under it and required blocks a plain submit until one is picked. Use Select for a long list.
  • Confirm a save with toast.success(message, { description, action }), not a custom banner. action: { label, onClick } adds one inline button (an Undo). Pass { id } to replace a toast in place, as a “Saving…” that becomes “Saved”. Identical messages stack as a count and at most 3 show at once.
  • toast renders nothing until a <Toaster /> is mounted. Inside the app shell recipe it already is, in (app)/+layout.svelte. A form outside it (a standalone page) mounts <Toaster /> once in its layout, never per page.
  • Validation messages and toast text are copy: they live in messages.ts like labels (f.errors.name), not as literals in schema or toast. Only .svelte templates are linted, but the same module keeps every language in one place.

See also: AppForm, useForm, TextField, ChipGroup, Toast, Switch, App copy.

Settings page

Use it for workspace, account or notification settings that split into a few tabs, each with sections of fields and toggles.

// src/lib/messages.ts (the keys the settings page reads)
export const m = {
  settings: {
    title: "Settings",
    description: "Manage your workspace.",
    save: "Save changes",
    saved: "Settings saved",
    tabs: { general: "General", notifications: "Notifications" },
    workspace: {
      title: "Workspace",
      description: "Basic information about your workspace.",
      name: "Workspace name",
      slug: "URL slug",
      url: (slug: string) => `acme.app/${slug}`,
    },
    email: {
      title: "Email",
      description: "Choose which events send you an email.",
      signup: { label: "New user signed up", description: "One email per registration." },
      payment: { label: "Payment events", description: "Charges, failures and refunds." },
      digest: { label: "Weekly digest", description: "A summary of key metrics every Monday." },
    },
  },
} as const;
<!-- src/routes/(app)/settings/+page.svelte -->
<script lang="ts">
  import {
    AppSettings,
    Button,
    Divider,
    FormSection,
    SettingItem,
    Switch,
    TextField,
    toast,
  } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";
  import { Bell, Save, Settings } from "@lucide/svelte";

  const s = m.settings;
  const tabs = [
    { value: "general", label: s.tabs.general, icon: Settings },
    { value: "notifications", label: s.tabs.notifications, icon: Bell },
  ];

  let workspaceName = $state("Acme Inc.");
  let workspaceSlug = $state("acme");
  let emailOnSignup = $state(true);
  let emailOnPayment = $state(true);
  let weeklyDigest = $state(false);
  let saving = $state(false);

  async function save() {
    saving = true;
    try {
      await fetch("/api/settings", {
        method: "PUT",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ workspaceName, workspaceSlug, emailOnSignup, emailOnPayment, weeklyDigest }),
      });
      toast.success(s.saved);
    } finally {
      saving = false;
    }
  }
</script>

<AppSettings title={s.title} description={s.description} {tabs}>
  {#snippet actions()}
    <Button variant="filled" size="sm" loading={saving} onclick={save}>
      {#snippet leading()}<Save size={14} />{/snippet}
      {s.save}
    </Button>
  {/snippet}

  {#snippet children(tab)}
    {#if tab === "general"}
      <FormSection title={s.workspace.title} description={s.workspace.description}>
        <TextField label={s.workspace.name} bind:value={workspaceName} />
        <TextField label={s.workspace.slug} bind:value={workspaceSlug} description={s.workspace.url(workspaceSlug)} />
      </FormSection>
    {:else if tab === "notifications"}
      <FormSection title={s.email.title} description={s.email.description}>
        <SettingItem label={s.email.signup.label} description={s.email.signup.description}>
          <Switch bind:checked={emailOnSignup} />
        </SettingItem>
        <Divider />
        <SettingItem label={s.email.payment.label} description={s.email.payment.description}>
          <Switch bind:checked={emailOnPayment} />
        </SettingItem>
        <Divider />
        <SettingItem label={s.email.digest.label} description={s.email.digest.description}>
          <Switch bind:checked={weeklyDigest} />
        </SettingItem>
      </FormSection>
    {/if}
  {/snippet}
</AppSettings>

Rules:

  • tabs is data: { value, label, icon?, badge?, danger? }. The children snippet receives the active value, so branch on it with {#if tab === "..."}. Do not build a tab bar yourself.
  • A toggle row is SettingItem (label and description on the left) with a Switch as its child, not a hand-made flex row. Put a Divider between rows.
  • One Save button for the whole page goes in the actions snippet. A tab-local save belongs in that tab’s content.
  • A Switch inside a SettingItem needs no aria-label: the row is a <label>, so its text names the switch and clicking the text toggles it. Add an aria-label only to a Switch that sits outside a SettingItem or a label prop.
  • To control the tab from the outside, bind:activeTab or listen with onTabChange.
  • The save confirmation is toast.success(...), which needs a mounted <Toaster />: inside the app shell it is already there. AppSettings fills its parent, so a page outside the shell needs a parent with a height and its own <Toaster /> in the layout.
  • A value built from data (description={s.workspace.url(workspaceSlug)}) is a function in messages.ts, so no template concatenates words around it.

See also: Page layouts (AppSettings), SettingItem, Switch, App copy.

Auth pages

Use it for sign in, sign up and password reset: a brand panel on one side and a card with the form on the other.

// src/lib/messages.ts (the keys the auth pages read)
export const m = {
  brand: "Acme",
  auth: {
    email: "Email",
    password: "Password",
    quote: "Ship the boring parts in a day.",
    quoteBy: "The Acme team",
    signIn: {
      title: "Welcome back",
      subtitle: "Sign in to your account to continue",
      submit: "Sign in",
      wrong: "Wrong email or password.",
      forgot: "Forgot password?",
      noAccount: "No account yet?",
      create: "Create one",
    },
    signUp: {
      title: "Create your account",
      subtitle: "Start free, no card needed",
      submit: "Create account",
    },
    mapLabel: "Where our customers are",
    waitlist: {
      title: "Join the waitlist",
      subtitle: "We open new regions every month",
      submit: "Join",
    },
  },
} as const;
<!-- src/routes/(auth)/sign-in/+page.svelte -->
<script lang="ts">
  import { goto } from "$app/navigation";
  import { AuthCard, AuthLayout, Button, Stack, TextField } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";

  const a = m.auth;
  let email = $state("");
  let password = $state("");
  let error = $state<string | undefined>();
  let loading = $state(false);

  async function submit(event: SubmitEvent) {
    event.preventDefault();
    loading = true;
    error = undefined;
    try {
      const response = await fetch("/api/sign-in", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ email, password }),
      });
      if (!response.ok) {
        error = a.signIn.wrong;
        return;
      }
      await goto("/dashboard");
    } finally {
      loading = false;
    }
  }
</script>

<AuthLayout logoMark="AC" logoName={m.brand} logoHref="/">
  {#snippet panel()}
    <blockquote>
      <p>{a.quote}</p>
      <footer>{a.quoteBy}</footer>
    </blockquote>
  {/snippet}

  <AuthCard title={a.signIn.title} subtitle={a.signIn.subtitle}>
    <form onsubmit={submit} novalidate>
      <Stack gap="4">
        <TextField
          label={a.email}
          type="email"
          autocomplete="email"
          required
          bind:value={email}
        />
        <TextField
          label={a.password}
          type="password"
          revealable
          autocomplete="current-password"
          required
          bind:value={password}
          {error}
        >
          {#snippet below()}
            <!-- Optional: delete this snippet when the app has no password reset page. -->
            <a href="/forgot-password">{a.signIn.forgot}</a>
          {/snippet}
        </TextField>
        <Button type="submit" variant="filled" fullWidth {loading}>{a.signIn.submit}</Button>
      </Stack>
    </form>

    {#snippet footer()}
      {a.signIn.noAccount} <a href="/sign-up">{a.signIn.create}</a>
    {/snippet}
  </AuthCard>
</AuthLayout>

A form-first variant: the panel on the right edge to edge (a map, image or video), a bar above the form with the logo and a language switcher, and the form flush to the start with AuthCard variant="plain".

<!-- src/routes/(auth)/sign-up/+page.svelte -->
<script lang="ts">
  import { AuthCard, AuthLayout, Button, LocaleSwitcher, Stack, TextField } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";

  const a = m.auth;
  const locales = [
    { value: "en", label: "English" },
    { value: "pt-BR", label: "Português", short: "PT" },
  ];
  let locale = $state("en");
  let email = $state("");
  let password = $state("");
</script>

<AuthLayout panelSide="right" panelBleed panelHidden="lg" align="start">
  {#snippet header()}
    <a href="/">{m.brand}</a>
    <LocaleSwitcher options={locales} bind:value={locale} compact />
  {/snippet}

  {#snippet panel()}
    <img src="/sign-up-cover.jpg" alt="" />
  {/snippet}

  <AuthCard variant="plain" title={a.signUp.title} subtitle={a.signUp.subtitle}>
    <form method="post">
      <Stack gap="4">
        <TextField label={a.email} type="email" name="email" autocomplete="email" required bind:value={email} />
        <TextField
          label={a.password}
          type="password"
          name="password"
          revealable
          autocomplete="new-password"
          required
          bind:value={password}
        />
        <Button type="submit" variant="filled" fullWidth>{a.signUp.submit}</Button>
      </Stack>
    </form>
  </AuthCard>
</AuthLayout>

A map in the panelBleed panel needs no wrapper: the panel slot is the full viewport height, and MapStage fills its parent. Install the map peers as in the Map page recipe.

<!-- src/routes/(auth)/waitlist/+page.svelte -->
<script lang="ts">
  import { AuthCard, AuthLayout, Button, Stack, TextField } from "@codeatlasdev/ui";
  import { MapStage } from "@codeatlasdev/ui/map";
  import { m } from "#lib/messages.ts";

  const a = m.auth;
  let email = $state("");
</script>

<AuthLayout panelSide="right" panelBleed panelHidden="lg" align="start">
  {#snippet panel()}
    <MapStage
      styles={{ light: "https://demotiles.maplibre.org/style.json", dark: "https://demotiles.maplibre.org/style.json" }}
      label={a.mapLabel}
      fit={[[-74, -34], [-34, 5]]}
    />
  {/snippet}

  <AuthCard variant="plain" title={a.waitlist.title} subtitle={a.waitlist.subtitle}>
    <form method="post" novalidate>
      <Stack gap="4">
        <TextField label={a.email} type="email" name="email" autocomplete="email" required bind:value={email} />
        <Button type="submit" variant="filled" fullWidth>{a.waitlist.submit}</Button>
      </Stack>
    </form>
  </AuthCard>
</AuthLayout>

Add mapLabel and waitlist: { title, subtitle, submit } to the auth keys above. The panel is hidden below the panelHidden width, so the map is decoration there: keep the form usable without it.

Rules:

  • AuthLayout renders the page <main id="main">. Do not wrap it in another <main>, and do not put the app shell around it: auth routes live in their own group, outside (app).
  • The logo comes from logoMark and logoName. The brand snippet replaces the whole logo, the mark and the default name (“CodeAtlas UI”); logoName is drawn beside it only when you pass it, so pass it for an SVG mark plus a name and leave it out for a full wordmark.
  • The form column holds an AuthCard with title, subtitle, the form as its child and links in the footer snippet. Do not render your own heading or card inside it.
  • novalidate on the <form> is what lets the library’s error message show. Without it a type="email" or required field fails the browser’s own validation first: the native bubble appears on submit, onsubmit never runs and the error you set is never seen. Keep type, required and autocomplete (they still drive the keyboard, the autofill and the screen reader) and do the checking yourself. For field-level messages (an invalid email, a password too short) wire useForm: error={form.fields.email.error}, oninput={(v) => form.set("email", v)} and onblur={() => form.touch("email")}.
  • Build the form with Stack and TextField, and a Button type="submit" with fullWidth and loading. Set autocomplete (email, current-password, new-password) so password managers work. A password field is type="password" with revealable (an eye toggle inside the field), never a hand-made button; a “Forgot password?” link goes in its below snippet.
  • The “Forgot password?” link is optional: it points at a /forgot-password route you build yourself (the same AuthLayout and AuthCard with one email field). An app with no password reset deletes the whole below snippet instead of linking to a page that does not exist.
  • panelSide (left or right), panelBleed (the panel fills edge to edge: no padding, logo or blobs, and the brand and logoName are not drawn there), panelHidden (md 900px or lg 1200px, from which the panel shows) and align="start" with AuthCard variant="plain" give a form-first page. The header snippet is a sticky bar over the form column and replaces the mobile brand strip.
  • Below the panelHidden width the panel is hidden and mobileBrand (on by default) shows a compact brand strip, so put nothing essential in panel. AuthCard display sets its title in the display role.
  • The language names in LocaleSwitcher options (English, Português) are the one exception to the copy rule: they are label keys of a data array, not template attributes, and each language is written in itself on purpose. Everything else, including the error and the footer links, comes from messages.ts.

See also: AuthLayout, TextField, Button, useForm, Map, App copy.

Dashboard

Use it for an overview page: a row of key numbers (one with a sparkline), then charts and a table in cards.

// src/lib/messages.ts (the keys the dashboard reads)
export const m = {
  dashboard: {
    title: "Dashboard",
    description: "Overview of this half year",
    export: "Export",
    revenue: "Revenue",
    users: "Active users",
    churn: "Churn",
    churnHint: "Down is good",
    seatsUsed: "Seats used",
    revenueByRegion: "Revenue by region",
    planMix: "Plan mix",
    plansTotal: "Accounts",
    revenueAndExpenses: "Revenue and expenses",
    seriesRevenue: "Revenue",
    seriesExpenses: "Expenses",
    topAccounts: "Top accounts",
    topAccountsCaption: "Top accounts by revenue",
    account: "Account",
    plan: "Plan",
    mrr: "MRR",
  },
} as const;
<!-- src/routes/(app)/dashboard/+page.svelte -->
<script lang="ts">
  import { Button, Card, Grid, KpiCard, KpiGrid, PageShell, Progress, SimpleTable } from "@codeatlasdev/ui";
  import { BarChart, LineChart, PieChart, Sparkline } from "@codeatlasdev/ui/charts";
  import { ArrowUpRight, DollarSign, UserMinus, Users } from "@lucide/svelte";
  import { m } from "#lib/messages.ts";

  const d = m.dashboard;

  const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
  const revenue = [42000, 51000, 58000, 63000, 72000, 80000];
  const expenses = [31000, 36000, 42000, 45000, 51000, 55000];
  const churn = [2.4, 2.3, 2.1, 2.2, 1.9, 1.7];
  const regions = ["North", "South", "East", "West"];
  const plans = [
    { name: "Free", value: 640 },
    { name: "Pro", value: 280 },
    { name: "Team", value: 96 },
  ];
  const accounts = [
    { id: "a1", name: "Acme", plan: "Team", mrr: "$4,200" },
    { id: "a2", name: "Globex", plan: "Pro", mrr: "$1,900" },
    { id: "a3", name: "Initech", plan: "Pro", mrr: "$1,450" },
  ];
  const formatMoney = (value: number) => `$${(value / 1000).toFixed(0)}k`;
</script>

<!-- Inside the app shell, which owns <main>. With no shell, see Standalone page. -->
<PageShell title={d.title} description={d.description} maxWidth="1200px">
  {#snippet actions()}
    <Button variant="ghost" size="sm">
      {#snippet leading()}<ArrowUpRight size={14} />{/snippet}
      {d.export}
    </Button>
  {/snippet}

  <KpiGrid>
    <KpiCard title={d.revenue} value="$80k" trend={11.1} icon={DollarSign} />
    <KpiCard title={d.users} value="24.8k" trend={12.1} icon={Users} />
    <KpiCard title={d.churn} value="1.7" unit="%" trend={-0.2} trendTone="positive-down" hint={d.churnHint} icon={UserMinus}>
      {#snippet chart()}<Sparkline data={churn} trend="up" />{/snippet}
    </KpiCard>
  </KpiGrid>

  <Grid columns={{ base: 1, lg: 2 }} gap="4">
    <Card title={d.revenueByRegion}>
      <BarChart series={[{ name: d.seriesRevenue, data: [32, 21, 18, 9] }]} categories={regions} height="240px" />
    </Card>
    <Card title={d.planMix}>
      <PieChart data={plans} donut centerLabel={{ value: "1,016", label: d.plansTotal }} height="240px" />
    </Card>
  </Grid>

  <Card title={d.revenueAndExpenses}>
    <LineChart
      series={[
        { name: d.seriesRevenue, data: revenue },
        { name: d.seriesExpenses, data: expenses },
      ]}
      categories={months}
      valueFormatter={formatMoney}
      area
      height="260px"
    />
  </Card>

  <Card title={d.seatsUsed} gap="md">
    <Progress value={72} label={d.seatsUsed} size="sm" />
  </Card>

  <Card title={d.topAccounts} padding="none">
    <SimpleTable>
      <caption class="ca-sr-only">{d.topAccountsCaption}</caption>
      <thead>
        <tr><th>{d.account}</th><th>{d.plan}</th><th>{d.mrr}</th></tr>
      </thead>
      <tbody>
        {#each accounts as account (account.id)}
          <tr><td class="primary">{account.name}</td><td>{account.plan}</td><td>{account.mrr}</td></tr>
        {/each}
      </tbody>
    </SimpleTable>
  </Card>
</PageShell>

Rules:

  • Charts come from @codeatlasdev/ui/charts and need the echarts peer (bun add echarts). Pass data only (series, categories, data): there are no ECharts options, colors or styles to set, and the chart follows the theme. PieChart takes donut and a centerLabel for a total in the ring.
  • A number tile is KpiCard inside a KpiGrid, with trend as a percentage and icon as a @lucide/svelte component. The arrow follows the sign of trend and the colour by default says up is good; for a metric where down is good (churn, latency, cost) pass trendTone="positive-down" ("neutral" to not judge it) instead of dropping trend. A number that sits inside a card, a panel or a row without its own surface is Stat (label, value, unit, hint, trend, trendTone, size, align, skeleton): it has no border or padding and takes its color from the container.
  • A tile with a sparkline is a KpiCard with a chart snippet holding a Sparkline (data, trend="up" | "down" | "neutral", variant="line" | "bar"), with its icon and hint as on any tile. Do not compose Card + Stat + Sparkline. The sparkline color comes from trend (success, error or neutral text): do not pass a hex color. It is independent of the trendTone of the card: for churn going down, give the Sparkline trend="up" (green) yourself.
  • Side-by-side cards are Grid columns={{ base: 1, lg: 2 }} gap="4" with one Card per cell. Do not write :global(.grid-auto-...) to size a grid: columns is the responsive API, and a template such as repeat(auto-fill, minmax(...)) is a class in your own <style>.
  • Each chart or table goes in a Card with a title. A table that should touch the card edges is Card padding="none" around a SimpleTable; give it a <caption class="ca-sr-only">. A card that holds several blocks (a Stat, a Progress, a TextField) takes gap="md" so they stack with space between them.
  • PageShell is the page container: title, description, the actions snippet and the spacing between blocks. Its gap is a number in px, unlike Flex, Stack and Grid, which take spacing steps. A thin bar is Progress size="sm"; Surface grain adds the paper-grain overlay to a panel that is not a Card.
  • Inside the app shell this page needs no extra wrapper. Without a shell, follow Standalone page. Charts render on the client, so SSR shows the card and the chart fills in after hydration.

See also: PageShell, KpiGrid, KpiCard, Card, Charts, Sparklines, Pie.

Map page

Use it when a live map (or canvas, video, 3D) is the page and the forms, lists and details float over it. AppShellLayout mounts the stage once, so the camera and the deck.gl layers survive every navigation; PageFrame lays the page out over the stage and reports what it covers. Install the peers: bun add maplibre-gl (6.4.1 or later; or mapbox-gl with engine="mapbox" and an accessToken), and for data layers @deck.gl/core @deck.gl/maplibre @deck.gl/layers (@deck.gl/mapbox for mapbox-gl), all @deck.gl/* on the same minor. maplibre-gl 6 also needs setWorkerUrl wired once; see Map.

The layout holds the stage and the page holds the panel, so they share one small state module: the results, the selected id and the map handle.

// src/lib/map-state.svelte.ts
import type { MapInstance } from "@codeatlasdev/ui/map";

export interface Place {
  id: string;
  name: string;
  coordinates: [number, number];
}

class MapState {
  places = $state<Place[]>([]);
  selectedId = $state<string | null>(null);
  map = $state.raw<MapInstance | null>(null);

  select(place: Place) {
    this.selectedId = place.id;
    // MapStage's own helper is not public: use the engine's map from `onmapready`.
    const options = { center: place.coordinates, zoom: 12 };
    const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) this.map?.jumpTo(options);
    else this.map?.flyTo(options);
  }
}

export const mapState = new MapState();
// src/lib/messages.ts (the keys the map page reads)
export const m = {
  map: {
    stageLabel: "Territory map",
    panelLabel: "Territory",
    title: "Territory",
    search: "Search places",
    count: (n: number) => `${n} places`,
    legend: "Pins: open leads",
    save: "Save territory",
  },
} as const;
<!-- src/routes/(app)/+layout.svelte: the stage is mounted once, here -->
<script lang="ts">
  import { AppShellLayout, stageInsets, type NavModule } from "@codeatlasdev/ui";
  import { MapControls, MapStage, mapColors, rgbaCss } from "@codeatlasdev/ui/map";
  import { ScatterplotLayer } from "@deck.gl/layers";
  import { Compass, Map as MapIcon } from "@lucide/svelte";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";
  import { mapState, type Place } from "#lib/map-state.svelte.ts";
  import { m } from "#lib/messages.ts";

  const t = m.map;

  let { children }: { children?: import("svelte").Snippet } = $props();

  const modules: NavModule[] = [
    { id: "field", label: "Field", icon: MapIcon, basePath: "/field", items: [] },
    { id: "routes", label: "Routes", icon: Compass, basePath: "/routes", items: [] },
  ];

  // An offline basemap per scheme: a background painted with the theme's own surface token, so it
  // follows the preset and the scheme. Never write a hex here. For tiles, pass a style URL instead.
  const background = (color: string) => ({
    version: 8 as const,
    sources: {},
    layers: [{ id: "bg", type: "background" as const, paint: { "background-color": color } }],
  });
  const styles = $derived({
    light: background(rgbaCss(mapColors().surface)),
    dark: background(rgbaCss(mapColors().surface)),
  });

  // Markers are a deck.gl layer in theme colors; `mapColors()` is reactive, so this re-runs on a theme change.
  const layers = $derived.by(() => {
    const c = mapColors();
    return [
      new ScatterplotLayer<Place>({
        id: "places",
        data: mapState.places,
        getPosition: (d) => d.coordinates,
        // Pixels, not meters: getRadius defaults to meters, and 500 m is invisible on a country-wide map.
        radiusUnits: "pixels",
        getRadius: (d) => (d.id === mapState.selectedId ? 14 : 9),
        getFillColor: (d) => (d.id === mapState.selectedId ? c.brand : c.brandMuted),
        getLineColor: c.brand,
        stroked: true,
        lineWidthMinPixels: 2,
        pickable: true,
        onClick: ({ object }) => object && mapState.select(object),
        updateTriggers: { getRadius: mapState.selectedId, getFillColor: mapState.selectedId },
      }),
    ];
  });

  // A full-page route (a report, a settings page) covers the stage: pause it, do not unmount it.
  const stageVisible = $derived(!page.url.pathname.startsWith("/reports"));
</script>

{#snippet stage()}
  <MapStage
    {styles}
    {layers}
    label={t.stageLabel}
    fit={[[-74, -34], [-34, 5]]}
    onmapready={(map) => (mapState.map = map)}
  >
    <MapControls locate />
    <p class="legend" style="--inset-left: {stageInsets.current.left}px">{t.legend}</p>
  </MapStage>
{/snippet}

<AppShellLayout
  {modules}
  pathname={page.url.pathname}
  onNavigate={(path) => goto(path)}
  {stage}
  {stageVisible}
>
  {@render children?.()}
</AppShellLayout>

<style>
  .legend {
    position: absolute;
    bottom: var(--ca-space-4);
    left: calc(var(--inset-left) + var(--ca-space-4));
    margin: 0;
    padding: var(--ca-space-2) var(--ca-space-3);
    border-radius: var(--ca-radius-md);
    background: var(--ca-surface-raised);
    font-size: var(--ca-font-size-caption);
  }
</style>
<!-- src/routes/(app)/field/+page.svelte: the page floats over the stage -->
<script lang="ts">
  import { Button, PageFrame, Stack, TextField } from "@codeatlasdev/ui";
  import { mapState, type Place } from "#lib/map-state.svelte.ts";
  import { m } from "#lib/messages.ts";

  const t = m.map;

  let query = $state("");
  const results = $derived(
    mapState.places.filter((p: Place) => p.name.toLowerCase().includes(query.toLowerCase())),
  );
</script>

<!-- Inside the app shell, which owns <main>. With no shell, see Standalone page. -->
<PageFrame mode="map" side="left" width="md" sheet label={t.panelLabel} headerBorder>
  {#snippet header()}
    <h2>{t.title}</h2>
  {/snippet}
  {#snippet toolbar()}
    <TextField label={t.search} bind:value={query} />
  {/snippet}

  <p>{t.count(results.length)}</p>
  <!-- A selectable list with no list component: one ghost Button per row, aria-pressed for the selection. -->
  <Stack as="ul" gap="1">
    {#each results as place (place.id)}
      <li>
        <Button
          variant="ghost"
          tone="neutral"
          align="between"
          fullWidth
          aria-pressed={place.id === mapState.selectedId}
          onclick={() => mapState.select(place)}
        >
          {place.name}
        </Button>
      </li>
    {/each}
  </Stack>

  {#snippet footer()}
    <Button variant="filled">{t.save}</Button>
  {/snippet}
</PageFrame>

Rules:

  • Mount MapStage in the shell’s stage snippet, not in a page: a page mount would recreate the map on every navigation. The layout and the page are different components, so they talk through a module-level $state class (map-state.svelte.ts, with the .svelte.ts extension so runes compile) or a context set in the layout: filters, the selection and the map handle live there.
  • The only public handle on the camera is onmapready(map), the engine’s map (flyTo, jumpTo, easeTo). Keep it in the shared state and call it from the result list to recenter. MapContext.move, which MapStage uses internally to fall back to jumpTo, is not exported, so check prefers-reduced-motion yourself as above. onmapready runs once after the first style loads: the map is null before that.
  • A pin that selects a row is a deck.gl layer with pickable: true and onClick; set updateTriggers for any accessor that reads the selection, or the layer keeps the old colors. Layers come from @deck.gl/layers: install it with @deck.gl/core and @deck.gl/maplibre (@deck.gl/mapbox for mapbox-gl).
  • An offline basemap is a style object with one background layer. Take its color from mapColors().surface through rgbaCss, inside a $derived, so it follows the theme; a hex in a style object fails codeatlas-ui lint. For real tiles pass a style URL per scheme: that needs network, so keep the offline background wherever the app must work without it.
  • ScatterplotLayer radii are in meters by default (radiusUnits: "meters"), so a 500 m dot is a speck on a country-wide map. For markers (pins, places) set radiusUnits: "pixels" and give getRadius pixel values: the dot keeps its size at every zoom. Keep meters only for a true geographic area (a 5 km coverage circle), and then add radiusMinPixels so it stays visible zoomed out.
  • MapStage pads fit by what the frame covers, so it needs no padding prop. Read stageInsets.current (or --ca-stage-inset-* on html) for anything you position by hand, and never copy the panel widths. A legend you place with stageInsets.left sits under the bottom sheet on a phone, where the insets are at the bottom: hide it below 900px or offset it by stageInsets.current.bottom too.
  • mode="page" (or stageVisible={false}) for a route that covers the map; mode="split" for a work area beside a map strip.
  • With sheet, the panel is a non-modal bottom sheet on phones: the map above it stays interactive and the toolbar snippet is hidden at the peek detent (it shows the header only). Do not wrap it in Sheet. bind:detent takes "peek" | "half" | "full".
  • Colors for deck.gl layers come from mapColors() or useMapTheme() (both reactive), never from getComputedStyle. Empty areas of the page pass pointer events to the stage; give your own floating panels class="ca-stage-panel".
  • A one-screen map with no other destinations does not need the shell: see Map page without a shell.

Map page without a shell

PageFrame does not need AppShellLayout: it fills its parent and floats the panel inside it, so any position: relative box with a height works as the stage host. For a single map screen, put MapStage and PageFrame in that host inside your own <main>, and share state with a plain module as above. There is no rail, panel or bottom navigation, so no one-icon rail either.

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { PageFrame, SkipLink, TextField } from "@codeatlasdev/ui";
  import { MapControls, MapStage, mapColors, rgbaCss } from "@codeatlasdev/ui/map";
  import { mapState } from "#lib/map-state.svelte.ts";
  import { m } from "#lib/messages.ts";

  const t = m.map;

  let query = $state("");

  // The same offline basemap as the shell recipe: no network needed, and it follows the theme.
  const background = (color: string) => ({
    version: 8 as const,
    sources: {},
    layers: [{ id: "bg", type: "background" as const, paint: { "background-color": color } }],
  });
  const styles = $derived({
    light: background(rgbaCss(mapColors().surface)),
    dark: background(rgbaCss(mapColors().surface)),
  });
</script>

<SkipLink />
<main id="main" tabindex="-1" class="host">
  <MapStage
    {styles}
    label={t.stageLabel}
    fit={[[-74, -34], [-34, 5]]}
    onmapready={(map) => (mapState.map = map)}
  >
    <MapControls locate />
  </MapStage>
  <div class="frame">
    <PageFrame mode="map" side="left" width="md" sheet label={t.panelLabel}>
      {#snippet header()}
        <h1>{t.title}</h1>
      {/snippet}
      {#snippet toolbar()}
        <TextField label={t.search} bind:value={query} />
      {/snippet}
    </PageFrame>
  </div>
</main>

<style>
  .host {
    position: relative;
    height: var(--ca-vh);
  }
  .frame {
    position: absolute;
    inset: 0;
    pointer-events: none;
  }
</style>
  • The host takes height: var(--ca-vh) (never 100vh). PageFrame is already pointer-events: none outside its panel; the wrapper keeps that so the map stays draggable around it.
  • Here the panel carries the page <h1> (there is no PageShell), and <main> is yours.
  • styles is the offline background from the shell recipe, so the screen works with no network. For real tiles pass a style URL instead, for example "https://demotiles.maplibre.org/style.json" for both schemes: it is fetched at runtime, so it needs network.

See also: PageFrame, Map, AppShell.

Landing page

Use it for a public page: a sticky header with navigation, a hero, a grid of feature cards, pricing cards, an FAQ, a footer and the metadata crawlers read. It lives outside the app shell. init writes a default <Seo> in the root layout: delete it when you add the page’s own (see the rules below).

// src/lib/landing.ts (the landing copy: one table per language, kept apart from the app's messages.ts)
export const messages = {
  en: {
    brand: "Acme",
    metaDescription: "The boring parts of your product, shipped in a day.",
    navLabel: "Main",
    navProduct: "Product",
    navPricing: "Pricing",
    navFaq: "FAQ",
    menu: "Menu",
    menuTitle: "Navigation",
    signIn: "Sign in",
    getStarted: "Get started",
    introduction: "Introduction",
    headline: "Ship the boring parts in a",
    headlineWord: "day",
    leadBefore: "Stop writing",
    leadStruck: "auth, billing and settings",
    leadAfter: "from scratch: Acme gives your team all three.",
    startFree: "Start free",
    seePricing: "See pricing",
    featuresTitle: "Everything in the box",
    features: [
      { title: "Fast by default", body: "Server-rendered pages that paint on the first frame." },
      { title: "Themed in one file", body: "One brand color becomes light and dark tokens." },
      { title: "Accessible", body: "Keyboard, focus and contrast handled in the components." },
    ],
    pricingTitle: "Pricing",
    plans: [
      { name: "Free", price: "$0", period: "/month", features: ["1 project", "Community support"], cta: "Start free", featured: false },
      { name: "Pro", price: "$29", period: "/month", features: ["Unlimited projects", "Email support", "Custom domain"], cta: "Choose Pro", featured: true },
      { name: "Team", price: "$99", period: "/month", features: ["Everything in Pro", "SSO", "Audit log"], cta: "Contact sales", featured: false },
    ],
    popular: "Most popular",
    faqTitle: "Questions",
    faq: [
      { question: "Can I cancel any time?", answer: "Yes. The plan ends at the close of the billing period." },
      { question: "Is there a free plan?", answer: "Yes, one project with community support, no card needed." },
    ],
    footerLabel: "Site",
    footerLinks: "Company",
    footerAbout: "About",
    footerPrivacy: "Privacy",
    footerTerms: "Terms",
    copyright: "© 2026 Acme Inc.",
  },
  "pt-BR": {
    brand: "Acme",
    metaDescription: "As partes chatas do seu produto, prontas em um dia.",
    navLabel: "Principal",
    navProduct: "Produto",
    navPricing: "Preços",
    navFaq: "Perguntas",
    menu: "Menu",
    menuTitle: "Navegação",
    signIn: "Entrar",
    getStarted: "Começar",
    introduction: "Introdução",
    headline: "Entregue as partes chatas em um",
    headlineWord: "dia",
    leadBefore: "Pare de escrever",
    leadStruck: "login, cobrança e configurações",
    leadAfter: "do zero: a Acme entrega os três ao seu time.",
    startFree: "Começar grátis",
    seePricing: "Ver preços",
    featuresTitle: "Tudo na caixa",
    features: [
      { title: "Rápido por padrão", body: "Páginas renderizadas no servidor que aparecem no primeiro quadro." },
      { title: "Tema em um arquivo", body: "Uma cor de marca vira tokens claros e escuros." },
      { title: "Acessível", body: "Teclado, foco e contraste resolvidos nos componentes." },
    ],
    pricingTitle: "Preços",
    plans: [
      { name: "Grátis", price: "R$ 0", period: "/mês", features: ["1 projeto", "Suporte da comunidade"], cta: "Começar grátis", featured: false },
      { name: "Pro", price: "R$ 149", period: "/mês", features: ["Projetos ilimitados", "Suporte por email", "Domínio próprio"], cta: "Escolher Pro", featured: true },
      { name: "Time", price: "R$ 499", period: "/mês", features: ["Tudo do Pro", "SSO", "Log de auditoria"], cta: "Falar com vendas", featured: false },
    ],
    popular: "Mais popular",
    faqTitle: "Perguntas",
    faq: [
      { question: "Posso cancelar quando quiser?", answer: "Sim. O plano termina no fim do período de cobrança." },
      { question: "Existe plano grátis?", answer: "Sim, um projeto com suporte da comunidade, sem cartão." },
    ],
    footerLabel: "Site",
    footerLinks: "Empresa",
    footerAbout: "Sobre",
    footerPrivacy: "Privacidade",
    footerTerms: "Termos",
    copyright: "© 2026 Acme Ltda.",
  },
} as const;

export type Locale = keyof typeof messages;
<!-- src/routes/(marketing)/+page.svelte -->
<script lang="ts">
  import {
    Accordion,
    AccordionItem,
    Badge,
    Button,
    Card,
    Container,
    Divider,
    Drawer,
    Flex,
    Grid,
    Heading,
    Highlight,
    I18nProvider,
    IconButton,
    LocaleSwitcher,
    NavigationMenu,
    NavigationMenuLink,
    Section,
    SiteHeader,
    Stack,
    StrikeThrough,
    ThemeIsland,
  } from "@codeatlasdev/ui";
  import { faqPage, Seo } from "@codeatlasdev/ui/seo";
  import { Menu } from "@lucide/svelte";
  import { page } from "$app/state";
  import { messages, type Locale } from "#lib/landing.ts";

  const origin = "https://acme.com";

  // Where the sign-up and sign-in buttons go. They are anchors here so this page prerenders on
  // its own (a link to a route that does not exist yet fails `vite build` with a 404). Once the
  // Auth pages recipe is in the app, set them to "/sign-up" and "/sign-in".
  const signUp = "#pricing";
  const signIn = "#pricing";

  const locales = [
    { value: "en", label: "English" },
    { value: "pt-BR", label: "Português", short: "PT" },
  ];

  // In-page switching: `locale` is state, `m` follows it, and I18nProvider re-renders the
  // package's own strings (aria-labels, the switcher) in the same language.
  let locale = $state<Locale>("en");
  const m = $derived(messages[locale]);
  // The one language the server renders and the first paint show: the page's own `lang`.
  const lang = $derived(locale);

  let menuOpen = $state(false);
</script>

<!-- `lang` keeps <html lang> in step with the in-page switch; `jsonLd` carries the FAQ the page shows. -->
<Seo
  title={m.brand}
  description={m.metaDescription}
  canonical={origin + page.url.pathname}
  {lang}
  jsonLd={faqPage([...m.faq])}
/>

<!-- A light page inside a dark app. `document` also paints <html>, so overscroll and the browser bar match. -->
<ThemeIsland scheme="light" document>
  <I18nProvider {locale}>
    <SiteHeader>
      {#snippet trailing()}
        <!-- Desktop navigation. It must be a direct child of `trailing`: below 600px the header hides it. -->
        <NavigationMenu aria-label={m.navLabel}>
          <NavigationMenuLink href="#features">{m.navProduct}</NavigationMenuLink>
          <NavigationMenuLink href="#pricing">{m.navPricing}</NavigationMenuLink>
          <NavigationMenuLink href="#faq">{m.navFaq}</NavigationMenuLink>
        </NavigationMenu>
        <!-- Phones: the same links in a Drawer, opened from a button that only exists below 600px. -->
        <span class="mobile-menu">
          <IconButton aria-label={m.menu} onclick={() => (menuOpen = true)}>
            <Menu size={18} />
          </IconButton>
        </span>
        <LocaleSwitcher options={locales} bind:value={locale} compact />
        <Button variant="filled" size="sm" href={signUp}>{m.getStarted}</Button>
      {/snippet}
    </SiteHeader>

    <Drawer bind:open={menuOpen} side="left" title={m.menuTitle}>
      <Stack as="ul" gap="2">
        <li><Button variant="ghost" tone="neutral" fullWidth href="#features" onclick={() => (menuOpen = false)}>{m.navProduct}</Button></li>
        <li><Button variant="ghost" tone="neutral" fullWidth href="#pricing" onclick={() => (menuOpen = false)}>{m.navPricing}</Button></li>
        <li><Button variant="ghost" tone="neutral" fullWidth href="#faq" onclick={() => (menuOpen = false)}>{m.navFaq}</Button></li>
        <li><Button variant="outlined" tone="neutral" fullWidth href={signIn}>{m.signIn}</Button></li>
      </Stack>
    </Drawer>

    <main id="main" tabindex="-1">
      <Container size="lg">
        <Section size="3" aria-label={m.introduction}>
          <Stack gap="5" align="start">
            <Heading level={1} size="display">
              {m.headline} <Highlight animated>{m.headlineWord}</Highlight>
            </Heading>
            <p>{m.leadBefore} <StrikeThrough animated>{m.leadStruck}</StrikeThrough> {m.leadAfter}</p>
            <Flex gap="3" wrap>
              <Button variant="filled" href={signUp}>{m.startFree}</Button>
              <Button variant="outlined" tone="neutral" href="#pricing">{m.seePricing}</Button>
            </Flex>
          </Stack>
        </Section>

        <Section id="features" aria-labelledby="features-title">
          <Stack gap="6">
            <h2 id="features-title">{m.featuresTitle}</h2>
            <Grid columns={{ base: 1, md: 3 }} gap="4">
              {#each m.features as feature (feature.title)}
                <Card title={feature.title} headingLevel={3}>{feature.body}</Card>
              {/each}
            </Grid>
          </Stack>
        </Section>

        <Section id="pricing" aria-labelledby="pricing-title">
          <Stack gap="6">
            <h2 id="pricing-title">{m.pricingTitle}</h2>
            <Grid columns={{ base: 1, md: 3 }} gap="4">
              {#each m.plans as plan (plan.name)}
                <Card title={plan.name} headingLevel={3} full>
                  {#snippet headerActions()}
                    {#if plan.featured}<Badge tone="brand" subtle>{m.popular}</Badge>{/if}
                  {/snippet}
                  <Stack gap="4">
                    <Heading level={4} size="display-3">{plan.price}<small>{plan.period}</small></Heading>
                    <Stack as="ul" gap="2">
                      {#each plan.features as feature (feature)}<li>{feature}</li>{/each}
                    </Stack>
                    <Button
                      class="ca-pricing-cta"
                      variant={plan.featured ? "filled" : "outlined"}
                      tone={plan.featured ? undefined : "neutral"}
                      href={signUp}
                    >
                      {plan.cta}
                    </Button>
                  </Stack>
                </Card>
              {/each}
            </Grid>
          </Stack>
        </Section>

        <Section id="faq" aria-labelledby="faq-title" size="1">
          <Stack gap="6">
            <h2 id="faq-title">{m.faqTitle}</h2>
            <Accordion type="single">
              {#each m.faq as item, i (item.question)}
                <AccordionItem value={String(i)} title={item.question}>{item.answer}</AccordionItem>
              {/each}
            </Accordion>
          </Stack>
        </Section>
      </Container>
    </main>

    <footer aria-label={m.footerLabel}>
      <Container size="lg">
        <Divider />
        <Section size="1">
          <Flex gap="4" wrap justify="between" align="center">
            <strong>{m.brand}</strong>
            <Stack as="nav" aria-label={m.footerLinks}>
              <Flex as="ul" gap="4" wrap>
                <li><a href="#features">{m.navProduct}</a></li>
                <li><a href="#pricing">{m.navPricing}</a></li>
                <li><a href="#faq">{m.faqTitle}</a></li>
              </Flex>
            </Stack>
            <small>{m.copyright}</small>
          </Flex>
        </Section>
      </Container>
    </footer>
  </I18nProvider>
</ThemeIsland>

<style>
  .mobile-menu {
    display: contents;
  }
  @media (min-width: 600px) {
    .mobile-menu {
      display: none;
    }
  }
</style>

Rules:

  • Delete the <Seo> that init put in src/routes/+layout.svelte when the page has its own: every <Seo> writes its tags into <head>, and only the <title> of the page replaces the layout’s. The layout’s description, canonical and og:* tags would stay next to the page’s, so a crawler reads two. One <Seo> per page (SEO page: the Seo component), in the page or in the layout, never both. A layout <Seo> is only right when every route shares it unchanged.
  • A landing page is not an app page: no AppShellLayout. Put it in the root route or a (marketing) group, with its own <main id="main" tabindex="-1">. The SkipLink is yours too: put one before SiteHeader when the header is not inside a shell.
  • Build the page from Container (width and gutter), Section (vertical rhythm, a real <section>: give it aria-labelledby) and Stack, Flex, Grid for arrangement. Grid takes columns (1 to 12, or a responsive object) and a step gap; for a template like 2fr 1fr, write the grid-template-columns in a class. A pricing table has no component of its own, and the recipe shows it: a Grid columns={{ base: 1, md: 3 }} of Cards, the price in Heading size="display-3", the features in Stack as="ul", class="ca-pricing-cta" on each Button (it pins the button to the bottom of the card), and a Badge tone="brand" subtle in headerActions for the highlighted plan. There is no footer component either: a <footer aria-label> with a Container, a Divider and plain links.
  • SiteHeader is a sticky bar with a breadcrumb that is empty outside an app shell; the site navigation goes in its trailing snippet, which stays on the right. Put the NavigationMenu there as a direct child (not wrapped in a div): below 600px the header hides it, and a phone gets the same links from a Drawer opened by an IconButton that you show only below 600px. Keep at most the LocaleSwitcher and one CTA beside it in the bar: at 390px a second button does not fit. LocaleSwitcher compact always shows the short code.
  • Switching language inside the page needs no navigation: bind LocaleSwitcher to a $state locale, derive your copy from it (messages[locale]) and pass the same value to I18nProvider locale={locale}, which is reactive, so the package’s own strings follow. To give each language its own URL, use onselect={(next) => goto(`/${next}`)} instead (it may return a promise; value changes once it settles) and read the locale from the route.
  • CTAs are Button with href: it renders a link, so no onclick navigation. One filled per view, the rest outlined or ghost; add tone="neutral" to an outlined or ghost button that sits beside the brand one. A tag on a card (a plan name, “New”) is Badge tone="brand" subtle, not a Chip.
  • The hero headline is Heading size="display", never a styled h1. level sets the tag and size the look. Heading takes no id (see Heading), so the title that names a Section is a plain <h2 id="features-title"> with aria-labelledby on the Section, as above; the hero names itself with aria-label. Highlight and StrikeThrough take animated to play on mount; under reduced motion they render fully drawn.
  • ThemeIsland keeps this page in its own scheme (and preset) inside a dark app. Add document for a full page so <html> follows it and is restored on leaving, and set setRouteTheme on the server (see Theming) so the first paint is already right.
  • Put Seo on every public page with title, description and an absolute canonical. With in-page language switching the page has one URL, so keep one canonical; use alternates only when each language has its own. The FAQ is an Accordion type="single" (one answer open at a time) and the same questions feed faqPage(...) into jsonLd, so the markup crawlers read is the text people see. Pass lang with an in-page language switch: Seo keeps <html lang> in step on the client and sets og:locale, which the LocaleSwitcher alone does not do. The <html lang> the server writes still belongs in app.html. Use noindex on app and auth pages.
  • Static hosting (adapter-static): set export const prerender = true; in the root +layout.ts so / is built. The recipe links only to in-page anchors (#features, #pricing, #faq): the sign-up and sign-in buttons use the signUp and signIn constants at the top of the page, which are anchors so the page prerenders on its own. Point them at /sign-up and /sign-in once the Auth pages recipe is in the app. SvelteKit fails the build when a prerendered page links to a route that does not exist yet (a /pricing or /docs you add to the nav): create the routes, or set prerender: { handleHttpError: "warn" } in the sveltekit({ ... }) options until they exist. adapter-static@4 needs Kit 3, so a Kit 2 app installs @sveltejs/adapter-static@3.

See also: Layout primitives, Card, Accordion, Heading, Button, NavigationMenu, Drawer, i18n, SEO.

Standalone page

Use it when the whole app is one screen (a single dashboard, a board, a map tool) and there is no rail or bottom navigation to host. Do not mount AppShellLayout for it: with one module it still draws a one-icon rail, a mobile header and a bottom bar. The page owns its landmark instead.

// src/lib/messages.ts (the keys the standalone page reads)
export const m = {
  standalone: {
    title: "Pipeline",
    description: "Open deals by stage",
    newDeal: "New deal",
  },
} as const;
<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { Button, PageShell, SkipLink } from "@codeatlasdev/ui";
  import { m } from "#lib/messages.ts";

  const p = m.standalone;
</script>

<SkipLink />
<main id="main" tabindex="-1">
  <PageShell title={p.title} description={p.description} maxWidth="1200px">
    {#snippet actions()}
      <Button variant="filled" size="sm">{p.newDeal}</Button>
    {/snippet}
    <!-- the screen: KpiGrid, Grid of Cards, Kanban, ... -->
  </PageShell>
</main>

Rules:

  • PageShell never renders <main>: it assumes a shell or the page already has one. Without the wrapper axe reports landmark-one-main. Put one <main id="main" tabindex="-1"> around it and a SkipLink before it (its href defaults to #main). One page, one <main>, one id="main".
  • AppShellLayout is for apps with several destinations. If a one-page app uses it anyway, pass mainId={null} and render your own <main id="main" tabindex="-1">, or keep the default and render no <main> of your own. Never both.
  • A board or a table that must fill the screen needs a parent with a height. Give your own wrapper a class with height: calc(var(--ca-vh) - 6rem) (never 100vh) rather than a :global rule on a ca-* class.
  • Add <RouteAnnouncer /> (@codeatlasdev/ui/kit) once in the root layout if the app has more than one route, and <Seo> with a canonical (or noindex) on the page.
  • A map-first one-page screen is the same idea with a stage behind it: see Map page without a shell.

See also: PageShell, SkipLink, Accessibility.

Theming

Use it to set the brand color, fonts, radius and surface style for the whole app from one object, with no per-component styling.

// src/lib/theme.ts
import type { ThemeOptions } from "@codeatlasdev/ui/vite";

export const theme: ThemeOptions = {
  presets: [
    {
      id: "acme",
      color: "#2563eb",
      label: "Acme",
      // Optional: the brand itself as the dark-mode button fill, with its text color.
      colors: { dark: { brandFill: "#2563eb", brandFillHover: "#3b82f6", brandContrast: "#ffffff" } },
      // Optional: a smaller font in every text field, in both schemes.
      sizes: { inputFontSize: "0.875rem" },
    },
  ],
  defaultPreset: "acme",
  defaultScheme: "system",
  defaultStyle: "solid",
  fonts: { body: "Geist, system-ui, sans-serif", display: "Fraunces, serif" },
  typography: { displayTracking: "-0.03em" },
  radius: 0.8,
};
// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { caErrorPlugin, caThemePlugin } from "@codeatlasdev/ui/vite";
import { defineConfig } from "vite";
import { theme } from "./src/lib/theme.ts";

export default defineConfig({
  plugins: [sveltekit(), caThemePlugin(theme), caErrorPlugin({ scaffold: true, theme })],
});
// src/hooks.server.ts
import { sequence } from "@sveltejs/kit/hooks";
import { caThemeHandle, createErrorHandler } from "@codeatlasdev/ui/kit";
import { theme } from "#lib/theme.ts";

export const handle = sequence(caThemeHandle(theme));
export const handleError = createErrorHandler();
/* src/app.css: imported after @codeatlasdev/ui/styles/global.css */
:root {
  --ca-card-padding: 20px;
  --ca-font-size-body1: 0.875rem;
}
// src/routes/(marketing)/+layout.server.ts: a light route inside a dark app, themed on the first paint
import { setRouteTheme } from "@codeatlasdev/ui/kit";

export const load = (event) => {
  setRouteTheme(event, { preset: "acme", scheme: "light" });
};
// src/hooks.server.ts: or declare a whole route group once
import { caThemeHandle, type RouteThemes } from "@codeatlasdev/ui/kit";
import { theme } from "#lib/theme.ts";

const routes: RouteThemes = { "/(marketing)": { preset: "acme", scheme: "light" } };

export const handle = caThemeHandle(theme, { routes });

Rules:

  • One ThemeOptions object in src/lib/theme.ts, passed to both caThemePlugin(theme) and caThemeHandle(theme). Called with no argument, either one falls back to the default brand and ignores your presets.
  • A brand color is a preset: give it a #rrggbb and the full light and dark token set is generated. Do not copy colors into component styles.
  • Import the theme in vite.config.ts with the .ts extension. Everything else (preset, scheme, style, fonts, radius, density) is a field of the same object.
  • To change one token, declare it on :root as --ca-*, without !important. Never write hex colors or pixel sizes in a component. For denser body text override only --ca-font-size-body1: it sizes the text on body and does not depend on the root rem, so never set html { font-size: ... }, which would rescale every label, input and button.
  • brandFill, brandFillHover and brandContrast under colors.dark make Button variant="filled" use the brand color itself in dark mode, with brandContrast as its text. sizes.inputFontSize is a plain length (px, rem, em). Both belong to the preset, never to app CSS.
  • Display text (Heading size="display", the display prop of PageShell, AuthCard and SiteHeader) reads --ca-font-display, which fonts.display sets, and typography.displayTracking. Never set a font family in a component.
  • A page that must keep another preset or scheme than the app (a light landing in a dark app) renders <ThemeIsland document>. Pair it with setRouteTheme from @codeatlasdev/ui/kit in load, or routes in caThemeHandle, so the server response already has that theme and nothing flashes.
  • Colors that are not UI roles (status, tier, channel, map pins) go under tokens on the preset, not in a domain.css: tokens.status.new gives --ca-token-status-new (+ -subtle, -contrast) per scheme, and tokenRgba("status.new") gives [r, g, b, a] for canvas and maps. A role color may carry alpha (oklch(1 0 0 / 0.62) or { color, alpha }): do not flatten it to a hex.
  • bunx @codeatlasdev/ui init --write creates and patches all of these files, and is a dry run without --write.

See also: Theming, Installation.

When the recipe does not fit

  • Several components could do the job: read the decision tables in Choosing components before improvising.
  • You are about to write a wrapper, a hand-made header or an override: check Common workarounds for the component that already does it.
  • You want a reference to adapt: browse the gallery and port a numbered demo. In the MCP server call search_demos, or fetch /api/v1/en/e/NNN.txt for demo NNN.
  • The library is missing something or gets in the way: call report_issue (see Reporting problems) instead of shipping a workaround in the app.