Skip to content

Latest commit

 

History

History
883 lines (680 loc) · 78.8 KB

AGENTS.md

File metadata and controls

883 lines (680 loc) · 78.8 KB

AGENTS.md: Guide for AI Agents

If you are building an app that uses @codeatlasdev/ui (not working on this repository), skip to "Critical rules" and the component sections; "Issue workflow" and "Tests" are for contributors. Recipes: https://ui.codeatlas.com.br/docs/getting-started/recipes/ . Choosing between components: https://ui.codeatlas.com.br/docs/getting-started/choosing/ . Common workarounds: https://ui.codeatlas.com.br/docs/getting-started/workarounds/. Upgrading between majors: https://ui.codeatlas.com.br/docs/getting-started/upgrading/

This document covers everything an AI agent needs to know to work in this repository.


What this project is

@codeatlasdev/ui is CodeAtlas's UI package. It is a Svelte 5 library published publicly on npm. Every company frontend imports components, tokens and styles from here.

It is theme-driven: one ThemeOptions object (preset color, scheme, defaultStyle, radius, density, fonts, paper) decides how every component looks. defaultStyle is one knob of that object and defaults to solid (opaque paper, no blur); flat, outlined, tonal, clay, neumorphic, frosted, glass and chrome are the other options. Glass is opt-in, never the default and never the identity: write examples and screens for the default style, and read "Glass and frosted styles" below only when blur is in play.


Monorepo structure

