xPulse
πŸ‡¬πŸ‡§ EN

@xpulse/theme – Component Spec

Status: CONCEPT Β· Created March 2026 Parent: GLOBAL_concept-ecosystem.md Supersedes: COMP_ui_spec.md, COMP_ui_concept.md (theme layer) Companion document: GLOBAL_concept-brand.md


Overview

CSS design system for all xPulse tools – structure, colours, effects and animations in a single package. No separate @xpulse/theme – everything lives in @xpulse/theme.

A theme is a CSS file that defines custom properties, component styles, hover/focus effects and animations. Themes are found automatically via discovery – no manual registration required.

What the package provides:


Custom Properties – Naming Convention

All custom properties follow the --xpulse-* prefix – unambiguous, no conflict with other libraries, consistent with the BEM approach.

/* Colours */
--xpulse-bg
--xpulse-surface
--xpulse-surface-strong
--xpulse-border
--xpulse-muted
--xpulse-text
--xpulse-text-dim
--xpulse-accent
--xpulse-accent2
--xpulse-accent-soft
--xpulse-warning
--xpulse-danger
--xpulse-shadow
--xpulse-input-bg
/* Typography */
--xpulse-font-mono
--xpulse-font-serif
--xpulse-font-size-body
--xpulse-font-size-display
--xpulse-font-size-h2
--xpulse-font-size-h3
--xpulse-font-size-lead
--xpulse-font-size-meta
/* Effects */
--xpulse-transition
--xpulse-radius
--xpulse-hover-opacity
/* Buttons */
--xpulse-button-text
--xpulse-button-radius
--xpulse-button-font-size
--xpulse-button-min-height
--xpulse-button-padding-y
--xpulse-button-padding-x
/* …as well as size variants: xs, sm, md, lg */
--xpulse-button-hover-opacity
--xpulse-button-active-scale

Themes

dark.css – Standard xPulse Theme

File: public/css/themes/dark.css

CSS selector: html[data-theme="dark"] (plus :root as default fallback).

Excerpt of the most important tokens:

:root,
html[data-theme="dark"] {
--xpulse-bg: #0d0d0d;
--xpulse-surface: #141414;
--xpulse-surface-strong: #1b1b1b;
--xpulse-text: #c8c8c8;
--xpulse-text-dim: #555;
--xpulse-border: #222;
--xpulse-muted: #444;
--xpulse-accent: #8703b0;
--xpulse-accent2: #7eb8a4;
--xpulse-accent-soft: rgba(135, 3, 176, 0.18);
--xpulse-warning: #b89a3e;
--xpulse-danger: #c0606a;
--xpulse-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
--xpulse-input-bg: #141414;
/* …font size tokens, button tokens etc. */
}
/* System preference fallback if no data-theme is set yet */
@media (prefers-color-scheme: dark) {
html:not([data-theme]) { ... }
}

light.css – Light Theme

Analogous to dark.css, selector html[data-theme="light"].

App Theme Extension

An app places its own CSS overrides under src/themes/:

/* src/themes/app.css */
.xpulse-shell {
width: min(1100px, calc(100% - 32px));
margin: 32px auto;
}

app.css is included as a separate <link> in addition to the theme bundle – it does not replace the base theme but extends it.


CSS Bundle and Discovery

All CSS files are combined into a single bundle at init() and served via a single route:

GET /_theme/css/xpulse.css ← minified all-in-one bundle
GET /_theme/theme.js ← optional browser runtime script

No separate loading of individual theme files in the browser – one request, one bundle.

Bundle Build Order

