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.
@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.
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"
- Claim before coding:
bun run issue:start <n>creates../ui-worktrees/<n>-<slug>onfix|feat/<n>-<slug>, opens a draft PR (Closes #<n>), labelsin 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 editsmain. Nevergit stash(the stash is shared by all worktrees). - Contributor PRs come first: if an issue already has an open PR (a fork included),
issue:startrefuses 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:triagecreates the labels and writes every open issue with comments, linked PRs and the commits onmainthat 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 currentmainsource (issues usually quote an olderdist/). (3) Already fixed: prove it as in Check first (a passing test onmain), comment the evidence and close (gh issue close <n> --reason completed); duplicate: close asduplicatepointing to the survivor; not planned:not plannedwith the reason. (4) Every remaining issue gets exactly one type (bug/enhancement/documentation) and one or morearea:*labels. (5) Link related issues with a commentRelated: #a, #b (same file/root cause)on each side; when one must land first, labelblockedand commentBlocked 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 labeledin progressby someone else. Re-read the issue against currentmain: 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.ymlis disabled andCI okis not required onmain; the pre-pushvalidateis 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-addCI okto themainruleset. - Gates are real: never push with
--no-verifyorLEFTHOOK=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 withsh scripts/issue-land.sh <n>: rebase onmain(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 committest:vrt:update, then rerun),validateon the push (90 s budget), squash-merge,issue:done.
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>.jsonand 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.tsinpackages/ui/tests/: unit test onbun:test(mock/spyOn/jest.useFakeTimers; DOM only through happy-dom'sGlobalRegistratorinside that file; fresh module viaimport("…?fresh=n")). Types checked bybun --check.*.svelte.test.ts: component in the browser, next to the component.*.vrt.test.ts: per-component screenshot; matrix harnessNameMatrix.test.svelte.*.test.svelte: harness for snippets/bind:(not shipped indist).site/tests/*.spec.ts(desktop+mobile+iphone),*.axe.spec.ts(axe over every route, its ownaxeproject),*.a11y.spec.ts,*.visual.spec.ts. A new route-level check goes intoroutes.spec.ts(it already loads every route once), never into a spec that loads every route again.- Keep test titles short:
describe+itcombined at most ~80 characters. Vitest names the trace attachment with path + title + hash, and names over 255 bytes fail withENAMETOOLONG, 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 oneid="main".DocsShellandAuthLayoutalready provide them;AppShellLayoutrenders<main id="main" tabindex="-1" class="ca-shell__content">as theSkipLinktarget (passmainId={null}if the page renders its own<main id="main" tabindex="-1">: the region becomes a plaindiv, andskipLink={false}when embedding the shell in a page that already has a skip link).PageShellnever emits<main>. - Screen reader announcements go through
announce(msg, politeness)(one shared live region); do not create a new localrole="status". In SvelteKit, mountRouteAnnouncer(@codeatlasdev/ui/kit) once in the root layout. - An
aria-labelon a button must contain the visible text (WCAG 2.5.3); compose witharia-labelledbyasComboboxdoes withariaLabel. - New surface CSS that blurs, adds a shadow or sets
border: noneneeds an entry in the@media (forced-colors: active)block ofmaterials.css(CanvasTextborder). Contrast tokens (prefers-contrast: more) come fromthemeCSS/presetCSS;tests/contrast.test.tsmeasures 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#mainand one skip link, no console or page errors, and the layout audit inlayout-audit.ts: no sideways scroll, no overlap, no clipped text, mobile 44px targets);site/tests/routes.axe.spec.ts(theaxeproject: axe in dark, light and forced colors on every route,color-contrastincluded in dark and light,<pre>excluded because Shiki's palette is proven byCodeSnippet.svelte.test.ts); andsite/tests/dist-html.spec.ts(page rules read fromsite/distwith a DOM parser, no browser; it also pins the known duplicate heading ids so a new one fails, #548).makeAxe(page)runsaxe.runin the page:@axe-core/playwright's builder re-injects axe and opens a blank page on everyanalyze()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, missingalt, a button without a name, same-origin andsrcdocframes). 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 ownastro:hydrateevent (waitIslands), andsettleDom(fonts, finite animations, shiki swap) replaces the 500 ms height-stability window that only screenshots pay (settle).scripts/route-impact.tsmaps 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.
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[](adesignStyleid or one of the planned recipes),motion(nonestate-free,subtlestate change,expressiveentrance/scroll/loop,immersivephysics/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 inpackages/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 runbun packages/ui/scripts/index-backfill.ts(it keeps every value already in a sidecar and fills these), then writeindustry,style,motion,interaction,layout,colorMode,density,aliases,relatedanda11y.keyboardby 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_RANGESinindex-schema.tsreserves 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 itskind/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_RATEinindex-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 inAUDITonly 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. Todayindustryis 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 atsite/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.tsscans the sidecars).
-
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.
-
Required setup: consumers import the CSS once (it already includes
materials.css, themes and utilities), register both Vite plugins (caThemePlugin: anti-FOUC script +ssr.noExternalfor TanStack Table and Shiki;caErrorPlugin: scaffolds+error.svelte/error.html) and exporthandle+handleErrorfrom the server hook. Plugin order inpluginsdoes not matter (built with every order on Kit 3.0.1 / Vite 8.3);initwrites them aftersveltekit():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 intosveltekit({ adapter, ... })(nosvelte.config.js, nokitnamespace),tsconfig.jsonextends$app/tsconfig,$libis removed in favor of#lib/*(with the.tsextension), andvite.config.tsimports the theme with its.tsextension.bunx @codeatlasdev/ui initdetects the Kit version. Details: the SvelteKit 3 section of.agents/skills/codeatlas-ui/SKILL.md. -
Svelte 5 runes: all code uses runes (
$state,$derived,$effect,$props). Never use legacywritable/readable. -
{@const}: valid only as an immediate child of{#each},{#if},{#snippet},{:else}, orComponent. Never inside<div>or another element. -
svelte:component: deprecated in runes mode. Use{@const Icon = X}<Icon />or<X />directly. -
Sub-components:
DropdownMenuItemetc. are separate.sveltefiles exported by name; there are no dotted namespaces. -
OKLCH: the whole color palette uses OKLCH. MUI does not accept OKLCH directly; if you integrate with MUI, convert to hex.
-
No inline CSS: never
style="padding: 20px"orstyle="max-width: 480px". Use CSS classes with tokens (var(--ca-space-5)). The only exception is dynamic CSS custom properties:style="--bar-w:{pct}%". -
No
Stack/Flexfor field layout insideFormSection:FormSectionalready haslayout="grid"(default),layout="list"andlayout="stack". Do not wrap fields inStack gap="4"as a layout workaround. -
ca-*classes are a public contract: consuming apps use the BEM classes (.ca-btn,.ca-dialog__body, …) as CSS targets. Renaming or removing aca-*class listed indocs/css-api.mdis a breaking change. When you create or rename a component, update that file. The stable parts, scoped variables anddata-*hooks of every component are generated from its CSS intoAPI.md,dist/parts.jsonand the MCPlist_parts; a class a component only uses internally is namedca-x-*(never a hook), and@keyframesnames are internal.codeatlas-ui workarounds --strictfails when an app selector targets anything else. -
Mobile: heights use
var(--ca-vh)(never100vh/100dvh);fixedUI usesvar(--ca-safe-*); a form with a keyboard usesuse:keyboardAware(@codeatlasdev/ui/actions); a full-screen mobile view isMobilePane, an action bar isStickyActions; 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.
-
Theme: a single
ThemeOptionsinsrc/lib/theme.ts(plain object,import typeonly:vite.config/astro.configload the file in Node, where the package root does not evaluate.svelte), passed tocaThemeHandle+caThemePlugin(SvelteKit) orcaUI(Astro).defaultStyle(the surface material,solidby default) is one field of it, not a separate setup. Override a token by declaring--ca-*on:rootin the app, without!important. Local islands:data-theme-scope="dark|light". Automatic setup:bunx @codeatlasdev/ui init. -
SEO: a public page uses
<Seo>from@codeatlasdev/ui/seowith a requiredcanonical; a private page passesnoindex.A11y tests: test every state (open, error, empty, light, dark) with
await expectNoA11yViolations(container)from@codeatlasdev/ui/testing(needs the optional peeraxe-core). Inside this repopackages/ui/tests/a11y.tsre-exports the samecheckA11y; never fork the axe options. -
Discovering the API: never guess a prop. Use the
ca-ui-mcpMCP 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 sourcessearch_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 bypackages/ui/scripts/build-manifest.ts(runs onbuild).[ca-ui]dev warnings (viadevWarn,esm-env) flag anIconButtonwithoutaria-labeland aDialogwithouttitle. -
Agent skills layout (
npx skills add codeatlasdev/ui): one folder per skill under.agents/skills/, named exactly like thename:in itsSKILL.md(codeatlas-ui,codeatlas-dx,codeatlas-writing). Never put aSKILL.mddirectly in.agents/skills/(the CLI then treats the directory as a single skill and lists nothing else), and quote anydescription:that contains:(an unquoted one is a YAML error and the CLI silently skips the skill).packages/ui/tests/skills.test.tsenforces this.
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:
- 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_PHRASESinsite/src/data/evidence/references.ts;packages/ui/tests/evidence-rules.test.tsfails on them). - 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(norefs) or cut it. - 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.
- Standards are not evidence of preference. WCAG, specs and platform guides are
practice: what is required or recommended. - No causal or demographic claims about taste. An audience is a labelled hypothesis.
- 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. - Verify before publishing. Open the DOI or URL, read the wording you rely on, and record
verified.date,verified.level(claim-read, oridentity-onlywhen the text was not reachable: then no<Claim>may cite it) and theexcerptinsite/src/data/evidence/references.json. Unverifiable sources are not added. A source's kind caps its strength (checkReferences): a standard or guideline ispractice, a preprint or a vendor study ispreliminary. - No number you cannot trace. A figure goes with its study and its conditions, or it does not go in.
- Design inspiration sites are never named; science and standards are cited by name where they are primary sources.
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-filteris declared instyles/surface.cssandstyles/materials.cssonly: a component setsdata-ca-blur="<role>"(roles:ultra-thin,thin,regular,thick,chrome,mat-thin,mat-regular,overlay,chrome-layer,tooltip;FloatingLayertakes it asblur="overlay"|"tooltip"), or is a.ca-surface, sohtml[data-style]drives every blur. The role rules write-webkit-backdrop-filterfirst.data-ca-blur-wide-offdrops it from 900px up (an inline layout);.ca-btn--glassand.ca-icon-btn--glassare theregularrole through their own class and lose it while disabled. The modal scrim isdialog.ca-overlay::backdropinsurface.css. New blurred element: add the attribute, never the property; the migration is guarded bysrc/lib/theme/surface-migration.svelte.test.ts(computed styles againstsurface-migration.baseline.json).isolation: isolate:backdrop-filterinside anisolation: isolateparent captures the isolated context, not the real backdrop.
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 */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.
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.
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 FormSectiondefaultlayout="grid"works for text fieldsFormSection layout="list"for Switch/SettingItem (no gap, flush)FormSection layout="stack"for composite blocks (Avatar + fields)
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"forSettingItem+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 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.
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.
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.
| 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 |
| 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 |
| 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 |
| 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).
import { DataTable, createCaTable, createCaColumnHelper, caTableFeatures } from "@codeatlasdev/ui/table";- Server-side:
createCaTable+caTableFeaturesfor 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
Avatar · Image · Badge · Alert · Chip · StatusChip · KpiCard · KpiGrid · EmptyState · Timeline · Sparkline · SimpleTable · LiveList · ExternalLink
-
Streaming rows (SSE/WebSocket/bulk job):
LiveList(items,itemsnippet,total,status,label). It is arole="log"witharia-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 callsfocus(). Do not putStatusChip(it hasrole="status") or any live region inside a row; useChipor text. -
External links:
ExternalLink(never hand-writetarget="_blank"): setsrel="noopener noreferrer", anaria-hiddenicon and a localised "(opens in a new tab)" for screen readers (externalLink.newTab).Button href target="_blank"already setsrel. -
Export (CSV/JSON):
ExportMenu(data,filename,columns,formats,onexport) or the helperstoCsv/toJson/downloadFilefrom the package root. CSV is RFC 4180 and neutralises spreadsheet formulas. -
SimpleTableresponsive:"scroll"(default) or"cards". Withcards, belowmdeach<tr>becomes a card and each<td>shows its label via::before { content: attr(data-label) }. Required: passdata-label="Column"on every<td>(a cell withoutdata-labelhas 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 becomesmin-height/min-widthofIconButton, theSelecttrigger,TabsTrigger,ToggleGroup,SegmentedControl,PinInputand calendar cells. It is a media query, not a prop: do not write 44px overrides in the app.
Button · IconButton · TextField · TextArea · Select · Combobox · Checkbox · Switch · Slider · NumberField · FileUpload · ToggleGroup · SegmentedControl · SwatchGroup · PinInput · CopyButton · DatePicker · DateRangePicker · DateField · TimeField · Calendar · RangeCalendar
Button/IconButtonloading: swapsleading(Button) or the icon (IconButton) for aSpinner, appliesaria-busy="true", disables the click (onclickdoes not fire) and keeps the label. Class:ca-btn--loading.ToggleGroup:type="single" | "multiple",bind:value(stringorstring[]),items: { value, label?, icon?, disabled?, 'aria-label'? }[](an icon-only item requiresaria-label),size="sm" | "md",variant="outlined" | "ghost",orientation,onchange.SegmentedControl: singleToggleGroupwith a sliding pill (.ca-seg__thumb, no animation underreducedMotion). Props:bind:value,items,size="sm" | "md",fullWidth,disabled,onchange.SwatchGroup: single-choice color picker (role="radiogroup").bind:value,items: { value, label?, color?, disabled? }[](withoutcolorthe swatch paints fromdata-preset={value};value=""means none selected),size="sm" | "md",disabled,onchange. Underpointer: coarsethe 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 becomesCheckfor 2s.- Dates (
DateValuevalues 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 }),closeOnRangeSelectDateField: only the typeable segments, no popoverTimeField:value: Time,granularity="hour" | "minute" | "second",hourCycleCalendar/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} />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 asynconconfirm: confirming shows a spinner, Cancel is disabled, and it closes on resolve (stays open if it throws). Replaces nativeconfirm().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 withSpring. Disabled underreducedMotion. The panel moves viatranslate(notransform, no backdrop-filter on the panel). The overlay and scrollable bodies of Sheet/Drawer/Dialog usetouch-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.chromelessomits header/description/actions and zeroes the body padding (passtitlefor 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>Tabs · TabsList · TabsTrigger · TabsContent · Breadcrumb · Pagination · Stepper
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>Spinner · Skeleton · Progress · Toast · Toaster
LineChart · BarChart · PieChart · ScatterChart · HeatmapChart · GaugeChart · RadarChart · Sparkline
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) |
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);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";/* 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
<html data-preset="ocean">
<!-- ocean | amber | crimson | cyan | violet | emerald | rose | slate -->
<html class="dark">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)]idmust not collide with a built-in preset;defaultPresetmust 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 insystembefore JS loads. - Charts use the brand hex as the first series.
colorSchemeinherits the defaults:colorScheme.presetstarts at"acme".
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).
<section data-theme-scope="dark">…dark mode tokens in here, on any page…</section>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.
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 desktopEvery 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
setLocaleon the server: it is a no-op there (dev warning), because module state would leak between concurrent requests. UseI18nProvider. A provider always wins oversetLocale. createErrorHandler({ locale })takes the fallback-message language explicitly (string or(event) => locale); it reads no global state.- The client
$statebehindsetLocaleis module-level: all islands on the same page share the language and re-render on change.locale.valuereads it; inside a provider useuseLocale(). - 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" } }}>orsetLocale("pt-BR", { … }). Deep-merged over the catalog; a newsetLocalecall replaces the previous overrides. - Catalog: one module per group in
packages/ui/src/lib/i18n/groups/<name>.ts(e.g.docs,dialog), each adefineGroup("docs", { en: {…}, "pt-BR": {…} }).enis the source of truth;pt-BRis typed against it (a missing key is a type error). Strings with a value are functions:notificationBell.label(unread). Groups are registered inall.ts(theMessagestype,getMessages, publicuseT()); the rune-freecatalog.tsholdsdefineGroup,getGroup(group, locale, override?)and theregisterLocaleregistry. - 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,getMessagesor the publicuseT()inside the package: they name every group and ship the whole catalog (bun run sizefails if Button's bundle contains another component's text). - New text in a component: add the key to the group's
enandpt-BR(new group: new file ingroups/plus one line inall.ts), then in<script>import { group } from "../../i18n/groups/<name>.js"and callconst t = useMessages(group);at the top level (or in a.svelte.tshelper called at component init;useMessagesreads Svelte context, so it cannot run in event handlers or module scope). Readt().group.keyin markup/$derived, and uselabel ?? t().group.keywhen there is a prop. Several groups:useMessages(a, b). Never import a globalt. - In browser tests, call
setLocale("pt-BR")where the test checks Portuguese text andsetLocale("en")inafterEach; the default is English. SSR tests (*.ssr.test.ts,bun run test:ssr) wrap the tree inI18nProvider. - The date/number
locale(DatePicker locale="pt-BR", masks) is independent of the UI language.
<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).
Every app that consumes the package must:
- Install
@codeatlasdev/uifrom public npm (no.npmrc/auth) - Import
@codeatlasdev/ui/styles/global.cssin the root layout and registercaThemePlugin()invite.config.ts - Define the brand theme (
ThemeOptions) and pass it tocaThemeHandleandcaThemePlugin - Use
AppShellLayoutas the root layout wrapper, withbrandfor the logo andrailBottomfor user actions - Build pages with
AppList,AppForm,AppSettings, orPageShell, never manual wrappers
- 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}%")
bun run --cwd packages/ui size(part ofvalidate) bundles each entry with esbuild and compares the gzip size withpackages/ui/size-limit.json. The limit only goes down:size:updatelowers limits with some headroom; raising a limit is a manual decision, explained in the PR. To add an entry, editENTRIESinscripts/size-limit.ts.bun run --cwd packages/ui lint:tokens(part ofvalidate) rejects:backdrop-filter(either spelling) outsidestyles/surface.cssandstyles/materials.css(backdrop-owner);will-changein a file that declaresbackdrop-filter, usesdata-ca-bluror.ca-surface; a literal100vh|100dvh|100svh|100lvh(usevar(--ca-vh));backdrop-filterdeclared before-webkit-backdrop-filter(Lightning CSS drops the standard one);font-size< 16px in an input rule (iOS focus zoom).- Content images:
Image(requireswidth/height;alt=""requiresdecorative;priorityonly on the LCP image). - Fonts:
defineTheme({ fonts: { body: { family, src, weight, preload, fallback, fallbackMetrics } } })generates@font-face, the fallback withsize-adjustand the<link rel="preload">. A plain string still works.