packages/ui/          → publishable library (@codeatlasdev/ui)
  bin/                → CLIs: codeatlas-ui (init, migrate, migrate-react) and ca-ui-mcp (MCP server)
  scripts/            → build-manifest.ts (components/tokens/guides.json), build-bin.ts, build-themes.ts
  src/cli/            → init and migrate logic (tested in tests/cli.test.ts)
  src/lib/
    components/       → Svelte 5 components
    styles/           → CSS: global.css (everything), core.css (no extra presets), presets/*.css, base.css, tokens.css, materials.css
    tokens/           → TypeScript constants (colors, typography, shadows, etc.)
    stores/           → reactive svelte $state (colorScheme, responsive, reducedMotion)
    utils/            → debounce, clipboard, localStorage, palette-generator
    index.ts          → main barrel export
site/                 → Astro site (landing + MDX docs + templates) at ui.codeatlas.com.br
  functions/          → Cloudflare Pages Functions: /changelog/ (live GitHub Releases), /changelog.json, /changelog/feed.xml; tested with `bun run test:unit`
  src/content/changelog/ → one long-form MDX post per release (`<version>.mdx`, URL `/changelog/<version>/`); data in src/data/changelog/<version>.json (generated by `bun scripts/changelog-data.ts <version>`); see CONTRIBUTING.md "Release posts"

Issue workflow (agents and humans)

  • Claim before coding: bun run issue:start <n> creates ../ui-worktrees/<n>-<slug> on fix|feat/<n>-<slug>, opens a draft PR (Closes #<n>), labels in progress, assigns and comments on the issue. Parallel agents each take their own issue and worktree; never share a checkout.
  • Stay in the worktree: use absolute paths under ../ui-worktrees/<n>-<slug>/ for every read, edit and command (cwd). A relative path resolves against the main checkout and silently edits main. Never git stash (the stash is shared by all worktrees).
  • Contributor PRs come first: if an issue already has an open PR (a fork included), issue:start refuses and names it. Review it, approve its CI run (first-time contributors need approval), suggest changes on the PR, and land theirs. Never open a competing PR, and never push to a contributor's branch without saying so on the PR.
  • Triage before a batch ("fix all issues"): never start coding first. (1) bun run issue:triage creates the labels and writes every open issue with comments, linked PRs and the commits on main that close it (closing keyword or (#issue) (#pr) subject; a bare (#n) is a PR number) to .cache/triage.md. (2) Read all of them and check each against current main source (issues usually quote an older dist/). (3) Already fixed: prove it as in Check first (a passing test on main), comment the evidence and close (gh issue close <n> --reason completed); duplicate: close as duplicate pointing to the survivor; not planned: not planned with the reason. (4) Every remaining issue gets exactly one type (bug / enhancement / documentation) and one or more area:* labels. (5) Link related issues with a comment Related: #a, #b (same file/root cause) on each side; when one must land first, label blocked and comment Blocked by #n. (6) Order the work: blockers, then bugs, then enhancements and docs; fix issues sharing a file in the same worktree sequence, never in parallel worktrees. Tooling edits go through a worktree and PR too, never on the main checkout.
  • Check first: bun run issue:status. Skip issues labeled in progress by someone else. Re-read the issue against current main: if it is already fixed, prove it with a passing test, comment the evidence and close it.
  • Test first (critical rule 0): failing regression test, then the fix, then the passing run. Both outputs go in the PR body. A test you did not run proves nothing: run every test you add or change (Playwright included) before pushing.
  • CI is paused (pre-stable): ci.yml is disabled and CI ok is not required on main; the pre-push validate is the gate, and run the browser suites locally when you touch them. Visual baselines are rendered natively on your machine (bun run test:vrt:update, bun run test:vrt; see Tests), never in Docker. Resume: gh workflow enable ci.yml + re-add CI ok to the main ruleset.
  • Gates are real: never push with --no-verify or LEFTHOOK=0. A gate that fails only under load is a bug to fix (#73 was exponential regex backtracking; #75, #76, #81 were real races), not to bypass. Open an issue for it.
  • Done: bun run validate, changeset when the package changes, docs when behavior changes; leave the PR as draft. A maintainer lands it with sh scripts/issue-land.sh <n>: rebase on main (generated files are rebuilt, real conflicts stop it), test-changed --land (the tests and screenshots the diff affects plus 5 canary routes, 3 minute budget; a screenshot mismatch stops it: review and commit test:vrt:update, then rerun), validate on the push (90 s budget), squash-merge, issue:done.

Tests

Unit tests run on bun test (repo root, bunfig.toml); component and visual tests need a real Chromium and stay on Vitest. A command is a time budget, not a coverage promise: day-to-day bun run test runs only what the diff against origin/main affects and takes seconds; the whole suite runs at release, as four commands (test:full, test:e2e, test:routes, then test:vrt) so that none takes five minutes. A run over its budget fails (below), so slowness is caught like any other regression.

Command What it runs Budget Measured
bun run test = test:changed what the diff affects: bun test --changed (unit); the component tests whose imports reach a changed file (scripts/test-graph.ts: a static import graph with the barrel flattened, because Vitest's own --changed selects every file in browser mode); the screenshots of those components; the docs routes mapped by scripts/route-impact.ts. Prints what it selected and why. Every component test runs when a shared file or a test-runtime dependency changed (styles, tokens, theme, stores, primitives, a test setup file, package.json, bun.lock): the run prints widened to all components because <file> and the budget widens with it. The site part needs a fresh site/dist (a build is ~75 s, over the budget) and says so when it skips 60 s narrow; 240 s (landShared) when widened to all components (scripts/test-changed.sh reads it from --print-budget) 28 s for a one-component edit with a fresh dist (unit 0.4, components 4.6, screenshots 6.7, routes 16.6; load ~15). Widened by the happy-dom bump (#588): 95 s at load 10.5 for the full component set (~2190 tests), which is why the narrow 60 s does not apply to it
bun scripts/test-changed.ts --land the same plus the package and site builds and 5 canary routes (home, recipes, app-shell, templates/app, theme). This is the gate issue-land.sh runs; validate runs on the push. The package builds first (the unit tests that read dist need it), then the site build runs beside the unit and component tests (the component tests render from src). A narrow run builds only the pages it audits (CA_SITE_ROUTES, site/src/lib/only-routes.ts filters the gallery, atlas and docs paths): 81 pages in ~45 s instead of 915 in 120 s, and static (the zero-browser HTML checks) reads 96 pages in 4 s instead of 918 in 47 s. A run that audits every page (a shared change, a changed behaviour spec) or any behaviour spec builds the whole site. site/dist/.ca-scope.json records what a build holds (written by the build, site/scripts/scope-integration.ts), so a dist is reused only when it covers the pages a run needs, and release-sweep.test.ts skips on a narrow one. When a shared file changed (styles, tokens, theme, stores, primitives, a test setup file, the site layout, or a file the route map cannot place) it runs every component test and a thinned route sweep and no screenshots, under the larger budget; --print-budget says which one applies. The thinned sweep (E2E_GALLERY=representatives) keeps 6 gallery items, 3 atlas terms and the fewest docs pages that still render every component folder and subpath (docsRepresentatives in scripts/route-impact.ts: 81 of 160), plus every page the diff itself changed (E2E_KEEP); test:full audits all of them. The ~880 social cards are cached by the sha256 of their HTML and the browser version in $TMPDIR/ca-og-cards (shared by every worktree, CA_OG_CACHE_DIR overrides, cards unused for 30 days are pruned) and rendered four at a time 180 s; 240 s when a shared file changed 104 s for a one-component edit (builds 17 + 58, routes 17; load ~15). Shared case at load 7 (components ALL 210 files): 193.5 s with a warm card cache (package 11 + site build 94 beside the tests + routes 88) and 224.8 s with a cold one (site build 125); it was 382.5 s before #598 (unit 8 + components 80 + package 12 + site 134 + routes 148)
bun run validate themes, token lint, Biome, unit tests, both type checks, build, size 90 s 51 s at load 11
bun run test:full scripts/test-full.ts: package build, then the site build beside the unit tests and every component test, then the fixtures. The build is part of the gate: a gate run on a dist older than src tested the previous release (#610). Release gate, part 1 of 4 300 s 164 s at load 6 to 16 (package 13 s, unit 35 s, components 111 s, site build 146 s beside them, fixtures 5 s)
bun run test:e2e the site's behaviour specs at both viewports, the iPhone WebKit specs, the overlay and theme-studio a11y specs, and static: the zero-browser HTML checks (dist-html.spec.ts: title, lang, viewport, description, canonical, og:title, one <main>, image alt, link and button names, frame titles, rel=noopener, skip target, duplicate ids) and brand assets on every page, gallery items and atlas terms included (~7 ms a page). Release gate, part 2 of 4; reads the site/dist of test:full 300 s 213 s at load 7 to 16 (1,282 tests)
bun run test:routes the page sweep: layout audit and smoke (routes-desktop, routes-mobile) and axe in dark, light and forced colors (axe) on every page except gallery items and atlas terms, which E2E_SWEEP=templates cuts to their representatives (GALLERY_REPRESENTATIVES, ATLAS_REPRESENTATIVES in scripts/route-impact.ts). /gallery/<id>/ is one viewer around a demo and /atlas/<slug>/ one term layout, so what differs between two of them is content, which the static checks read on every page and the demo's own docs route renders; packages/ui/tests/release-sweep.test.ts fails when the cut drops any other page. Proof it is not blind (#613): a deliberate regression in each template (a 900 px fixed-width box and an unnamed button in the term template, a second <main> in the item template) turned red all 3 term and all 6 item representatives on both viewports in the layout and smoke sweep (<main> landmarks, layout overflow), and axe flagged the unnamed button on all 3 terms; the static checks read the same two regressions red on 269 of 269 terms and 410 of 410 items. Release gate, part 3 of 4 300 s 148 s at load 7 to 16 (628 tests)
bun run test:unit bun test on packages/ui/tests/*.test.ts (fails any test without an expect) 30 to 40 s
bun run test:browser Vitest browser project (real Chromium, axe), every component test 109 s (load 25)
bun run test:fixtures packages/ui/tests/*.e2e.ts: builds a real fixture app (Astro, Kit) and drives it in Chromium (e.g. caUI + <ClientRouter />). Never name these *.test.ts: the unit suite runs in validate and in the release job, which have no browsers (tests/unit-suite.test.ts guards it) 7 s
bun run test:a11y Playwright a11y project only (overlays, theme studio) 40 s
bun run test:vrt every screenshot against the committed baselines of the platform you are on: package Vitest vrt (405) and the site's visual-desktop/visual-mobile (landing, templates, theme at the viewport). Release gate, part 4 of 4; also the way to check screenshots after a shared change, which the land gate leaves out 180 s components 105 to 137 s + site 7 to 11 s
bun run test:vrt:update the same, rewriting the baselines of your platform. Review the changed images against an intended change before committing; run test:vrt twice afterwards to prove they are stable components 133 s, site 70 s
bun run lighthouse Lighthouse (SEO and accessibility must be 100, performance floor) plus the installability contract (standalone manifest with id, 192/512/maskable icons, twitter:title, og:image, lang) on the built site served locally, offline. Not in validate: it needs a production build and a Chrome, is CPU-heavy and its performance score moves with machine load, which AGENTS.md forbids as a gate. Run it when you touch SEO, the manifest, <Seo> or init output; E2E_PREBUILT=1 skips the build
bun run clean:caches the size of every store the tooling writes outside the source tree (STORES in scripts/caches.ts: OG cards, Playwright's per-launch directories in $TMPDIR, run timings, the last run's reports and traces) against its cap, and what an eviction would free; -- --clean frees it
bun run test:status live and dead test runs of this repo, the test lock and its holder, and the browser count
bun run test:reap kill the browsers of runs whose runner died (this repo only)

"Measured" is wall time on the owner's M4 Pro (12 cores) with the load average noted; other agents and projects share that machine, so a number is only comparable with another taken in the same window. Budgets live in one table, BUDGETS in scripts/test-run.ts, and are raised only in their own commit with the measured reason.

Budgets are enforced. scripts/test-run.ts --budget=<name> starts the clock once it holds the lock, prints the 10 slowest tests when the run ends (runners append to CA_TEST_TIMINGS: tests/timing-reporter.ts for Playwright, packages/ui/tests/timing-reporter.ts for Vitest) and fails a run over its budget. A budget is wall time, so on an oversubscribed machine (load average above the core count) the verdict is printed as OVER BUDGET, NOT ENFORCED and does not fail the run: it would only measure the other processes. Rerun when the load is below the core count.

The Release workflow's Verify is unbudgeted, on purpose (#643). It runs bun scripts/validate.ts (every check, no wall time) instead of bun run validate: budgets are measured on the owner's machine, and the 4-core GitHub runner took 110.6 s against the 90 s budget for 4.6.0 while every check passed. The budgets stay enforced locally (validate on push, the land gate, and the four release commands bun run release runs before it tags), so a slow change is still caught before a tag exists. If CI is re-enabled, give each CI job its own measured budget in its own commit instead of reusing these.

One suite at a time. A run takes a machine-wide lock ($TMPDIR/ca-test-lock, shared by every worktree and checkout; CA_TEST_LOCK_DIR overrides it) and a second run waits, printing who holds it, instead of starting more browsers. A holder that died is taken over, and a nested test-run (the commands above call each other) reuses its parent's lock.

The package build skips itself when nothing it reads changed. bun run build in packages/ui is scripts/build-package.ts: it hashes the bytes of every input (INPUTS in packages/ui/scripts/build-inputs.ts: the package sources and scripts, the site's demos, docs, data and changelog parser, bun.lock, the versions of bun and svelte-package; scripts/content-hash.ts) as they are on disk, uncommitted edits included, and stops with build: dist is current when the hash is the one dist/.ca-input-hash holds (0.1 s instead of 12 to 15 s; the land gate builds, then validate on the push builds again). The stamp is written only after the last step succeeds. tests/build-cache.test.ts fails when a file the build scripts import is not under an input. A release never builds from the skip: release.js and test:full set CA_BUILD_COLD=1; set it yourself to force a build.

Nothing the tooling writes grows without bound. Every store has an age limit and a size cap in one table (STORES, scripts/caches.ts); scripts/test-run.ts enforces them each time it takes the lock (so nothing else is running), and a top-level run also empties the previous run's reports, traces and attachments (site/test-results, site/playwright-report, packages/ui/.vitest): a run keeps its own only. Playwright leaves one empty directory per browser launch in $TMPDIR and never removes it (2,059 had piled up); they are reaped after an hour. tests/caches.test.ts runs a store twice and asserts the footprint does not grow. A cache or artifact directory a script adds goes into STORES in the same commit.

Never call vitest / playwright test directly for browser tests: always go through the scripts above. They run under scripts/test-run.ts, which:

  • records each run in .cache/test-runs/<id>.json and tags every browser it starts with --ca-test-run=<id>;
  • tears down the whole test process group on exit, Ctrl+C, SIGTERM or SIGHUP;
  • at the start of every run, kills browsers left by runs whose runner died (SIGKILL, closed terminal, killed agent job).

Ownership is explicit: only processes carrying the tag of a dead run of this repo are touched. Other projects on the machine (../ac, …) share ~/Library/Caches/ms-playwright; never kill Playwright browsers by name or path. To pass extra args: bun run test:browser -- src/lib/components/Button. A browser test that must hold on a loaded machine gets its own *.throttled.svelte.test.ts (CDP Emulation.setCPUThrottlingRate, see CommandPalette.throttled); a full run under parallel CPU load (#544) must stay green. Multi-step keyboard flows take SLOW_FLOW from tests/settled.ts as their timeout; a condition is awaited with expect.poll/expect.element, never a fixed setTimeout deadline. A "does not happen" assertion on a component timer installs a fake clock BEFORE render (vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })), advances it with vi.advanceTimersByTimeAsync, restores real timers before Playwright calls and axe, and awaits settled() before asserting anything a frame produces (announce, observers), or the negative passes whatever the component does; prove each one fails by mutating the component (#568). devWarn prints once per key per page: run the silent cases before the one that warns. tests/unit-suite.test.ts fails a new Promise(r => setTimeout(r, n)) in a *.svelte.test.ts unless the line above it says real-time: and why. Local runs use a quarter of the cores (maxWorkers, Playwright workers: "25%", also when a shell exports CI=true: only GITHUB_ACTIONS gets every core, #539); behavior tests use the headless shell. Visual baselines are rendered natively and never in Docker. The platform is part of every baseline's file name (Name-dark-glass-chromium-darwin.png for Vitest, home-dark-darwin.webp for Playwright), so a platform is only ever compared with its own baselines and the linux and darwin sets sit side by side. The vrt project runs on darwin and linux and compares nothing elsewhere. The Vitest screenshot setup loads the same self-hosted typefaces as the site (@fontsource-variable), so text does not depend on the machine's fonts. On a platform with no baselines, run bun run test:vrt:update and review the images before committing. The linux set is what a future CI would use, or regenerate; CI stays disabled (ci.yml is untouched), so nothing but the local commands above renders or compares pixels. The site visual project captures the visible viewport of five pages (landing, templates, theme): the docs pages are a stack of component demos that the component screenshots already compare one by one, and the layout audit checks their geometry, so a tall full-page docs screenshot duplicated both and was the flaky one.

Naming conventions:

  • *.test.ts in packages/ui/tests/: unit test on bun:test (mock/spyOn/jest.useFakeTimers; DOM only through happy-dom's GlobalRegistrator inside that file; fresh module via import("…?fresh=n")). Types checked by bun --check.
  • *.svelte.test.ts: component in the browser, next to the component.
  • *.vrt.test.ts: per-component screenshot; matrix harness NameMatrix.test.svelte.
  • *.test.svelte: harness for snippets/bind: (not shipped in dist).
  • site/tests/*.spec.ts (desktop+mobile+iphone), *.axe.spec.ts (axe over every route, its own axe project), *.a11y.spec.ts, *.visual.spec.ts. A new route-level check goes into routes.spec.ts (it already loads every route once), never into a spec that loads every route again.
  • Keep test titles short: describe + it combined at most ~80 characters. Vitest names the trace attachment with path + title + hash, and names over 255 bytes fail with ENAMETOOLONG, losing the trace exactly when the test fails.

Rule: every new component needs a .svelte.test.ts with expect(await checkA11y(screen.container)).toHaveNoViolations() in each state (open/closed). render from vitest-browser-svelte is async. a11y_* warnings are errors: bun run check uses --fail-on-warnings and the site build fails via onwarn (site/svelte.config.js). (svelte-package does not compile .svelte, so in the package it is check that blocks.)

Accessibility rules (package and apps):

  • A page has exactly one <main> and one id="main". DocsShell and AuthLayout already provide them; AppShellLayout renders <main id="main" tabindex="-1" class="ca-shell__content"> as the SkipLink target (pass mainId={null} if the page renders its own <main id="main" tabindex="-1">: the region becomes a plain div, and skipLink={false} when embedding the shell in a page that already has a skip link). PageShell never emits <main>.
  • Screen reader announcements go through announce(msg, politeness) (one shared live region); do not create a new local role="status". In SvelteKit, mount RouteAnnouncer (@codeatlasdev/ui/kit) once in the root layout.
  • An aria-label on a button must contain the visible text (WCAG 2.5.3); compose with aria-labelledby as Combobox does with ariaLabel.
  • New surface CSS that blurs, adds a shadow or sets border: none needs an entry in the @media (forced-colors: active) block of materials.css (CanvasText border). Contrast tokens (prefers-contrast: more) come from themeCSS/presetCSS; tests/contrast.test.ts measures 7:1 for text and 3:1 for outlines in every preset.
  • Every prerendered route is checked by three specs that each load it ONCE per project, never again: site/tests/routes.spec.ts (desktop 1280x800 and mobile 360x780: 200, a title, exactly one <main>, at most one #main and one skip link, no console or page errors, and the layout audit in layout-audit.ts: no sideways scroll, no overlap, no clipped text, mobile 44px targets); site/tests/routes.axe.spec.ts (the axe project: axe in dark, light and forced colors on every route, color-contrast included in dark and light, <pre> excluded because Shiki's palette is proven by CodeSnippet.svelte.test.ts); and site/tests/dist-html.spec.ts (page rules read from site/dist with a DOM parser, no browser; it also pins the known duplicate heading ids so a new one fails, #548). makeAxe(page) runs axe.run in the page: @axe-core/playwright's builder re-injects axe and opens a blank page on every analyze() and was 4 to 7 times slower on the same page and rules (568 against 155 ms on a 1,027-node page, 3,661 against 514 ms on /gallery/), with identical findings on real and deliberately broken pages (low contrast, missing alt, a button without a name, same-origin and srcdoc frames). The in-between widths (768, 1024, 844x390, resized inside the same load) run only on routes that render layout primitives, on composed pages (templates, landing, theme, gallery) and on routes the diff touches. Hydration is awaited with Astro's own astro:hydrate event (waitIslands), and settleDom (fonts, finite animations, shiki swap) replaces the 500 ms height-stability window that only screenshots pay (settle). scripts/route-impact.ts maps a changed file to its routes and fails safe: a shared path (styles, tokens, theme, primitives, the site layout or shell, a package component the shell renders) or any file it does not recognise selects every route, and says which file and why. The axe timeout is sized per route from the page's node count, never a hand-kept list of slow routes; A11Y_CPU_THROTTLE=<n> (CDP CPU throttling) reproduces a loaded machine deterministically.

The browser setup (tests/setup.browser.ts) parks the Playwright pointer outside the viewport before a test that follows a pointer event (and before the first test of a file; the CDP call costs ~17 ms and almost no test moves the pointer): the pointer is page state, so without it a test that hovers leaves :hover active on whatever the next file renders at the top-left (a filled Button reads its hover fill, a tooltip is already open) and results depend on file order. A test that needs a pointer moves it itself (userEvent.hover); src/lib/testing/pointer-reset.svelte.test.ts guards the hook. checkA11y settles by jumping running animations to their end state (settled() in @codeatlasdev/ui/testing), not by sitting through a 150 ms entrance transition on each of ~430 checks. Component tests keep the default per-file isolation: isolate: false was tried on the browser project and failed 775 of 2095 tests (every file shares one page) in a run that took 642 s, so it is not an option here; vitest doctor stops on this suite when any test flakes under load, which is why the comparison was run by hand.

Reading failures: locators fail with an ARIA snapshot; traces are in __traces__/ (Vitest) and site/test-results/<id>/trace.zip (bunx playwright trace open <zip>); visual diffs are in packages/ui/.vitest/attachments and site/playwright-report. For lean output: bunx vitest run --reporter=minimal. Optional: npx @playwright/mcp to inspect the site.


The gallery index (demo facets)

Every numbered demo (site/src/demos/<group>/<page>/<Variant>.svelte, id in site/src/demos/catalog.ts) has a Variant.meta.json sidecar, and the sidecar carries an index block: the facets the gallery filters and searches on. bun run --cwd packages/ui manifest -- --check (and every build) fails when the block is missing or wrong, so a new demo cannot ship without it.

  • Facets: kind + subtype, industry[], style[] (a designStyle id or one of the planned recipes), motion (none state-free, subtle state change, expressive entrance/scroll/loop, immersive physics/3D/shader/cursor-driven scene), interaction[], layout, colorMode, density, tech[], cost, components[], aliases[] (lowercase search synonyms), related[] (catalog ids), a11y { reducedMotion, keyboard }, added (ISO date). The closed vocabularies live in packages/ui/scripts/index-schema.ts; an unknown value fails the check.
  • Inferred, never hand-typed: kind, subtype, components (must equal the package imports of the demo), tech, cost, added. For a new demo run bun packages/ui/scripts/index-backfill.ts (it keeps every value already in a sidecar and fills these), then write industry, style, motion, interaction, layout, colorMode, density, aliases, related and a11y.keyboard by reading what the demo renders. Do not copy another demo's block.
  • Id ranges: ids stay append-only; 3 digits to 999, 4 digits from 1000. ID_RANGES in index-schema.ts reserves a block per kind (components 001-299 and 400-999, motion 300-399 by subtype, sections 1000-2999 in blocks of 100 per subtype, backgrounds 3000-3199, techniques 3200-3399, 3D 3400-3499, shaders 3500-3599, pages 4000-4499, templates 4500-4999). The check fails when an id sits outside the block of its kind/subtype. Four legacy ids (256, 387-389) are exempt (LEGACY_OUT_OF_RANGE); a new id never is.
  • Publishing gate: a hand-read facet reaches index.json, the markdown twins and every filter only when the one-sided 95% upper bound of its error rate on a seeded, folder-stratified audit is at most 5% (AUDIT, errorUpperBound, MAX_ERROR_RATE in index-schema.ts). 0 wrong of 73 reads is "at most ~4% wrong", never "0%". A field above the limit stays in the sidecar, marked "review": { "<field>": "pending" }, validated but hidden until it is corrected and re-audited with a new seed; flip it in AUDIT only with the new numbers. Only 203 of 385 entries were ever re-read; the other 182 hold the first pass's value as corrected by rules, and are unverified beyond them. Today industry is pending (5 wrong of 73, upper bound ~14%). Never fix a field by editing the sample you measured it on: re-audit on entries you have not read.
  • Output: the manifest build writes site/public/api/v1/index.json (every entry with its facets and per-facet counts) and one markdown twin per id at site/public/gallery/<id>.md. Both are generated: never edit them; commit the regenerated files.
  • Originality: write every title, summary, alias and prompt in our own words. Never name, link to or credit an external site or product in a demo, sidecar or generated file (tests/demos-index.test.ts scans the sidecars).

Critical rules

  1. Test first, always. Every bug fix and issue starts with a regression test that reproduces the problem. Run it and confirm it fails on the current code, then fix, then confirm it passes. A PR reports that fail-before/pass-after evidence. Tests exist to prove a behavior or a fix, never to pad coverage: no tests of wiring, copies, mock echoes or incidental defaults. If a test cannot fail for a plausible bug, delete it.

  2. Required setup: consumers import the CSS once (it already includes materials.css, themes and utilities), register both Vite plugins (caThemePlugin: anti-FOUC script + ssr.noExternal for TanStack Table and Shiki; caErrorPlugin: scaffolds +error.svelte/error.html) and export handle + handleError from the server hook. Plugin order in plugins does not matter (built with every order on Kit 3.0.1 / Vite 8.3); init writes them after sveltekit():

    import "@codeatlasdev/ui/styles/global.css";
    // vite.config.ts
    import { caErrorPlugin, caThemePlugin } from "@codeatlasdev/ui/vite";
    import { theme } from "./src/lib/theme.ts";
    export default defineConfig({
      plugins: [sveltekit(), caThemePlugin(theme), caErrorPlugin({ scaffold: true, theme })],
    });
    // src/hooks.server.ts: injects the theme script into SSR/prerendered pages, sanitizes server errors
    import { sequence } from "@sveltejs/kit/hooks";
    import { caThemeHandle, createErrorHandler } from "@codeatlasdev/ui/kit";
    import { theme } from "$lib/theme"; // Kit 3: "#lib/theme.ts"
    export const handle = sequence(caThemeHandle(theme));
    export const handleError = createErrorHandler();

    SvelteKit 3 (@sveltejs/kit ^3): config goes into sveltekit({ adapter, ... }) (no svelte.config.js, no kit namespace), tsconfig.json extends $app/tsconfig, $lib is removed in favor of #lib/* (with the .ts extension), and vite.config.ts imports the theme with its .ts extension. bunx @codeatlasdev/ui init detects the Kit version. Details: the SvelteKit 3 section of .agents/skills/codeatlas-ui/SKILL.md.

  3. Svelte 5 runes: all code uses runes ($state, $derived, $effect, $props). Never use legacy writable/readable.

  4. {@const}: valid only as an immediate child of {#each}, {#if}, {#snippet}, {:else}, or Component. Never inside <div> or another element.

  5. svelte:component: deprecated in runes mode. Use {@const Icon = X}<Icon /> or <X /> directly.

  6. Sub-components: DropdownMenuItem etc. are separate .svelte files exported by name; there are no dotted namespaces.

  7. OKLCH: the whole color palette uses OKLCH. MUI does not accept OKLCH directly; if you integrate with MUI, convert to hex.

  8. No inline CSS: never style="padding: 20px" or style="max-width: 480px". Use CSS classes with tokens (var(--ca-space-5)). The only exception is dynamic CSS custom properties: style="--bar-w:{pct}%".

  9. No Stack/Flex for field layout inside FormSection: FormSection already has layout="grid" (default), layout="list" and layout="stack". Do not wrap fields in Stack gap="4" as a layout workaround.

  10. ca-* classes are a public contract: consuming apps use the BEM classes (.ca-btn, .ca-dialog__body, …) as CSS targets. Renaming or removing a ca-* class listed in docs/css-api.md is a breaking change. When you create or rename a component, update that file. The stable parts, scoped variables and data-* hooks of every component are generated from its CSS into API.md, dist/parts.json and the MCP list_parts; a class a component only uses internally is named ca-x-* (never a hook), and @keyframes names are internal. codeatlas-ui workarounds --strict fails when an app selector targets anything else.

  11. Mobile: heights use var(--ca-vh) (never 100vh/100dvh); fixed UI uses var(--ca-safe-*); a form with a keyboard uses use:keyboardAware (@codeatlasdev/ui/actions); a full-screen mobile view is MobilePane, an action bar is StickyActions; the 44px touch target is global under (pointer: coarse), so do not write an override in the app. App viewport: width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content.

10a. Cursor: never write cursor: pointer for a clickable element. The reset (@layer ca.reset) gives pointer to button, a[href], label[for], summary, [role=button|tab|option|menuitem*] and not-allowed to :disabled / [aria-disabled=true]; a custom clickable tile is <button> or carries a role from that list. Override it with an unlayered app rule.

10b. Hover UI under a modal: never open a hover- or edge-triggered panel while a modal is open. Tooltip, HoverCard and NavigationMenu already check the layer stack; for your own, gate on layers.modalOpen (or layers.blocks(trigger), false for a trigger inside the topmost modal) from @codeatlasdev/ui. Never fight it with z-index: it has no effect in the top layer.

10c. Code surfaces (an editor, a line highlight, a gutter, a custom pre): line-height: var(--ca-code-line-height) (an integer px; a fractional one makes a caret and an active-line highlight drift apart) and font-family: var(--ca-font-mono) on every layer, code included. CodeSnippet, .ca-code and .ca-prose pre already do.

  1. Theme: a single ThemeOptions in src/lib/theme.ts (plain object, import type only: vite.config/astro.config load the file in Node, where the package root does not evaluate .svelte), passed to caThemeHandle + caThemePlugin (SvelteKit) or caUI (Astro). defaultStyle (the surface material, solid by default) is one field of it, not a separate setup. Override a token by declaring --ca-* on :root in the app, without !important. Local islands: data-theme-scope="dark|light". Automatic setup: bunx @codeatlasdev/ui init.

  2. SEO: a public page uses <Seo> from @codeatlasdev/ui/seo with a required canonical; a private page passes noindex.

    A11y tests: test every state (open, error, empty, light, dark) with await expectNoA11yViolations(container) from @codeatlasdev/ui/testing (needs the optional peer axe-core). Inside this repo packages/ui/tests/a11y.ts re-exports the same checkA11y; never fork the axe options.

  3. Discovering the API: never guess a prop. Use the ca-ui-mcp MCP server (get_component, get_export, get_equivalent, list_components, list_parts, get_migration, search_tokens, list_surfaces, get_guide, get_style, list_concepts, get_concept, and for the design atlas and its verified sources search_terms, get_term, get_reference) or .agents/skills/codeatlas-ui/API.md; both cover components and every non-component export (stores, kit, vite, astro, actions, seo, forms, tokens: signature + summary) and are generated from the code by packages/ui/scripts/build-manifest.ts (runs on build). [ca-ui] dev warnings (via devWarn, esm-env) flag an IconButton without aria-label and a Dialog without title.

  4. Agent skills layout (npx skills add codeatlasdev/ui): one folder per skill under .agents/skills/, named exactly like the name: in its SKILL.md (codeatlas-ui, codeatlas-dx, codeatlas-writing). Never put a SKILL.md directly in .agents/skills/ (the CLI then treats the directory as a single skill and lists nothing else), and quote any description: that contains : (an unquoted one is a YAML error and the CLI silently skips the skill). packages/ui/tests/skills.test.ts enforces this.


Writing the Concepts and the atlas (evidence rules)

Concept pages (site/src/content/docs/concepts/) and the design atlas (site/src/data/atlas/) make claims about users. Full text: https://ui.codeatlas.com.br/docs/concepts/evidence/. Binding for every page, dataset entry and agent answer you write:

  1. Never brand as science. The section is "Evidence", the atlas is a "design atlas". Never write "scientifically proven", "science says", "science-based", "studies show", "research proves" or "research says" (BANNED_PHRASES in site/src/data/evidence/references.ts; packages/ui/tests/evidence-rules.test.ts fails on them).
  2. Claim-level sourcing. Every empirical claim about users is a <Claim strength="established|supported|preliminary|practice|heuristic" refs="E01" scope="who, which task, which context">. No verified source: heuristic (no refs) or cut it.
  3. Scope with every claim: who was studied, the task, the context ("brief exposures in a lab; no preference for any style"). Never generalize beyond it.
  4. Standards are not evidence of preference. WCAG, specs and platform guides are practice: what is required or recommended.
  5. No causal or demographic claims about taste. An audience is a labelled hypothesis.
  6. Each concept page ends with <Limits reviewed="YYYY-MM-DD"> and <Refs ids="…">, in the template order: definition and vocabulary, evidence, contract, how the library implements it, anti-patterns, checklist, prompt for your AI, limits, references.
  7. Verify before publishing. Open the DOI or URL, read the wording you rely on, and record verified.date, verified.level (claim-read, or identity-only when the text was not reachable: then no <Claim> may cite it) and the excerpt in site/src/data/evidence/references.json. Unverifiable sources are not added. A source's kind caps its strength (checkReferences): a standard or guideline is practice, a preprint or a vendor study is preliminary.
  8. No number you cannot trace. A figure goes with its study and its conditions, or it does not go in.
  9. Design inspiration sites are never named; science and standards are cited by name where they are primary sources.

Glass and frosted styles

glass, frosted and the liquid recipe are opt-in surface styles (the package default is solid: opaque, no blur; a page with no data-style on <html> is solid too). The rules below apply only when blur is used: html[data-style] is glass or frosted, a component sets data-ca-blur, or a surface has material="glass" | "frosted" | "liquid". An app on the default style never needs them, and a new example must not add material="glass" just to look finished.

  • backdrop-filter + will-change: transform: never combine the two on the same element or an ancestor. It kills the glass blur. backdrop-filter is declared in styles/surface.css and styles/materials.css only: a component sets data-ca-blur="<role>" (roles: ultra-thin, thin, regular, thick, chrome, mat-thin, mat-regular, overlay, chrome-layer, tooltip; FloatingLayer takes it as blur="overlay"|"tooltip"), or is a .ca-surface, so html[data-style] drives every blur. The role rules write -webkit-backdrop-filter first. data-ca-blur-wide-off drops it from 900px up (an inline layout); .ca-btn--glass and .ca-icon-btn--glass are the regular role through their own class and lose it while disabled. The modal scrim is dialog.ca-overlay::backdrop in surface.css. New blurred element: add the attribute, never the property; the migration is guarded by src/lib/theme/surface-migration.svelte.test.ts (computed styles against surface-migration.baseline.json).
  • isolation: isolate: backdrop-filter inside an isolation: isolate parent captures the isolated context, not the real backdrop.

Spacing system (global tokens)

Every component uses CSS variables, never hardcoded px values.

/* Spacing scale: base 4px */
--ca-space-1:  4px
--ca-space-2:  8px
--ca-space-3:  12px
--ca-space-4:  16px
--ca-space-5:  20px
--ca-space-6:  24px
--ca-space-7:  28px
--ca-space-8:  32px
--ca-space-10: 40px
--ca-space-12: 48px
--ca-space-16: 64px

