Foundations
Warm, technical, restrained.
Primitives hold the palette; a semantic tier names the roles components actually use. Every value below is read from @loidolt/theme-tokens rather than copied.
Primitives
Semantic roles
The stylesheets read only this tier. Redefine a role and every component that plays it follows;
redefine a primitive and it propagates, because roles link with var() rather than baking in a resolved value.
| Role | Custom property | Light value |
|---|---|---|
| surface | --loidolt-surface | #f5f2e9 |
| surface-alt | --loidolt-surface-alt | #efebe1 |
| text | --loidolt-text | #20231d |
| text-muted | --loidolt-text-muted | #5f5b50 |
| border | --loidolt-border | #c8c1b1 |
| border-control | --loidolt-border-control | #847d6a |
| accent | --loidolt-accent | #c65224 |
| on-accent | --loidolt-on-accent | #ffffff |
Two distinctions carry weight. --loidolt-danger is a fill with --loidolt-on-danger as the ink that sits on it, while --loidolt-text-danger is the same status used as text on a surface — a fill tuned
for white ink is too light to read as text. And --loidolt-border is a decorative
hairline, while --loidolt-border-control is the boundary of an input, held to 3:1 because
that border is the only thing identifying the control.
/* Restyle a scope by redefining the semantic tier only. For a full
style set with both schemes, define a preset instead (below). */
[data-brand='midnight'] {
color-scheme: dark;
--loidolt-surface: #10212c;
--loidolt-text: #dceaf2;
--loidolt-border: #29414f;
--loidolt-accent: #e0672f;
--loidolt-on-accent: #10212c;
}Dark mode
A tested dark theme ships with the package — every pair is asserted at WCAG AA, including the on-* inks, so you do not have to get them right yourself.
@import '@loidolt/theme-styles'; @import '@loidolt/theme-styles/dark'; /* [data-theme="dark"] */ /* or */ @import '@loidolt/theme-styles/dark-auto'; /* also follows prefers-color-scheme */
The runtime that drives it is createTheme(), with themeScript() for the first paint. This site uses exactly this setup; the picker in
the top bar is ThemeToggle.
// src/lib/theme.ts
import { createTheme } from '@loidolt/theme-svelte';
export const theme = createTheme();
// src/hooks.server.ts — stops the light flash on reload
import { themeScript } from '@loidolt/theme-svelte';
export const handle = ({ event, resolve }) =>
resolve(event, {
transformPageChunk: ({ html }) => html.replace('%theme%', themeScript()),
});Presets
A preset is a complete style set: colours for both schemes, plus the corners, strokes, density
and typographic voice of every component. Presets and colour schemes are independent axes — data-preset and data-theme — so each preset has a tested light and
dark side. Use the style picker in the top bar to switch this whole site, or compare every
preset in both schemes on the Presets page.
Saved
Saved
Saved
| Custom property | Loidolt | Soft | Compact |
|---|---|---|---|
--loidolt-radius-control | 0px | 6px | 0px |
--loidolt-radius-surface | 0px | 8px | 0px |
--loidolt-stroke-indicator | 2px | 2px | 2px |
--loidolt-pad-control-x | 0.85rem | 0.9775rem | 0.6375rem |
--loidolt-pad-cell | 0.7rem 0.8rem | 0.805rem 0.92rem | 0.525rem 0.6rem |
--loidolt-pad-surface | 1rem | 1.15rem | 0.75rem |
--loidolt-label-transform | uppercase | none | uppercase |
--loidolt-label-tracking | 0.1em | 0em | 0.1em |
--loidolt-control-weight | 400 | 500 | 400 |
--loidolt-heading-weight | 500 | 600 | 500 |
Import the presets you offer, then hand their names to createTheme() and themeScript(). An app that only ever uses one preset can import its .root stylesheet from @loidolt/theme-tokens/css/presets/ instead and skip
the attribute.
@import '@loidolt/theme-styles'; @import '@loidolt/theme-styles/dark'; /* Each preset you offer, with the dark flavour matching the one above. */ @import '@loidolt/theme-styles/presets/soft'; @import '@loidolt/theme-styles/presets/soft-dark'; @import '@loidolt/theme-styles/presets/compact'; @import '@loidolt/theme-styles/presets/compact-dark';
// src/lib/theme.ts
export const presets = ['loidolt', 'soft', 'compact'] as const;
export const theme = createTheme({ presets });
// src/hooks.server.ts — restores the preset before first paint, too
html.replace('%theme%', themeScript({ presets }));
// anywhere
theme.preset = 'soft'; // or <PresetPicker {theme} /> Your own presets use the same API the built-in ones do. Whatever a preset does not set comes
from the base system, or from the preset it extends, and every preset can be
audited against the same contrast pairs the package tests itself with.
// build/presets.ts — run at build time
import { definePreset, presetStylesheets, soft } from '@loidolt/theme-tokens';
export const studio = definePreset({
name: 'studio',
extends: soft, // optional: start from another preset
density: 0.9, // scales every pad-* and gap-* role
roles: { radiusControl: '4px', labelTransform: 'uppercase' },
colors: {
light: { accent: '#0b6bcb', accentHover: '#0956a3', focusRing: '#0b6bcb' },
dark: { accent: '#5aa7f0', accentHover: '#7ab8f3', focusRing: '#7ab8f3' },
},
});
// studio.css, studio-dark.css, studio-dark-auto.css, and the same with .root
for (const [file, css] of Object.entries(presetStylesheets(studio))) {
await writeFile(`static/presets/${file}`, css);
}
// in a test: every pair the stylesheets draw, at WCAG AA
expect(auditContrast(studio.resolved.dark).filter((check) => !check.pass)).toEqual([]);Type and space
Type scale
xsAa0.6875remsmAa0.75rembaseAa0.875remmdAa1remlgAa1.25remxlAa1.75remdisplayAa2.5rem
Spacing
0010.25rem20.5rem30.75rem41rem51.25rem61.5rem82rem102.5rem123rem164rem
Breakpoints
compact760pxexpanded1024pxwide1400px
Breakpoints are not custom properties — @media cannot read var() — but breakpointQuery() turns them into queries, and createMediaQuery() makes them reactive.
Long-form content
Wrap rendered Markdown or other HTML you do not author element by element in .ldt-prose. Its rules carry zero specificity, so components placed inside still
look like components; mark an island of bare UI markup with .ldt-not-prose. --ldt-prose-size scales the text.
<article class="ldt-prose" style="--ldt-prose-size: 0.9375rem">
{@html renderedMarkdown}
<div class="ldt-not-prose">…ordinary UI…</div>
</article> Printing the labels
Labels are bought only when someone runs Generate labels. Each one is a 4×6 PDF; see the size table for printer settings.
Check the return address before the first batch of the season.
- Load 4×6 stock
- Print a test label
| Printer | Scale | Margin |
|---|---|---|
| Thermal | 100% | 0 |
| Laser, 2-up | Fit | 0.25in |
npm run backfill -- --year 2026Cascade layers
@layer loidolt.tokens, loidolt.reset, loidolt.base,
loidolt.components, loidolt.utilities, loidolt.a11y; Unlayered application CSS outranks every one of these, so a plain selector in your app always
wins — no !important needed. loidolt.reset is deliberately empty and
reserved for an app's own reset, and loidolt.a11y is last so the focus ring beats component
styles by layer order alone.