1. public/css/base.css ← reset, body, noise overlay, animations
2. public/css/components/*.css ← all components (via @import in components.css)
3. public/css/themes/*.css ← built-in theme tokens (dark, light)
4. src/themes/*.css ← app-specific overrides (optional)

Theme Name Discovery

Theme names are extracted from the bundled CSS via regex – no filename parsing, no manual registration:

[data-theme="dark"] β†’ 'dark'
[data-theme="light"] β†’ 'light'
[data-theme="app"] β†’ 'app'

Only themes that appear as selectors in the CSS are considered known.

App Theme Overrides

App CSS under src/themes/ is appended to the end of the bundle. The app theme link is additionally included as a separate <link> (via themeAssets()).


xpulse.json Configuration

{
"theme": {
"default": "dark",
"allow": ["dark", "light"],
"cache": {
"enabled": true,
"ttl": 0
}
}
}
Key Description Default
theme.default Active theme on startup "dark"
theme.allow Which themes the user may choose. [] = all discovered themes allowed []
theme.cache.enabled Cache the CSS bundle (see caching section) true
theme.cache.ttl Cache expiry in seconds. 0 = no expiry 0

If allow is empty or not set – all discovered themes are permitted. If allow contains exactly ["dark", "light"] – toggle button instead of select. If allow contains more than 2 themes – select dropdown. If allow contains only one theme – no switcher in the UI.

Component Default (`@xpulse/theme/xpulse.json`)

{
"theme": {
"default": "dark",
"allow": [],
"cache": {
"enabled": true,
"ttl": 0
}
}
}

Server API (Node.js)

import theme from '@xpulse/theme';
// Initialise – build bundle, register routes
await theme.init();
// With options
await theme.init({ root: process.cwd(), force: false });
// root β†’ where to look for src/themes/ (default: cwd)
// force β†’ ignore cache and rebuild bundle
// Active default theme (from config)
theme.current(); // β†’ 'dark'
// All themes discovered in the CSS
theme.available(); // β†’ ['dark', 'light']
// Permitted themes (filtered by theme.allow)
theme.allowed(); // β†’ ['dark', 'light']
// Existence check
theme.has('dark'); // β†’ true
// Theme switch (server-side)
theme.set('light');
// Toggle (only when exactly 2 themes in allowed())
theme.toggle();
// URLs
theme.href(); // β†’ '/_theme/css/xpulse.css'
theme.scriptHref(); // β†’ '/_theme/theme.js'

How the Toggle Works

Theme switching is controlled by the data-theme attribute on <html> – CSS selectors html[data-theme="dark"] take effect without any JS-side DOM styling. As a safeguard, the class theme-${name} is also set.

<html data-theme="dark" class="theme-dark"> ← default
<html data-theme="light" class="theme-light"> ← after toggle

Anti-FOUC: the inline script generated by themeAssets() sets data-theme and the class in the <head> before the first paint occurs.

User preference is stored in the browser's localStorage:

// localStorage key: xpulse_theme
'dark' | 'light' | ...

Emitted Events

Lifecycle

Event Payload When
themes:load:start β€” Bundle build begins
theme:basics:loaded β€” base.css loaded
theme:components:loaded β€” All component CSS loaded
theme:loaded { name } Individual theme CSS loaded
themes:concatenated β€” All parts merged
themes:minified β€” Bundle minified
themes:cached { path } Bundle written to disk
themes:load:end β€” Bundle build complete
theme:ready { theme, available } init() fully complete

Cache

Event Payload When
themes:cache:hit { path } Valid cache file found
themes:cache:miss β€” No cache, bundle is rebuilt
themes:cache:loaded { serveCount } Bundle request served

Runtime

Event Payload When
theme:changed { from, to } after theme.set() / theme.toggle()
theme:warning { type, … } Invalid theme, toggle not possible etc.
theme:error { type } No themes found in bundle

Caching

The CSS bundle is written to disk on the first init() and read directly from there on subsequent starts – no repeated reading and minifying.

var/cache/theme/xpulse.css

Behaviour by Environment

Situation Behaviour
NODE_ENV !== 'production' (dev mode) Cache always skipped, bundle rebuilt
Production, cache file present Cache is loaded (event: themes:cache:hit)
Production, no cache file Bundle is built and cached (event: themes:cache:miss)
init({ force: true }) Cache is ignored, bundle rebuilt
theme.cache.ttl > 0 Cache file too old β†’ rebuilt

Configuration

{
"theme": {
"cache": {
"enabled": true,
"ttl": 0
}
}
}

ttl: 0 means no expiry – cache persists until the next manual build or force: true.


CSS Reset + Base

Extracted from pwa/public/css/base.css – completely generic:

/* Reset */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }
/* Base */
body {
background: var(--xpulse-bg);
color: var(--xpulse-text);
font-family: var(--xpulse-font-mono);
font-size: 13px;
line-height: 1.7;
}

Noise Overlay

The characteristic brand effect – part of every xPulse tool:

body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}

Disabled in the light theme:

html.theme-light body::before { display: none; }

Animations

@keyframes xpulse-fadeIn /* opacity 0→1 + translateY 8px→0 */
@keyframes xpulse-msgIn /* opacity 0→1 + translateY 4px→0 (subtler) */
@keyframes xpulse-pulse-dim /* opacity 0.4↔1, waiting states */
@keyframes xpulse-dots /* '' β†’ '.' β†’ '..' β†’ '...' loading indicator */
@keyframes xpulse-shake /* horizontal shake for error feedback */
@keyframes xpulse-spin /* 360Β° rotate for spinner */
@keyframes xpulse-typingBounce /* typing indicator dots */

Utility classes:

.xpulse-fade-in { animation: xpulse-fadeIn 0.3s ease both; }
.xpulse-spin { animation: xpulse-spin 1s linear infinite; }

Component Classes

All classes follow the .xpulse-* prefix – consistent with the custom properties.

Typography

.xpulse-h1 /* Heading 1 – Fraunces, large, accent */
.xpulse-h2 /* Heading 2 */
.xpulse-h3 /* Heading 3 */
.xpulse-label /* Uppercase label – mono, small, letter-spacing */
.xpulse-text /* Body text – mono, 13px, line-height 1.6 */
.xpulse-text-sm /* Small text – 11px */
.xpulse-code /* Code block – mono, surface BG, border */
.xpulse-code-inline /* Inline code */

