Robert Helewka r

@heluca/svelte (0.4.0)

Published 2026-08-29 13:53:40 +00:00 by r

Installation

@heluca:registry=https://git.helu.ca/api/packages/r/npm/
npm install @heluca/svelte@0.4.0
"@heluca/svelte": "0.4.0"

About this package

Heluca Design System — Svelte 5 implementation: tokens, daisyUI bridge, Phosphor icons, and component primitives.

@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';
@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,
		terminal;
}

/* 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 three temperaments are optional; drop them from themes: if you don't use them.

Temperaments

Three product moods, each a self-contained theme. 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
terminal near-black tty, mono end to end, ~3px radii, amber glow, optional .heluca-scanlines
<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:

  1. Theme semantics. The export declares dark and light neutrals side by side in :root and points its semantic aliases (--bg, --surface-card) at the dark set. It does not switch themes. theme.css re-points those per [data-theme] and adds resolved --t-* neutrals.
  2. daisyUI bridge. Mapping Heluca tokens onto daisyUI's --color-* slots means existing btn / input / card / badge markup 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.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 direct font-size in the bridge was beating daisyUI's own per-size rules; the bridge now sets only the --font-size override point. .select / .file-input keep the work face and daisyUI's own size scale.
  • Fields and modals sit on a surface. .input, .textarea, .select, .file-input and .modal-box were rendering in --t-base (the page colour); they now use --t-surface in both themes, matching TextField.
  • 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). TextField and TextArea use $props.id() to generate the for / aria-describedby ids when you don't supply an id.
  • 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

Keywords

heluca design-system svelte
Details
npm
2026-08-29 13:53:40 +00:00
16
UNLICENSED
23 KiB
Assets (1)
Versions (5) View all
0.5.0 2026-08-29
0.4.0 2026-08-29
0.3.0 2026-08-29
0.2.0 2026-08-28
0.1.0 2026-08-28