@heluca/svelte (0.5.0)
Installation
@heluca:registry=https://git.helu.ca/api/packages/r/npm/npm install @heluca/svelte@0.5.0"@heluca/svelte": "0.5.0"About this package
@heluca/svelte
The Heluca Design System implemented for Svelte 5 — brand tokens, the daisyUI bridge, Phosphor icons, and 16 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 16 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'; /* Google-hosted; see Offline fonts */
@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,
consulting,
business,
business-dark,
terminal,
terminal-light;
}
/* 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.
The five temperaments are optional; drop them from themes: if you don't use
them.
Offline fonts
tokens/fonts.css pulls the six faces from fonts.googleapis.com, which is
fine for a web app but not for a GUI that has to work on a flaky or absent
connection — the text silently falls back to system-ui and the design goes
with it. Icons are already safe: @phosphor-icons/web ships its own
.woff2 and references it relatively, so importing the three weights from the
package (as above, not from a CDN) bundles them.
To bundle the text faces too, install them from Fontsource — the same OFL
fonts, self-hosted — and replace the fonts.css line:
npm i @fontsource/public-sans @fontsource/ibm-plex-sans \
@fontsource/jetbrains-mono @fontsource/marcellus @fontsource/literata
/* 1. Fonts — instead of '@heluca/svelte/tokens/fonts.css'. */
@import '@fontsource/public-sans/400.css'; /* + 500 600 700 800 */
@import '@fontsource/ibm-plex-sans/400.css'; /* + 500 600 700 */
@import '@fontsource/jetbrains-mono/400.css'; /* + 500 700 */
@import '@fontsource/marcellus/400.css'; /* 400 only, by design */
@import '@fontsource/literata/400.css'; /* + 700; only for long-form */
/* …then colors.css, typography.css and the rest, unchanged. */
Import only the weights you actually render — each is a separate file. Latin cost per weight: Public Sans ~28K, IBM Plex Sans ~24K, JetBrains Mono ~40K, Marcellus ~16K, Literata ~52K. A typical GUI needing chrome + content + mono lands around 160K.
Use @fontsource/*, not @fontsource-variable/*. The variable packages
register their family as "Public Sans Variable", which does not match the
"Public Sans" in --font-sans, so every token would quietly fall back to
system-ui. The plain packages register exactly the names typography.css
asks for, so no token needs changing. Add @fontsource/atkinson-hyperlegible
only if the app offers that accessibility override.
Verified with every external request blocked: all five faces load and apply from the bundle, and the page makes no network calls.
Temperaments
Five product moods, each a self-contained theme — three temperaments, two of
them with a light/dark counterpart. Set data-theme on any subtree and the
components inside inherit it — no per-component work.
| theme | mood |
|---|---|
consulting |
warm paper, Marcellus display, amber demoted to gilt, ink for dark actions |
business |
cool neutrals, tighter radii, JetBrains Mono on every numeric value |
business-dark |
night Business — cool near-black, deliberately not the warm torch-black of dark |
terminal |
near-black tty, mono end to end, ~3px radii, amber glow, optional .heluca-scanlines |
terminal-light |
daytime Terminal — same sharp mono aesthetic, amber darkened to gilt for ink on paper |
<section data-theme="terminal" class="heluca-scanlines">…</section>
<div data-theme="business"><span class="font-num">3,940</span></div>
In Business, .font-num (or daisyUI's .stat-value) sets a value in mono.
Terminal is the one mood that deliberately overrides chrome≠content — it is
mono for both. No new fonts: all three use faces already in tokens/fonts.css.
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.5.0
Verbatim resync plus one port change:
--warningmoved#f5b70a→#d4c81a. It sat 4.5° from--amber-glow, so "caution" and the amber on-state carried the same signal; it is now chartreuse, 16.4° away, per the system's own "never reuse a brand hue" rule. Anything already using--warningshifts hue.DeviceTileunpairedis no longer warning-tinted. "Not set up yet" now reads as absent — neutral fill, dashed hairline, faint icon — so it never looks like a lit tile. Border style is exposed as--hl-bsalongside the other tint tokens.- Two more temperaments,
business-darkandterminal-light— see Temperaments. - daisyUI's sizing slots (
--border,--depth,--noise,--size-*) are now pinned for every theme, not just light/dark. The temperaments previously inherited daisyUI's defaults by accident, which mattered most for Terminal — a 1px-ruled-border aesthetic.
Migrating to 0.4.0
Additive in API, but the bridge fixes change how existing daisyUI markup renders — no call-site edits needed.
New and widened, from the 2026-08-29 08:38 export:
| component | added |
|---|---|
Select |
new primitive — dropdown for many/dynamic options; options takes string[] or {value,label}[]. Use Segmented for a few inline options |
TextField |
size (md/sm/xs), icon (leading Phosphor class), suffix, type="number"; label-less compact use is first-class — omit label, pass aria-label |
TextArea |
size (md/sm/xs) |
Button |
size="xs", variant="danger" |
IconButton |
size — sm 32 / md 44 / lg 52 (md unchanged) |
Badge |
size (xs/sm/md), variant (soft/outline/ghost) |
DeviceTile |
busy, states sleeping + unpaired, onDetails overlay action, children for extra content; tint tokens --hl-bg/--hl-bd/--hl-ic are exposed so an app can add a state without forking |
Button grouping has no primitive by design — daisyUI join markup is the
sanctioned pattern, and the bridge themes it.
The bridge fixes:
- daisyUI field sizes now scale their type.
input-sm/input-xs/textarea-*previously rendered at 16px regardless of the box; they now read 13px and 12px. A directfont-sizein the bridge was beating daisyUI's own per-size rules; the bridge now sets only the--font-sizeoverride point..select/.file-inputkeep the work face and daisyUI's own size scale. - Fields and modals sit on a surface.
.input,.textarea,.select,.file-inputand.modal-boxwere rendering in--t-base(the page colour); they now use--t-surfacein both themes, matchingTextField. - Badges are chrome, not content — see Type: chrome vs content.
Public Sans by default; add
userContent(or.badge-content) for user-authored values. - Three temperament themes are available — see Temperaments. Register the names you use in the daisyUI plugin block.
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, badges. Words the user typed are IBM Plex Sans
(--font-work) — inputs, textareas. theme.css wires this by default; the
exception is a custom control that should opt into --font-work.
Badges are chrome by default. Across the estate the common case is system status ("revoked", health, counts), which the app authors. A badge that carries a genuinely user-authored value — a tag name, a device label — opts into the work face:
<Badge tone="neutral" userContent>{tag.name}</Badge>
<!-- bridged daisyUI markup: -->
<span class="badge badge-content">{tag.name}</span>
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 |
DeviceTile |
two literal elements plus a separate hit-area <button>, not <div onClick> |
same reason as ListCard — and onDetails renders a button, which cannot nest inside the clickable tile. The overlay action sits outside the hit area and stops propagation |
DeviceTile |
busy spinner stops under prefers-reduced-motion |
the spinner still marks the busy state; only the rotation stops, per the motion rules |
TextField |
border and focus ring live on a wrapper, with :focus-within |
icon and suffix sit inside the field box, so the input itself is transparent and unbordered. The wrapper never takes focus, so :focus-within is what shows the ring |
| 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 |