Atlas's design system, as one package: the primitives, the tokens, the themes and the ratchet that keeps them honest. Desktop, the web app and the docs all install it, so a button looks the same in all three because it is the same button.
Desktop is the source of truth (ADR-0027). Everything here was copied out of the desktop app and is changed here from now on; desktop installs it back.
| Path | What it is |
|---|---|
src/ui/ |
Desktop's primitives, on @base-ui/react: button, dialog, menus, select, tooltip, popover, segmented, switch, slider, command, colour picker… |
src/components/ |
Components only the old standards gallery had (avatar, toast, table, tag, sheet, tabs…, plus patterns/, blocks/ and shell/), restyled to desktop's language, layout/: the controlled frame, dock, splits and tab strip both apps' shells are built from, and code-surface/: the editor and terminal chrome. |
src/styles/ |
styles.css (the entry), styles-core.css (the entry with only the default theme), tokens.css, utilities.css, and the generated themes.css and default-theme.css |
src/theme/ |
The theme engine's pure half: the resolver, the colour maths, the generated key registry |
src/ratchet/ |
The design-system ratchet, as rules and a runner |
keys.toml |
The one hand-edited source of every derived colour |
themes/*.toml |
The built-in themes |
theme-rules/*.txt |
Generated tables desktop's Rust reads |
scripts/generate-theme.ts |
The generator |
stories/ |
Storybook: the foundations pages and a story for every src/ui primitive and src/components component (see Storybook) |
A component belongs here if it renders from its props alone. Anything that
calls invoke(), fetch, a router or a store stays in its app.
The shell components (AppShell, Sidebar, SettingsShell,
OrgSwitcher, CommandMenu…) need links, so they take them through
LinkProvider instead of importing a router:
import { LinkProvider } from "@tryatlas/design"
// TanStack Router
<LinkProvider
Link={({ href, ...props }) => <Link to={href} {...props} />}
location={useRouterState({ select: (s) => s.location })}
navigate={(href) => router.navigate({ to: href })}
>Without a provider, links are plain <a href> and nothing is "active".
A website that only wants the illustration blocks imports ./blocks and
./blocks.css; docs/website-adoption.md is the
guide.
Peer dependencies, never bundled: react, react-dom, @base-ui/react,
lucide-react, motion, and optionally @tanstack/react-virtual — only
./timeline-calendar and ./checkpoint-trace import it, which is why each
is an entry of its own.
./session is an entry of its own for the same reason: it needs
react-markdown, remark-gfm, remark-parse and unified (optional peers,
which desktop and web already have), and the root entry imports none of them.
Desktop and web each adapt their own session shape (the delta wire, the
server's TimelineEntry pages) onto one presentational type, SessionView,
and render it with the same blocks. The field-by-field mapping is the
docblock of src/components/session/session-view.ts.
import rehypeHighlight from "rehype-highlight";
import { SessionTimeline, toolKindOf, type SessionView } from "@tryatlas/design/session";
const plugins = [rehypeHighlight]; // stable identity: blocks re-render on a new array
<SessionTimeline
session={toSessionView(source)} // the app's adapter
rehypePlugins={plugins}
commentSlot={(anchor) => <CommentButton anchor={anchor} />}
onLoadPayload={(callId, part) => loadPayload(callId, part)}
errorAction={<Button onClick={signIn}>Sign in</Button>}
/>;The package highlights nothing itself: pass rehype-highlight (or anything
that emits highlight.js classes), and syntax.css, part of styles.css,
colours them from the theme's syntax.* keys. StreamingMarkdown never
re-parses a finished block, so stream into it as fast as text arrives.
Every module that uses hooks, context or a Base UI part starts with
"use client", and nothing touches window at import time. That makes the
components safe to render from a React Server Component, but it does not make
the root entry a good import there:
- The root is a barrel of client modules, so importing it from a server component puts the whole package in the client graph.
- A function exported from a client module reaches a server component as a
client reference, which cannot be called.
buttonVariants()from the root throws on the server.
So import by what the file is:
// A server component: the class recipes, Icon, Badge and cn. No "use client"
// anywhere behind this entry (a test holds it).
import { BookIcon } from "lucide-react";
import { Badge, buttonVariants, Icon } from "@tryatlas/design/variants";
<a href="/docs" className={buttonVariants({ variant: "outline", size: "sm" })}>
<Icon icon={BookIcon} size="sm" /> Docs
</a>;
// A client component ("use client" at its top): anything, from the root.
import { Button, Dialog, Tooltip } from "@tryatlas/design";The recipes on ./variants are buttonVariants, iconButtonVariants,
badgeVariants, kbdVariants, inputVariants, toggleVariants,
tabsListVariants, linkVariants and calloutVariants, plus the menu's
menuContentVariants, menuItemVariants, menuLabelVariants and
menuShortcutVariants (for a list that cannot take focus, such as an editor's
slash menu: highlight: "active" styles the row the caller marks data-active). The root re-exports the same
functions, so client code can keep importing them from there.
keys.toml + themes/*.toml
│ bun run theme
├──▶ src/theme/theme-key-registry.ts the resolver's rules
├──▶ theme-rules/*.txt desktop's Rust interpreter
├──▶ src/styles/themes.css every built-in theme, resolved
└──▶ src/styles/default-theme.css Atlas alone, the floor in styles-core.css
A theme is a shadcn base (background, primary, foreground… every one
optional), an optional eight-hue palette, and optional theme keys
(element.hover, syntax.keyword). Whatever a theme leaves out is derived
by the rules in keys.toml, in one place (ADR-0022).
Those rules run in three places, and all three are generated from the same file:
themes.css, for anything that only shows built-in themes (web, docs, Storybook). One block per theme and appearance, fully resolved; no colour code runs.:rootis Atlas dark.- The TypeScript resolver (
@tryatlas/design/theme), for a user theme or a live theme-editor preview. - The Rust tables, which desktop's Rust interpreter runs for import and
export. The golden fixture
theme-rules/base-derivation.json(exported as@tryatlas/design/theme-rules/base-derivation.json) holds both interpreters to the same answers.
Pick a theme with two attributes, on <html> or on any element:
<html data-theme="tokyo-night" data-appearance="dark"></html>A theme key's CSS variable is its dotted name with . and _ turned into
-, with no prefix: element.hover is --element-hover. Tailwind sees
them through utilities.css, so you write bg-element-hover, never the
variable. A test fails if two names would write the same variable.
The palette's eight hues are identities (a label, a project), not
statuses, and reach Tailwind as bg-hue-red, text-hue-blue… through
their own keys, hue.red … hue.pink, plus hue-grey (the theme's
muted-foreground) for no hue. Each key derives from the palette hue of the
same name, so an "orange" chip is the theme's orange; a theme that wants its
chips to differ from its palette sets the key. Atlas does, for dark yellow:
its palette's yellow is a terminal's #ffff00, which ANSI yellow, the
warning status and search matches keep, and its chips use #e0ca66.
To change a rule or a built-in theme, edit keys.toml or themes/, run
bun run theme, and commit what it writes. bun run theme:check (and the
test suite) fails when a generated file is stale. Never derive a theme colour
with color-mix() in CSS; a tint at a call site is fine.
In the consumer's entry CSS, after Tailwind:
@import "tailwindcss";
@import "@tryatlas/design/styles.css";styles.css does not import Tailwind itself, so there is one preflight. It
brings Geist and Geist Mono (via @fontsource-variable, so they work
offline in Tauri) and an @source for the package's compiled files, so your
Tailwind keeps the classes only the package uses.
An app that applies themes itself — resolving a theme with resolveTheme
and writing its cssVars onto <html>, as desktop does — imports
@tryatlas/design/styles-core.css instead. It is styles.css with
default-theme.css in place of themes.css: the same tokens, utilities,
fonts and @source, minus the other themes' [data-theme][data-appearance]
blocks, which are most of the bytes and never match in such an app.
default-theme.css is Atlas alone, as a floor for the first paint, before
the app's engine has written anything: Atlas dark at :root, Atlas light
when the OS prefers light, and data-appearance="dark|light" on <html>
overriding the OS. Every selector is :root (specificity 0,1,0), so write
the app's own theme one step above it, at html:root as desktop does, and
it wins wherever either block sits — including a launch-cache <style> that
lands before the package's stylesheet. In the consumer fixture the core
entry builds to about 149 KB against 273 KB for styles.css.
- Desktop imports components and styles from the package and pins an
exact version.
bun run theme:keyscopieskeys.toml, the built-in themes and the Rust tables fromnode_modulesintocrates/atlas-theme/, so the Rust build never readsnode_modules. It runs the ratchet from its own test withrunRatchet, turning onbanAtlasPrefixonce its--atlas-rename is done. - Web and docs import components and
styles.css, and pick themes withdata-theme/data-appearance. They need no theme code.
The ratchet, from any consumer's tests:
import { TRANSITION_ALL_RULE, formatViolations, runRatchet } from "@tryatlas/design/ratchet";
it("holds the design system at zero", () => {
const violations = runRatchet({
roots: ["src/features", "src/components"],
exemptFiles: {
"src/brand.tsx": "Shape 2. A third party's logo; a recoloured logo is wrong.",
},
// Optional: rules of your own, each { id, pattern (/g), instead, scope? }.
// `scope` replaces `roots` for that rule (directories or single files, read
// whatever `include` says), and a rule with a built-in's id replaces it.
rules: [{ ...TRANSITION_ALL_RULE, scope: ["src", "index.html"] }],
});
expect(violations, formatViolations(violations)).toEqual([]);
});Exemptions and ratchet-allow: markers apply to a consumer's rules as to the
built-in ones.
Or the whole test as config: defineRatchetSuite registers the suite with
your runner's describe and it, fails with the same message (plus
footer), and with minFiles also fails when the roots hold too few files —
a root that moved scans nothing and would pass. strictRules is the next
notch, which web holds: rounded-[inherit] allowed, no raised shadows
(shadow-sm, shadow-xl…), no [var(--…)] arbitrary values, no dark:.
import { defineRatchetSuite, strictRules } from "@tryatlas/design/ratchet";
import { describe, it } from "vitest";
defineRatchetSuite(
{
root: import.meta.dirname + "/..",
roots: ["src/components", "src/routes", "src/lib"],
rules: strictRules,
minFiles: 200,
exemptFiles: { "src/lib/seo.ts": "<meta name=theme-color> is read before any CSS loads." },
footer: "Roles and scales come from @tryatlas/design; see its token CSS.",
},
{ describe, it },
);Two guards for a consumer's own tests, from the root entry:
PACKAGE_SENTINEL_CLASS("[--tryatlas-design-sentinel:1]") is named only in the package'sdist/, so the rule--tryatlas-design-sentinel: 1is in the consumer's compiled CSS only if Tailwind scanned the package throughstyles.css's@source. Assert on the declaration; import the constant rather than writing it out, or your own source generates the rule.D23_RENAMESmaps each renamed colour name (without--) to{ variable, utility }: the oldstandardsroles (hairline→border-subtle,success→status-success-foreground…) and desktop's--atlas-<key>→--<key>, generated from the key registry.utilityis the Tailwind colour name that replaces<prefix>-<old>, the old name itself where the package still bridges it (text-successstill works), ornullwhere no utility exists.
@tryatlas/design/animations lists every animation in the package that
never stops (INFINITE_ANIMATIONS), for an app that has to cap them — desktop
does on Linux, where WebKitGTK presents a frame per tick. A css entry gives
the selector to override and a steps count near 12 fps; a js entry (the
marquee, the globe's turn, the dither field) reads --infinite-animation-fps
when it starts:
html.platform-linux {
--infinite-animation-fps: 12;
}
html.platform-linux [data-slot="globe-live-dot"] {
animation-timing-function: steps(24);
}A link that stands on its own in the interface — "View all", a footer link, "Learn more" after a line of UI copy — has no underline at rest and underlines on hover and on keyboard focus. Its place and its ink already say it is a link. A link inside running text — prose, rendered markdown, a field's description — keeps its underline at rest, because there the underline is the only thing telling it apart from the words around it.
TextLink(href,tone="default" | "muted") is the standalone link. It renders throughLinkProvider, so the app's router handleshref.linkVariantsis its recipe, for a plain<a>in a server component.Button variant="link"follows the standalone rule..prose-session a(the session blocks' markdown),FieldDescriptionandAccordioncontent underline their links at rest.
The package replaced the old standards gallery (its last commit is
172759a). The web app's adoption (tryatlas/server#42) ran into everything
below. The list comes from diffing that commit's components, lib and
stylesheets against today's src.
- Import from the package root, not a deep path:
import { Button } from "@tryatlas/design". The primitives moved fromcomponents/ui/tosrc/ui/(desktop's versions); the root hides the move. - In a server component, import from
@tryatlas/design/variants: the cva recipes,Icon,Badgeandcn. See Server components. styles.cssreplaces the oldtokens.css,themes.css,globals.cssandutilities.css. Import it after Tailwind (see Using it).
| standards | the package |
|---|---|
SegmentedPills (onChange, SegmentOption) |
Segmented (onValueChange, SegmentedOption, a required aria-label). It is a radio group, so tests query getByRole("radio"), not "tab" |
UnderlineTabs onChange |
unchanged: still onChange |
DeltaPill value={8.0} (a percentage) |
DeltaChip delta={0.08}. delta is a fraction and renders as ▲ 8% (▼, or — when flat; null renders nothing). tone="up-good" | "down-good" replaces DeltaPill's tone (since 0.1.5) |
Sparkline |
removed |
LogoMarquee with built-in logos (src, srcLight, alt) |
logos is required: { mark, … }[], so the caller brings its own |
UiScaleProvider, useUiScale, uiScaleInitScript |
removed. The package applies no interface scale; the app owns it (desktop and web set zoom on <html>). UiScaleControl and UiScalePanel take scale and onScaleChange |
OrgProvider, useOrg |
removed. OrgSwitcher takes orgs, org, onOrgChange, email, settingsHref, and optionally membersHref and onLogOut |
| shell components reading the router | AppShell (title, header, nav, footer, actions, and the controlled collapsed and dockMode), SettingsShell (groups, backHref, org) and the sidebar-controls menus (user, links, onLogOut, …) take everything as props; links go through LinkProvider |
StatCard index (staggered entrance) |
removed; stat cards don't animate in |
Select, SelectTrigger, SelectContent, SelectItem, SelectValue, SelectGroup, SelectLabel, SelectSeparator, SelectScroll*Button |
one Select with value, onValueChange and options (SelectOption[]). value={null} shows placeholder; a chosen option's label replaces it (until 0.1.2 a placeholder always won, so a "Mixed" select now passes value={null}). id, name, required and the aria-* field props pass through, so <Label htmlFor> works. empty is the no-options slot |
| a table sorted on the server | DataTable with sort + onSortChange (controlled, DataTableSort) and manualSorting to draw rows as given; a column without sortValue opts in with sortable. Local sorting stays the default |
ScrollBar |
removed; ScrollArea draws its own |
ContextMenuRadioItem |
removed |
menu-styles.ts (menuItem, menuPopup…) |
removed; the menus style themselves |
ProgressiveBlur |
removed. GradualBlur / BlurEdge are the package's edge blur, with a different API |
TAG_CLASS, HUE_VAR |
HUE_BG and HUE_CHIP (class strings), or bg-hue-* / text-hue-* |
tag and hue indigo, amber |
blue, yellow; pink is new. hueFor hashes over the nine identity hues and never returns grey, so most entities change colour (they now match desktop) |
Badge variant="error" |
variant="destructive"; severe is new |
Button default variant secondary |
default is default (primary). A bare <Button> that meant the quiet one needs variant="secondary" |
Button variant="destructive" (a tint) |
solid. The tint is destructive-outline; ghost-destructive and warning-outline are new |
Button size="xl" |
lg |
Button size="icon", icon-xs, icon-sm, icon-lg |
IconButton with size md, xs, sm, lg (label required) |
DialogContent size="sm" | "md" | "lg" |
removed; set a max width with a class |
Icon sizes xs 12 · sm 14 · md 16 · lg 18 · xl 22 |
xs 10 · sm 12 · md 14 · lg 16 · xl 20: each name is one step smaller, stroke 1.75 |
initials from the mock folder |
initialsOf from the root |
New since standards, and worth knowing: Hint / HintGroup (tooltips with
key hints), KbdKeys and KbdCombo, the Command* palette parts,
SecretInput, the colour picker, and more brand marks (Claude, OpenAI,
Gemini, Cursor, OpenCode).
| standards | the package |
|---|---|
*-hairline |
*-border-subtle |
bg-surface, ring-surface |
bg-panel, ring-panel |
*-shell-canvas, *-shell-edge |
*-sidebar, *-sidebar-border |
bg-segment-thumb |
bg-element-active |
micro |
eyebrow |
figure |
font-semibold tabular-nums |
text-4xs |
text-3xs (the smallest step, 9px) |
h-control-xl, size-control-xl |
*-control-lg (32px, the largest) |
rounded-2xl, rounded-3xl |
rounded-xl, the top of the radius scale |
hide-scrollbar and the global hidden scrollbars |
not in the package; an app that wants them keeps its own utility |
layout sizes (w-sidebar, w-dock, w-list-pane, w-properties, w-settings-nav, max-w-settings, h-topbar, h-row) |
removed; the shell components size themselves, and an app's own layout constants live in the app |
tag, tag-<hue> |
<Tag>, or HUE_CHIP |
glass |
glass-hud (with backdrop-blur-glass) |
diff-*, syntax-*, illustration colours |
the theme keys' own names: text-syntax-keyword, bg-diff-added-text; no illustration |
surface-raised, segment-track, element-emphasis |
removed; pick the role from the Colour foundations page |
--ease-panel |
ease-out-strong (CSS) or EASE_PANEL (motion) |
Only 500 and 600 weights exist, and headings tighten by size (see the Type foundations page).
- Pick a theme with two attributes:
data-theme="atlas"(or any built-in id) plusdata-appearance="dark|light". Standards useddata-theme="dark|light"alone. - Theme variables are unprefixed:
--element-hover, not desktop's--atlas-element-hover. A theme key's variable is its dotted name with.and_turned into-. In Tailwind you writebg-element-hover, never the variable. - The package sets no root font size; desktop and web both use 13px, so
remspacing matches between them.
Additive, plus two fixes.
- New entry:
@tryatlas/design/blockswith@tryatlas/design/blocks.css, for the website. It holds the illustration blocks (Globe,DitherField,DashedRails,LogoMarquee,GhostCards,ProgressiveBlur,InfiniteSlider,PlusDecorator,PlusCorners) andthemeInitScript. Seedocs/website-adoption.md. Globeloads cobe lazily, once it nears the viewport and the main thread is idle, and draws a token-coloured fallback (orfallback) in its box until then, and for good without WebGL.DitherFieldpaints its first frame on idle behind a static fallback (orfallback), and now follows Reduce Motion live: turning it on stops the drift without a remount. It renders a fallback element beside its canvas.TabStripdraws the keyboard ring inside each tab, so the scrolling strip no longer clips it.dither-fieldandlogo-marqueemoved fromcomponents/ui/tocomponents/blocks/; the root entry exports the same names.
Everything here is additive. Three defaults change, all of them fixes: keycaps follow the host's keyboard, Hint draws a string shortcut as keycaps, and CheckpointTrace stacks faces side by side.
- Keycaps follow the keyboard.
Kbd,KbdKeysandKbdCombonow detect the platform. On Windows and Linux, ⌘ and ⌃ read Ctrl, ⌥ reads Alt and ⇧ reads Shift, in desktop'sdisplayKeysorder (⌃⌘ reads Ctrl Win). Passplatform="mac"or"other"to override. A server render, and the first client render, draw"mac". A test that expects "⌘" under happy-dom now needsnavigator.platformstubbed to a Mac. The package also exportsplatformKeys,platformKey,detectKeyPlatformand theKeyPlatformtype.KbdKeysandKbdComboalso takevariantandsize. Hintkeycaps. A stringshortcut("⌘K", or "Ctrl+Shift+B" as Windows spells it) is drawn as keycaps in the newKbd variant="inherit", which takes its ink from the tooltip, so it reads on any tooltip fill. A tooltip that matched the shortcut as one text node ("⌘+") now finds one[data-slot="kbd"]per key. Any other node is still drawn as given.CheckpointTrace:- Faces in a cluster and in a lane header sit side by side instead of overlapping, so both sets of initials read whole ("AT JW", not "A JW"). Clusters grow a few px wider.
TraceAuthoradds an optionalinitials, used when a name would make poor ones ("You" → "ME", "A member" → "?").- The default face is exported as
TraceAvatar, so arenderAvatarcan wrap it instead of redrawing it.
- New:
CodeFenceis a message's fenced block, with its language label inside the box. Desktop's team-chat block: it cancelshljspadding and background inside it. Hand it the<code>from react-markdown'spreoverride.min-w-control-2xssits besideh-control-2xsandsize-control-2xs.- The
.diff-syntaxscope insyntax.cssis desktop'sdiff-syntax.css, rule for rule, so desktop can delete its copy.
- New props on existing components:
DialogTitleaddsvariant="quiet", for a filename over avariant="media"lightbox.SectionHeaderaddsicon, and itstitlenow type-checks as any node (it wasstring, through the HTMLtitleattribute).CommsComposerShelladdsreplyEmptyLabel("Message deleted" by default). A reply to a message with no text reads "Lin · Message deleted", not "Lin · ".StepListaddsstartingMessage. It announces "Starting…" while every step is pending. Passnullfor a list shown before anything starts.CommsConversationRow.onSelectreceives a second argument,{ newTab, event }:newTabis ⌘-click on a Mac and Ctrl-click elsewhere. A one-argument handler still type-checks.
- Canvas:
CanvasPagesPaneladdslastPageLabel, the disabled delete's hint ("The last page cannot be deleted." by default).CanvasMediaaddsloadingLabel("Loading image…"), shown under the spinner.CanvasHeaderPilladdsemptyPagesLabel("No pages yet." by default), for a switcher with no pages. It also addsofflineTone:"warning"stays the default, because offline there means edits are held, which is the status scale's "degraded". Pass"error"for desktop's red.CanvasActionPilladdsexportLabel, a visible label beside the export icon.
One component changes: CheckpointTrace is redesigned so a week is readable at a glance. Two of its defaults change.
- Titles on the chart. A checkpoint shows its title whenever there is room, then its time and lines changed, or "6 checkpoints · Priya, Sam" for a cluster. Hosts that fill
titleget the full effect; without one, the short sha stands in, set in mono. - Bigger faces and lanes. Faces are 24px and lanes 52px. A person without a photo gets initials in their own hue. A session with no checkpoints is captioned "No checkpoints".
- Week zoom fits the range to the screen instead of showing a fortnight of empty canvas.
- New:
density("comfortable"by default, or"compact"), and the optional labelsclusterSummary,handoffShort,latestandnoCheckpoints. - Changed:
renderAvataris now asked for 24, 20 and 18px faces, not 16 and 14, and must render exactly the size it is given. - Changed:
defaultZoomnow defaults to"week". PassdefaultZoom="day"to keep the old default.
Everything here is additive. Two defaults change, both of them fixes: the InputGroup focus ring and the icon alignment in DropdownMenu items.
- New components:
SplitButton,StepList,Rail/RailNode(exported from the root), andSegmentMeter variant="gauge". - New props on existing components:
DiffStataddscompact,hideZero,layout="aligned"andlabel. The package also now exportsformatDiffCount.Progressaddstone.Switchaddsappearance="check".ModelBadgegets a newsize="2xs", sized by the new 18pxh-control-2xs/size-control-2xsutilities.IconButtonaddsloadingand a newvariant="floating".EmptyState'siconnow accepts an element as well as an icon.
- Dialogs:
DialogContentaddssize="lg".- Dialog and AlertDialog share a new
bluroption. AlertDialogContentaddsdismissOnBackdrop, which reports a dismissal asclose-press, the same as Cancel.
TabStrip:- Its default behaviours can now be turned off:
rovingFocus,closeOnMiddleClick,closeOnDeleteandcloseLabel. tabPropslets the host implement drag-to-reorder.
- Its default behaviours can now be turned off:
AppFrameaddsinset="sides"for the macOS titlebar.- Menus: the package now exports
menu*Variants({ highlight: "active" })recipes. CommsComposerShelladds new slots: a tool strip, an overlay,invalid/dropActive, a mention popup,renderSend, and a way to turn its Escape handling off.Skeletonaddsanimated={false}andradius.- Model marks can take an optional
title. AtlasBadgeaddstone="app-icon".
CheckpointTraceis new. It ships as its own entry,@tryatlas/design/checkpoint-trace, and like./timeline-calendarit needs the optional@tanstack/react-virtualpeer.- It draws sessions as lanes on a time axis, with each checkpoint shown as its author's face.
- Zoom by hour, day or week, drag to pan, and navigate by keyboard. Close checkpoints cluster together, and it shows handoffs, orphaned commits and live edges. Lanes can be grouped by project or by person.
- It takes pure props: pass one project's sessions for a project view, or the organisation's for an org view.
- Lanes are memoized on
renderAvatar,renderAgentMark,onOpenSessionandlabels, so keep those referentially stable.
INFINITE_ANIMATIONSgainscheckpoint-trace-live-edge.
- Popovers, menus, Select, Combobox and Tooltip now animate out: they scale out over 120ms, or fade under reduced motion. The exit is applied through a
data-ending-style:variant, so a caller'sclassName="animate-none"doesn't stop it. A test that expects a popup to be gone on the same tick as Escape now needs to wait about 120ms. Callout variant="tucked"is a strip tucked into the top of a composer. Place it directly above the composer, and give the composerrelativeand a higher z-index.Callout animatemakes it rise in on mount; this adds theanimate-pill-intoken.- Sessions:
SessionTimelineaddsrevealItemId, which keeps any item drawn even when it is past the window. It also addsheading;falsehides the heading but still shows the error.- The rail node has two new slots:
agentMarkandauthorFace. TurnRowandSessionTimelineaddrenderCheckpointFooter,clampResponsesandtoolRunSummary("kinds"or"files").Payloadaddsload("loading"or"failed"), so "Load full …" can show a loading state and a retry.ToolCallCardLabelsadds an optionalloadFailedlabel.- Leaving any of these unset changes nothing.
- Inline pickers:
CommandItem,CommandGroupLabelandCommandFootertakesize="compact".CommandItem listbox={false}drops the option role and itsaria-*attributes. DialogContent variant="media"is a full-bleed viewer centred by margins rather than a translate, with no padding. PassshowCloseButton={false}if your header row has its own close button.IconButton size="2xs"is a 16px square. Itssizeunion gains"2xs", so an exhaustiveRecordover that union needs the new key.Pilltakessize="sm",trailingActionandsurface="raised".
- Canvas blocks (listed under 0.1.8 by mistake; they first ship here): the glass chrome,
CanvasToolbar,CanvasHeaderPill,CanvasActionPill,PresenceCursor/PresenceLayer,CanvasPagesPanel,CanvasNodeFrameand the five node bodies,CanvasMinimap,actorHue,ACTOR_COLOUR_HUEandPEER_INK.canvas.css(imported by both stylesheets) themes xyflow's--xy-*variables, so an app can drop its own xyflow overrides.
- New block families, all exported from the root (canvas came in 0.1.9):
- Team chat:
CommsConversationRow,CommsMessageRow,CommsDayDivider,CommsUnreadLine,CommsComposerShell, their view types and helpers (transcriptRows,reactionChips,conversationTitle,unreadBadge), androunded-composer. - Code surface:
EditorFrame,EditorBreadcrumbs,EditorStatusBar,EditorPreview,FindBar,TerminalBlock,TerminalOutput,TerminalCommandInput,TerminalStatus,CommandSuggestions,TerminalStopControl,TerminalPalette, the helpers incode-surface.ts, and Tailwind bridges for the syntax, editor, search-match, diff-text and terminal keys (bg-terminal-backgroundand friends).
- Team chat:
- Session blocks take more opt-in props (unset, nothing changes):
ToolCallCard: controlled open state,onCopy,labels, and adata-tool-call-idon every card.TurnRowandSessionTimeline:onLoadBody("Read the rest" on a truncated prompt or reply),openToolRunIds/onOpenToolRunIdsChange(open a folded tool-call group from outside),renderItemFooter, the thinking props andonOpenAttachment.SessionTimelineforwards them all.TurnRownow callscommentSlotfor thinking items too.
AppFrameish-dvh(wash-svh): no gap under the composer when a phone's toolbar collapses. Desktop windows see no difference.TimelineCalendar endSpace="measure"subtracts whatever follows the list in its scroller.trueandfalsebehave as before.UiScalePanellooks like desktop's:ring-1 ring-border shadow-sm(2px shorter), aborder-foreground/8divider, 8%/6% preset fills.PopoverContent surface="glass"is new, for small menus only (it blurs).IconButton iconalso takes an element, drawn as given. Wrap it inIconor use a package glyph so it stays hidden from screen readers.- Accessibility fixes that change names and tab stops:
- The account button's name starts with its initials
(
"UM, account — Name"), and the collapsed org switcher's is"AT, Atlas". Collapsed nav links carry their label. - Block
Code, markdown<pre>and theDataTablescroller are tab stops. The combobox's "Show options" button is not (tabIndex -1). SegmentMetertakeslabel;CommandListtakesaria-label/aria-labelledby. An emptySelector command popup is no longer an empty listbox.- Markdown task checkboxes are named after their item.
- The account button's name starts with its initials
(
- The ratchet's colour rule also bans
oklch(),oklab(),lab(),lch(),hwb()and acolor-mix()with a named colour. A mix ofvar()s,currentColorandtransparentis still fine. - Storybook runs every story as a test (
bun run test:storybook: Chromium, the story's play, and axe with colour-contrast skipped). Foundations gain a Spacing page and anchored, cross-linked Principles.
StatusDothas its own module and more of what the apps hand-roll. Same export and default look (a 6px muted disc), now withsize(xs4px ·sm6px ·md8px ·lg10px), the tonessubtle(offline),primary(unread) andforeground,hollowfor the off state,cutoutto ring it in the surface it sits on (an avatar's corner), andlabelto name it. Without alabel(oraria-label) it is nowaria-hidden. Presence readssuccessonline,warningaway,subtleoffline. A deep import fromcomponents/ui/pill.tsxmoves to the root.- The segmented
TabsListlooks likeSegmented. Tabs sit at their own width (they wereflex-1, so equal), the chosen one's fill is a thumb that slides between them (Base UI'sTabs.Indicator,data-slot="tabs-indicator"), andsize="sm" | "md"matchesSegmented's two. Still atablistoftabs. The active tab no longer carriesbg-element-selecteditself. - Links follow one underline rule (see Links):
TextLinkandlinkVariants(also on./variants) are new;Button variant="link"underlines on keyboard focus too;.prose-session ais underlined at rest, where it used to show a dotted rule only on hover. KiloMarkis Kilo Code's published logo (a framed monogram, its own0 0 100 100viewBox) instead of the placeholder K.- Guards for consumers' tests:
PACKAGE_SENTINEL_CLASSandD23_RENAMESon the root, anddefineRatchetSuiteandstrictRuleson./ratchet(see Using it).RatchetOptions.rulestakes a readonly array. NextPageis new: the end of an infinite list (hasNext,fetching,failed,onNext,skeleton,failure, optionalroot).InputGroupInputandInputGroupTextareadraw no focus ring of their own (focus-ring-none). The group shows focus: its border lifts toborder-strong, and on keyboard focus the global ring (2px of--ring) goes around the whole group, where it used to sit inside its border.Avatartakessize="2xs"(16px) and"3xs"(14px), below the control ladder, for a face in a line of text or a dense graph.
@tryatlas/design/sessionis new:SessionViewand its helpers,StreamingMarkdown,ToolCallCard,DefaultDiff,TurnRow,SessionTimelineandSessionPipeline. It needs the optional peersreact-markdown,remark-gfm,remark-parseandunified.SessionTimelineandSessionPipelinemoved from the root to./sessionand take aSessionView(session) instead of the oldstatus/sessionplusentries.ENTRY_ICON,TimelineEntryandTimelineEntryKindare gone.styles.cssandstyles-core.cssimportsyntax.css(highlight.js classes from the theme'ssyntax.*keys) andsession.css(the.prose-sessionreading type and the streaming caret).Segmentedis reachable by Tab when nothing is selected. Whenvaluematches no option (a "Custom" range outside the presets), the first option is the tab stop, and Home on it selects it. Until 0.1.6 no option was.
0.1.5 also ships the layout primitives (see Layout primitives,
including the Resizable* and Dock* renames), the palette patterns
(CommandPalette, SearchOverlay, HintOverlay) and the inline brand marks.
styles-core.csscarries Atlas as a first-paint floor (the new generateddefault-theme.css), so an app that applies themes itself no longer paints unthemed before its engine runs. It is:root-specific only: an app writing its theme at plain:rootmust move tohtml:root(or anything more specific) to be sure of winning. Desktop already does.styles.cssis unchanged.- Atlas dark's
hue.yellowis#e0ca66, pinned inthemes/atlas.toml; it was the palette's#ffff00. Only the identity hue moved: ANSI yellow, the warning status and search matches still read the palette. The key's default (for a theme with no palette) is the same colour. A consumer that copies the generated files re-copies them. DitherFieldneeds noThemeProvider. Its ink is its own CSScolor(text-foregroundby default, soclassName="text-…"recolours it), and it repaints whenclass,style,data-theme,data-appearanceordata-theme-appearancechanges on<html>, or the OS colour scheme does. Inside the provider nothing changes.UiScalePanelandUiScaleControltakeshortcuts({ decrease?, increase? }, any node): given, the stepper buttons get hints, "Smaller ⌘-" / "Larger ⌘+". Without it, no hints, as before.TimelineCalendar:- leaves room after the last day (
endSpace, on by default), sized from the scroller, so the last day can become the sticky date. PassendSpace={false}to keep the old end. - is semantic again: each day is a
<section>named "November 4, Today" with its<h2>and arole="list"ofrole="listitem"rows carryingaria-posinset/aria-setsizefor the whole day. The wrappers aredisplay: contents. The sticky date is now a plain<div>(nobannerlandmark) and its date is hidden from assistive tech. Tests that query rows can usegetAllByRole("listitem"). - takes
flushto put the dates on the column's edge. By default they sit 8px in, on the text of a row whose fill has its ownpx-2inside the hairlines (desktop'sSessionRow); a row that overhangs with-mx-2 px-2wantsflush. - measured the sticky header as 0px when the scroller was a parent's ref, so the date changed a header's height late. Fixed.
- leaves room after the last day (
SettingRowwraps: once the label column would drop under 12rem, the control moves under the label.DeltaChiptakestone:"neutral"(default, unchanged),"up-good"or"down-good", turning the chip success or error the wayBadgedoes; a flat change stays neutral.KpiCell.toneandStatCard'strendTonepass it through.DeltaToneis exported.Segmentedlabels are nodes (label: React.ReactNode), with an optionalariaLabelper option for the accessible name (and aniconOnlyoption's tooltip) when the label is not plain text.
- Identity hues are theme keys.
hue.red…hue.pink(variables--hue-red…--hue-pink) are new, in a new "Identity hues" group, each derived from the palette hue of its name.--color-hue-*reads them instead ofsyntax.number,syntax.escapeand the ANSI slots, soorangeandpinkchips change colour in every theme whose syntax colours differ from its palette (Atlas, Dracula, Mirage, Monokai, One Dark, Rosé Pine, Tokyo Night, Vesper). The Atlas and Mirage palettes'pinkis now a pink. A consumer that copies the generated files (desktop'sbun run theme:keys) re-copies them; there is no fallback for themes written before the keys existed, which simply derive them. styles-core.cssis new;styles.cssis unchanged.- The ratchet bans
transition-all(andtransition: all) by default, as ruletransition-all.runRatchettakesrules, andTRANSITION_ALL_RULEis exported to widen its scope. @tryatlas/design/animationsis new.SessionPipeline's running spinner andGlobe's ping rings and live dot gaineddata-slots for it.
A new src/components/layout/ group: router-free, storage-free layout pieces
that desktop and web compose their shells from. Every one is controlled: it
reports a change and draws what it is given, so the app decides what is
remembered and where.
TabStrip(items: TabStripItem[],activeId,onSelect,onClose,focused,leading,trailing,renderTab, a requiredaria-label): desktop's pane tabs. ATabStripItemis{ id, title, icon?, closable?, dirty?, busy?, pinned? }. Manual activation (arrows move focus, Enter or Space opens), middle-click and Delete close, edges fade and scroll arrows appear on overflow.renderTab(item, tab)wraps a tab for a context menu or a drag handle.ResizableSplit,ResizablePane,ResizableHandlereplaceResizableGroup,ResizablePanelandResizableHandle. The split takesorientation="vertical"as well ashorizontal, needs anid(and so does every pane), and reportsonLayoutCommit(layout)once per drag, never for a layout the library settled on by itself.ResizableHandle hiddenkeeps a handle mounted but inert. The handle now lights inprimaryon hover and drag, as desktop's do.AppFrame(sidebar,topBar?,overlay?,dockMode,onDockModeChange,children): the canvas, the page panel and the dock slot, with nothing else in it.SideDock,SideDockSizeToggle,useSideDock,useSideDockRoomand the typeSideDockModeare the dock, renamed fromDock,DockSizeToggle,useDock,useDockRoomandDockMode, and moved out ofapp-shell. A borrow fromuseSideDockRoomwidens the dock without reachingonDockModeChange, so it can never become the size an app remembers; a pick from the toggle ends every borrow; unmounting releases.AppShellisAppFramefilled in, and no longer reads or writeslocalStorage. It takescollapsed,onCollapsedChange,dockModeandonDockModeChange; an app that wants the old behaviour keeps both values in its own storage.- The
Sidebarrail and its active pill snap under reduced motion instead of springing. tests/router-agnostic.test.tsfails if anything insrcimports a router.
Install from a git tag. The package is not published to npm; every consumer is an Atlas repo.
bun add github:tryatlas/design#v0.1.9Bun does not run a git dependency's build, so a release tag carries the
built dist/. Cut one from a clean main:
bun run release # builds, commits dist/ on a detached HEAD, tags v<version>, pushes the tagThe version comes from package.json; bump it on main first.
main itself never holds dist/.
# here
bun link
bun run dev # tsc --watch into dist/
# in the consumer
bun link @tryatlas/designA linked package resolves its peers from its own node_modules, so while
linked the consumer must dedupe them (Vite: resolve.dedupe with react,
react-dom, @base-ui/react and motion), or it renders with two copies of
React and every hook throws. bun run test:consumer installs a packed
tarball instead, which is what a real install looks like.
bun install
bun run typecheck
bun run test # vitest: primitives, resolver, golden fixture, generator, ratchet
bun run test:storybook # every story in Chromium: render, play, axe (see Storybook)
bun run build # tsc into dist/
bun run test:consumer # packs, installs into fixtures/consumer, builds its CSS, renders on the server
bun run format:checkWhy tsc builds it, not Vite library mode or tsdown. The package
ships one ESM file per source file, with types, and nothing in it needs
bundling: no CSS imports in JS, no assets, no code to tree-shake that a
consumer's bundler would not shake anyway. tsc does exactly that, keeps
"use client" at the top of every file (bundlers strip module-level
directives unless a plugin puts them back), and rewrites the .ts/.tsx
import extensions to .js (rewriteRelativeImportExtensions). It is one
tool we already need for the type check, with no plugin to keep in step.
Storybook is the package's review surface: the foundations pages (principles,
colour, type, control heights, spacing, radius, elevation, z-index, motion,
icons) and a story for every src/ui primitive and every src/components
component, under every built-in theme. Stories live
in stories/, outside src, so they never ship in dist/.
Start here: Foundations/Principles,
how Atlas looks and moves and why. When a story or a redesign makes a
judgment call, cite the principle by its anchor, e.g.
Principles#rings-not-shadows.
bun run storybook # dev server on http://localhost:6006
bun run build-storybook # static build into storybook-static/ (gitignored)
bun run storybook:smoke # after a build: fails if a primitive or component has no story
bun run test:storybook # every story as a browser test: renders, play passes, axe is cleantest:storybook runs each story in headless Chromium through
@storybook/addon-vitest: the story has to render, its play function has
to pass, and @storybook/addon-a11y's axe run has to find nothing, since
.storybook/preview.tsx sets parameters.a11y.test to "error". Label a
story's controls to fix a violation. When the violation is the component's
own, set parameters: { a11y: { test: "todo" } } on that story with a
comment naming the axe rule and the bug: it shows as a warning, and the
story says what to fix. The run skips color-contrast, whose failures are
the palette's (desktop sets secondary text below 4.5:1 on purpose); the a11y
panel in Storybook still reports it. Install the browser once with
bunx playwright install chromium (add --with-deps on Linux, as CI does).
A primitive at src/ui/<name>.tsx keeps its stories in
stories/ui/<name>.stories.tsx, and a component at
src/components/<group>/<name>.tsx in
stories/components/<group>/<name>.stories.tsx, which must use every
component the file exports. The smoke test holds both conventions. The
ratchet scans stories/ too.
Four globals, all of which round-trip through the URL:
| Global | Values | Reaches the DOM as |
|---|---|---|
theme |
every id in themes/*.toml |
data-theme on <html> |
appearance |
dark, light |
data-appearance on <html> |
scale |
0.9, 1, 1.15, 1.3 (desktop's presets) |
CSS zoom on <html>, and --ui-zoom |
viewport |
phone (375), tablet (768), desktop (1280) |
Storybook's viewport |
scale is a zoom rather than a root font size because desktop's interface
scale is the WebView's zoom (applyUiScale in desktop): the type and control
scales are in px, so a root font size would only move rem spacing. The
preview sets a 13px root, as desktop does, so rem spacing matches the app.
/?path=/story/primitives-button--variants&globals=theme:dracula;appearance:light
/iframe.html?id=foundations-colour--page&globals=theme:tokyo-night;appearance:dark;scale:1.15
/iframe.html?id=primitives-dialog--open&globals=viewport:phone
iframe.html renders the story alone, which is what a screenshot wants.
Overlays (dialog, popover, dropdown and context menu, tooltip, select,
command, and among the components alert dialog, combobox, sheet, toast and the
shell menus) each have an Open story that shows the overlay on load.
A build writes storybook-static/index.json; the dev server serves the same
file at /index.json. Each entry has an id, a title, a name, a type
(story or docs) and the importPath of its file:
curl -s localhost:6006/index.json \
| jq -r '.entries[] | select(.type == "story") | .id'Feed each id to iframe.html?id=<id>&globals=… to visit every story in
every theme.
Third-party material is listed in THIRD_PARTY_NOTICES.md.
{ ".": "components, lib helpers (cn, motion, LinkProvider…)", "./variants": "the server-safe subset: the cva recipes (buttonVariants…), Icon, cn", "./blocks": "the illustration blocks for a marketing page (Globe, DitherField, LogoMarquee…), react and motion only", "./styles.css": "the stylesheet", "./styles-core.css": "the stylesheet with only Atlas as a :root floor, for an app that applies themes itself", "./blocks.css": "the stylesheet for ./blocks alone: Atlas, the utilities, and only the blocks' classes", "./theme": "resolveTheme, resolveBase, themeFromToml, the registry, colour utils", "./ratchet": "runRatchet, defineRatchetSuite, formatViolations, RULES, strictRules, ATLAS_PREFIX_RULE, TRANSITION_ALL_RULE", "./animations": "INFINITE_ANIMATIONS, INFINITE_ANIMATION_FPS_VAR, infiniteAnimationFps (no React, no DOM at import)", "./session": "the SessionView types and helpers, StreamingMarkdown, ToolCallCard, TurnRow, SessionTimeline, SessionPipeline", "./timeline-calendar": "TimelineCalendar, which needs @tanstack/react-virtual", "./checkpoint-trace": "CheckpointTrace and its types, which needs @tanstack/react-virtual", "./keys.toml": "the derivation source", "./themes/*": "the built-in theme files, e.g. ./themes/dracula.toml", "./theme-rules/*": "theme-keys.txt and base-rules.txt", }