@spunto/design-system
v0.36.0
Published
Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.
Maintainers
Readme
@spunto/design-system
Spunto's shared design system — the warm/flame tokens, JS color constants, and the base UI primitives used across Spunto apps (the dashboard and Spunto Lite). One source of truth so replicated features stay visually consistent.
Built on shadcn/ui. This package is an adaptation of shadcn/ui, not a design system written from scratch. Its token names and structure, its
cva+cnvariant pattern, and most of its primitives were copied from shadcn/ui (the Base UI variant) and then restyled for the Spunto palette and extended with Spunto-specific components. Full credit for the original design and API goes to shadcn and the shadcn-ui/ui contributors — MIT licensed, and the reason this package exists at all. If you're not tied to Spunto's look, use shadcn/ui directly.
Usage
Import the tokens once in your app's entry CSS, right after Tailwind:
@import "tailwindcss";
@import "@spunto/design-system/styles.css";
@custom-variant dark (&:is(.dark *));Then use the primitives and helpers:
import { Button, Card, Badge, cn } from "@spunto/design-system"
import { chartColors } from "@spunto/design-system/colors"
<Button variant="outline" size="sm">Deploy</Button>In a Next.js app, add the package to transpilePackages (it ships TypeScript source):
// next.config.ts
const nextConfig = { transpilePackages: ["@spunto/design-system"] }What's inside
./styles.css— oklch token scale (light + warm dark), the@theme inline→ Tailwind color/radius mapping, a minimal base layer, and thedot-grid/grid-linesbackground utilities. Brand color is flame (--primary≈#ea5400); radius base is0.25rem;Build/Ship/Runare semantic (bg-build,text-run…). Plus the two unthemed marketing surfaces —--night(bg-night) and--code-surface(bg-code-surface) — and thenight-grid/night-glow-a/night-glow-b/night-scrollutilities they come with.cn—clsx+tailwind-merge../colors—cssVar,chartColors,chartRamp,segColorsfor JS/chart contexts.- Primitives — form + layout building blocks styled on
@base-ui/react:- Basics —
Button(+buttonVariants),Card(+parts),Badge(+badgeVariants),Separator,Spinner,Skeleton,Avatar(+AvatarImage/AvatarFallback),Kbd— one keyboard cap, or a whole shortcut (keys="mod+k"→ ⌘ K, Ctrl K off Apple), spelled out for screen readers. - AvatarGroup — the stack of overlapping avatars.
size(xs…xl) sizes the whole stack at once — onAvatarit sets the circle and its initials, because a 64 px avatar with 14 px initials looks broken. Compositional: the group never reads a{ name, src }shape, it wraps whatever you hand it (anAvatar, one inside aTooltip, a roundSkeletonwhile the list loads) in its own span, so a loading stack has exactly the geometry of the loaded one. It owns only what a caller can't get right by hand: the overlap (a ratio of the diameter,spacing= ~40/29/20%), theringClassNamethat separates two faces (match the surface behind the stack —ring-cardinside aCard), the paint order, and the+N. Counting has two rules: the counter adds the childrenmaxcut and the people never rendered (total− children), so a paginated list shows+38without fabricating 38 ghost avatars; and a+1never appears — one hidden child takes exactly the room its counter would, saying less, so the face is drawn instead.onOverflowClickmakes the counter a real button. - Forms —
Input,Textarea,Label,Switch,Checkbox,RadioGroup(+RadioGroupItem),Select(+SelectTrigger/SelectValue/SelectContent/SelectItem/SelectGroup/SelectGroupLabel/SelectSeparator). - Combobox —
Combobox(+ComboboxInput/Clear/Trigger/Value/Search/Chips/Chip/ChipsInput/Content/List/Group/GroupLabel/Separator/Item/Empty/Loading), theSelectyou can type into — for the list nobody wants to scroll (branches, repos, model ids, members, tags). Base UI'sComboboxunderneath, and compositional likeSelect: items are children. Three things it adds. (1) Items filter themselves, with the same collatorCommandPaletteuses, so no caller re-.filter()s its options and "deploi" finds "déploiement"; groups hide themselves through:has()(no moreif (items.length === 0) return null), and so does a separator with nothing left after it. (2) A query is only a query when someone typed it — Base UI writes the selected item's label into the input, and taken at face value that text filters the list down to the row you just picked; the filter keys off theinput-changereason and ignores anything the component wrote itself. (3) In multi-select the chips field registers itself as the popup's anchor, so the list opens under the whole field instead of the caret-sized<input>— no ref to remember, noanchorprop to pass. Two field shapes, two components rather than a flag:ComboboxInput(the combobox is the field, same 32 px/border/ring asInput, chevron out of the tab order) orComboboxTrigger+ComboboxValuewith aComboboxSearchat the top of the popup, for when the value is rendered rather than typed. Presentational only:loadingis a prop and a server-side search isonInputValueChange+filter={null}. - Navigation & feedback —
Tabs(+TabsList/TabsIndicator/TabsTab/TabsPanel),Alert(+AlertTitle/AlertDescription, +alertVariants) — an inline, persistent, declarative status banner, the counterpart to the ephemeral imperativetoast(). - Overlays (plug into the
SpuntoProviderumbrella) —Dialog,AlertDialog(a confirmation variant, non-dismissible),Sheet,Tooltip. Their portals render into the provider's overlay container;Tooltip's shared delay group is mounted by the provider too. - ActionMenu —
ActionMenu(+ActionMenuItem,ActionMenuEntry), the⋯menu described instead of assembled:itemsis a list of{ label, icon?, onClick }(orhref, for a real<a>via Base UI'sMenu.LinkItem) and the component builds the trigger, the portal, the popup, the items and their states —loadingswaps the icon for a spinner and disarms the entry,destructiveturns it red,shortcutright-aligns aKbd. A menu is the one place an app brings nothing but data, and every app was re-typing the same twenty lines of Base UI markup around it. Two things the data form gets right that hand-written menus don't: entries may befalse/null/undefined, so a conditional menu stays one flat array; and"separator"is an entry like any other, normalized — a rule left leading, trailing or doubled by a missing neighbour disappears instead of floating at the top of the popup. Empty after that pass → nothing renders, not even the⋯. Still presentational: it never knows what an entry does. For what a list can't say (a submenu, a checkbox item), compose Base UI directly — that's whatWorkerCard'sactionsslot is for. - Sheet —
Sheet(+SheetTrigger/SheetClose/SheetContent/SheetHeader/SheetFooter/SheetTitle/SheetDescription), the same Base UI dialog entering from an edge.side(4) decides the edge,size(sm…full) how far it comes in — two props rather than one bundle ofdata-[side=right]:-prefixed classes, precisely soclassNamestays the layout escape hatch (an app widens a panel or lays it out in two columns without!important). - Table —
Table(+TableHeader/TableBody/TableFooter/TableRow/TableHead/TableCell/TableCaption). Presentational only: no sorting, no pagination, no column model — apps already own that logic and disagree on it; what they were duplicating is the markup and the token choices.containerClassNamereaches the scroll container (max-height, border, sticky-header context). - CommandPalette —
CommandPalette(+CommandPaletteInput/List/Group/Item/Empty/Loading/Separator/Footer/Trigger), the ⌘K palette: an input, groups, items, and a jump. Domain-free, hence its place in the root entry — it knows nothing of projects, workers or deployments; those are items the app hands it. Base UIAutocomplete(combobox/listbox roles,aria-activedescendant, ↑↓ + scroll-into-view, ↵ activates the highlighted item) rendered inline inside a Base UIDialog(focus trap, focus restore, Escape, backdrop), portalled into the provider's overlay container like the other overlays. Compositional likeDialog/Select, not a config array — which is why the root runs Base UI inmode="none"and each item decides for itself whether it matches, with the sameAutocomplete.useFilter()collator. Presentational only:loadingis a prop, results are children, and a server-side search isvalue/onValueChange+filter={null}. Navigation ishref+render.link, nevernext/link— same rule asWorkerCard. Not virtualized: a few hundred items stay fluid (a non-matching item renders nothing), beyond that filter server-side. - CommandMenu —
CommandMenu(+CommandMenuInput/List/Group/Item/Empty/Loading/Separator), the palette's vocabulary inline: a Base UIPopoveranchored to an element or a virtual element (a caret rect), which never moves focus (initialFocus/finalFocusoff, no backdrop, non-modal) and claims ↑ ↓ ↵ Esc ondocumentin the capture phase, before the field underneath. That's what a "/" menu inside a block editor needs and what the dialog-based palette can't do without losing the caret — hence a separate component rather than amodal={false}flag: the contract is the opposite one. Enter is only claimed when an item is highlighted, so a query with no result still inserts a line break. The query either comes from the caller (query, the "/" case) or from aCommandMenuInputrendered inside the popup (a filter dropdown on a button;autoFocusis opt-in). - Auth —
AuthLayout,AuthBrandPanel(+AuthBrandMark),AuthForm(+authFormCopy),OAuthButton(+GoogleIcon/GitHubIcon) andAuthSeparator: the login/signup screen in bricks, not one<AuthScreen />. In the root entry for the same reason as the palette — nothing in it knows a Spunto concept: every word of the brand panel is a prop, and the form authenticates nothing. No framework, no network: nonext/navigation, nofetch;AuthFormhands backonSubmit({ mode, email, password, name }),error/loadingcome back down as props, and anything that navigates takes anhref(+render.linkonOAuthButton) — the route, the endpoints and the redirect stay in the app. Both controlled (mode, for/login+/signup) and uncontrolled (initialMode+ the in-place toggle). The fields are the package's ownInput/Label/Button, in this screen's taller register throughclassName— the shape belongs to the page, not to inputs in general. Assembling the whole thing: Créer sa page de login (markdown). - Terminal —
Terminal(+TerminalHandle,TerminalOptions), a transport-agnostic xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where bytes come from (WebSocket, SSE, a static string) — feed it via thewrite/writelnref handle. Opt-in addons throughoptions(webLinksfor clickable URLs,searchfor the find API). The handle also reads the buffer back (getContent, for copy/ download) and reports viewport movement (onViewportChange, for tail following). - TerminalPanel —
TerminalPanel(+TerminalPanelStatus), the frame around aTerminal: a composable bar (title, mono subtitle, status dot, reconnect, freeactionsslot) and the states pages used to re-improvise —placeholder/loadinginstead of an ad-hoc empty box,statusinstead of[connection closed]written in ANSI into the log. Opt-in bar tools, all off by default:search,followTail,copy,download. Its ref is the wrappedTerminalHandle. - ImagePull —
ImagePull(+PullLayer,PullLayerStatus,ImagePullState), a Docker image pull drawn as a real UI instead of ANSI art redrawn in a terminal: one overall bar whose colour follows the phase (flame downloading → amber unpacking over it → solid green), with the per-layer detail in an expandable list. Presentational only — hand it a snapshot, it draws; throughput and ETA are derived internally. Same#09090bsurface and ANSI palette asTerminal, so the two stack into one object.
- Basics —
SpuntoProvider+toast()— the imperative toast system (see below). The provider is also the umbrella that mounts the tooltip delay group and the overlay portal container the dialogs/selects render into.
Domain components — /workers, /devcontainer, /projects, /tasks, /observability
Everything above is domain-free: a Button knows nothing about Spunto. Components
that do know a Spunto concept live behind their own entry point, so the root
export stays primitives-only and a third-party consumer never carries the notion
of a worker. The rule for what comes next: any new component that knows a domain
concept goes behind a domain sub-export, never in the root entry.
import { Button, Card } from "@spunto/design-system" // primitives
import { WorkerCard } from "@spunto/design-system/workers" // domain
import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
import { TaskList, TaskCockpit, TaskEventStream } from "@spunto/design-system/tasks" // domain
import { SpanWaterfall, TracesOverview } from "@spunto/design-system/observability" // domain
import { Section, ProductHeader } from "@spunto/design-system/marketing" // not a domain — see belowOne entry per domain, not per component: a project's form and a project's
panel are the same concept seen twice, so they share /projects.
/marketing is the exception that proves the rule: it knows no Spunto concept
(that's exactly why the product registry stayed in the site), but it carries
opinions a dashboard doesn't want, so it gets an entry too.
@spunto/design-system/workers
WorkerCard— a Spunto worker as a card: state, author, node, setup progress, git branches, resources, banners, actions. Purely presentational — hand it a snapshot plus slots and it draws. The rule that keeps it that way: everything that CALLS the API is data, a slot or a callback; everything that DRAWS is in the component. So what the⋯entries do (menu), the footer (footer), the link (href+render.link, nevernext/link), the rebuild (onRebuild) and the tag persistence (onAddTag/onRemoveTag) all stay in the app.- The
⋯menu is a prop, not markup.menutakes theActionMenuEntry[]of theActionMenuprimitive —{ label, icon?, onClick }, falsy entries allowed,"separator"normalized — and the card draws the whole menu. Before, every app handed the card a fully-built menu through theactionsslot and re-typed the same Base UI markup to do it.actionsstays as the escape hatch (a submenu, a checkbox item) and wins overmenuwhen both are given;render.menuLinkis the link slot for an entry with anhref. - Its bricks, exported individually because the table view assembles them
differently:
WorkerStatusDot/WorkerStatusPill(+resolveWorkerStatus,workerStatusConfig),SetupProgress(+setupProgress,phaseLabel,setupTotalMs),StepIndicator,ResourceBars,GitBranchChips/GitStatusSummary,TagChips(+tagColor),CreatorAvatar. - One list of repositories, drawn as given.
gitStatusanswers "what is this worker working on", and the tense of that answer belongs to the caller: the live checkout once it's known, the branches the worker was asked to check out while it isn't (setup in flight, stopped worker). The card doesn't hold a second, worker-level branch and doesn't decide when the list is meaningful — only the caller knows whether its own data is still fresh. Keep the samepathfor both tenses (Spunto clones into/workspace/<workspacePath>, which is whatgit-statusreports) and the transition is a value change on the same chip. - Types are structural, never the apps' OpenAPI types: the dashboard and
Spunto Lite model a worker differently, so
WorkerCardWorkerasks for the minimum, everything optional butid, and an unknown state falls back topendinginstead of throwing.
@spunto/design-system/devcontainer
The three catalogs you compose an environment from. They exist identically in the
dashboard and in Spunto Lite, and were hand-drawn as raw <button>/<li> on both
sides — exactly the duplication this package is for.
ImageCard— a devcontainer base image: runtime mark, label, publisher + avatar, image reference in mono, description.FeatureCard— a devcontainer feature: the author's avatar (the GitHub org behind theghcr.ioref), label, description, option chips (version=lts…), OCI reference.ImageTile/FeatureTile(+ the sharedCatalogTile) — the same entries at chip size: a mark, a name, one trailing detail. What a catalog needs past a dozen entries, where picking is a logo task and full cards become a wall. They flow and wrap like tags; the pickers pair them with the full card of what's selected.ExtensionCard(+ExtensionCardSkeleton) — an IDE extension: its real Open VSX icon, display name, publisher + verified badge, version, formatted install count, rating.
All three share one selectable state (flame check + ring), one compact
read-only variant for the project sheet, and one custom variant for a
hand-typed reference that degrades cleanly.
- Nothing here fetches anything.
onSelectis a callback, the option row (optionsSlot) and the trailing control (action) are slots. The author and the logo are derived from the reference by pure functions, because the catalogs carry neither:parseRegistryRef,derivePublisher(ghcr.io/<owner>/…→ that GitHub org, avatar included viagithub.com/<owner>.png;mcr.microsoft.com/devcontainers/…→ Dev Containers) andresolveStackMark.publisher/mark/iconUrlare the overrides for the day the API knows better — no component change needed then. - Brand marks ship with the package as single-path SVGs drawn with
currentColor(StackMark,STACK_MARK_PATHS): no third-party CDN, and each logo inherits the warm accent of its card instead of dragging 20 clashing brand palettes into one grid. - Its bricks, exported individually:
CatalogCard(the selectable shell),CatalogMark(the icon → brand → avatar → monogram cascade),CatalogChip,PublisherBadge/PublisherAvatar,RegistryRef(mono + copy button), plus the pure helpersparseRegistryRef,derivePublisher,githubAvatarUrl,resolveStackMark,resolveFeatureOptions,formatCount,parseExtensionId. - Types are structural here too:
DevcontainerImageEntry,DevcontainerFeatureEntryandVscodeExtensionEntryare all-optional, and an unknown registry degrades to a monogram instead of an invented author.
@spunto/design-system/tasks
Delegated work — a task is a worker, a branch, an agent session and something to review. Every surface that shows one:
TaskList— one project's tasks grouped by state in the order a task travels (queued → running → in review, then failed and done, folded), Accept/Drop on the row.TaskConversationList(+TaskConversationPlaceholder) — every conversation of an organization as a messaging app's list: search, state chips whose counts ignore the filter, grouped rows. Fully controlled. Sized bypointer-coarse, not width.TaskCockpit/TaskCockpitMobile— one conversation, as a layout: top bar + Accept/Drop, the Session/Diff/Terminal switch, and where each slot goes. The panels are slots filled with the package's own components (session/diff/terminalare render functions receiving the switch to draw in their header; an omitted slot is no tab). The phone one is its own screen — avisualViewport-sized layer, details in a sheet, Drop at the bottom.TaskEventStream— the agent session (RFC 0020): prose first, tool calls folded into runs ("read 8 files · ran 3 commands"), the session-wide tool stack in a sheet, virtualized (@tanstack/react-virtual), opened at its end, paging older history as the reader scrolls up.TaskComposerunder it says why it is closed when it is.TaskDiffPanel— the branch's diff: summary as a prop, each file's patch throughloadPatch(a promise; the component owns per-file loading state), intra-line highlighting, and a sentence for every reason it could not be read.TaskTerminalPanel— the frame of the Terminal tab: the header with the switch, optionalactions, and a dark surface for the app's own shell (the socket is the app's), or a sentence saying why there is none. No composer under it.TaskDetails(sidebar|sheet) withTaskMachine,TaskBoot,TaskSessionUsage,TaskPullRequest,TaskCommandLog— the facts about a task, in one order, instead of two hand-kept copies.NewTaskDialog— delegating: prompt, files, title, base branch, model.- Vocabulary and helpers:
TaskStateBadge,taskStateConfig,useElapsed,AgentMarkdown,useAttachmentDraft/prepareFile/TaskFiles(files, RFC 0022),runStats/toolVerb/toolSubject,bootSteps,indexTasksByWorker.
Same rules as /workers: nothing fetches, types are structural and lax
(TaskItem, TaskEvent, TaskDiff…), links go through render.link.
@spunto/design-system/observability
Traces — what an OpenTelemetry backend hands back, drawn. Every surface that shows one:
SpanWaterfall— a trace as a tree of spans on a shared time axis (children under their parent, not the backend's start-time order), colour by nature (http → build, db → ship, agent RPC / job → run, error → destructive), attributes of the clicked span. Any span with children folds (chevron, Collapse/Expand all, ←/→; a folded row shows+N, red when an error is hidden under it;defaultCollapsedDepth, or controlledcollapsedSpanIds). Hand itlogsand a Logs section appears underneath, linked by the span id the logger stamped: a line selects its span, unfolding its ancestors if needed.SpanWaterfallSkeletonfor the fetch.TraceList— one row per request (method, route, latency bar, when), orvariant="compact"for root spans that aren't necessarily HTTP.TraceFiltersBarnext to it: window and min latency are meant for the backend query, status / method / route are applied client-side byfilterTraces;traceFilterOptionsderives the select options.TracesOverview— stat tiles, requests and p95 over time, per-service cards (showServices), slowest routes.TraceRouteDetail— one route's stats and example traces. Both are derived from the traces they're handed, so a list and its dashboard can't disagree and no aggregation endpoint is needed.LogLines— log records with severity and trace/span ids: under a waterfall (origin,onSelectSpan) or as a service's feed (onOpenTrace).- Pure helpers:
computeStats,byRoute,byService,timeBuckets,exampleTraces,percentile,methodOf/routeOf,orderSpans,spanColor; and one latency scale —formatLatency(sub-millisecond precise),latencyTone/latencyColor/latencyTextClass.
Same rules as /workers: nothing fetches (no URL, no query client — the org's traces view,
a deployment's Observability tab and the admin each pass their own data), types are
structural and lax (TraceItem, TraceSpan, TelemetryLogLine).
@spunto/design-system/projects
ProjectForm— the whole "compose a dev environment" screen: identity, base image, repos, features, extensions, lifecycle, ports, prewarm images, docker-in-docker, secrets, plus the live build manifest that recaps it and carries the submit button.- Controlled by one value. A single
ProjectFormValuein, oneonChangeout,onSubmit(value)at the end. Start fromemptyProjectFormValue(), ortoProjectFormValue(partial)to fill the gaps of an existing project. The value is always readable — which is what makes validation, drafts and previews possible at all. - Which sections show is a prop (
CREATE_SECTIONS/EDIT_SECTIONS/ your own list, in your own order). Creation and editing are the same component with a different list, not two screens that drift apart. - Everything past identity / base image / repos folds under "Advanced
options" (
advancedSections,[]to disable). Two rules keep that honest: the closed fold writes out what it holds ("2 features · lifecycle · 3 secrets") instead of showing a bare chevron, and the form opens it by itself when any of those sections already has a value — so editing a project never hides a setting you made earlier. - Git hosting is a list of providers, not GitHub (0.21.0).
gitProviders(id, name,connected,accounts,installUrl,operations,icon) andgitRepos(each tagged with itsprovider) replacegithubRepos/github/connectUrl/githubIcon, andProjectRepo.providerwidens from"github" | "git"to astring— the set of forges belongs to the deployment, not to this package. The component readsoperationsto decide what to offer rather than branching on a provider id, so a second forge needs no change here."git"(exported asRAW_GIT) still means "a raw clone URL", which is the absence of a provider rather than one more of them. - One "Add repository" button, with no brand on it. The forge is a property
of the repository you pick, not a decision to make before you know which
repository you want — so the field offers every connected provider's repos at
once and
renderRepoField'sonChange(value, option?)hands back the chosen entry, which is how the row learns its provider. A brand glyph on a generic button is wrong as soon as two providers are connected. - Nothing fetches. Catalogs come in as data, the marketplace search is
onSearchExtensions, the repository combobox isrenderRepoField(without it: a plain<input list>+<datalist>, no dependency, keyboard-friendly), andextrasslips app-specific content inside a section — the deploy key card, a warning — without forking the component. - Its bricks, exported individually:
FormSection,Field,BuildManifest,RepoList(+deriveFromGitUrl),SecretList,ImagePicker/FeaturePicker/ExtensionPicker, and the puremanifestRows. - Responsive by container query, not viewport. The form is a full page in one app and a 700 px panel in another; it reads its own width to decide whether the manifest sits beside it and whether the catalogs are one column or two.
And the read-only twin of that form:
ProjectPanel— the left-hand panel of a project page: identity, image, repositories, deploy key, features, extensions, build cache, ports, lifecycle commands, secrets, integrations, version history, footer. It draws the same configuration once it exists, plus what only a live project has. Each band disappears when its data is absent, so a bare project collapses to a header plus an image without a single caller-side conditional.- Same rule as
WorkerCard: the versions, secrets, builds, nodes and integration statuses are fetched by the app and come down as props; the pre-build and the restore are callbacks (onPrebuild,onRestoreVersion); the links go througheditHref/secretsHref+render.link, nevernext/link. The card is the component's, its placement is not — the<aside className="lg:w-72 lg:sticky">stays in the app. - Its bricks, exported individually because a project's edit page (and
tomorrow a deployment card) assembles part of them without the panel:
PanelSection/Eyebrow,RepositoryList(+projectRepoName,projectRepoSource),ChipList(+FeatureChips,ExtensionChips,PortChips,SecretChips),BuildCacheList/BuildStateLabel/PrebuildButton,LifecycleCommands,IntegrationsList,VersionHistory,DeployKeySection,PanelMeta,ProjectStatePill(+projectStateConfig). - Two type modules, on purpose. The form owns a complete value it edits
(
ProjectFormValue, every field present); the panel reads a partial snapshot it merely displays (ProjectPanelProject, everything optional butname/image/createdAt, inpanel-types.ts). Opposite invariants — one shared type would have to lie for one of the two. TheLinkRenderslot, on the other hand, is shared with the worker surface: re-exported, not redefined. - The clipboard is UI, not network, so
DeployKeySection's copy button (and its toast) lives in the package; only the registration guidance, which differs per app, comes down aschildren. - The logos are derived, not asked for. The panel reuses
/devcontainer's pure helpers on data it already has:resolveStackMarkturns the base image, each feature and each extension into a brand mark,githubAvatarUrlturns anowner/reporemote into its org avatar, andRegistryRefprints the image reference with the registry dimmed andname:tagintact (copyable). Nothing unrecognised gets a wrong logo — it falls back to a generic glyph — and an integration only needs aniconwhen the shipped marks don't know it. - Container queries here too: the panel is 288 px in a sidebar on a 27" screen and full width on a phone — and the desktop case is the narrow one.
And the build that configuration triggers, while it runs:
BuildSteps— an image build as the list of blocks it is made of (base image, bootstrap, one per devcontainer feature, IDE extensions, finalize, DinD seed) rather than a wall of log. Each row lights up in turn and carries its own duration, so "what is it doing, and for how long" reads at a glance and a slow feature is visibly the slow one.- The whole plan is drawn greyed out from the first frame, instead of rows
appearing one by one: the list doubles as a table of contents for what the
image contains, the
done/totalcounter has a denominator immediately, and nothing shifts under the cursor for the four minutes of a build. A block absent from the plan isn'tskipped—skippedis a block that was reached and had nothing to do. - Presentational, so it isn't only about Docker: it knows no WebSocket, no marker, no daemon — hand it a snapshot of timestamped blocks and it draws. Anything that can describe itself as ordered blocks (a CI pipeline, a migration, a multi-phase command) can feed it.
- Timestamps in, durations out. The running block counts from
startedAtitself, every second — a duration computed app-side freezes between two snapshots and reads as a stuck build. Under a minute the package'sformatDurationis reused (one formatter, no drift); past it the format switches to2m 13s.
Marketing — /marketing
import { Section, H2, Kicker, SpecList, Disclosure, CodeBlock, Figure,
CopyCommand, NightBand, NightChip, ProductHeader, Reveal,
usePrefersReducedMotion, useLoopClock } from "@spunto/design-system/marketing"The vocabulary of a marketing page — a numbered chapter, a display headline, a mono kicker, key/value rows, a disclosure row, a copyable command, a night band. Its own entry rather than the root, for two reasons: an app that draws a dashboard has no use for it, and these primitives carry opinions the root deliberately doesn't (a display face, a fixed dark surface).
Section— the chapter: hairline, number in the accent, mono title, marginnote, then the content. Repeating one opening down a page is what makes it read as a document rather than a stack of blocks.H2/Kicker— the display headline (Syne, capped at 24ch) and the mono line above it. OneKicker, not two: the site hadKickerandEyebrowdoing the same job under two names and two letter-spacings; the wider one is now aclassName.SpecList— key/value rows, the default way to list facts without bullets.Disclosure— the row you open.<details name>gives the exclusive accordion natively: no state, no hydration surface, and it works before the JS lands.CodeBlock/Figure— a snippet on the--code-surfacetoken (deliberately unhighlighted: a highlighter is a dependency and ~30 kB for a six-line snippet), and a framed, captioned screenshot whose image element is a slot (render.image) — same rule as links, the package never importsnext/image.CopyCommand— the call to action when it's a command. One component, two registers (tone="day" | "night"), where the site hadCopyCommandandNightCommand: same behaviour, two files, two places to fix a bug. What lands in the clipboard is the joined-up one-liner, not the wrapped version.NightBand/NightChip— the dark band a page opens on, lit by itsglowaccent.--nightis not themed: it's the dark moment of the page, so it stays deeper than the dark theme's own background and identical in both. The drifting light sources are CSS animations (stopped byprefers-reduced-motion), never a rAF loop.ProductHeader— mark, name, kind, headline, promise, the one command, the chips. Generalised on the way in: the site's version took aProductout of its own registry, this one knows about no product at all — everything product-specific goes throughactions.Reveal+usePrefersReducedMotion/useLoopClock— scroll reveal as anIntersectionObserverand a CSS transition, and the "how far into the loop are we" clock that pauses off screen and commits at ~14 Hz.
Two rules the package enforces here:
- No framework imports. No
next/link, nonext/image. Navigation is the caller's (actionsslots), andFiguretakes an imagerenderslot — the same motif asCommandPaletteLinkRenderandWorkerCard'srender.link. - No motion library.
framer-motionas a hard dependency would weigh on every consumer, including the ones that never draw a landing page.Revealis ~20 lines of observer + transition instead.
Section, H2, Kicker, SpecList, Disclosure, CodeBlock, Figure,
NightBand, NightChip and ProductHeader carry no "use client" — they
render from a React Server Component. Only CopyCommand, Reveal and the hooks
are client files. (Same split, same care, as buttonVariants/alertVariants.)
What stays in the site, on purpose: the product registry and anything reading it, the navs and footers (they hard-code URLs and a session), the product logos and OG cards. The rule: if it needs to know a product, a URL or a session, it's the site; if it only lays out or types, it's the system.
Brand — /brand
The Spunto logo, behind its own entry: the one sub-export that knows Spunto by construction, kept out of the root so a third-party consumer of the primitives never ships the company's mark.
import { SpuntoLogo } from "@spunto/design-system/brand"
<SpuntoLogo /> // the brand: flame tile, night ink
<SpuntoLogo variant="mark" tone="night" /> // S + dot only, what survives 16 px
<SpuntoLogo tone="ink" className="text-primary" /> // no tile, currentColorThe drawing is a 7 × 7 grid — blocks of 10 on a pitch of 14 — and it tells the
product: the dot is your container, the two brackets are an S with the dot for
its middle stroke, three blocks on the left are Build, three lights on the right
are Run, the belt along the bottom is Ship, three squares on top are the
machines you already own. tone picks a declension (flame, build,
ship, run, night, cream, pillars, ink); paint overrides
any role on top of it.
The geometry lives as data in src/brand/logo.ts, React-free, so the same
shapes come out as an element (SpuntoLogo) and as a file
(spuntoLogoSvg()). npm run brand:export -- <dir> writes one .svg per
declension — favicon, per-product tiles — which is how a site gets its static
copies without ever hand-editing an SVG.
Toasts — SpuntoProvider + toast()
SpuntoProvider is the design system's client umbrella provider. Mount it once
around your app; it renders the toast viewport, mounts the shared Tooltip
delay group, and hosts the overlay portal container that Dialog/AlertDialog/
Select render into — the single seam every overlay feature plugs into. It also
takes tooltipDelay (default 600) and tooltipCloseDelay (default 0).
toast() is a sonner-style imperative
API callable from anywhere — event handlers, async code, even outside the
React tree (it's backed by Base UI's createToastManager).
In a Next.js App Router app, the root layout stays a Server Component — just
render the (client) provider around {children}:
// app/layout.tsx
import { SpuntoProvider } from "@spunto/design-system"
import "@spunto/design-system/styles.css"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SpuntoProvider toastPosition="top-right">{children}</SpuntoProvider>
</body>
</html>
)
}Then fire toasts from anywhere:
import { toast } from "@spunto/design-system"
toast.success("Worker ready", { description: "spunto-abc is up" })
toast.error("Deploy failed", { action: { label: "Retry", onClick: redeploy } })
// Persistent (no auto-dismiss) + manual control:
const id = toast.info("Deploying…", { duration: 0 })
toast.dismiss(id)toast(message, options) and the toast.success/error/warning/info helpers take
{ title?, description?, variant?, duration?, action? } and return the toast id;
toast.dismiss(id?) closes one toast (or all). SpuntoProvider accepts
toastPosition (default "top-right"), toastDuration (default 5000, 0 =
persistent) and toastLimit (default 3).
LLM / agent docs (llms.txt)
The showcase also exposes its documentation as plain markdown,
so coding agents can consume the component APIs directly. Everything is generated
from the same content that drives the visual pages
(showcase/src/content/components.ts) — there is no second source to keep in sync.
Served both in dev (Vite middleware) and in the built site (static files) at
https://design.spunto.net:
/llms.txt— index (llmstxt.org convention) linking every page./<component>.md— one page per component with import, example and the full props API (e.g./button.md,/select.md)./colors.md,/typography.md,/radius.md— the foundations/tokens./llms-full.txt— every page concatenated into a single file.
Adding a component = add its entry to content/components.ts + a demo file in
showcase/src/demos/; the visual page and its .md are then produced
automatically (see showcase/vite-plugin-llms.ts).
Credits
- shadcn/ui (shadcn-ui/ui, MIT) —
the base of this package. The token contract (
--background/--foreground,--card,--muted-foreground,--primary,--ring…), thecnhelper, thecvavariant pattern and most primitives here started as shadcn/ui components (Base UI variant) and were restyled, not reinvented. The parts that are ours are the palette values, theBuild/Ship/Runsemantics, and the Spunto-specific components (Terminal,TerminalPanel,ImagePull, and everything under the domain sub-exports). - Base UI (MIT) — the unstyled primitives underneath.
- Tailwind CSS (MIT) — the styling pipeline.
- xterm.js (MIT) — the terminal surface.
- lucide (ISC) — the icons.
Peer requirements
- React 19+, and a Tailwind CSS v4 pipeline in the consuming app.
lucide-reactis a regular dependency (since0.9.3): the worker components use ~15 icons, and inlining them as SVG or passing them all as props would have been worse than the dependency. It's tree-shakable and both apps already ship it.