/* Page layout: consistent padding across ALL page layouts */
--ca-page-px:    24px   /* default horizontal padding */
--ca-page-px-sm: 16px   /* mobile horizontal padding */
--ca-page-pt:    20px   /* default top padding */
--ca-page-pt-sm: 14px   /* mobile top padding */
--ca-page-gap:   20px   /* gap between sections */

Utility classes (global.css → available in every app)

Imported automatically via global.css. No extra configuration needed.

Class Use
ca-search-field Toolbar search field: flex:1, min-width:180px, max-width:320px
ca-toolbar-spacer flex:1: pushes items to the right side of the toolbar
ca-page-info "1–8 of 240" text in the pagination footer
ca-row-actions Row actions: hidden by default, visible on hover
ca-btn-danger Red outlined button
ca-btn-danger-ghost Red ghost/text button
ca-bar-fill Dynamic progress bar: uses the --bar-w CSS var
ca-bar-fill--warn Warning variant of the bar
ca-pricing-cta CTA button at the bottom of a pricing card (width:100%; margin-top:auto)
ca-sr-only Visually hidden, still in the a11y tree (table <caption>, unlabeled control, extra AT-only text). Never display:none/aria-hidden for this
ca-sr-only ca-sr-only--focusable Same, but shown again while it or a descendant has focus (skip links)

