npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spunto/design-system

v0.36.0

Published

Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.

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 + cn variant 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 the dot-grid / grid-lines background utilities. Brand color is flame (--primary ≈ #ea5400); radius base is 0.25rem; Build/Ship/Run are semantic (bg-build, text-run…). Plus the two unthemed marketing surfaces — --night (bg-night) and --code-surface (bg-code-surface) — and the night-grid / night-glow-a / night-glow-b / night-scroll utilities they come with.
  • cn — clsx + tailwind-merge.
  • ./colors — cssVar, chartColors, chartRamp, segColors for 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 — on Avatar it 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 (an Avatar, one inside a Tooltip, a round Skeleton while 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%), the ringClassName that separates two faces (match the surface behind the stack — ring-card inside a Card), the paint order, and the +N. Counting has two rules: the counter adds the children max cut and the people never rendered (total − children), so a paginated list shows +38 without fabricating 38 ghost avatars; and a +1 never appears — one hidden child takes exactly the room its counter would, saying less, so the face is drawn instead. onOverflowClick makes 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), the Select you can type into — for the list nobody wants to scroll (branches, repos, model ids, members, tags). Base UI's Combobox underneath, and compositional like Select: items are children. Three things it adds. (1) Items filter themselves, with the same collator CommandPalette uses, so no caller re-.filter()s its options and "deploi" finds "déploiement"; groups hide themselves through :has() (no more if (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 the input-change reason 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, no anchor prop to pass. Two field shapes, two components rather than a flag: ComboboxInput (the combobox is the field, same 32 px/border/ring as Input, chevron out of the tab order) or ComboboxTrigger + ComboboxValue with a ComboboxSearch at the top of the popup, for when the value is rendered rather than typed. Presentational only: loading is a prop and a server-side search is onInputValueChange + filter={null}.
    • Navigation & feedback — Tabs (+TabsList/TabsIndicator/TabsTab/TabsPanel), Alert (+AlertTitle/AlertDescription, +alertVariants) — an inline, persistent, declarative status banner, the counterpart to the ephemeral imperative toast().
    • Overlays (plug into the SpuntoProvider umbrella) — 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: items is a list of { label, icon?, onClick } (or href, for a real <a> via Base UI's Menu.LinkItem) and the component builds the trigger, the portal, the popup, the items and their states — loading swaps the icon for a spinner and disarms the entry, destructive turns it red, shortcut right-aligns a Kbd. 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 be false/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 what WorkerCard's actions slot 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 of data-[side=right]:-prefixed classes, precisely so className stays 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. containerClassName reaches 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 UI Autocomplete (combobox/listbox roles, aria-activedescendant, ↑↓ + scroll-into-view, ↵ activates the highlighted item) rendered inline inside a Base UI Dialog (focus trap, focus restore, Escape, backdrop), portalled into the provider's overlay container like the other overlays. Compositional like Dialog/Select, not a config array — which is why the root runs Base UI in mode="none" and each item decides for itself whether it matches, with the same Autocomplete.useFilter() collator. Presentational only: loading is a prop, results are children, and a server-side search is value/onValueChange + filter={null}. Navigation is href + render.link, never next/link — same rule as WorkerCard. 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 UI Popover anchored to an element or a virtual element (a caret rect), which never moves focus (initialFocus/finalFocus off, no backdrop, non-modal) and claims ↑ ↓ ↵ Esc on document in 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 a modal={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 a CommandMenuInput rendered inside the popup (a filter dropdown on a button; autoFocus is opt-in).
    • Auth — AuthLayout, AuthBrandPanel (+AuthBrandMark), AuthForm (+authFormCopy), OAuthButton (+GoogleIcon/GitHubIcon) and AuthSeparator: 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: no next/navigation, no fetch; AuthForm hands back onSubmit({ mode, email, password, name }), error/loading come back down as props, and anything that navigates takes an href (+ render.link on OAuthButton) — 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 own Input/Label/Button, in this screen's taller register through className — 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 the write/writeln ref handle. Opt-in addons through options (webLinks for clickable URLs, search for 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 a Terminal: a composable bar (title, mono subtitle, status dot, reconnect, free actions slot) and the states pages used to re-improvise — placeholder/loading instead of an ad-hoc empty box, status instead 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 wrapped TerminalHandle.
    • 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 #09090b surface and ANSI palette as Terminal, so the two stack into one object.
  • 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 below

One 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, never next/link), the rebuild (onRebuild) and the tag persistence (onAddTag/onRemoveTag) all stay in the app.
  • The ⋯ menu is a prop, not markup. menu takes the ActionMenuEntry[] of the ActionMenu primitive — { 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 the actions slot and re-typed the same Base UI markup to do it. actions stays as the escape hatch (a submenu, a checkbox item) and wins over menu when both are given; render.menuLink is the link slot for an entry with an href.
  • 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. gitStatus answers "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 same path for both tenses (Spunto clones into /workspace/<workspacePath>, which is what git-status reports) 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 WorkerCardWorker asks for the minimum, everything optional but id, and an unknown state falls back to pending instead 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 the ghcr.io ref), label, description, option chips (version=lts…), OCI reference.
  • ImageTile / FeatureTile (+ the shared CatalogTile) — 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. onSelect is 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 via github.com/<owner>.png; mcr.microsoft.com/devcontainers/… → Dev Containers) and resolveStackMark. publisher / mark / iconUrl are 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 helpers parseRegistryRef, derivePublisher, githubAvatarUrl, resolveStackMark, resolveFeatureOptions, formatCount, parseExtensionId.
  • Types are structural here too: DevcontainerImageEntry, DevcontainerFeatureEntry and VscodeExtensionEntry are 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 by pointer-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/terminal are render functions receiving the switch to draw in their header; an omitted slot is no tab). The phone one is its own screen — a visualViewport-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. TaskComposer under it says why it is closed when it is.
  • TaskDiffPanel — the branch's diff: summary as a prop, each file's patch through loadPatch (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, optional actions, 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) with TaskMachine, 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 controlled collapsedSpanIds). Hand it logs and a Logs section appears underneath, linked by the span id the logger stamped: a line selects its span, unfolding its ancestors if needed. SpanWaterfallSkeleton for the fetch.
  • TraceList — one row per request (method, route, latency bar, when), or variant="compact" for root spans that aren't necessarily HTTP. TraceFiltersBar next to it: window and min latency are meant for the backend query, status / method / route are applied client-side by filterTraces; traceFilterOptions derives 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 ProjectFormValue in, one onChange out, onSubmit(value) at the end. Start from emptyProjectFormValue(), or toProjectFormValue(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) and gitRepos (each tagged with its provider) replace githubRepos / github / connectUrl / githubIcon, and ProjectRepo.provider widens from "github" | "git" to a string — the set of forges belongs to the deployment, not to this package. The component reads operations to decide what to offer rather than branching on a provider id, so a second forge needs no change here. "git" (exported as RAW_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's onChange(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 is renderRepoField (without it: a plain <input list> + <datalist>, no dependency, keyboard-friendly), and extras slips 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 pure manifestRows.
  • 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 through editHref/secretsHref + render.link, never next/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 but name/image/createdAt, in panel-types.ts). Opposite invariants — one shared type would have to lie for one of the two. The LinkRender slot, 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 as children.
  • The logos are derived, not asked for. The panel reuses /devcontainer's pure helpers on data it already has: resolveStackMark turns the base image, each feature and each extension into a brand mark, githubAvatarUrl turns an owner/repo remote into its org avatar, and RegistryRef prints the image reference with the registry dimmed and name:tag intact (copyable). Nothing unrecognised gets a wrong logo — it falls back to a generic glyph — and an integration only needs an icon when 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/total counter has a denominator immediately, and nothing shifts under the cursor for the four minutes of a build. A block absent from the plan isn't skipped — skipped is 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 startedAt itself, every second — a duration computed app-side freezes between two snapshots and reads as a stuck build. Under a minute the package's formatDuration is reused (one formatter, no drift); past it the format switches to 2m 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, margin note, 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. One Kicker, not two: the site had Kicker and Eyebrow doing the same job under two names and two letter-spacings; the wider one is now a className.
  • 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-surface token (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 imports next/image.
  • CopyCommand — the call to action when it's a command. One component, two registers (tone="day" | "night"), where the site had CopyCommand and NightCommand: 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 its glow accent. --night is 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 by prefers-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 a Product out of its own registry, this one knows about no product at all — everything product-specific goes through actions.
  • Reveal + usePrefersReducedMotion / useLoopClock — scroll reveal as an IntersectionObserver and 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, no next/image. Navigation is the caller's (actions slots), and Figure takes an image render slot — the same motif as CommandPaletteLinkRender and WorkerCard's render.link.
  • No motion library. framer-motion as a hard dependency would weigh on every consumer, including the ones that never draw a landing page. Reveal is ~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, currentColor

The 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…), the cn helper, the cva variant 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, the Build/Ship/Run semantics, 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-react is a regular dependency (since 0.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.