Layout

.xpulse-container /* max-width, padding, centered */
.xpulse-stack /* flex-column with gap */
.xpulse-row /* flex-row with gap */
.xpulse-divider /* <hr> equivalent */

Buttons

.xpulse-btn-primary /* accent BG, uppercase, full width */
.xpulse-btn-secondary /* border-only, hover β†’ accent */
.xpulse-btn-danger /* border danger, hover β†’ filled */
.xpulse-btn-icon /* icon-only, no border */

Inputs & Fields

.xpulse-input /* text/password input – styled, focus β†’ accent border */
.xpulse-field /* container with gap */
.xpulse-field-label /* uppercase, small */
.xpulse-field-hint /* dim, small */

Cards & Elements

.xpulse-card /* surface box with border, padding, border-radius */
.xpulse-badge /* small label element */
.xpulse-tag /* tag/pill */
.xpulse-table /* table with correct spacing */
.xpulse-menu-btn /* menu trigger button */
.xpulse-dropdown /* dropdown container */

Miscellaneous

.xpulse-spinner /* inline loading spinner */

Chat-specific (stays in xpulse-chat)

The following classes and properties do not belong in the package – they are specific to the chat app and remain in its local CSS:

/* Properties */
--xpulse-sidebar-width
--xpulse-topbar-height
/* Classes */
.xpulse-status-dot /* chat peer status */
.xpulse-status-badge /* chat peer status badge */
.xpulse-waiting-dots /* chat typing indicator */

Usage

<!-- Recommended: use template methods -->
{% themeAssets() %}
{% themeSwitcher() %}

themeAssets() renders:

  1. <link> to the CSS bundle /_theme/css/xpulse.css
  2. Optional: <link> to /_theme/app.css if an app theme is present
  3. Inline anti-FOUC script (sets data-theme + class immediately in <head>)
  4. <script defer src="/_theme/theme.js"> for the browser toggle
// Server: initialise the theme system
import theme from '@xpulse/theme';
await theme.init();

Package Structure

@xpulse/theme/
src/
index.js ← server API: init, set, toggle, current, available, has, href, scriptHref
template-methods/
theme-assets.js ← themeAssets() template method
theme-switcher.js ← themeSwitcher() template method
theme-token-editor.js ← themeTokenEditor() template method
public/
css/
base.css ← reset, body styles, noise overlay, animations
components.css ← @import list of all components
components/
boxes.css
brand.css
buttons.css
cards.css
forms.css
navigation.css
…
themes/
dark.css ← built-in dark theme
light.css ← built-in light theme
js/
theme.js ← browser runtime: toggle, token editor
docs/
de/
index.md
guide.md
api.md
styleguide/
en/
…
README.md
package.json
xpulse.json

Dependencies

@xpulse/theme
└── @xpulse/event ← emit theme:ready, theme:changed

Migration from Previous CSS Custom Properties

Existing xPulse projects still use the old unprefixed properties. Migration at v1.4.0 (component based):

Old New
--bg --xpulse-bg
--surface --xpulse-surface
--border --xpulse-border
--muted --xpulse-muted
--text --xpulse-text
--text-dim --xpulse-text-dim
--accent --xpulse-accent
--accent2 --xpulse-accent2
--danger --xpulse-danger
--mono --xpulse-font-mono
--serif --xpulse-font-serif
.btn-primary .xpulse-btn-primary
.btn-secondary .xpulse-btn-secondary
.btn-danger .xpulse-btn-danger
.btn-icon .xpulse-btn-icon
.field .xpulse-field
.field-label .xpulse-field-label
.field-hint .xpulse-field-hint
@keyframes fadeIn @keyframes xpulse-fadeIn
@keyframes spin @keyframes xpulse-spin

Chat-specific properties (--sidebar-width, --topbar-height) and classes (.status-dot, .status-badge, .waiting-dots) remain in the chat CSS without renaming.


Documentation

The component documentation is rendered as a standalone HTML page – analogous to Bootstrap/Tailwind: live preview + code snippet side by side.

xpulse.one/doc/component/theme/

Structure:

The docs HTML lives in docs/de/components.html + docs/en/components.html as standalone HTML docs – rendered directly in the docs section of xpulse.one via <iframe> or embedded inline (see GLOBAL_concept-docs.md).


Consumers

Project Status Note
chat.xpulse.one βœ… Source Migration at v1.4.0
xpulse.one πŸ”œ First smoke test Determines whether the package is truly generic
board.xpulse.one πŸ’‘ Planned Starts after smoke test

Future: ui.xpulse.one

Visual theme generator – a standalone xPulse tool on its own subdomain:

xpulse.one/doc/component/theme/ ← documentation + live preview
ui.xpulse.one ← theme generator + export

Status: idea – conceivable after board.xpulse.one.

en/spec.md 2026-04-13