Skip to content

Theme โ€‹

mono-helper's look is Basecoat UI's. Three independent axes, all plain CSS classes on a root element, all crossing the shadow boundary through inherited custom properties:

AxisWhat it controlsDefaultOptions
Flavor (ONE, or a Basecoat style)Per-component metrics โ€” the control ladder, radius steps, paddings, text sizes, shadowsONEone, vega, nova, maia, lyra, mira, luma, sera, rhea
Theme color (the palette)Every colour token (light and, where the preset has one, dark)ONEone, basecoat
ModeLight or dark values of the palette โ€” ONE has no dark palette and stays light; Basecoat has bothlightdark

They are orthogonal: Nova metrics with the ONE palette in dark mode is a valid combination. Components don't change โ€” once a class is active, every mono-* component (light and shadow build alike) picks it up.

ONE (the EJI ONE design system) is the default: index.css applies it at :root, nothing to import or class. The other eight flavors are Basecoat's eight styles, mirrored 1:1: a style never touches tokens or fonts, only how each component is sized and shaped. Each ships as its own sheet โ€” a vega base that resets what ONE sets, then the style's own overrides for the knobs ported components read (--mono-control-height-*, --mono-button-<size>-radius, --mono-input-outline-*, โ€ฆ). Ported so far: button, input, select, date, tag-input, checkbox, radio, switch, card, textarea. node scripts/basecoat-styles.mjs --varying "^\.btn" prints what a style changes for any upstream selector.

Stylesheets โ€‹

ts
// Required โ€” tokens, both colour presets, every component, the ONE flavor.
import 'mono-helper/index.css'

// Optional โ€” a Basecoat style. Import only the one(s) you actually use.
import 'mono-helper/ui/theme/vega.css'
import 'mono-helper/ui/theme/nova.css'
// โ€ฆ maia, lyra, mira, luma, sera, rhea

// Optional โ€” the token layer on its own (for styling your own markup with the same vocabulary).
import 'mono-helper/ui/theme/tokens.css'