This is the most important rule for staying consistent across the site and real apps.

AppList: fullscreen list/CRUD

When to use: an entity list page (users, orders, invoices, etc.)

Behavior: the page does NOT scroll. Only the table scrolls. Header, toolbar and pagination footer stay fixed.

<AppList title="Users" data={users} searchKeys={['name', 'email']} perPage={8}>
  {#snippet actions()}
    <Button variant="filled" href="/users/new">+ New user</Button>
  {/snippet}

  {#snippet filters(list)}
    <FilterGroup options={roleOpts} value={list.filters['role']}
                 onchange={(v) => list.setFilter('role', v)} />
  {/snippet}

  {#snippet children(list)}
    <SimpleTable>
      <thead><tr><th>Name</th><th>Role</th><th>Status</th></tr></thead>
      <tbody>
        {#each list.rows as u (u.id)}
          <tr>
            <td class="primary"><UserCell name={u.name} email={u.email} /></td>
            <td>{u.role}</td>
            <td><StatusChip status={u.status === 'active' ? 'success' : 'neutral'} /></td>
          </tr>
        {/each}
      </tbody>
    </SimpleTable>
  {/snippet}
</AppList>

With data, AppList renders search, count, pagination and empty state on its own. Use manual toolbar/footer only when pagination happens on the server.


AppForm: create/edit form

When to use: a page to create or edit an entity (New User, Edit Post, etc.)

Behavior: header and footer stay fixed. Only the form body scrolls.

<AppForm title="New User" description="Fill in the details to create a new user.">
  {#snippet leading()}
    <!-- back button goes here -->
    <Button variant="ghost" size="sm" href="/users">
      {#snippet leading()}<ArrowLeft size={15} />{/snippet}
    </Button>
  {/snippet}

  <!-- FormSection layout="grid" is the default: text fields sit in 2 columns -->
  <FormSection title="Account details" description="Basic information.">
    <TextField label="Full name" bind:value={name} />
    <TextField label="Email" type="email" bind:value={email} />
    <Select label="Role" bind:value={role} options={roleOpts} />
  </FormSection>

  <!-- layout="list" for Switch, SettingItem, Divider -->
  <FormSection title="Notifications" layout="list">
    <Switch label="Send welcome email" bind:checked={notify} />
  </FormSection>

  {#snippet footer()}
    <Button variant="ghost" href="/users">Cancel</Button>
    <Button variant="filled" onclick={save} disabled={saving || !name || !email}>
      {saving ? 'Saving…' : 'Create user'}
    </Button>
  {/snippet}
</AppForm>

AppForm rules:

  • The back button goes in {#snippet leading()}, not as the first child of the form
  • Cancel/save actions go in {#snippet footer()}, never in the body
  • Do not pass maxWidth: the content takes all available space
  • FormSection default layout="grid" works for text fields
  • FormSection layout="list" for Switch/SettingItem (no gap, flush)
  • FormSection layout="stack" for composite blocks (Avatar + fields)

AppSettings: settings with side navigation

When to use: a settings page with multiple sections (Workspace, Notifications, Security, etc.)

Behavior: fixed side nav on the left. The right content scrolls.

<script lang="ts">
  import { AppSettings, FormSection, SettingItem, Switch, Button, Divider } from '@codeatlasdev/ui';
  import { Settings, Bell, Shield } from '@lucide/svelte';

  const TABS = [
    { value: 'general',  label: 'General',       icon: Settings },
    { value: 'notifs',   label: 'Notifications',  icon: Bell     },
    { value: 'security', label: 'Security',       icon: Shield   },
  ];

  let tab    = $state('general');
  let saving = $state(false);

  function save() { saving = true; setTimeout(() => (saving = false), 900); }
</script>

<AppSettings
  title="Settings"
  description="Manage your workspace."
  tabs={TABS}
  activeTab={tab}
  onTabChange={(t) => (tab = t)}
>
  {#snippet actions()}
    <Button variant="filled" size="sm" onclick={save} disabled={saving}>
      {saving ? 'Saving…' : 'Save changes'}
    </Button>
  {/snippet}

  {#if tab === 'general'}
    <FormSection title="Workspace">
      <TextField label="Name" bind:value={workspaceName} />
      <Select label="Timezone" bind:value={timezone} options={tzOpts} />
    </FormSection>
  {/if}

  {#if tab === 'notifs'}
    <FormSection title="Email" layout="list">
      <SettingItem label="New signups" description="Email on every new user.">
        <Switch bind:checked={emailSignup} />
      </SettingItem>
      <Divider />
      <SettingItem label="System errors" description="Critical alerts.">
        <Switch bind:checked={emailErrors} />
      </SettingItem>
    </FormSection>
  {/if}
</AppSettings>

AppSettings rules:

  • Do not pass maxWidth: the content takes the whole available area
  • Each tab renders with {#if tab === 'x'} in the body
  • FormSection layout="list" for SettingItem + Switch (the most common pattern in settings)
  • FormSection layout="grid" for text fields (name, email, etc.)
  • Global actions (Save/Cancel) go in {#snippet actions()}

MobilePane and StickyActions: native-feeling mobile

<MobilePane bind:open={chatOpen} title="Maria" onback={() => (chatId = null)}>
  {#snippet actions()}<IconButton aria-label="More">…</IconButton>{/snippet}
  <Messages />
</MobilePane>

<StickyActions align="stretch" show={dirty}>
  <Button variant="ghost">Cancel</Button>
  <Button variant="filled">Save</Button>
</StickyActions>

MobilePane: bind:open, title, onback?, actions?, headerTitle? (replaces the title text), scroll=true (false when the body has its own scroll and composer). Never hand-roll position: fixed for full-screen mobile views. AuthLayout shows a brand strip below 768px (mobileBrand, default true); do not create a local AuthBrand.


PageShell: dashboards and free-form content pages

When to use: dashboards, reports, analytics. Any page that is not a list, form or settings page.

<PageShell title="Dashboard" description="Monday, Sep 22, 2026" maxWidth="1200px" gap={24}>
  {#snippet actions()}
    <Button variant="ghost" size="sm">Export</Button>
  {/snippet}

  <KpiGrid>
    <KpiCard title="Revenue" value="$112k" trend={18.4} icon={DollarSign} />
    <KpiCard title="Users"   value="24.8k" trend={12.1} icon={Users}      />
  </KpiGrid>

  <Card title="Revenue vs Expenses">
    <LineChart series={[...]} categories={months} area height="260px" />
  </Card>
</PageShell>

Units: PageShell gap is a number of pixels, and KpiGrid gap is a number of pixels or a CSS length. Flex, Stack and Grid take a spacing step instead (gap="4" is --ca-space-4, 16px) and Grid takes columns, not cols.


FormSection: quick reference

layout Use when Behavior
"grid" (default) TextField, Select, NumberField Auto-fill 2 columns, minmax(260px, 1fr)
"list" Switch, SettingItem, Divider Single column, no gap between items
"stack" Composite blocks (Avatar + fields, groups) Single column, gap: 16px

Never wrap fields in <Stack gap="4"> inside FormSection. Let layout do the work.


Available components

Page layout (App-level)

Component Use
AppList Fullscreen list: table with internal scroll, fixed pagination
AppForm Form: header + scrolling body + sticky footer
AppSettings Settings: side nav + scrollable content
PageShell Dashboard/content: normal scroll, max-width, configurable gap
MobilePane Full screen above the shell on mobile (chat thread, history, detail): header with back, Escape closes, focus enters/returns, safe-area and virtual keyboard
StickyActions Form action bar stuck to the end of the scroll (align="end" | "stretch", show): themed bar chrome, safe-area and virtual keyboard

Content layout

Component Use
FormSection Form section: title, description, grid/list/stack of fields
SettingItem Settings row: label + description + control (Switch, Button)
Card Titled container: title, headingLevel, padding, flush, full, skeleton, href, headerActions
Surface Painted container: elevation, material, tone, variant, radius, interactive, glow, p/px/py
KpiCard Metric card: value, trend, icon
KpiGrid Row of KpiCards that never ends on an empty cell (counts the children; never use Grid auto-fill for KPIs)
Grid CSS grid with equal tracks: columns (1-12 or { base: 1, md: 3 }), gap (spacing step). For 2fr 1fr write grid-template-columns in a class
Flex Flex container with gap (spacing step), align, justify, direction, wrap
Stack Flex column with gap (spacing step)
Box, Container, Section Padding box, width-capped centered column, page band
EmptyState Empty state with icon, title, action

AppShell (app shell)

Component Description
AppShellLayout Root layout: Rail + Panel + content
Rail 52px side dock. Logo via {#snippet brand()}, actions via {#snippet railBottom()}
Panel 200px navigation panel with spring animation
BottomNav Bottom navigation for mobile
MobileHeader Mobile header

Surfaces

Component Use
Surface Generic container with material (auto follows the theme; solid, frosted, glass, liquid are explicit), elevation, tone, variant, radius
Card Surface with default padding, optional title, flush, full
LiquidGlass The SVG refraction layer Surface paints for material="liquid"; rarely used directly

GlassCard, GlassSurface and Row are deprecated and not for new code: use Card, Surface and Flex (bunx @codeatlasdev/ui migrate --write).

DataTable (TanStack Table v9)

import { DataTable, createCaTable, createCaColumnHelper, caTableFeatures } from "@codeatlasdev/ui/table";
  • Server-side: createCaTable + caTableFeatures for server-side sorting/filtering/pagination
  • CaColumnDef<T>: column type with metadata (meta.label, meta.filterVariant)
  • For simple lists use SimpleTable + UserCell + StatusChip, which is lighter

Display

Avatar · Image · Badge · Alert · Chip · StatusChip · KpiCard · KpiGrid · EmptyState · Timeline · Sparkline · SimpleTable · LiveList · ExternalLink

  • Streaming rows (SSE/WebSocket/bulk job): LiveList (items, item snippet, total, status, label). It is a role="log" with aria-live="off": rows are summarised as one polite announcement per batch, auto-scroll follows only while the user is at the bottom (scrolling up pauses it and shows a "N new" button) and nothing ever calls focus(). Do not put StatusChip (it has role="status") or any live region inside a row; use Chip or text.

  • External links: ExternalLink (never hand-write target="_blank"): sets rel="noopener noreferrer", an aria-hidden icon and a localised "(opens in a new tab)" for screen readers (externalLink.newTab). Button href target="_blank" already sets rel.

  • Export (CSV/JSON): ExportMenu (data, filename, columns, formats, onexport) or the helpers toCsv / toJson / downloadFile from the package root. CSV is RFC 4180 and neutralises spreadsheet formulas.

  • SimpleTable responsive: "scroll" (default) or "cards". With cards, below md each <tr> becomes a card and each <td> shows its label via ::before { content: attr(data-label) }. Required: pass data-label="Column" on every <td> (a cell without data-label has no label). No local scroll wrappers needed.

    <SimpleTable responsive="cards">
      <thead><tr><th>Name</th><th>Status</th></tr></thead>
      <tbody>{#each rows as r}<tr><td data-label="Name">{r.name}</td><td data-label="Status">{r.status}</td></tr>{/each}</tbody>
    </SimpleTable>
  • Touch target: --ca-touch-target: 44px (tokens.css). Under @media (pointer: coarse) it becomes min-height/min-width of IconButton, the Select trigger, TabsTrigger, ToggleGroup, SegmentedControl, PinInput and calendar cells. It is a media query, not a prop: do not write 44px overrides in the app.

Inputs (own primitives, themed by tokens)

Button · IconButton · TextField · TextArea · Select · Combobox · Checkbox · Switch · Slider · NumberField · FileUpload · ToggleGroup · SegmentedControl · SwatchGroup · PinInput · CopyButton · DatePicker · DateRangePicker · DateField · TimeField · Calendar · RangeCalendar

  • Button / IconButton loading: swaps leading (Button) or the icon (IconButton) for a Spinner, applies aria-busy="true", disables the click (onclick does not fire) and keeps the label. Class: ca-btn--loading.
  • ToggleGroup: type="single" | "multiple", bind:value (string or string[]), items: { value, label?, icon?, disabled?, 'aria-label'? }[] (an icon-only item requires aria-label), size="sm" | "md", variant="outlined" | "ghost", orientation, onchange.
  • SegmentedControl: single ToggleGroup with a sliding pill (.ca-seg__thumb, no animation under reducedMotion). Props: bind:value, items, size="sm" | "md", fullWidth, disabled, onchange.
  • SwatchGroup: single-choice color picker (role="radiogroup"). bind:value, items: { value, label?, color?, disabled? }[] (without color the swatch paints from data-preset={value}; value="" means none selected), size="sm" | "md", disabled, onchange. Under pointer: coarse the swatch itself is the 44px target.
  • PinInput: OTP. length=6, bind:value, type="numeric" | "text", label, description, error, mask, oncomplete(value).
  • CopyButton: value, label="Copy" ('' = icon only), size, variant="ghost", toast?: string (success message), oncopy. The icon becomes Check for 2s.
  • Dates (DateValue values from @internationalized/date, re-exported by the root: CalendarDate, Time, today, parseDate…): locale="pt-BR", weekStartsOn=1, minValue, maxValue, label, description, error, disabled, required, bind:value.
    • DatePicker: field + popover with a calendar (bind:open, granularity, numberOfMonths, closeOnDateSelect, name, triggerLabel)
    • DateRangePicker: value: DateRange ({ start, end }), closeOnRangeSelect
    • DateField: only the typeable segments, no popover
    • TimeField: value: Time, granularity="hour" | "minute" | "second", hourCycle
    • Calendar / RangeCalendar: inline grid (fixedWeeks, isDateDisabled, isDateUnavailable)
<Button loading={saving} onclick={save}>Save</Button>
<SegmentedControl bind:value={view} items={[{ value: "list", label: "List" }, { value: "grid", label: "Grid" }]} />
<DatePicker label="Delivery" bind:value={date} minValue={today(getLocalTimeZone())} />
<TimeField label="Time" bind:value={time} />
<PinInput length={6} oncomplete={verify} />

Overlays

Dialog · AlertDialog · Drawer · DropdownMenu · Popover · Tooltip · CommandPalette · NotificationBell

  • AlertDialog: confirmation that requires an answer (role="alertdialog", no "X", clicking outside does not close). Props: bind:open, title, description?, confirmLabel="Confirm", cancelLabel="Cancel", destructive?, pending?, onconfirm: () => void | Promise<void>, oncancel?. With an async onconfirm: confirming shows a spinner, Cancel is disabled, and it closes on resolve (stays open if it throws). Replaces native confirm().
  • Sheet: bottom sheet below 768px, centered dialog above. swipeToDismiss = true (bottom layout only): drag the handle or the header (.ca-sheet__grab, touch-action: none, pointer capture) downward; it closes past 30% of the height or a velocity above 0.5 px/ms, otherwise it springs back with Spring. Disabled under reducedMotion. The panel moves via translate (no transform, no backdrop-filter on the panel). The overlay and scrollable bodies of Sheet/Drawer/Dialog use touch-action: none / overscroll-behavior: contain; the primitives' scroll lock (primitives/scroll-lock.ts) is enough on WebKit.
  • Dialog: size="sm" | "md" | "lg" | "xl" | "split". split = 1100×720, 2-column grid at ≥md, 1 column below. chromeless omits header/description/actions and zeroes the body padding (pass title for the accessible name; it stays visually hidden).
<AlertDialog bind:open title="Delete lead?" description="This cannot be undone." confirmLabel="Delete" destructive onconfirm={() => api.remove(id)} />
<Dialog bind:open size="split" chromeless title="Lead">…</Dialog>

Navigation

Tabs · TabsList · TabsTrigger · TabsContent · Breadcrumb · Pagination · Stepper

Docs (documentation sites)

DocsShell (header + sidebar + page index; on mobile a compact header and menu in a Sheet) · DocsHeader (that header alone, so a landing or template page shares one top bar with the docs; opaque, no <main>) · DocsNav · TableOfContents (variant="rail" | "inline") · DocsSearch + searchDocs · CodeTabs/CodeTab (copy, sync across groups) · Callout · Steps · LinkCard · CardGrid · MethodBadge. Markdown typography: wrap the HTML in <div class="ca-prose">; tables in .ca-table-wrap, code in .ca-code (shiki dual theme). Syntax highlighting is up to the consumer (shiki at build).

<DocsShell {sections} active="api" {groups} {pathname} {headings}
  searchIndex={() => fetch('/search.json').then((r) => r.json())} onnavigate={goto}>
  {#snippet brand()}<Logo /> Docs{/snippet}
  <article class="ca-prose">…</article>
</DocsShell>

Feedback

Spinner · Skeleton · Progress · Toast · Toaster

Charts (ECharts): @codeatlasdev/ui/charts, requires echarts installed

LineChart · BarChart · PieChart · ScatterChart · HeatmapChart · GaugeChart · RadarChart · Sparkline

Subpaths with an optional peer

Charts, DataTable and CodeSnippet are NOT in the root import. Each subpath requires its peer:

import peer
@codeatlasdev/ui/charts echarts
@codeatlasdev/ui/table @tanstack/svelte-table
@codeatlasdev/ui/code shiki
@codeatlasdev/ui/forms none (Brazilian masks, MoneyInput, ready-made fields)
@codeatlasdev/ui/testing axe-core (test-only: expectNoA11yViolations, checkA11y)

@codeatlasdev/ui/forms: Brazilian fields

import { MaskedField, MoneyInput, PhoneField, CepField, mask,
  maskCPF, maskCNPJ, maskCPFCNPJ, maskCEP, maskPhoneBR, maskDateBR, maskRG,
  isValidCPF, isValidCNPJ, isValidDateBR, centsToDisplay, parseInputToCents, currencyDecimals } from "@codeatlasdev/ui/forms";
Symbol Use
MaskedField TextField + mask: (raw) => string + validate?: (v) => string | undefined (runs on blur). Automatic inputmode (numeric for CPF/CEP/phone/date)
MoneyInput value in the currency's minor unit (integer, bindable). 123456 displays R$ 1.234,56 (BRL default). Props: currency (ISO 4217, decimals from Intl: USD 2, JPY 0, BHD 3), locale (BCP 47; defaults to the active i18n locale when currency is set, pt-BR when it is not), min, max, allowNegative (default false), onchange(minorUnits)
PhoneField bindable value in E.164 (+5511987654321; '' if incomplete). country="BR"
CepField masked bind:value + onlookup?: (cep) => Promise<CepAddress | null> (the package does no fetching: inject ViaCEP), onresolve(address), bind:lookupPending to disable dependent fields
maskCPF maskCNPJ maskCPFCNPJ maskCEP maskPhoneBR maskDateBR maskRG Pure functions (raw: string) => string
isValidCPF isValidCNPJ isValidDateBR Validation (check digits / real date)
centsToDisplay(n, {currency, locale}?) · parseInputToCents(text, {currency, locale}?) · currencyDecimals(currency) 123456 ⇄ "R$ 1.234,56" (BRL / pt-BR default); centsToDisplay(123456, {currency:"USD", locale:"en-US"}) → "$1,234.56"
use:mask={maskCPF} Action for a native <input>; preserves the cursor (counts digits before the caret)
<MaskedField label="CPF" mask={maskCPF} validate={(v) => (isValidCPF(v) ? undefined : "Invalid CPF")} bind:value={cpf} />
<MoneyInput label="Amount" bind:value={cents} min={0} />
<MoneyInput label="Price" currency="USD" locale="en-US" bind:value={usdCents} />
<PhoneField label="WhatsApp" bind:value={e164} />
<CepField label="CEP" bind:value={cep} bind:lookupPending onlookup={viaCep} onresolve={(a) => (street = a.logradouro)} />

useForm (root) accepts arrays for repeatable rows. Each array is a FieldArrayState<T> with items, add(item), remove(index) and move(from, to):

const form = useForm({ name: "" }, { arrays: { phones: [{ number: "" }] } });
form.arrays.phones.add({ number: "" });
form.arrays.phones.move(0, 1);

TypeScript tokens

import {
  caColors,      // full OKLCH palette per preset
  caTypography,  // type scale (h1..h6, body1, body2, caption, label, button)
  caShape,       // border radii (xs, sm, md, lg, xl, pill)
  caTransitions, // easing and duration (fast, base, slow, spring)
  caZIndex,      // z-index scale (nav, dropdown, sticky, fixed, drawer, modal, snackbar, tooltip)
  caShadows,     // shadows
  caMaterials,   // material tokens (regular, thick, thin, ultrathin, ultrachick)
} from "@codeatlasdev/ui";

CSS tokens (CSS variables)

/* Typography */
--ca-font-size-h1..h6 / body1 / body2 / caption / label / button

/* Radius */
--ca-radius-xs / sm / md / lg / xl / pill

/* Z-index */
--ca-z-nav / dropdown / sticky / fixed / drawer / modal / snackbar / tooltip

/* Animation */
--ca-ease-standard / decelerate / accelerate / spring
--ca-duration-fast / base / slow

/* Surface fill and blur (every style rewrites these; they are translucent only under glass and frosted) */
--ca-glass-bg / --ca-glass-separator / --ca-glass-tint-* / --ca-glass-border
--ca-blur-ultra-thin / thin / regular / thick / chrome

/* Semantic colors */
--ca-brand-main / --ca-brand-light / --ca-brand-dark / --ca-brand-contrast / --ca-brand-h
--ca-semantic-success / warning / error / info
--ca-text-primary / secondary / disabled   (disabled = label of disabled controls only, >= 3:1; de-emphasised information uses --ca-text-hint)
--ca-surface-base / raised / sunken / overlay
--ca-outline-subtle / default / strong / control   (control = field and control edge, >= 3:1; subtle = dividers only)
--ca-focus-ring / --ca-state-indicator   (>= 3:1 on every surface and on hover/selected fills; components read these, never --ca-brand-main)
--ca-semantic-error-fill / --ca-semantic-error-contrast   (pair, like brand-fill / brand-contrast)
--ca-action-hover / selected / disabled / disabled-bg   (disabled controls use tokens, never opacity)
--ca-overlay-bg / --ca-panel-bg   (near-opaque by default; --ca-overlay-bg-glass / --ca-panel-bg-glass are the see-through tints)

/* Spacing (base 4px) */
--ca-space-1 (4px) .. --ca-space-16 (64px)

/* Page layout */
--ca-page-px    (24px)  ← horizontal padding on every page
--ca-page-px-sm (16px)  ← mobile
--ca-page-pt    (20px)  ← top padding
--ca-page-gap   (20px)  ← gap between sections

Themes

<html data-preset="ocean">
<!-- ocean | amber | crimson | cyan | violet | emerald | rose | slate -->
<html class="dark">

Brand theme

Does the product have its own color? Generate a preset from the logo hex. The library derives hue, chroma and lightness (OKLCH) and emits the full token set, light and dark, inline in the <head>. No flash.

// src/lib/theme.ts: one place, used by the hook and the plugin
import type { ThemeOptions } from "@codeatlasdev/ui/vite"; // = Partial<ThemeDefinition>; "/astro" in Astro. Never the package root: Node cannot load its .svelte files

export const theme: ThemeOptions = {
  presets: [{ id: "acme", color: "#2563eb" }],
  defaultPreset: "acme",   // used until the user picks another
  defaultScheme: "system", // "light" | "dark" | "system"
  defaultStyle: "solid",   // the default; also "flat" | "outlined" | "tonal" | "clay" | "neumorphic" | "frosted" | "glass" | "chrome"
  fonts: { body: "Geist, system-ui, sans-serif" },
  radius: 1,               // multiplier of the radius scale
  density: "comfortable",  // or "compact"
  overrides: { light: { "--ca-card-padding": "16px" } },
};

// src/hooks.server.ts
export const handle = sequence(caThemeHandle(theme));

// vite.config.ts
plugins: [sveltekit(), caThemePlugin(theme)]
  • id must not collide with a built-in preset; defaultPreset must exist (build error).
  • The init script discards saved values the app does not know (old preset, invalid scheme/style) and falls back to the default.
  • Sets <meta name="theme-color"> to the preset surface and follows the OS in system before JS loads.
  • Charts use the brand hex as the first series.
  • colorScheme inherits the defaults: colorScheme.preset starts at "acme".

Cascade and overriding tokens

core.css declares @layer ca.reset, ca.tokens, ca.themes, ca.base, ca.materials, ca.utilities, ca.components. ca.reset (styles/reset.css) makes button, input, select, textarea inherit the page font (font: inherit); a control needs no font rule of its own unless it sets a different one. Every component <style> block (and Dialog/dialog.css, Date/date.css) is wrapped in @layer ca.components { ... }, the last kit layer; app CSS is unlayered. Unlayered beats any layer regardless of specificity or source order, so an app overrides a kit rule with the same single-class selector (.ca-btn:disabled, .ca-tf__wrap, .ca-dialog__body), no !important, no doubled classes. To override package tokens, declare --ca-* on :root.

Rules when editing components: (1) new component CSS goes inside @layer ca.components { ... } and the <style> starts with the full order statement (@layer ca.reset, ..., ca.components;), because the first mention of a layer name fixes its priority and a bundler can emit a component chunk before core.css; tests/component-layer.test.ts fails otherwise. (2) Inside the layer, :global(...) and scoped selectors both work (Svelte keeps the hash class; app rules still win by layer). (3) Avoid !important in component CSS: a layered !important beats unlayered app CSS (the reverse of normal declarations), so apps could not override it. Same-layer conflicts (error vs focus) are settled by selector order/specificity. Allowed only to beat an inline style= the component itself sets (Progress indeterminate width, .ca-main spring margin, Shiki inline background) and for prefers-reduced-motion; base.css focus rules use it on purpose. (4) Unlayered kit CSS is not allowed except marks.css and inline brand <style id="ca-theme">, which are intentionally app-level.

global.css = core.css (includes only the amber preset) + the other 7 presets. Apps that use a single preset import only styles/core.css + styles/presets/<id>.css (see site/.../getting-started/performance.mdx).

The built-in presets are generated: edit packages/ui/src/lib/theme/builtins.ts and run bun run --cwd packages/ui themes (validate fails if styles/presets/*.css is out of date).

Theme islands

<section data-theme-scope="dark">…dark mode tokens in here, on any page…</section>

Runtime tokens

colorScheme.setTokens({ "--ca-brand-main": "#ff5500" }) writes a single <style id="ca-runtime-tokens">, persists it, and the init script reapplies it before the first paint. setTokens({}) clears it.


Stores

import { colorScheme, reducedMotion, responsive } from "@codeatlasdev/ui";

colorScheme.init()          // in the root layout (onMount); returns cleanup
colorScheme.toggle()        // switch dark/light
colorScheme.setPreset(id)   // change the color preset (colorScheme.presets lists the valid ones)
colorScheme.isDark          // reactive boolean

responsive.init()           // measures and starts following resize; returns cleanup
responsive.current          // 'xs' | 'sm' | 'md' | 'lg' | 'xl'; before init it is desktop

Language (i18n)

Every piece of visible text the package generates (aria-label, placeholder, empty states, toasts, "Copy", "On this page"…) comes from a catalog. The default is English. Locale is any BCP 47 string; en and pt-BR are built in, others are added with registerLocale.

<!-- SSR apps: src/routes/+layout.svelte. Request-scoped, never shared between requests. -->
<script>
  import { I18nProvider } from "@codeatlasdev/ui";
  let { data, children } = $props();
</script>
<I18nProvider locale={data.locale}>{@render children()}</I18nProvider>
// Client-only apps (SPA, Astro islands): one module-level locale.
import { registerLocale, setLocale } from "@codeatlasdev/ui";
registerLocale("es", { common: { close: "Cerrar" } }); // partial; missing keys fall back to en
setLocale("es");
  • Never call setLocale on the server: it is a no-op there (dev warning), because module state would leak between concurrent requests. Use I18nProvider. A provider always wins over setLocale.
  • createErrorHandler({ locale }) takes the fallback-message language explicitly (string or (event) => locale); it reads no global state.
  • The client $state behind setLocale is module-level: all islands on the same page share the language and re-render on change. locale.value reads it; inside a provider use useLocale().
  • An explicit prop always wins over the catalog: <TableOfContents label="In this article" />. Text props (label, placeholder, confirmLabel, emptyText, ofLabel…) no longer have a default; without the prop, the component uses the catalog.
  • Override phrases without creating a locale: <I18nProvider locale="pt-BR" messages={{ docs: { toc: "In this article" } }}> or setLocale("pt-BR", { … }). Deep-merged over the catalog; a new setLocale call replaces the previous overrides.
  • Catalog: one module per group in packages/ui/src/lib/i18n/groups/<name>.ts (e.g. docs, dialog), each a defineGroup("docs", { en: {…}, "pt-BR": {…} }). en is the source of truth; pt-BR is typed against it (a missing key is a type error). Strings with a value are functions: notificationBell.label(unread). Groups are registered in all.ts (the Messages type, getMessages, public useT()); the rune-free catalog.ts holds defineGroup, getGroup(group, locale, override?) and the registerLocale registry.
  • A component pays only for its own strings. The built-in locales travel inside each group and a component imports only the groups it reads, so tree-shaking drops the rest. Never import all.ts, getMessages or the public useT() inside the package: they name every group and ship the whole catalog (bun run size fails if Button's bundle contains another component's text).
  • New text in a component: add the key to the group's en and pt-BR (new group: new file in groups/ plus one line in all.ts), then in <script> import { group } from "../../i18n/groups/<name>.js" and call const t = useMessages(group); at the top level (or in a .svelte.ts helper called at component init; useMessages reads Svelte context, so it cannot run in event handlers or module scope). Read t().group.key in markup/$derived, and use label ?? t().group.key when there is a prop. Several groups: useMessages(a, b). Never import a global t.
  • In browser tests, call setLocale("pt-BR") where the test checks Portuguese text and setLocale("en") in afterEach; the default is English. SSR tests (*.ssr.test.ts, bun run test:ssr) wrap the tree in I18nProvider.
  • The date/number locale (DatePicker locale="pt-BR", masks) is independent of the UI language.

AppShell: usage pattern

<script>
  import { AppShellLayout, NotificationBell, Avatar } from '@codeatlasdev/ui';
  import { page } from '$app/state';
  import { goto } from '$app/navigation';
  import { MODULES } from '$lib/nav'; // Kit 3: '#lib/nav.ts'

  let { children } = $props();
  let notifications = $state([]);
</script>

<!-- In any authenticated app layout -->
<AppShellLayout
  modules={MODULES}
  pathname={page.url.pathname}
  persistPanel={false}
  onNavigate={(path) => goto(path)}
>
  {#snippet brand()}
    <!-- Logo at the top of the Rail and in the mobile header. Without it: "CA" -->
    <a href="/" aria-label="Acme"><AcmeLogo size={28} /></a>
  {/snippet}

  {#snippet railBottom()}
    <!-- Fixed items at the bottom of the rail: notifications, avatar, search -->
    <button class="ca-rail-btn" onclick={() => (cmdOpen = true)}>
      <Search size={16} />
    </button>
    <NotificationBell {notifications} />
    <a href="/profile" class="ca-rail-avatar">
      <Avatar alt={user.name} size="sm" />
    </a>
  {/snippet}

  <!-- No {#snippet header()}. The global header was removed -->
  <!-- Each page defines its own "header" via AppList/AppForm/AppSettings/PageShell -->

  {@render children?.()}
</AppShellLayout>

Rule: AppShellLayout has no global header. Each page defines its own top via AppList (header+toolbar), AppForm (header), AppSettings (header), or PageShell (header+body).


Relationship with consuming projects

Every app that consumes the package must:

  1. Install @codeatlasdev/ui from public npm (no .npmrc/auth)
  2. Import @codeatlasdev/ui/styles/global.css in the root layout and register caThemePlugin() in vite.config.ts
  3. Define the brand theme (ThemeOptions) and pass it to caThemeHandle and caThemePlugin
  4. Use AppShellLayout as the root layout wrapper, with brand for the logo and railBottom for user actions
  5. Build pages with AppList, AppForm, AppSettings, or PageShell, never manual wrappers

Code conventions

  • Indentation: tabs (Biome)
  • Quotes: double (Biome)
  • Semicolons: always
  • lineWidth: 100
  • No Tailwind: styles via CSS vars and classes
  • No MUI in this package: it is pure Svelte on its own primitives layer (src/lib/primitives)
  • No inline CSS except dynamic CSS custom properties (style="--bar-w:{pct}%")

Performance (package guards)

  • bun run --cwd packages/ui size (part of validate) bundles each entry with esbuild and compares the gzip size with packages/ui/size-limit.json. The limit only goes down: size:update lowers limits with some headroom; raising a limit is a manual decision, explained in the PR. To add an entry, edit ENTRIES in scripts/size-limit.ts.
  • bun run --cwd packages/ui lint:tokens (part of validate) rejects: backdrop-filter (either spelling) outside styles/surface.css and styles/materials.css (backdrop-owner); will-change in a file that declares backdrop-filter, uses data-ca-blur or .ca-surface; a literal 100vh|100dvh|100svh|100lvh (use var(--ca-vh)); backdrop-filter declared before -webkit-backdrop-filter (Lightning CSS drops the standard one); font-size < 16px in an input rule (iOS focus zoom).
  • Content images: Image (requires width/height; alt="" requires decorative; priority only on the LCP image).
  • Fonts: defineTheme({ fonts: { body: { family, src, weight, preload, fallback, fallbackMetrics } } }) generates @font-face, the fallback with size-adjust and the <link rel="preload">. A plain string still works.