@l4st/driftwood (0.1.5)
Installation
@l4st:registry=npm install @l4st/driftwood@0.1.5"@l4st/driftwood": "0.1.5"About this package
driftwood
A hand-written Svelte 5 + Tailwind v4 component kit. Zero UI dependencies — no shadcn, no Melt, no Bits, no Flowbite. Extracted from Fathom and carscope, which both grew 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 Input Label Kbd Skeleton Spinner Progress |
| Interactive | Select MultiSelect Switch Checkbox Toggle Slider Accordion AccordionItem Collapsible Menu Tree Tooltip Dialog Toaster + toast() |
| Numeric | NumberInput — unit suffix, clamps on commit, arrow stepping |
| Helpers | cn() · the reveal / popIn / fadeUp transitions · three 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.
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 your 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. Nothing about this package 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.1" }
Until the first successful publish, the registry has nothing to serve and that dependency will not resolve. The working install today is
git+https://code.letsnot.run/L4stIdi0t/driftwood.git#mainunder npm, which prepares a git dependency and producesdist/correctly. pnpm refuses that route (see below), so a pnpm app has to wait for the registry.
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 |
npm and pnpm both auto-install missing peers, so pnpm add @l4st/driftwood
pulls @lucide/svelte on its own (verified — it lands in .pnpm/).
tw-animate-css still has to be imported by your CSS, so install it explicitly.
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 */
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
The default palette follows the operating system and needs no wiring: both
themes live in one token block via light-dark(), and :root declares
color-scheme: light dark. That 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 rather than staying stubbornly
light.
To pin a theme, flip color-scheme; the tokens follow on their 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. Add
<meta name="color-scheme" content="light dark"> to your app.html so the
first paint is right too.
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.
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 shots # regenerate docs/images/ (light + dark, 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 |
|---|---|
| @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 |
| 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 |