@l4st/driftwood (0.24.0)
Installation
@l4st:registry=npm install @l4st/driftwood@0.24.0"@l4st/driftwood": "0.24.0"About this package
driftwood
One screen, one DOM, split down the middle: the right half is the same
markup inside color-scheme: dark. That is all the kit's dark mode is — no
class, no script. The image is generated by pnpm shots from
a real page, so it cannot drift from the
components.
A hand-written Svelte 5 + Tailwind v4 component kit. Zero UI dependencies — no shadcn, no Melt, no Bits, no Flowbite. Extracted from two apps that had both grown the same set of primitives and were copying fixes between each other.
Personal library: no npm registry, no semver theatre. It exists so one component gets fixed once.
What's in it
| Presentational | Button ButtonGroup Badge Card StatCard Alert Avatar Image Separator Table Input Textarea Label Kbd Skeleton Spinner Progress |
| Interactive | Select MultiSelect Combobox TagsInput RadioGroup Switch Checkbox Toggle Slider Tabs Accordion AccordionItem Collapsible Menu Tree Pagination Tooltip Popover Dialog CommandPalette Toaster + toast() |
| Forms | Field — label, control, hint and error as one unit, with the ids wired. Every control takes name and submits like a native one |
| Numeric | NumberInput — unit suffix, clamps on commit, arrow stepping |
| In place | InlineEdit — a value shown as text that becomes an Input or NumberInput when clicked |
| Editing | CodeEditor — highlighted layer behind a real textarea, folding, its own undo stack. Bring a grammar |
| Helpers | cn() · thumbHashToDataURL · portal · place / track · the reveal / popIn / fadeUp transitions · four stylesheets |
Browse the components → — one page each, with a
screenshot in both themes, the props read straight out of the source, and the
example that produced the image. The same pages are on the
wiki, rendered, with a
sidebar and a "which one do I want" table — that is the one to send a person to.
Both are generated: pnpm images writes the pages, pnpm wiki publishes them.
Every interactive component implements its own ARIA roles, states and keyboard handling — that is the whole reason the kit is hand-written rather than wrapping a headless library.
Install
The target registry is this Forgejo instance, not npmjs.com — publishConfig
points at https://code.letsnot.run/api/packages/L4stIdi0t/npm/, and pnpm is
only the client doing the upload. Publishing needs credentials; reading does
not, because the repository is public.
npm version patch # the tag is what consumers pin
git push --follow-tags # CI publishes on a v* tag
Publishing uses the instance's existing secrets, FORGEJO_USER and
FORGEJO_API — a username and a user access token carrying write:package.
Forgejo's automatic per-job token is scoped to the repository and the package
API rejects it for writes, which is why the workflow does not use it. The auth
job checks both secrets exist and that the token identifies a user before
anything is uploaded.
To publish from a laptop instead, the same credential goes in ~/.npmrc —
never in the repo. The registry answers a Bearer token with
[E401] … need: Basic realm="Gitea Package API", so use _auth, which is the
base64 of user:token and is sent as Basic:
printf 'L4stIdi0t:<token>' | base64
# ~/.npmrc
//code.letsnot.run/api/packages/L4stIdi0t/npm/:_auth=<that base64>
//code.letsnot.run/api/packages/L4stIdi0t/npm/:always-auth=true
The consuming app maps the scope to the registry in its own .npmrc:
@l4st:registry=https://code.letsnot.run/api/packages/L4stIdi0t/npm/
// package.json
"dependencies": { "@l4st/driftwood": "^0.1.5" }
0.1.5 is published and serves anonymously — verified by installing it into an
empty project with pnpm: the package arrives with dist/ already built, every
export resolves, and no build script runs.
Do not install
v0.1.0. It predates thepreparefix, so it installs a package with nodist/— every export points at a file that is not there.0.1.1is the first version that installs correctly by any route.
The other two paths, and what they cost
| Path | Works? |
|---|---|
| Registry tarball (above) | Yes, everywhere. Verified by packing and pnpm add-ing the tarball into a scratch project: 63 files, dist/ present, no build step |
git+https://…#v0.1.0 |
npm only. npm prepares a git dependency itself, so svelte-package runs and dist/ appears (verified). pnpm refuses: ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED, and the allowBuilds key it demands contains the commit SHA — so every kit update means editing pnpm-workspace.yaml in every app |
link:../../WebstormProjects/driftwood |
Local development only, for editing the kit and an app together. Run pnpm package in the kit after a change; the app reads dist/ |
A git install also depends on prepare running svelte-package. Until that was
fixed, a git install produced a package containing package.json and
README.md and nothing else, with every export pointing at a file that did not
exist.
Peer dependencies
The app provides these; the kit bundles none of them:
| Peer | Why | If it is missing |
|---|---|---|
svelte ^5.56.1 |
runes, $props.id() |
nothing works |
tailwindcss ^4.3.0 |
every component is Tailwind classes | nothing is styled |
@lucide/svelte ^1.23.0 |
icons inside Select, MultiSelect, Menu, Dialog, Tree, Accordion, Collapsible | the build cannot resolve the import |
tw-animate-css ^1.4.0 |
animate-in / fade-in-0 / zoom-in-95 in Dialog and Tooltip |
the build passes and the entrance animation silently disappears — those classes simply do not exist |
Install the peers explicitly — pnpm add @lucide/svelte tw-animate-css.
Both package managers auto-install a missing peer, so the build succeeds either
way, but vite dev then fails to render with
Unknown file extension ".svelte" for …/@lucide/svelte/dist/icons/a-arrow-down.svelte
because vite-plugin-svelte only adds Svelte packages it can see in your
dependencies to ssr.noExternal; an auto-installed peer is invisible to it
and Node's ESM loader is handed a .svelte file. tw-animate-css has to be in
your CSS regardless.
Wire up the CSS
In your app's app.css — verified against a clean SvelteKit app that installed
the packed tarball and built:
@import 'tailwindcss';
@import 'tw-animate-css'; /* required: Dialog and Tooltip entrances */
@import '@l4st/driftwood/base.css'; /* .indeterminate-bar, reduced-motion */
/* Your palette. Copy the token list from @l4st/driftwood/tokens.css and change
the values — that is where your app gets its identity. Or import
'@l4st/driftwood/tokens.css' for the neutral default. */
:root { --background: …; --primary: …; --radius: 0.625rem; /* … */ }
@import '@l4st/driftwood/theme.css'; /* tokens -> utilities, and the @source */
@import '@l4st/driftwood/code.css'; /* only if you use CodeEditor */
Order matters in one direction: theme.css last. Its @theme inline block and
its @layer base defaults have to see the token names, and it is what maps them
onto utilities. Your palette can sit anywhere before it.
theme.css carries @source "../components", which is what keeps the kit's own
classes in your build — Tailwind skips node_modules otherwise. Verified twice:
a Tailwind CLI run with source(none) and no consumer markup at all still emits
bg-primary, border-border and .indeterminate-bar; and the clean consumer
app's built stylesheet contains those plus animate-in and zoom-in-95.
Dark mode follows the system
The default palette needs no wiring: both themes live in one token block via
light-dark(), and :root declares color-scheme: light dark. No class on
<html>, no script, no flash-of-wrong-theme. That color-scheme declaration is
the part a media query alone never gave you — it is what makes the browser's own
furniture (scrollbars, form controls, autofill, the caret) match the theme.
tokens.css also ships a single @media (prefers-color-scheme: dark) rule. It
only restates that declaration, but it is the query some dark-mode extensions
(Noir, for one) look for. A site without it looks light-only to them, and they
repaint a dark theme that is already there. Keep it if you write your own palette.
Add this to app.html so the first paint is right too:
<meta name="color-scheme" content="light dark" />
To pin a theme, flip color-scheme; every token follows on its own:
:root.dark, :root[data-theme='dark'] { color-scheme: dark; }
:root.light, :root[data-theme='light'] { color-scheme: light; }
Both selectors ship in tokens.css, so a class-based switcher like
mode-watcher works alongside the system default with nothing added. For a
three-way switcher, remove the attribute for "system" rather than writing
data-theme="system" — absence is what hands the decision back to the OS.
No component uses a dark: variant — they read tokens only — so an app with its
own palette can keep using two blocks (.dark, or a media query) instead;
nothing in the kit cares how the tokens are produced. light-dark() needs
Chrome 123 / Safari 17.5 / Firefox 120.
Verified in a real browser (pnpm probe): surfaces, text and body background
all change with the emulated OS theme, opacity modifiers like bg-primary/90
resolve per theme through Tailwind's color-mix(), and both pinning strategies
override the system.
Full detail — how light-dark() and color-scheme combine, writing your own
palette, switchers, older browsers: docs/theming.md.
Wire up your coding agent
The kit ships an agent bundle — the same instructions that keep an agent
from writing bg-slate-800, re-implementing dark mode, or vendoring a copy of a
component:
sh node_modules/@l4st/driftwood/agents/install.sh
That drops the driftwood-ui skill and a /driftwood-upgrade command into the
app's .claude/. One manual step is left: paste
agents/snippets/agents-md.md into the app's own AGENTS.md or CLAUDE.md.
The package also carries docs/ — a page per component with props, keyboard
contract and a working example, plus docs/theming.md — so an agent can read
the real API out of node_modules instead of inferring one. See
agents/README.md.
Use it
<script lang="ts">
import { Button, Card, Dialog, toast } from '@l4st/driftwood';
import { cn } from '@l4st/driftwood/utils';
</script>
Mount <Toaster /> once, in your root +layout.svelte.
Develop
pnpm install
pnpm dev # the gallery — every component, live, at :5173
pnpm check # svelte-check; 0 errors expected
pnpm verify # kit invariants + doc freshness
pnpm probe # behaviour probes in a real browser
pnpm images # regenerate every image (components + hero) and the docs
pnpm shots # just the images (light + dark + hero, headless Chromium)
pnpm docs:gen # regenerate docs/components/ from the source
pnpm package # build dist/ + publint
pnpm verify is the one that catches what a component change forgot — the
barrel export, the gallery entry, the screenshot, the doc page. See
AGENTS.md for the full definition of done.
src/routes/ is the gallery, a reference page for the kit; it is not published.
src/lib/ is the package.
WebStorm
The .idea/ project is committed: Open the folder and the four scripts above
are already there as run configurations (dev, check, build, package), the
generated folders (.svelte-kit/, dist/, build/, node_modules/) are
excluded from indexing, and the code style is set to tabs and single quotes so
Reformat Code agrees with the rest of the repo. .editorconfig says the same
thing for any other editor. Per-developer files (workspace.xml, shelves) are
ignored by .idea/.gitignore.
Install the Svelte plugin if it is not already enabled — that is what gives
.svelte files their component and rune awareness.
See AGENTS.md for the conventions any change here has to hold to.
Dependencies
Dependencies
| ID | Version |
|---|---|
| clsx | ^2.1.1 |
| tailwind-merge | ^3.6.0 |
Development dependencies
| ID | Version |
|---|---|
| @axe-core/playwright | ^4.13.0 |
| @lucide/svelte | ^1.28.0 |
| @sveltejs/adapter-auto | ^7.0.1 |
| @sveltejs/kit | ^2.69.1 |
| @sveltejs/package | ^2.5.4 |
| @sveltejs/vite-plugin-svelte | ^7.1.2 |
| @tailwindcss/vite | ^4.3.3 |
| axe-core | ^4.13.0 |
| playwright | ^1.62.1 |
| publint | ^0.3.16 |
| svelte | ^5.56.1 |
| svelte-check | ^4.6.0 |
| tailwindcss | ^4.3.3 |
| tw-animate-css | ^1.4.0 |
| typescript | ^6.0.3 |
| vite | ^8.0.16 |
Peer dependencies
| ID | Version |
|---|---|
| @lucide/svelte | ^1.23.0 |
| svelte | ^5.56.1 |
| tailwindcss | ^4.3.0 |
| tw-animate-css | ^1.4.0 |