index.css already contains both colour presets (and Basecoat's dark values), so you never need a second import for colours โ€” only the Basecoat flavors live in their own opt-in files.

Fonts โ€‹

Every flavor names its own font โ€” ONE uses Poppins, the Basecoat styles use Geist (Geist Mono for code) โ€” but no font files ship in index.css. Self-hosted sheets are opt-in:

ts
import 'mono-helper/ui/font/poppins.css'    // sans โ€” ONE's own family
import 'mono-helper/ui/font/geist.css'      // sans โ€” the Basecoat styles' own family
import 'mono-helper/ui/font/inter.css'      // sans โ€” not a flavor default, an alternative
import 'mono-helper/ui/font/geist-mono.css' // mono โ€” code, the rich-text editor's code view

Importing a sheet makes that font universal. Besides its @font-face rules, a sheet sets one override hook on :root: --mono-font-sans or --mono-font-mono. Every flavor declares its family through that hook, --font-sans: var(--mono-font-sans, <its own stack>), and no flavor sets the hook itself. So:

You importResult
nothingeach flavor's own default, drawn only if the browser already has it (ONE: Poppins; Basecoat styles: "Geist Sans"), else the system font
inter.cssInter in every flavor, and it stays Inter when the user switches flavor
poppins.cssPoppins in every flavor, the Basecoat styles included
geist.cssGeist in every flavor, ONE included

The hook is plain inheritance, so it is live: loading a sheet at runtime (await import('mono-helper/ui/font/inter.css'), or a <link> you add or remove) switches the whole page, shadow components included, with nothing to call. Import one sheet per kind; with two sans sheets loaded, the later one in the cascade wins.

To use a font mono does not ship, set the hook yourself after your own @font-face:

css
:root { --mono-font-sans: "Figtree", ui-sans-serif, system-ui, sans-serif; }

The sheets load their .woff2 files from node_modules/mono-helper/dist/ui/font/files/, so a Vite dev server must be allowed to serve that path (server.fs.allow); otherwise it answers 403.

How activation works โ€‹

Default (no class)Activate with class
FlavorONEtheme-vega ยท theme-nova ยท theme-maia ยท theme-lyra ยท theme-mira ยท theme-luma ยท theme-sera ยท theme-rhea (and theme-one to re-apply ONE inside another flavor's subtree)
Theme colorONEtheme-color-one ยท theme-color-basecoat
Modelightdark โ€” on <html>, <body> or a .mono-theme wrapper (never on a component: .mono-card.dark is the dark colour variant)
FlavorBasecoat styleFeel
(none) / theme-oneโ€”EJI ONE design system: Poppins, 40px controls, 8px control radius, semibold labels, soft shadows
theme-vegavegaThe Basecoat default: rounded-md, h-9 controls
theme-novanovaCompact, rounded-lg, h-8 controls
theme-maiamaiaPill-shaped, roomy padding, tinted outline surfaces
theme-lyralyraSquare corners, small type, 1px focus ring
theme-miramiraDense: h-7 controls, text-xs, tight padding
theme-lumalumaPill-shaped and soft, 30% focus ring
theme-seraseraEditorial: square, uppercase, h-10 controls, wide padding
theme-rhearheaCompact, rounded-2xl, 30% focus ring

See Color for every palette value in both modes.

Tokens โ€‹

The vocabulary is shadcn/ui's, so a TweakCN theme pastes in unchanged:

css
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  /* card, popover, secondary, muted, accent, destructive, border, input, ring, chart-1..5, sidebar-* */
}
.dark {
  --background: oklch(0.145 0 0);
  /* โ€ฆ */
}

Plus mono's extension roles โ€” --success, --warning, --info, --teal, --purple, --neutral, --dark, each with a -foreground โ€” and the engine subset ported components read: --mono-spacing, --mono-text-*, --mono-radius-sm|md|lg|xl (derived from --radius), --mono-shadow-*, --mono-ring-width / --mono-ring-alpha, --mono-control-height-xsโ€ฆxxl.

Override tokens on :root / .dark, or on a wrapper that carries class="mono-theme" โ€” that class is what lets the derived tokens (the radius ladder, the ring, the legacy --theme-* aliases) re-derive below the root.

The pre-Basecoat --theme-* names

Every --theme-* variable still resolves (--theme-primary โ†’ var(--primary), the -rgb triplets are generated per mode) through ui/theme/legacy.css, which index.css bundles until 2.0 and which is an opt-in import until 3.0. Prefer the Basecoat names in new code.


Manual (CSS classes) โ€‹

html
<html>
  <!-- ONE + ONE palette -->
</html>

<html class="theme-nova theme-color-basecoat">
  <!-- nova metrics, Basecoat palette (import nova.css) -->
</html>

<html class="theme-vega theme-color-basecoat dark">
  <!-- vega + Basecoat palette, dark (ONE's palette has no dark and would stay light) -->
</html>

<div class="mono-theme theme-sera theme-color-basecoat dark">
  <!-- only this subtree is sera + Basecoat + dark -->
</div>

Automatic (Vue app) โ€‹

mono-helper exports a small theming API so you can switch at runtime. It applies the right classes for you.

Naming note: for backwards-compatibility the API calls the color axis theme (applyTheme, themes, ThemeName) and the flavor axis flavor (applyFlavor, flavors, FlavorName). applyTheme({ color, flavor }) is the clearest entry point.

1. Set a theme on app start โ€‹

ts
// main.ts
import 'mono-helper/index.css'
import 'mono-helper/ui/theme/nova.css'        // only if you use nova
import { applyTheme } from 'mono-helper'

applyTheme({ color: 'basecoat', flavor: 'nova' })   // defaults are { color: 'one', flavor: 'one' }

Shortcuts:

ts
import { applyTheme, applyFlavor } from 'mono-helper'

applyTheme('basecoat')     // change only the colour palette
applyFlavor('sera')        // change only the flavor (needs sera.css imported)
applyFlavor('one')         // back to the default flavor

2. A settings toggle with <mono-select> โ€‹

themes / flavors give you the option lists (with display names), and getCurrentTheme / getCurrentFlavor read the active values; flavorPresets[name].summary is a one-line description of each style.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { applyTheme, getAllFlavors, getCurrentFlavor, type FlavorName } from 'mono-helper'
import 'mono-helper/ui/select'

const flavor = ref<FlavorName>(getCurrentFlavor())
const flavorOptions = computed(() => getAllFlavors())
function onFlavor(e: CustomEvent) {
  flavor.value = e.detail.value
  applyTheme({ flavor: flavor.value })
}
</script>

<template>
  <mono-select :model-value="flavor" :options.prop="flavorOptions" key-value="name" display-value="displayName" @change="onFlavor" />
</template>

3. Persist the choice โ€‹

ts
import { applyTheme, isValidTheme, isValidFlavor, DEFAULT_THEME, DEFAULT_FLAVOR, type ThemeName, type FlavorName } from 'mono-helper'

export function restoreTheme() {
  if (typeof document === 'undefined') return     // SSR guard
  const savedColor = localStorage.getItem('app-theme-color')
  const savedFlavor = localStorage.getItem('app-theme')
  applyTheme({
    color: isValidTheme(savedColor ?? '') ? (savedColor as ThemeName) : DEFAULT_THEME,
    flavor: isValidFlavor(savedFlavor ?? '') ? (savedFlavor as FlavorName) : DEFAULT_FLAVOR,
  })
}

4. React to changes โ€‹

Every change dispatches a theme-changed event on window:

ts
window.addEventListener('theme-changed', (e) => {
  const { color, themeData, flavor, flavorData } = (e as CustomEvent).detail
})

SSR note: the API touches document, so call it on the client only.


Scrollbar โ€‹

Every mono scroll area โ€” dialog bodies, dropdown panels, the table's horizontal scroller, the filter builder โ€” paints the same scrollbar in place of the browser's own: an 8px pill thumb on a transparent track. It is one design shared by every theme and flavor, so switching palette or structure never changes it.

The thumb colour derives from currentColor โ€” the text colour of the panel the scroll area sits in. That is what keeps it right everywhere with no configuration: dark on a white modal, pale on a panel a flavor paints in a brand colour, and correct on a theme that does not exist yet.

VariableDefaultControls
--mono-scrollbar-size8pxRail width (and height, for horizontal bars)
--mono-scrollbar-radius999pxThumb corner radius
--mono-scrollbar-thumbcurrentColor at 22%Thumb fill
--mono-scrollbar-thumb-hovercurrentColor at 38%Thumb fill under the pointer
--mono-scrollbar-tracktransparentThe groove behind the thumb

Set them on any ancestor to retheme a subtree, or on :root for the whole app:

css
:root {
  --mono-scrollbar-size: 6px;
  --mono-scrollbar-thumb: color-mix(in oklab, var(--primary) 30%, transparent);
}

On your own elements โ€‹

Add class="mono-scrollbar" to any scrollable element of your own to give it the same bar:

html
<div class="mono-scrollbar" style="max-height: 12rem; overflow-y: auto">โ€ฆ</div>

Firefox exposes no numeric scrollbar width โ€” thin is all it has โ€” so --mono-scrollbar-size and --mono-scrollbar-radius apply in Chrome, Edge and Safari, while Firefox gets a thin bar in the same thumb colour.

Note also that Chrome ignores all ::-webkit-scrollbar styling on an element that sets scrollbar-width or scrollbar-color. If you want a different scrollbar, override the tokens above rather than writing those properties yourself โ€” setting them would switch Chrome back to its own bar.


API reference โ€‹

ExportPurpose
applyTheme(name | { color?, flavor? })Set the colour palette and/or the flavor
applyFlavor(name)Set only the flavor
themes / flavorsRecords of { name, displayName } for colour presets / flavors
themePresets / flavorPresetsThe authored palettes (hex, light + dark) / each flavor's summary and control ladder
DEFAULT_THEME / DEFAULT_FLAVOR'one' / 'one'
getCurrentTheme() / getCurrentFlavor()Read the active colour / flavor
getAllThemes() / getAllFlavors()Arrays of the configs (for dropdowns)
isValidTheme(s) / isValidFlavor(s)Type-guards for stored strings
resetTheme() / resetFlavor()Reset to the defaults
createThemeClass(name) / createFlavorClass(name)Build the CSS class string (e.g. for SSR markup)
ThemeName / FlavorName / ThemeMode'one' | 'basecoat' / 'one' | 'vega' | โ€ฆ | 'rhea' / 'light' | 'dark' | 'system'

The API applies classes to <body> and mirrors the state on <html> via data-theme-color / data-theme. If you only ever theme statically, skip the API and use the CSS classes from the Manual section.