@jelinek/ui
v0.9.4
Published
JELÍNEK Svelte 5 component kit, generated from the brand guide
Readme
@jelinek/ui
Svelte 5 + Tailwind v4 component kit generated from the JELÍNEK brand guide.
The design authority is assets/css/jelinek.css in the root of this
repository (the brand guide site), not this package. This kit reimplements
a subset of that CSS as Svelte components; when the two disagree, the guide
is right and the kit needs fixing. See "Sync contract" below for how (and
how incompletely) that is enforced.
Install
Published public on npmjs.com as @jelinek/ui — no token, no .npmrc,
nothing to log into:
pnpm add @jelinek/uiUp to 0.2.0 it was @jelinek-nabytek-a-matrace/ui on GitHub Packages, which
required a personal access token even for public packages, and consuming apps
therefore aliased it to the short name. Both the alias and the .npmrc lines
are now dead weight — delete them, or pnpm install will keep asking GitHub
Packages for a package that is no longer published there:
{
"dependencies": {
"@jelinek/ui": "^0.9.2"
}
}The package ships component sources and CSS only. It carries no font
files — fonts.css merely references /assets/fonts/*.woff2, which the
consuming app must serve itself. The fonts are licensed and stay out of the
published tarball.
import { Button } from '@jelinek/ui';Setup in a consuming app
The package's exports map (from package.json):
{
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js",
"default": "./dist/index.js"
},
"./theme.css": "./dist/theme/tokens.css",
"./extras.css": "./dist/theme/extras.css",
"./base.css": "./dist/theme/base.css",
"./fonts.css": "./dist/theme/fonts.css"
}In your app's global CSS entry point:
@import "tailwindcss";
@import "@jelinek/ui/theme.css";
/* @import "@jelinek/ui/extras.css"; -- optional, hand-written theme additions, import after theme.css */
/* @import "@jelinek/ui/base.css"; -- optional but recommended, import after theme.css (and extras.css, if used) */
@source "../node_modules/@jelinek/ui/dist";theme.css (dist/theme/tokens.css) is generated — see "Sync contract"
below — and defines the semantic color/spacing/radius/shadow/typography
variables plus the @theme inline block that maps them onto Tailwind
utilities. The @source line is required: without it, Tailwind never scans
the kit's own compiled class names and utilities the kit relies on (e.g.
rounded-(--radius-button)) can be missing from your build. Adjust the
relative path to match where node_modules actually sits from your CSS
file — the path above assumes a typical src/app.css one level under the
app root.
Consumer implication — theme.css pins Tailwind's --spacing to a fixed
4px. Tailwind v4's own default, --spacing: 0.25rem, is rem-relative,
so it tracks whatever root font-size is in play. That root is a flat
16px today (see below), which makes 0.25rem and 4px agree — but the
agreement is a property of the current root value, not of the system, and
before this kit's type-scale rework the root was
clamp(13px, 8px + 1.4vw, 16px) and the unpinned default shrank every
p-*/px-*/m-*/gap-*/w-* utility, kit-wide, by up to 19% below
roughly 571px viewport width. Type scales with the viewport; spacing does
not, and the two must not share one lever. This import is required (unlike base.css/extras.css
below), which is deliberate: components reproduce guide padding/margin/gap
values as bare Tailwind spacing utilities (Card's p-6 for the guide's
padding: 24px, for example) and rely on this pin to stay correct at every
width, so it has to live in the one file every consumer of the components
necessarily imports, not an optional one. If your app sets its own
--spacing after this import, components using the bare numeric scale will
resize with it — same tradeoff as overriding any other token here.
@jelinek/ui/base.css (dist/theme/base.css) is optional but recommended.
Without it, this kit gives you JELÍNEK colours and components but plain
markup — a bare <h2>, a <p>, an <a> — still looks generic, because
theme.css only defines custom properties, never element defaults. It
reproduces the guide's global element layer (*, html, body, main,
h1-h6, p, a, img, code, plus the
.muted/.muted2/.serif/.small/.tiny/.sub text-role utilities)
inside @layer base — the same layer Tailwind's own preflight populates —
so import it after tailwindcss (same layer, later source wins the
preflight h1-h6 reset) and it will still lose to any Tailwind utility
class you apply (text-step2, font-bold, …), since utilities is a
later layer than base and layer order always beats specificity. Position
relative to theme.css/extras.css doesn't affect that layering — those
two are unlayered token files — but importing it after them means every
token it references (--font-primary, --text-black, the --step*
scale, …) is already defined, including any you've added yourself in
extras.css.
Consumer implication — a fixed 16px root font size, with the fluidity on
the steps. html's font-size is a flat 16px. It used to be
clamp(13px, 8px + 1.4vw, 16px), which made the root — and with it every
rem in the system — shrink to 13px on the narrowest phones; the guide
now keeps the root fixed so body copy is 16px at every viewport width, and
carries the responsiveness on the individual scale steps instead. Every
--stepN token (theme.css) is its own clamp() interpolating over
320–1024px viewport width, so headings still grow with the viewport
(--step6, i.e. h1, runs 33px → 48px) while --step0 (body) stays
16px and --step-down1/--step-down2 (.small/.tiny) are fixed at
15px/13px. Importing base.css therefore gives plain unstyled markup the
same type behaviour as brand.jlnk.cz.
If your app needs the root to follow the reader's own browser font-size
setting, override html's font-size (e.g. to 100%) in your own CSS
after this import. Be aware of what that does and does not carry: each
step's min, max and intercept are in rem and scale with the new root,
but its slope is in vw and does not. The interpolation window
therefore moves outward in proportion to the root: at a 24px root the
320–1024px range becomes 480–1536px, so a step reaches its (now larger)
maximum at a wider viewport, not a narrower one, and the ramp between min
and max is no longer a clean scale-up of the 16px curve — still monotonic,
still bounded by the scaled min/max, just a different shape in between. The
guide keeps 16px for now for a different reason (--header-h/--subnav-h
are hand-measured px while the header's own text is rem, so an unpinned
root exposes that seam); if you take the override, sanity-check your own
layout at a raised browser default rather than assuming. Note that seam is
now a floating-header-only problem: since Task 26 the guide's phone
header is position: sticky and reserves no space through those constants
at all, and re-measuring the raised-root sweep confirms it — at a 24px
browser default with font-size: 100% the two glass bars still meet with a
0px seam at every width below 640px, and the overlap above it is a flat
6.5px (measured at 640, 700, 768, 1024, 1440 and 1920px: the fixed header
grows to 101.5px against the hand-measured --header-h: 94px). Task 30's
move to the designer's 640px ladder removed the anomaly this note used to
call out — the old 621px boundary produced a much larger overlap in a
narrow band right at the switch, and with header and chapter-nav now
flipping on the same 640px query there is no band left. At the pinned 16px
root the seam is 0px at every width, 320 to 1920.
Consumer implication — box-sizing: border-box on every element. This
matches Tailwind's own preflight exactly, so it changes nothing for an app
that already runs preflight; it only matters if you import base.css
without Tailwind preflight (or without Tailwind at all), in which case
your elements now size padding/border inward instead of the browser's
default content-box behaviour.
Not included — scroll-behavior: smooth. The guide sets this on
html, but base.css deliberately leaves it out: it's a global scroll
behaviour, not a visual default, it isn't load-bearing for anything the
guide itself does (its own chapter navigation is cross-page links, not
in-page anchors), it can conflict with an app's own scroll management
(virtualised lists, scroll-linked animation, router-driven scroll
restoration), and the guide doesn't gate it behind
prefers-reduced-motion. Add html { scroll-behavior: smooth } yourself,
after this import, if you want it.
html also gets a static background-color: var(--bg-white) — the
canvas colour the guide reveals on elastic overscroll (rubber-banding past
the top/bottom of the page). The guide swaps this dynamically by scroll
position via its own <script> (assets/js/jelinek.js); base.css ships
only the static starting colour, not that JS behaviour.
body's background-color/color now transition (0.2s ease): if you
use this kit's ThemeToggle, flipping data-theme animates instead of
snapping. Override body's transition after import if you don't want
that.
Consumer implication — this assumes a page shell. body is
min-height: 100%; display: flex; flex-direction: column and main is
flex: 1 0 auto (html gets height: 100% to make the percentage mean
anything) — the guide's sticky-footer trick, so a page with little content
still holds its footer to the bottom of the viewport instead of leaving a
white gap below it. That means base.css expects your page's direct body
children to be exactly: a header, zero or more chrome elements, one
<main>, and a footer — the same shape this package's own showcase uses
(src/routes/+layout.svelte: SiteHeader, optionally ChapterNav,
<main>, SiteFooter, with nothing wrapping them so they're real flex
children of body). If your app already owns a different page shell
(its own scroll container, a fixed-height app frame, more than one
<main>, …), either don't import base.css and take its other rules
(typography, links, …) from your own reset, or import it and override
body's display/flex-direction/min-height afterwards in your own
CSS — everything else base.css provides (headings, links, the text
utilities) is independent of this and keeps working.
@jelinek/ui/fonts.css (dist/theme/fonts.css) is optional. It declares
@font-face rules assuming the .woff2 files are served from
/assets/fonts/ in your app (the same absolute path the brand guide site
itself uses). If you host fonts elsewhere, skip this import and declare
your own @font-face rules — the kit only depends on the
--font-primary/--font-secondary/--font-serif variables, which all
have fallbacks.
This package's own showcase is the example of hosting fonts elsewhere-but-really-the-same-place, and it needs two different answers because it's served two ways:
- Deployed behind the
brandWorker at/ui/,/assets/fonts/...already resolves on the same origin for free —wrangler.jsoncserves the whole repo root as static assets, andassets/fonts/*.woff2(the one canonical source, perassets/fonts/README.md) already lives there. Nothing to configure. - Standalone
pnpm dev/pnpm previewonly know about thesvelte/project, sovite.config.tsadds a small dev-only middleware that reads/assets/fonts/*.woff2straight out of the repo root'sassets/fonts/and serves it at that same unprefixed path. Astatic/symlink was tried first and rejected: this project setspaths.base: '/ui'(it's deployed at/ui/, not the site root), and SvelteKit's dev server serves everything understatic/— symlinked or not — prefixed by that base, so a symlink would only ever be reachable at/ui/assets/fonts/..., never at the unprefixed pathfonts.cssactually requests. The middleware reproduces the literal production URL instead. Either way, the licensed font files themselves are never copied intosvelte/.
Use with htmx / server-rendered HTML
The kit is a Svelte library, but a consuming app doesn't have to be a
Svelte app. An htmx project (or any server-rendered site) can use it on
two levels; both need one small asset build, because the package ships
uncompiled .svelte source (that's what svelte-package produces —
see dist/), and someone has to run the Svelte compiler and Tailwind. In
an htmx project that someone is a tiny Vite build producing one JS and one
CSS file that your server-rendered pages link.
Level 1 — CSS layers only. Import the CSS entry points from "Setup in
a consuming app" above (theme.css, optionally extras.css/base.css/
fonts.css) into a stylesheet compiled by Tailwind, and add a @source
line pointing at your own template directory so utilities you use in
server-rendered markup get generated:
@import "tailwindcss";
@import "@jelinek/ui/theme.css";
@import "@jelinek/ui/base.css";
@source "../node_modules/@jelinek/ui/dist";
@source "../templates"; /* your server's HTML templates */This gives server-rendered markup the brand's tokens, element defaults (headings, links, body copy) and the full utility vocabulary. It does not give you the components themselves — hand-copying a component's class string into a template will drift the first time the component changes. For anything that looks like a component, use level 2.
Level 2 — components as islands. Interactive components mount into
server-rendered pages as Svelte islands. Author each island as a small
.svelte wrapper file in your asset bundle — not by constructing props
from plain JS — because many components take Snippet props (Button's
children, for example), and snippets are markup, which you want to write
in a .svelte file anyway:
<!-- islands/ProductTabs.svelte -->
<script>
import { Tab, Tabs } from '@jelinek/ui';
let { value = 'popis' } = $props();
</script>
<Tabs bind:value>
<Tab value="popis">Popis</Tab>
<Tab value="parametry">Parametry</Tab>
</Tabs>The entry point mounts islands on load and after every htmx swap, and unmounts them when htmx removes their element — without the cleanup listener, every swap leaks the previous instance:
// islands.js — the single <script type="module"> your layout links
import { mount, unmount } from 'svelte';
import './app.css'; // the stylesheet from level 1
import ProductTabs from './islands/ProductTabs.svelte';
const REGISTRY = { ProductTabs };
const mounted = new WeakMap();
function hydrate(root) {
const targets = [root, ...root.querySelectorAll('[data-island]')];
for (const el of targets) {
if (!el.matches?.('[data-island]') || mounted.has(el)) continue;
const Component = REGISTRY[el.dataset.island];
if (!Component) continue;
const props = el.dataset.props ? JSON.parse(el.dataset.props) : {};
mounted.set(el, mount(Component, { target: el, props }));
}
}
// Runs once on page load and again for every htmx swap.
htmx.onLoad(hydrate);
// htmx removes swapped-out elements itself; Svelte has to be told.
document.body.addEventListener('htmx:beforeCleanupElement', (e) => {
const app = mounted.get(e.target);
if (app) {
unmount(app);
mounted.delete(e.target);
}
});Server-rendered pages then place islands declaratively; htmx attributes and islands coexist freely:
<div data-island="ProductTabs" data-props='{"value":"parametry"}'></div>The Vite project around this is three files (package.json with
@jelinek/ui, svelte, vite, @sveltejs/vite-plugin-svelte,
@tailwindcss/vite; a vite.config.js with the two plugins and
build.rollupOptions.input: 'islands.js'; the islands.js above). Point
build.outDir wherever your server serves static assets from.
Two caveats, both inherited from the setup section above: the @source
line for the kit's dist/ is still required (island components' own
classes live there), and fonts.css still assumes /assets/fonts/ on
your origin — host the .woff2 files there or write your own
@font-face. And a boundary worth knowing before you commit to it:
paired components that talk through Svelte context (Tabs → Tab,
RadioGroup → Radio, Container → PageHero) must live inside one
island — context does not cross island boundaries, so a Tab in one
data-island cannot report to a Tabs in another.
Dark mode and divisions
- Dark mode: set
data-theme="dark"on the document element (or add class.dark— both are supported so pim/admin's existing.darkconvention keeps working). - Division re-theme:
data-division="mattress"swaps the brand's brown accent for magenta (Zdravý spánek / mattress division).data-theme="mattress"is kept as an alias for the same effect, for apps whose existing markup already sets it that way.
ThemeToggle's theme prop defaults to undefined, not 'light'.
When left unbound, it seeds itself from whatever is already on the
document — document.documentElement.dataset.theme if you've already set
data-theme yourself (a persisted choice from localStorage, a cookie, an
SSR-rendered attribute), falling back to prefers-color-scheme only if
nothing is set — instead of writing a hardcoded default back onto <html>
on mount and clobbering whatever you'd already put there. If you do bind
theme yourself (bind:theme={yourState}), that binding is authoritative
as before; the DOM-seeding only applies when it's left unbound.
Components
Alert, Breadcrumbs, Button, Card, CartDrawer, CartLineItem, ChapterNav, Checkbox, CheckoutProgress, CmsButton, CmsColumns, CmsCover, CmsHeading, CmsImage, CmsMediaText, CmsText, CodeCopy, ConfigSection, Container, ContentLogo, Demo, DimensionSlider, Drawer, EmptyState, Eyebrow, FilterChip, FreeShippingBar, Gallery, Glass, Grid, Icon, Label, ListingToolbar, Modal, OptionTile, OrderSummary, PageHero, Pager, Pagination, Panel, PhotoTile, PriceDisplay, ProductCard, ProductHeader, ProductStickyBar, ProductTabs, QuantityStepper, Radio, RadioGroup, RatingStars, SearchField, Select, SideToggle, SiteFooter, SiteHeader, Skeleton, Slider, StickyBar, StockBadge, Swatch, Switch, Tab, Tabs, Tag, TextField, Textarea, ThemeToggle, Tile, TileScroller, Toaster, TrustBadges.
See src/lib/index.ts for the exact export list and src/lib/components/
for source. Per-component contracts, including the two below, are printed at
/ui/api.
Two constraints worth knowing before you use them
Tabs — the scroll arrows are pinned to the WRAPPER, not to the row of
pills. When the segments overflow, Tabs grows a wrapper with a back/forward
arrow on each side, and those arrows sit at the wrapper's edges. If the row is
narrower than its wrapper — which is what happens the moment you constrain it
with w-3/4, max-w-* or a mx-auto width — the arrows detach from the pills
and point at empty space. Measured: 439px adrift with a 200px row inside a
1082px wrapper, and already 68px off at this showcase's own min-[640px]:w-3/4
call site (invisible there only because nothing overflows at that width). Keep
Tabs at the full width of its wrapper anywhere the segments can overflow, or
constrain the wrapper rather than the component.
SiteHeader floats at every width, so the page must reserve its height.
The bar is absolute below 640px and fixed from 640px up; it never adds
padding to a sibling. Reserve --header-h (70px phone, 94px from 640px) plus
--subnav-h (58px) when a ChapterNav is present — PageHero and this
showcase's +layout.svelte both model it. And below 640px ChapterNav renders
nothing at all: pass the same chapter list to SiteHeader's chapterItems so
a phone reader can still reach the chapters from the menu panel.
SiteHeader — the way back to the company portal is homeHref, not a
button in actions. Company apps built from the templates link back to the
rozcestník. Pass its URL as homeHref and the header renders a home icon
(from the kit's icon set, no text) as the first item of the bar, at the far
left before the logo, at every width including phones. It is a 44px square
— the burger's size — rendered through the kit's own ghost/black Button,
so hover, transition and the global focus-visible ring match the nav links.
homeLabel (default 'Zpět na rozcestník') is its aria-label and title.
Without homeHref the header is unchanged. Do not put a "Zpět na rozcestník"
Button into actions any more: it sat on the right next to the nav links at
a different size (owner's decision, 15. 9. 2026).
<SiteHeader subtitle="Objednávky" items={menu} homeHref="https://jlnk.cz/" />Sync contract — and its limits
Two automated checks keep this kit from silently drifting away from the guide. Both run in CI (see below) and both are honest about what they do not catch.
pnpm check:tokens (scripts/gen-tokens.js --check) regenerates
src/lib/theme/tokens.css from the guide's :root / :root[data-theme="dark"]
blocks and fails if the committed file is stale. It also fails the build
outright — gen-tokens.js, unchecked — if the guide defines a :root
custom property that is in neither TOKEN_MAP nor PASSTHROUGH in
scripts/token-map.js. That's deliberate: a brand-new design token
(a new color, spacing step, radius, etc.) must become a visible task for
someone to map, not a silent omission. tokens.css is generated —
never hand-edit it; run pnpm gen:tokens and commit the result.
Hand-written theme additions belong in src/lib/theme/extras.css, which
the generator never touches.
pnpm check:icons (scripts/gen-icons.js --check) does the same for
the interface icon set: src/lib/icons/material-icons.generated.ts is
generated from assets/icons/material/ (Google icons as per-icon SVGs plus
our own drawings in vlastni/) and fails if it is stale. The generator also
refuses any SVG without fill="currentColor" on its root or carrying its
own colours — an icon that would not re-theme. The module is generated —
never hand-edit it; add or remove SVG files, run pnpm gen:icons, commit.
It exists so that <Icon name="…" /> works from the installed package:
the earlier import.meta.glob reached outside dist/ and only worked
inside this repo (review, 1. 9. 2026). The whole set is ~18 kB gzipped and
ships with the package.
pnpm check:css-lock (scripts/check-css-lock.js) hashes the guide's
CSS rule groups for two fixed, hardcoded allow-lists in that file:
GUARDED — class names (btn, tag, alert, tile, panel, input,
grid, site-header, and so on, one entry per class a component in this
kit reimplements — including the six text-role utility classes .muted,
.muted2, .serif, .small, .tiny, .sub, whose "component" is
src/lib/theme/base.css itself) — and GUARDED_ELEMENTS — bare type
selectors (*, html, body, main, h1-h6, p, a, img, code)
that base.css also reproduces and that have no class for GUARDED to
match. Both are compared against component-css.lock.json. If a guarded
name's rules change in the guide, the check fails and names which
component needs review (.grid rules changed for a class, body rules
changed for an element — no leading dot); --write refreshes the lock
once you've handled it.
The gap, stated plainly: check:css-lock only re-checks names that are
already on GUARDED or GUARDED_ELEMENTS. It cannot detect that a
brand-new component-shaped class — or bare element rule — was added to
the guide — say, a designer adds .badge-new-something { ... } to
jelinek.css for a component this kit doesn't have yet, or a new
blockquote { ... } that base.css doesn't reproduce. That rule gets no
bucket in the hash and the check reports "no drift", exit 0, with nothing
in the kit reviewing it. This was verified by executing exactly that
experiment during the final branch review. It is not a bug: reliably
telling "this new CSS rule is a component that needs tracking" from "this
is a one-off utility" cannot be inferred from selector shape alone, so
GUARDED and GUARDED_ELEMENTS stay manual allow-lists rather than
something auto-detected. In practice this means: when you add a new
component to this kit (or extend base.css), add its guide class name to
GUARDED — or its bare element name to GUARDED_ELEMENTS — in
scripts/check-css-lock.js and run --write — don't rely on the check
to remind you it's missing. See the header comment in that file for the
full reasoning.
CI
.github/workflows/ui.yml runs on pull requests touching svelte/** or
assets/css/jelinek.css, and on push to master. It does not run on
a bare feature-branch push (no PR yet) — only PR and master events
trigger the verify job: token drift, CSS-lock drift, check, test,
build, build:showcase (the only step that actually compiles Tailwind
and prerenders — see its own comment in the workflow for why it's
separate from build), and finally a Playwright e2e suite
(e2e/theme-division.spec.ts) run against that showcase build — browser-
level regression coverage for the theme × division custom-property
cascade that pnpm test's jsdom environment structurally cannot check
(jsdom never resolves a CSS custom-property cascade at all).
The publish job is separate and gated on a tag matching ui-v* — it
does not run on ordinary merges to master, only when someone pushes a
ui-v* tag, and it also depends on verify passing first. It publishes
to npmjs.org with --access public.
Releasing
- Bump
versioninsvelte/package.json, commit, pushmaster. git tag ui-v<version> && git push origin ui-v<version>.verifyruns, thenpublish— nothing else to do.
The tag is what records which commit a version came from, so push it even if a release has to be finished by hand.
How the publish authenticates
Trusted publishing (GitHub Actions OIDC), no secret. The job asks
GitHub for an OIDC token and npm exchanges it for a short-lived publish
credential. Registered on npmjs under the package's Trusted publisher:
org JELINEK-nabytek-a-matrace, repo brand-guide, workflow filename
ui.yml, environment left empty. All three are checked on every publish
— renaming ui.yml, or adding an environment: to the publish job,
breaks releases until the publisher entry is edited to match.
Two consequences worth knowing:
- The job runs
npm publish, notpnpm publish. The OIDC exchange lives in the npm CLI (11.5.1+, which the job installs explicitly); pnpm would look for an auth token, find none, and fail. - Provenance stays off (
NPM_CONFIG_PROVENANCE=false). npm can only attest a build from a public repository and this one is private (licensed fonts). Trusted publishing does not require provenance.
There is no NPM_TOKEN secret any more. It never worked: npm requires a
second factor for every publish, and a granular token only satisfies that
if "Bypass two-factor authentication (2FA)" was ticked when the token was
created — a setting that cannot be added afterwards, on a mechanism npm
is retiring outright (account changes Aug 2026, direct publishing Jan
2027). Without it the registry answered a PUT with a misleading E404
Not Found in CI. 0.3.0 through 0.5.0 were released by hand because of it.
0.5.1 was the first release published by CI, which settles the doubt this section used to carry: a private repository does not block trusted publishing. Only the provenance attestation needs a public repo, and that is opt-out, not a precondition.
Fallback: publishing by hand
If the job fails, the release is not stuck — publish from a machine that can reach a browser:
cd svelte && npm publish --access public --auth-type=webThat prints a npmjs.com/auth/cli/… URL; approving it there with a
passkey (or an authenticator app) completes the publish. --otp=<code>
works too, but only with an authenticator app — a passkey produces no
code.
Known design debt
The guide does not yet define everything this kit's consumers need. See:
/audit.html#plan-prace— the single work plan (formerly the two backlog documents): missing interface components, the CMS content blocks still to be designed, admin components, with priority and deadline.
Five components in this kit have no corresponding CSS in the guide and
were necessarily invented rather than reimplemented: Checkbox, Radio,
RadioGroup, Slider, and ProductCard. Treat their current appearance
as a placeholder, not as an extension of the brand guide's authority —
they should be revisited once the guide actually specifies them.
