@heluca/svelte (0.3.0)
Installation
@heluca:registry=https://git.helu.ca/api/packages/r/npm/npm install @heluca/svelte@0.3.0"@heluca/svelte": "0.3.0"About this package
@heluca/svelte
The Heluca Design System implemented for Svelte 5 — brand tokens, the daisyUI bridge, Phosphor icons, and 15 component primitives.
This repo is maintained by hand. Its upstream, heluca-design-system, is a
verbatim mirror of the design system as it exists in Claude Design and is never
hand-edited. This repo is the translation of that into code your apps run.
Tokens and theme.css are byte-identical copies from the export; the
components and the Icon wrapper are written here.
src/
tokens/*.css verbatim copies from the export — do not hand-edit
theme.css verbatim copy from the export — do not hand-edit
the bridge: Heluca tokens -> daisyUI + light/dark semantics
Icon.svelte Phosphor icon wrapper
components/ the 15 primitives, ported from the export's React .jsx
index.js package entry
Install
Published to the Gitea npm registry on git.helu.ca. The consuming app needs an
.npmrc pointing the @heluca scope at it:
@heluca:registry=https://git.helu.ca/api/packages/r/npm/
No credential, and do not add one. The registry serves reads anonymously,
the same way the Gitea PyPI registry does for pip. An _authToken line is
worse than useless here: Gitea rejects a malformed credential rather than
falling back to anonymous, and npm only omits the header when the referenced
variable is set to the empty string — leave it unset and npm sends a literal
Bearer ${VAR}, so every command 401s. A token is needed only to publish.
Then:
npm i @heluca/svelte @phosphor-icons/web
Peer dependencies — Svelte 5, Tailwind 4, daisyUI 5, @phosphor-icons/web —
are expected to already be in the consuming app.
Why a registry rather than a git URL: Docker builds have no git binary and
no SSH keys, so a git+ssh:// dependency fails inside a container. An
anonymous HTTPS registry works everywhere the app builds.
Wire up the styling
Order matters. In your app's app.css:
@import 'tailwindcss';
/* 1. Tokens first — everything below references them. */
@import '@heluca/svelte/tokens/fonts.css';
@import '@heluca/svelte/tokens/colors.css';
@import '@heluca/svelte/tokens/typography.css';
@import '@heluca/svelte/tokens/spacing.css';
@import '@heluca/svelte/tokens/elevation.css';
@import '@heluca/svelte/tokens/motion.css';
/* 2. daisyUI. */
@plugin 'daisyui' {
themes:
light --default,
dark --prefersdark;
}
/* 3. The bridge — must come after the plugin. */
@import '@heluca/svelte/theme.css';
/* 4. Phosphor. Only the three weights Heluca uses. */
@import '@phosphor-icons/web/regular';
@import '@phosphor-icons/web/fill';
@import '@phosphor-icons/web/bold';
Your app is responsible for setting data-theme="light" or "dark" on
<html> — theme.css keys off that attribute and defines nothing without it.
Use
<script>
import { Button, Card, Badge, Icon } from '@heluca/svelte';
</script>
<Card>
<Button variant="primary" icon="ph-fill ph-navigation-arrow">Get directions</Button>
<Badge tone="success">Open</Badge>
<Icon name="map-pin" class="h-4 w-4" />
</Card>
What theme.css does
Two jobs the export leaves to the consuming app:
- Theme semantics. The export declares dark and light neutrals side by
side in
:rootand points its semantic aliases (--bg,--surface-card) at the dark set. It does not switch themes.theme.cssre-points those per[data-theme]and adds resolved--t-*neutrals. - daisyUI bridge. Mapping Heluca tokens onto daisyUI's
--color-*slots means existingbtn/input/card/badgemarkup inherits the brand with no per-component edits. This is the highest-leverage part of the package — it is why adopting Heluca in an existing daisyUI app is mostly a stylesheet change rather than a rewrite.
Prefer tokens over hex in app code: var(--orange), var(--blue),
var(--success), var(--t-text-muted), var(--radius-card), var(--dur-fast).
Body text colour is var(--t-text), not var(--text-body).
--text-body is the 16px body size, consistent with --text-heading,
--text-label and --text-caption. It used to be defined twice — as that size
in tokens/typography.css and as a colour in tokens/colors.css — which made
font-size: var(--text-body) invalid at computed-value time everywhere. The
2026-08-29 export removed the colour alias, so the clash is fixed at source.
Migrating to 0.3.0
Additive. TextField and TextArea are new, and ListCard gains selected
and size. Two things to know:
- Svelte 5.20 is now the minimum (was 5.0).
TextFieldandTextAreause$props.id()to generate thefor/aria-describedbyids when you don't supply anid. - If you skipped 0.2.0, apply its one change:
color: var(--text-body)->color: var(--t-text).font-size: var(--text-body)needs no change.
Icons
Phosphor, per the Heluca spec. Weight carries state: regular = resting, fill = active/on/selected, bold = FAB and true emphasis only.
<Icon name="map-pin" class="h-4 w-4" />
<Icon name="star" weight="fill" class="h-5 w-5" />
Icons take the same Tailwind sizing utilities as everything else; the wrapper converts that box into a font-size, since Phosphor is an icon font.
Type: chrome vs content
The app's own words are Public Sans (--font-sans) — nav, buttons, labels,
tables. Words the user typed are IBM Plex Sans (--font-work) — inputs,
textareas, badges. theme.css wires this by default; the exception is a custom
control that should opt into --font-work.
Product names and titles use Marcellus via .heluca-display, never below
~22px. Data and metrics use JetBrains Mono.
Deviations from the React sources
Faithful except where the source was demonstrably wrong for a real app. Each is commented at its site:
| Component | Deviation | Why |
|---|---|---|
TextField, TextArea |
label is a sibling; hint/error linked with aria-describedby |
the source nests them inside the <label>, so the hint is folded into the input's accessible name and an error replaces the label. Also adds aria-invalid, which the source signals by border colour alone |
ListCard |
renders a real <button type="button"> when onclick is set |
the source is a <div onClick> — not focusable or keyboard-operable. Two literal elements rather than <svelte:element>, so no ARIA is needed to prop up what is already a button |
| all | --t-* theme-aware neutrals, not the dark-only --text / --surface |
the sources are written against the dark theme; the ports have to work on paper too |
| all | classes + <style> instead of React inline styles |
idiomatic Svelte, and inline styles cannot express :hover / :active / :focus-visible |
The 2026-08-29 export corrected five earlier deviations at source — Checkbox
and Radio real inputs, Slider's real range input, Switch's white knob,
Button's disabled surface tone, DeviceTile's color-mix tints. Those rows
are gone because the sources now match the ports.
DeviceTile comes from Hecate's device grid; its on/off/media/offline pattern
suits any stateful tile.
Updating from upstream
When heluca-design-system gets a new export:
cp ~/git/heluca-design-system/tokens/*.css src/tokens/
cp ~/git/heluca-design-system/theme.css src/
Both are verbatim, so that copy is the whole job for a token or bridge change.
If the export's components/*.jsx or .d.ts contracts changed, the
corresponding .svelte file here needs a matching update by hand.
Token files are deliberately excluded from Prettier (see .prettierignore) so
they stay byte-identical and re-syncing is a clean cp.
Publishing a new version
Consumers resolve by semver from the registry, so a change is not live until it
is published. Publishing is done by CI, not by hand — pushing a version tag
runs .gitea/workflows/publish.yml, which checks the tag against
package.json, verifies every exports target exists, and uploads the tarball:
npm version patch # or minor / major — bumps package.json, tags vX.Y.Z
git push && git push --tags
Watch the run at https://git.helu.ca/r/heluca-svelte/actions. Then in each
consuming app: npm update @heluca/svelte.
The workflow authenticates with the repo secret PACKAGE_TOKEN — a Gitea
access token with read:package + write:package, set under Settings →
Actions → Secrets. That is the same secret name the PyPI-publishing repos
(themis, html2msoffice, pallas) use. The registry rejects re-publishing a
version that already exists, so the tag is the release gate: bump first.
A manual workflow_dispatch run is the fallback — it publishes whatever
version package.json declares on the chosen ref.
Consuming the package needs no credential at all — see Install. The publish token is the only one in play, and only CI holds it.
Consumers
- periplus — first adopter
- hecate, daedalus — not yet migrated
Dependencies
Peer Dependencies
| ID | Version |
|---|---|
| @phosphor-icons/web | ^2.1.0 |
| daisyui | ^5.0.0 |
| svelte | ^5.20.0 |
| tailwindcss | ^4.0.0 |