🎨 Styling & Design System
LibreFolio uses Tailwind CSS v4 for styling, providing a utility-first approach with a custom design system.
🧰 Technology Stack
- Tailwind CSS v4 (
tailwindcss, through@tailwindcss/postcss): the utility-first CSS framework. - CSS-first configuration:
src/app.cssimports Tailwind and declares the design tokens with the@themedirective.tailwind.config.jsonly holds an emptyextendand is not loaded: Tailwind v4 reads a JavaScript config only through@config, whichapp.cssdoes not use. - CSS Variables: Used for theming (Dark/Light mode) and brand colors.
- Fonts: system and locally installed fonts, plus one self-hosted face for flags — see Fonts.
🎯 Design System
The design system is defined in src/app.css using the @theme directive.
🎨 Brand Colors
| Color Name | Hex | Usage |
|---|---|---|
libre-green |
#1a4031 |
Primary brand color, buttons, active states. |
libre-beige |
#f5f4ef |
Backgrounds, cards, warmth. |
libre-sage |
#9caf9c |
Secondary accents, borders. |
libre-dark |
#111111 |
Text, dark mode backgrounds. |
In dark mode html.dark redefines --color-libre-green (#00d681) and --color-libre-banner
(#00834f). @theme also defines libre-banner (#1a4031, not used by components today), a
primary-50…900 (green) and a surface-50…500 (beige) palette, and the shadow-card /
shadow-card-hover shadows.
💻 Usage
You can use these colors directly in Tailwind classes:
<div class="bg-libre-green text-white p-4 rounded-xl">
Primary Button
</div>
<div class="bg-libre-beige text-libre-dark">
Card Content
</div>
🔤 Fonts
Every font stack the app sets starts with the 'LF Flags' face:
/* src/app.css — @theme */
--font-sans: 'LF Flags', Inter, system-ui, sans-serif;
--font-mono: 'LF Flags', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
The html element uses the same sans stack. Inter is not self-hosted: it is used where it is
installed, and system-ui takes over elsewhere.
'LF Flags' is declared in static/lf-flags.css, linked by src/app.html and by
static/offline.html. Its unicode-range: U+1F1E6-1F1FF covers only the regional indicator
symbols that make up a flag, so it draws flags and nothing else: digits, #, * and every other
emoji keep the fonts after it.
- Apple devices draw their own flags: the face resolves to the system Apple Color Emoji
through its
local()sources, and nothing is downloaded. - Everywhere else — Windows included, whose Segoe UI Emoji has no flags and prints the two
letters instead — it uses a locally installed Noto Color Emoji when there is one, and otherwise
the self-hosted Noto Color Emoji flags subset,
/fonts/noto-color-emoji/noto-color-emoji.0.woff2, fetched only when a flag is on screen.
The subset is not committed (frontend/static/fonts/ is gitignored): scripts/update_js_cache.py
downloads Noto Color Emoji from Google Fonts and keeps only the flags range
(keep_unicode_ranges). ./dev.py front build and ./dev.py docker build run it first and stop
when the font is missing and cannot be fetched; ./dev.py server runs it too.
Never name an emoji font in a font-family
No stack may name Apple Color Emoji, Noto Color Emoji, Segoe UI Emoji or Segoe UI Symbol: an
emoji font placed before the text font draws digits, # and * with emoji glyphs. Those names
appear only as local() sources in static/lf-flags.css. A component that sets its own stack
(a monospace cell, an inline style) starts it with 'LF Flags', or wraps the flag in
.emoji-flag, which applies the app's sans stack.
The gate src/flagFont.gate.test.ts checks the face, the stacks of app.css and
offline.html, and that no source names an emoji font; e2e/fx/fx-flag-font.spec.ts reads
from the browser (Chrome DevTools Protocol) which font actually draws a flag.
🌙 Dark Mode
Dark mode is the dark class on the <html> element. An inline script in src/app.html sets
dark or light before the first paint, from the stored preference (librefolio-theme in
localStorage) or, when there is none, from prefers-color-scheme. app.css binds Tailwind's
dark: variant to that class:
Components write explicit dark: variants next to the light classes
(bg-white dark:bg-slate-800): this is the main mechanism.
🔧 Theme Variables
app.css also defines semantic variables for both themes (:root, html.light and html.dark):
--theme-bg-*, --theme-text-*, --theme-border-*, --theme-accent* and --theme-shadow*.
:root,
html.light {
--theme-bg-primary: #ffffff;
--theme-text-primary: #111827;
}
html.dark {
--theme-bg-primary: #0f172a;
--theme-text-primary: #f8fafc;
}
Components do not read them directly. They feed a set of global html.dark overrides in app.css
that remap common light utilities — .bg-white, .bg-gray-50, .text-gray-800,
.border-gray-200, inputs, .bg-libre-beige, … — to the dark palette, a safety net for markup
written without a dark: variant.
🛠️ Utility Classes
Global classes defined in src/app.css:
| Class | Purpose |
|---|---|
.emoji-flag |
Applies the app's sans stack, so a flag inside an element with its own font (a monospace cell) is still drawn by 'LF Flags'. |
.lf-compact-number-input |
Shared typography of compact numeric inputs: inherited font, 0.75rem/1rem, tabular numerals. Below 768 px the anti-zoom rule still sets inputs to 16 px, and the class only raises the line height. Used by the custom axis bounds of ChartAestheticsSection.svelte. |
.zoom-guard-exempt |
Opts an input out of the mobile 16 px font size that prevents iOS auto-zoom, for dense toolbars (date fields). |
.safe-top, .safe-top-offset |
Padding / offset for the iOS safe area (notch, status bar) in standalone mode. |
.scrollbar-hidden |
Scrollable without a visible scrollbar. |
.lf-price-flash-up, .lf-price-flash-down |
One-shot colour flash of a live price — see Live Prices. |
🧱 Component Styling
The recurring patterns in the code base:
- Cards:
bg-white dark:bg-slate-800 rounded-xl shadow-sm, often withborder border-gray-100 dark:border-slate-700 - Inputs:
rounded-lgborders withfocus:ring-libre-green/focus:border-libre-green - Primary buttons:
bg-libre-green text-white rounded-lg(dark mode darkensbg-libre-greento#166534for contrast)