Skip to content
LoidoltTheme 0.11.1
Svelte 5

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

Paper#ebe7dc
Panel#f5f2e9
Canvas#d8d3c7
Deep#20231d
Muted#5f5b50
Line#c8c1b1
Orange#c65224
Orange dark#a9441d
Success#3f5139
Warning#7c4d17
Error#9b2822
Info#265162

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.

The same components, one wrapper with overridden roles
Default
Live
Overridden
Live
Semantic roles
RoleCustom propertyLight 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.

The same components under each built-in preset
Loidolt
Live

Saved

Changes sync in the background.
Soft
Live

Saved

Changes sync in the background.
Compact
Live

Saved

Changes sync in the background.
Shape and voice roles by preset
Custom propertyLoidoltSoftCompact
--loidolt-radius-control0px6px0px
--loidolt-radius-surface0px8px0px
--loidolt-stroke-indicator2px2px2px
--loidolt-pad-control-x0.85rem0.9775rem0.6375rem
--loidolt-pad-cell0.7rem 0.8rem0.805rem 0.92rem0.525rem 0.6rem
--loidolt-pad-surface1rem1.15rem0.75rem
--loidolt-label-transformuppercasenoneuppercase
--loidolt-label-tracking0.1em0em0.1em
--loidolt-control-weight400500400
--loidolt-heading-weight500600500

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.6875rem
  • smAa0.75rem
  • baseAa0.875rem
  • mdAa1rem
  • lgAa1.25rem
  • xlAa1.75rem
  • displayAa2.5rem

Spacing

  • 0 0
  • 1 0.25rem
  • 2 0.5rem
  • 3 0.75rem
  • 4 1rem
  • 5 1.25rem
  • 6 1.5rem
  • 8 2rem
  • 10 2.5rem
  • 12 3rem
  • 16 4rem

Breakpoints

  • compact760px
  • expanded1024px
  • wide1400px

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
PrinterScaleMargin
Thermal100%0
Laser, 2-upFit0.25in
npm run backfill -- --year 2026

Cascade 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.