@archetypeai/ds-ui-svelte-console
v0.17.0
Published
Archetype AI Design System Console Primitives (Svelte 5 + ds-lib-tokens Integration)
Readme
@archetypeai/ds-ui-svelte-console
Console primitives of the Archetype AI design system. Svelte 5 primitives built on bits-ui, styled with Tailwind v4 and tailwind-variants, and wired to the @archetypeai/ds-lib-tokens theme. TypeScript source ships as .svelte files (no precompilation) so consumers get full type-checking and IDE go-to-definition.
The console flavor ships two ways from the same source, with different roles:
- npm package (this document's default) - how apps consume primitives: they stay in
node_modules, imported per-subpath, updated by version bump. The package is the source of truth. - shadcn-svelte registry - how primitives get modified or extended: pull a primitive's editable source into your app, change it, then port the change back into the package. See Modifying a primitive (registry workflow).
Install
npm i @archetypeai/ds-ui-svelte-consoleThen install all peer dependencies:
npm i svelte tailwindcss bits-ui tailwind-variants tailwind-merge clsx @lucide/svelte @archetypeai/ds-lib-tokens svelte-sonner shiki yaml @cfworker/json-schemaTailwind v4 setup (mandatory)
Tailwind v4 does not scan node_modules by default. Without the @source directive below, every class in this package is purged at consumer build time and components render unstyled - this is the single most common silent failure.
In your consumer's app.css, in this order:
@import 'tailwindcss';
@import '@archetypeai/ds-lib-tokens/theme.css';
@source "../node_modules/@archetypeai/ds-ui-svelte-console/dist";Order matters:
tailwindcssregisters the engine.@archetypeai/ds-lib-tokens/theme.cssdeclares the CSS variables that component classes consume (atai-neutral,bg-card,stroke-icon-default, etc.).@sourcemakes Tailwind scan this package's.sveltefiles so utility classes used inside the package are emitted into your consumer bundle.
Adjust the @source path if your app.css lives elsewhere - it must resolve to the package's dist/ directory.
TypeScript setup
In your consumer's tsconfig.json (or jsconfig.json), set:
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}"node16" and "nodenext" also work. Without one of these, the types condition in this package's exports map is bypassed, types fall back to the default JS file, and editor features degrade.
Usage
Each primitive lives at its own subpath. There is no barrel import { Button } from '@archetypeai/ds-ui-svelte-console' - subpaths keep svelte-sonner, shiki, yaml, and @cfworker/json-schema out of bundles for consumers who never touch the codeblock or sonner primitives.
<script lang="ts">
import { Button } from '@archetypeai/ds-ui-svelte-console/primitives/button'
import { Badge } from '@archetypeai/ds-ui-svelte-console/primitives/badge'
let buttonRef: HTMLButtonElement | null = $state(null)
</script>
<Badge variant="status">Hello</Badge>
<Badge variant="status" size="sm">Tight</Badge>
<Button bind:ref={buttonRef} variant="primary" size="md">Click me</Button>Per-primitive subpaths
| Primitive | Import path |
|---|---|
| Alert | @archetypeai/ds-ui-svelte-console/primitives/alert |
| Avatar | @archetypeai/ds-ui-svelte-console/primitives/avatar |
| Badge | @archetypeai/ds-ui-svelte-console/primitives/badge |
| Button | @archetypeai/ds-ui-svelte-console/primitives/button |
| Card | @archetypeai/ds-ui-svelte-console/primitives/card |
| Checkbox | @archetypeai/ds-ui-svelte-console/primitives/checkbox |
| CodeBlock | @archetypeai/ds-ui-svelte-console/primitives/codeblock |
| Collapsible | @archetypeai/ds-ui-svelte-console/primitives/collapsible |
| CopyButton | @archetypeai/ds-ui-svelte-console/primitives/copy-button |
| Counter | @archetypeai/ds-ui-svelte-console/primitives/counter |
| Dialog | @archetypeai/ds-ui-svelte-console/primitives/dialog |
| DropdownMenu | @archetypeai/ds-ui-svelte-console/primitives/dropdown-menu |
| DropZone | @archetypeai/ds-ui-svelte-console/primitives/dropzone |
| EmptyState | @archetypeai/ds-ui-svelte-console/primitives/empty-state |
| Input | @archetypeai/ds-ui-svelte-console/primitives/input |
| InputGroup | @archetypeai/ds-ui-svelte-console/primitives/input-group |
| Item | @archetypeai/ds-ui-svelte-console/primitives/item |
| Label | @archetypeai/ds-ui-svelte-console/primitives/label |
| Progress | @archetypeai/ds-ui-svelte-console/primitives/progress |
| Select | @archetypeai/ds-ui-svelte-console/primitives/select |
| Separator | @archetypeai/ds-ui-svelte-console/primitives/separator |
| SidePanel | @archetypeai/ds-ui-svelte-console/primitives/side-panel |
| Sonner | @archetypeai/ds-ui-svelte-console/primitives/sonner |
| Spinner | @archetypeai/ds-ui-svelte-console/primitives/spinner |
| Table | @archetypeai/ds-ui-svelte-console/primitives/table |
| Tabs | @archetypeai/ds-ui-svelte-console/primitives/tabs |
| Textarea | @archetypeai/ds-ui-svelte-console/primitives/textarea |
| Tooltip | @archetypeai/ds-ui-svelte-console/primitives/tooltip |
The cn() helper and shared TS type helpers (WithElementRef, WithoutChild, WithoutChildren, WithoutChildrenOrChild) are exported from @archetypeai/ds-ui-svelte-console/primitives/utils. The dark-mode controller lives at @archetypeai/ds-ui-svelte-console/primitives/theme (see below).
Composition patterns will arrive at @archetypeai/ds-ui-svelte-console/patterns/<name> in a later release without breaking any of the primitive paths above.
Dark mode (primitives/theme)
The package manages dark mode without any external dependency. darkMode is a reactive singleton that mirrors the dark class on <html>:
<script lang="ts">
import { darkMode } from '@archetypeai/ds-ui-svelte-console/primitives/theme'
</script>
<button onclick={() => darkMode.toggle()}>
{darkMode.current ? 'Switch to light' : 'Switch to dark'}
</button>darkMode.current- reactiveboolean,truewhile<html>carries thedarkclass. Safe to read in$derived/$effect.darkMode.set(value)- adds/removes the class. CSS transitions are suppressed for one frame during the swap, so the whole page flips at once instead of staggering per-element.darkMode.toggle()- convenience forset(!current).
A MutationObserver keeps current in sync even when something else toggles the class (e.g. an app-level theme store or SSR-rendered markup), and the module is SSR-safe - it no-ops without a document. Primitives themselves don't consume darkMode — they theme off the class through CSS tokens (CodeBlock's syntax highlighting renders both themes up front and switches between them in CSS).
Avatar (primitives/avatar)
Root plus a fallback is the minimum - Avatar.Root alone renders an empty circle:
<script lang="ts">
import * as Avatar from '@archetypeai/ds-ui-svelte-console/primitives/avatar';
</script>
<Avatar.Root size="sm">
<Avatar.Image src={user.avatarUrl} alt={user.name} />
<Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar.Root>The fallback covers three cases, not one: the image is still loading, there is no src, and
the image failed - so initials belong there even when you expect a photo. Avatar.Image is a
real <img>, so alt is the caller's to pass; use the person's name.
size is sm (28px) / default (36px) / lg (48px) and shape is circle / squircle -
circle for people, squircle for entities like an org or a workspace. The fallback's type scales
off the root's data-size, so resize with the variant rather than a hand-written size-*
override, or the two desynchronise. Pass loadingStatus="loaded" to skip the fallback flash for
an already-cached image; it is bindable if you need to read the outcome.
To stack a group, put flex -space-x-2 plus
*:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background *:data-[slot=avatar]:border-0
on the container - the ring cuts each avatar out of the one behind it, and hangs off data-slot
rather than needing a prop. Drop the border in a stack: the root's border-border sits just
inside the ring, so keeping it renders a grey rim, then a white gap, then the next rim, and the
overlap reads muddy. Stack images, not initials: the overlap covers each avatar's trailing edge,
which a face survives and two letters do not.
CopyButton (primitives/copy-button)
A copy control that owns its own copied state, so a table of them needs no bookkeeping in the caller:
<script lang="ts">
import { CopyButton } from '@archetypeai/ds-ui-svelte-console/primitives/copy-button';
</script>
<CopyButton value={job.id} label="Copy job ID" title={job.id} />label is the accessible name - say what is being copied. Pass the value as children
instead and the whole control becomes one copy target; the hit area follows automatically
(the 36px icon size when icon-only, inline when the value is inside).
It toasts by default, so a <Toaster /> must be mounted (see primitives/sonner) or
the toast silently no-ops. Pass toast={false} where the inline check mark is feedback
enough - a table of IDs, where a toast per copy is noise - and the control announces the
copy to screen readers itself instead. successMessage / errorMessage change the
wording; onCopy(copied) hooks anything else.
copyText(text) is exported from the same subpath for a labelled action that happens to
copy, without the button.
CodeBlock editor & validation
primitives/codeblock exports two components: CodeBlock (read-only, shiki-highlighted) and CodeBlockEditor (editable, with a YAML + JSON Schema validation pipeline):
<script lang="ts">
import { CodeBlockEditor } from '@archetypeai/ds-ui-svelte-console/primitives/codeblock'
let value = $state('name: my-config')
let valid = $state(true)
</script>
<CodeBlockEditor bind:value bind:valid schema={mySchema} showDownload downloadBaseName="config" />value(bindable) - the YAML text being edited.schema- optional JSON Schema object; when provided, the parsed YAML is validated with@cfworker/json-schema.valid(bindable) -truewhile the text parses and passes the schema.schemaUnavailable(bindable) -truewhen the schema itself can't be compiled into a validator.schemaFallbackFix- overrides the fix line shown when the schema fails without a specific field to point at (the generic default is "Check each setting against the expected format.").placeholder,showDownload,downloadBaseName- editor chrome.
Validation is debounced (250 ms) and errors are mapped back to source lines as what-happened + fix pairs: schema pointers render as model.learning_rate (not /model/learning_rate), schema types as plain words ("a number", "a whole number", "text"), and the common schema keywords (type, required, unrecognized settings, enum/const, min/max) get dedicated wording. The shiki highlighter is lazy-loaded, and highlights against github-light and github-dark in a single pass — the dark colours ride --shiki-dark custom props that CSS swaps in under .dark, so a theme flip costs no re-highlight. This pipeline is why yaml and @cfworker/json-schema are peer dependencies.
Tooltip (primitives/tooltip)
Tooltip.Trigger variant="info" is the field-help affordance, and it renders itself - a filled
16px disc holding a mono i. Pass it no children:
<script lang="ts">
import * as Tooltip from '@archetypeai/ds-ui-svelte-console/primitives/tooltip';
</script>
<div class="flex items-center gap-2">
<Label variant="sectionHeader">Job Name</Label>
<Tooltip.Root>
<Tooltip.Trigger variant="info" aria-label="About Job Name" />
<Tooltip.Content>A human-readable label for this batch job.</Tooltip.Content>
</Tooltip.Root>
</div>aria-label is required and is yours to pass - name what is being explained. The glyph is
aria-hidden, so a trigger without a label has no accessible name at all rather than one called
"i". An icon child still renders (scaled down to fit), but it is no longer the recipe: the disc is
already the affordance, and an icon inside it reads as two.
The disc is size-4, which squares against Counter size="sm"'s h-4 on purpose - a section
title, its Counter and its info disc in one flex items-center gap-2 row is the intended cluster,
the solid pill reading as the count and the muted one as the meaning. Both stay filled; a stroked
ring beside a filled pill reads as a half-drawn one.
16px is also exactly a Label variant="sectionHeader"'s box height, so the disc sits level with the
title instead of towering over it. That label is text-xs, and every step of the type scale carries
its own line-height, so text-xs alone makes it a 16px box. workbenchHeader (text-sm, a 20px
line box) is the variant size="lg" lines up with.
size="lg" is the 20px disc (size-5, glyph at text-xs), for the rarer cluster built around
Counter size="default" rather than a section label. size only bites on the info variant - the
other trigger treatments have no box for it to change - and it is a compound axis, so size on
inlineLink is inert rather than wrong.
Both sizes set the glyph's type size on the glyph ([&>span]:text-[10px]), not just on the
button. That looks redundant but is not: ds-lib-tokens' base layer carries span { @apply text-sm },
and the info glyph is a span, so a glyph left to inherit renders at 14px no matter what the button
says. Editing the disc's type size means editing the [&>span]: half too, or the glyph silently
stops tracking the disc - tooltip.test.ts pins it against Counter size="sm" to catch that.
variant="inlineLink" is the other trigger treatment - an underlined span flowing inside prose,
for a term that needs a definition without a disc interrupting the line.
Tooltip.Content takes arrowClasses for the arrow's own styling, but not for placement: the
arrow centres itself on the trigger on all four sides and protrudes half its box. A tip that looks
off-centre is a bug in tooltipArrowVariants to fix here, not something to counter-shift at the
call site - a call-site nudge is invisible to every other consumer and silently double-corrects the
day the primitive is fixed.
API conventions
bind:reffor element handles. Every primitive exposesrefas a$bindableprop. Use<Button bind:ref={el} />to capture the underlying DOM element.data-*attributes are public API. Every variant slot carriesdata-slotplus variant attributes (data-variant,data-size,data-state,data-typography, etc.). Use them as descendant-selector hooks. Renaming requires a major version bump.Composite primitives compose by sub-part. Card, Dialog, Item, Select, DropdownMenu, Tabs, Collapsible, Tooltip, InputGroup, and DropZone export their sub-parts (e.g.
Dialog.Trigger,Dialog.Content). Passclassto each sub-part directly - there is noheaderClass/contentClassshorthand on the root.childsnippet on Item is the bits-uiasChildrender-prop pattern, not a typo forchildren.Counts are their own primitive, and absent is not zero.
Counter(andTabs.Count, the tab-context wrapper around it) takes avalue: a number renders the pill,nullrenders the not-yet-loaded pill at the same reserved width, and omitting the component entirely is how a tab says it has no count. Values past 999 abbreviate (1.2K,123K) so a growing count can never widen the strip; the exact number stays ontitleand in the accessible name. A caller that knows only "at least this many" (a keyset-paginated list, a capped query) passesatLeastalongside the value to render it as a floor (103+, accessible name "at least 103"); the abbreviation truncates rather than rounds in that mode, so the floor never claims more than was loaded. The pill is the default, not the whole component:variant="bare" size="inherit"drops the background, the radius and the mono face and takes the typography of whatever it sits in, so a headline figure, a table cell or a number mid-sentence can roll on the same odometer. Bare counters sit on the surrounding baseline and reserve no width of their own;exactturns off the abbreviation where the reader is meant to read the figure rather than glance at it.A sortable column header never holds the sort.
Table.Head sortableturns the header into a button and owns the affordance, the three-state indicator andaria-sort; the consumer passesdirection('asc' | 'desc' | null) and anonSortcallback and keeps the state itself.nullmeans another column holds the sort, which is what renders that header's unsorted arrow. Because the state lives with the consumer, a Sort menu and the column headers can drive the same state and stay in step. Heads withoutsortableare unchanged and are simply not clickable.Variant configs are exported. Each
index.tsre-exports itstv()configs (e.g.buttonVariants,inputGroupButtonVariants) so consumers can compose variants without forking source.
Modifying a primitive (registry workflow)
The npm package is the source of truth for primitives - consume every primitive from it, and don't keep local forks or hand-roll one-offs. What you will hit is a primitive that needs to change: an internal refactor, a variant's styling adjusted, or the API extended with a new variant or prop axis. For that, the same primitives are served as a shadcn-svelte registry at https://design-system-console.archetypeai.workers.dev/, so you can work on the real source inside your app and upstream the result:
Pull the editable source into your app:
npx shadcn-svelte@latest add https://design-system-console.archetypeai.workers.dev/r/button.jsonComponents install flat under your
uialias -$lib/components/ui/{button,codeblock,...}/, plusutils.tsandtheme.svelte.tsat theuiroot. Cross-component dependencies are declared with the registry'slocal:prefix, so installing one component pulls everything it needs from the same registry - e.g.codeblockpullsbutton,spinner,theme, andconsole-utils.Make the change against the local source and validate it in-app.
Port the change back into
ds-ui-svelte/console/primitives/in the design-system repo and release. Then bump the package version in your app, delete the local copy, and import fromprimitives/<name>again. A copy living long-term inui/is drift - the package, not the app, owns primitives.
A genuinely new primitive follows the same path in reverse: build it in ui/ mirroring the existing primitives (a tailwind-variants config, cn from utils, data-slot/data-variant attributes, runes, token classes throughout - read an installed primitive's source as the template), then ship it into the package.
Notes:
- Always install via the full registry URL (or a project script wired to it - the console app exposes
npm run ds [<name>]for exactly this). A barenpx shadcn-svelte@latest add buttonresolves against the default shadcn-svelte registry and installs the generic upstream component, not the Archetype one. - The utils item is named
console-utils(shadcn-svelte reservesutils); it ships the consolecn()with therounded-interactivetailwind-merge extension. Without it, class merging on interactive radii silently breaks. /r/all.jsonlists all component URLs for programmatic access.- Registry-installed files don't need the Tailwind
@sourcedirective (they live in your repo and are scanned normally), but the@archetypeai/ds-lib-tokens/theme.cssimport remains mandatory.
Extending in labs
Console is the stable base tier: its API contract is frozen and its data-* attributes are public API that only a major version may change. When you need new or experimental behavior on top of a console primitive, build it in the labs tier (@archetypeai/ds-ui-svelte-labs) - never fork or mutate console to accommodate a one-off.
Labs adapts console without changing it, using an escape-hatch ladder - reach for the lowest rung that solves the problem:
classprop override. Every primitive forwardsclassto its root slot; pass Tailwind utilities to restyle in place.data-slot/data-*selectors in your CSS. Target the public attributes ([data-slot="..."],[data-variant="..."],data-state) with descendant selectors - no source changes.tv({ extend })on the exported variant cluster. Each primitive re-exports itstailwind-variantsconfig (e.g.buttonVariants); extend it to add a variant or override defaults without forking the component.- A labs wrapper component (rare). When the first three can't express it, compose the console primitive inside a labs primitive.
This ladder keeps the modification surface inside labs. Behavior that proves itself there graduates back into console in a deliberate, API-hardening change - the flow is always labs → console, never console reaching up into labs. See the labs README for the consumer-side view of the same ladder.
License
MIT. See LICENSE.
