Design tokens & the <lw-*> vocabulary
Who this page is for: anyone writing templates or styles in a weaver or a distribution — with Tailwind, with Bootstrap or another CSS framework, or with no framework at all.
Every colour and font in a LoomWeaver UI comes from a small set of plain CSS custom properties
(the --lw-* tokens), plus CSS class contracts (.lw-*) and host elements (<lw-*>). Because the
tokens are ordinary CSS variables, they work with any CSS framework. Tailwind is one way to
consume them — not a requirement.
Which path am I on?
| You are… | You consume the tokens through… |
|---|---|
| Using Bootstrap, another framework, or no framework | the pre-compiled @loom/shell/styles/shell.css — no Tailwind install needed. Setup: bringing your own CSS framework. loom theme --preset bootstrap maps the tokens onto Bootstrap’s own variables so both worlds share one palette. In your own CSS, read a token directly: color: var(--lw-content). |
| Using Tailwind | the setup below — every token doubles as a semantic utility (bg-surface, text-brand, …). |
| Writing a sandboxed (iframe) plugin | the sandbox UI kit: <link rel="stylesheet" href="/sandbox-kit/lw-sandbox.css"> — the same tokens and .lw-* contracts; the host pushes its resolved token values into your iframe over RPC. |
Everything below applies on all three paths, because the token names and class contracts are
identical either way. The one rule everywhere: use only the semantic tokens, never raw palette
colours (no bg-slate-800, no hard-coded hex for UI chrome). All colour tokens are
WCAG-AA-verified; which tone is for what (text vs. fill vs. icon) →
accessibility.md.
Setup with Tailwind
Only needed if you write Tailwind utilities of your own — the other two paths need no setup.
- Entry point: your app’s
src/styles.css, building against the published@loom/shellpackage:(Inside the LoomWeaver monorepo the same file uses source paths instead:@import 'tailwindcss';@source '../node_modules/@loom/shell'; /* also scan the shell library's classes */@source './app';@import '@loom/shell/styles/theme.css';@source '../../../libs/core/shell/src'+@import '../../../libs/core/shell/src/lib/styles/theme.css'.) - Tailwind reports no error for a mistyped/non-existent class — it is silently ignored. The
ESLint guardrail (
no-unregistered-classes, see below) catches that. - No
tailwind.config.js— everything is CSS-first. Built via@tailwindcss/postcss(.postcssrc.json). - Tokens + theme: one source =
libs/core/shell/src/lib/styles/theme.css. - Dark mode:
ThemeServicetoggles thedarkclass on<html>. The--lw-*ladder flips in:root.dark; the semantic utilities stay identical (nodark:prefix needed).
Token precedence & cascade layers
The --lw-* tokens live in CSS cascade layers, declared at the top of theme.css:
@layer lw-base, lw-plugin-theme, lw-tenant-theme;A later layer wins → product default (lw-base) < plugin (lw-plugin-theme) < tenant
(lw-tenant-theme). A plugin contributes tokens through ctx.contributeTheme(tokens, dark?)
(injected into lw-plugin-theme as :root/:root.dark, never inline styles — those would beat
every layer); the optional second dark argument overrides tokens in dark mode only (worked example
in authoring a weaver → custom theme). A
distribution/tenant theme CSS uses lw-tenant-theme (the devkit theme generator emits that) and
thereby overrules every plugin. Layer order beats specificity, so that also holds against a
:root.dark token from a lower layer. A minimal tenant override:
@layer lw-tenant-theme { :root { --lw-brand: #2e96c9; --lw-accent: #c59a2f; } :root.dark { --lw-brand: #3aa9dd; --lw-accent: #d8b45a; }}Dimensions: there are no tokens, and how to change them anyway
The token vocabulary is colour and type only — 27 colours plus the two font families. Sizes, radii, spacing and border widths are ordinary utility classes in the shell’s own templates, and that is a decision rather than an omission: tokenising every number would turn every rule of our chrome into a promise we could never revise without breaking somebody’s product. The one measurement that is a contract is the shared bar-control height (see below), and it exists because it drifted twice without one.
One measurement is not a size question at all and therefore is in the contract: whether the host
insets a surface from its pane edges. A surface that owns its edges — a viewer, a canvas, a map —
declares padded: false instead of fighting the
inset from CSS. How wide the inset is stays a stylesheet question, below.
If your product must change a size anyway, you can, with plain CSS and no API. Everything the shell
paints lives in a cascade layer, and unlayered CSS beats layered CSS regardless of specificity or
load order — so a normal stylesheet in your app wins by construction, without !important:
/* src/styles.css — after the shell import, and NOT inside a @layer */.lw-icon-btn { border-radius: 0; height: 2.5rem; } /* squarer, roomier buttons */.lw-segmented { height: 3rem; }lw-shell-rail nav { width: 64px; } /* a wider activity bar */Two kinds of target, with different promises:
- The
.lw-*class contracts (.lw-btn,.lw-icon-btn,.lw-field,.lw-segmented,.lw-badge, …) are documented and stable — the same contract a plugin paints against. - The element tags (
lw-shell-rail,lw-shell-panel,lw-content-area,lw-pane-tab-strip, …) reach the structure, but they are not a versioned contract. They move when we restructure the chrome, and nothing warns you. Targeting them is supported in the sense that it works and we will not fight it; it is not supported in the sense that we promise it keeps working.
Two honest limits. The rules do not reach a sandboxed plugin’s surface — that is a separate document which receives our compiled stylesheet and the token push, not your stylesheet. And a size you change is yours to re-check on upgrade; colours you set through tokens are not.
If you find yourself writing more than a handful of these rules, say so — a small, named set of dimension tokens is the answer at that point, and knowing which sizes real products ask for is exactly what decides the set.
Our own showcase is the first measurement of that. A look that only recolours needs two of these rules. A look that also changes the geometry needed nine (fourteen selectors), and they clustered tightly: corner radii on the control classes, the control height, the activity-bar width, the top-bar and tab-strip heights, and the panel gap. So the shape of a dimension-token set is already visible — it is small, and it is about controls and chrome bands rather than about spacing everywhere. We are holding off until a second product asks for the same handful, because one data point names a set that happens to fit one design rather than the set products actually need.
Semantic colour utilities
The naming rule is mechanical: every utility stem below is backed by the CSS custom property
--lw-<stem> — brand → --lw-brand, surface-raised → --lw-surface-raised, on-negative →
--lw-on-negative. The --lw-* names are what a tenant theme or ctx.contributeTheme overrides;
theme.css maps each to a Tailwind --color-<stem> so the utilities exist. Every token exists as
bg-*, text-*, border-*. Use only these names for colour — never raw hex/palette values.
Not on Tailwind? The stems in these tables are still your vocabulary — as CSS variables:
surface-raised is var(--lw-surface-raised) in your own stylesheet, brand is var(--lw-brand)
in a Bootstrap override. The pre-compiled shell.css already contains every utility the host chrome
itself uses, so the shell renders correctly without any of this.
Brand / accent
| Utility stem | Meaning |
|---|---|
brand |
primary brand (LoomWeaver blue) — logo/fills/icons |
brand-strong |
brand, stronger (hover/active) |
brand-text |
brand as text — deeper tone, WCAG AA (≥4.5:1) on surfaces |
brand-fill |
brand as a filled surface with a label (primary button) — deeper, so on-brand text stays AA |
on-brand |
text colour on brand surfaces (contrast) |
accent |
accent (gold) |
accent-strong |
accent, stronger |
Surfaces & borders (elevation ladder)
| Utility stem | Meaning |
|---|---|
surface |
the app’s base background |
surface-raised |
raised surface (card, active tab, panel body) |
surface-overlay |
overlay/popup/chrome controls (dropdown, toolbar buttons) |
field |
input-field background |
border |
default divider/border (border-border) |
Text
| Utility stem | Meaning |
|---|---|
content |
primary text |
content-muted |
secondary text (labels, metadata) |
content-faint |
tertiary/decorative (placeholders, disabled) |
States
| Utility stem | Meaning |
|---|---|
positive |
success / valid (green) |
on-positive |
text on positive surfaces (success button) |
negative |
error / destructive (red) — error text & icon |
negative-fill |
danger button fill — deeper than negative, so on-negative text stays AA |
on-negative |
text on negative surfaces (danger button) |
caution |
warning (amber) |
on-caution |
text on caution surfaces (warning button) |
info |
info (blue) |
on-info |
text on info surfaces (info button) |
Tooltip (special case, normally not used directly)
tooltip / tooltip-content — reserved for <lw-tooltip>.
Typography
--lw-font-sans (the UI font, driving body plus Tailwind’s font-sans utility) and --lw-font-mono
(code) are theme tokens as well — a theme/tenant can set the brand font through
ctx.contributeTheme / tenant CSS, like any colour. Font size is deliberately NOT a token but a
user setting (the built-in “text size” under Settings → Options scales the whole UI via the :root
font-size) — it is not part of the theme contract; branding and accessibility stay cleanly
separated.
Host building blocks (<lw-*>) instead of hand work
- Icons:
<lw-icon name="add" size="1rem" />. Names come from the semantic first-party registrylibs/core/shell/src/lib/elements/icon/loom-icons.ts(e.g.add,close,menu,chevronDown,check,reset,sort,info). Contributable names: a distribution adds its own withprovideIcons({ name: svgRef })(from@loom/shell), a weaver adds them at runtime withctx.contributeIcons({ name: svg }). A distribution may also replace a first-party glyph by naming it, and the replacement travels into sandboxed surfaces; a weaver never can, so an installed plugin cannot repaint the chrome. Contributed SVG is sanitised at registration (DOMPurify, SVG profile —<script>/event handlers/javascript:removed; markup that does not survive is discarded). A weaver then references the name in its contributions (icon: 'name') or draws it directly by tag.<lw-icon>is a framework-agnostic custom element: it resolves the name through the module-global icon registry (no Angular DI, no@ng-iconsruntime), so it is usable inside your own weaver body too (schemas: [CUSTOM_ELEMENTS_SCHEMA]). The SVG usescurrentColor→text-*tokens colour it.sizesets width/height; an unknown name renders nothing. - Tooltip (
<lw-tooltip>): a framework-agnostic custom element — like<lw-icon>, usable inside your own weaver body (by tag, no@loom/shellimport; in an Angular weaver setschemas: [CUSTOM_ELEMENTS_SCHEMA]). As the last child of aclass="relative"trigger:Attributes:<button type="button" class="relative lw-icon-btn size-9"><lw-icon name="settings" size="1.25rem" /><lw-tooltip [text]="'settings.title' | transloco" position="bottom"></lw-tooltip></button>text(what is shown — a key or a literal; the host translates nothing),position(top|bottom|left|right), optionallydelay-ms,max-width. Only shown on hover-capable pointers (never touch). The bubble is a[popover]in the browser top layer (the element callsshowPopover) and is positioned in JS (no CSS anchor positioning): cursor-anchored on mouse hover (like a nativetitle— it appears where the mouse is, which matters for wide triggers such as list rows), and anchored to the trigger element viapositionon keyboard focus. It is therefore not clipped by atransform/overflowancestor (a virtual-scroll row) or by region z-order. The look lives as.lw-tooltip-bubbleintheme.css(like.lw-btn), so it themes automatically. - Single-value selection (
<lw-select>+<lw-option>): a framework-agnostic custom element (like<lw-tooltip>) for “one value out of a set” (language, theme, settings) — a select, not a menu (actions are<lw-menu>). Options are light-DOM children (like native<select><option>), and it works in Angular and plain HTML/iframes:Attributes:<lw-select [attr.label]="'language.label' | transloco" [attr.value]="lang()" (lw-select-change)="onSelect($event)">@for (l of languages(); track l.value) {<lw-option [attr.value]="l.value" [attr.icon]="l.flag">{{ l.label }}</lw-option>}</lw-select>label(accessible name),value(current value),placeholder,disabled;<lw-option>:value, optionallyicon(v1 = a literal glyph/emoji — registry icon names follow with the<lw-icon>element), label = text content. Thelw-select-changeevent (detail.value) fires only on user selection, not whenvalueis set programmatically (no feedback loop). ARIA listbox keyboard handling (↑/↓/Home/End/Enter/Esc + typeahead) and CSS anchor positioning sit inside the element; the look lives as.lw-select-*intheme.css. - Menus/context menus (
<lw-menu>): on host chrome a weaver does not draw the menu itself — it contributes items throughctx.registerMenuItem({ menu, command, when? })into a named slot (e.g.content/tab/context), or attaches amenu?slot to its rail/bar/view item (region-agnostic); the host renders the<lw-menu>(a framework-agnostic custom element) at the cursor. The look lives as.lw-menu*intheme.css. Command-referenced (so it crosses the sandbox boundary),when= coarse context filtering. An item automatically shows its command’s icon + shortcut hint;checkedWhenturns it into amenuitemcheckbox(e.g. “Pinned ✓” instead of a Pin/Unpin pair). Exception — your own sandbox surface: there the plugin draws<lw-menu>itself (from the sandbox UI kit —/sandbox-kit/lw-elements.global.jsdefines the whole<lw-*>family,/sandbox-kit/lw-sandbox.csssupplies the compiled look) and positions it withopenAt(x, y)at the iframe-local cursor — no cross-frame work, no RPC. - Buttons in bar/rail: register them declaratively as a
BarButtonItem/rail item (the host renders button + tooltip from data) — no need for your own button component per icon. - Buttons in content (dialogs, about/settings, toasts): put the
<lw-button>primitive on a real<button>/<a>— it keeps the native semantics and uses semantic tokens only:Variants<button lwButton variant="primary" (click)="save()">Save</button><button lwButton variant="ghost" size="sm" iconOnly aria-label="…"><lw-icon name="close" size="1rem" /></button>primary·default·success·danger·warning·info·ghost; sizesmd(default) ·sm;iconOnly= square. The directive must be in the component’simports(imports: [LwButton], from@loom/shell) — unlike the<lw-button>element and.lw-btnclasses below, which need no import. - From an SDK-only plugin (
scope:weaver): thelwButtondirective lives in the shell and is not importable across the Nx boundary. Two framework-free ways, both on the same.lw-btncontract and therefore theme-/tenant-reactive:The contract: base class<!-- (a) the <lw-button> custom element — by tag, role=button + keyboard included --><lw-button variant="primary" (click)="save()">Save</lw-button><lw-button variant="ghost" size="sm" icon-only aria-label="…"><!-- icon --></lw-button><!-- (b) the .lw-btn CSS class contract directly on a native <button> --><button class="lw-btn lw-btn--primary">Save</button><button class="lw-btn lw-btn--default lw-btn--sm">Cancel</button>lw-btn(which already carries the default sizemd— height, padding, font — solw-btn lw-btn--<variant>alone already sits correctly) plus one variant modifier (lw-btn--<variant>);lw-btn--smmakes it smaller (optional),lw-btn--iconmakes it square. The variant names areLwButtonVariantfrom@loom/plugin-sdk. Defined intheme.css; thelwButtondirective emits exactly these classes (one source of truth). There is nolw-btn--md—mdis the default. - Icon buttons in chrome (
.lw-icon-btn): the shared core of the subtle icon buttons in host toolbars/strips (pane toolbar, tab-strip controls, sidebar header, panel actions): centred,text-content-faint, hover =bg-surface-overlay+text-content. Size and radius stay a utility decision at the call site (h-6 w-6,rounded-md, …) — the contract only supplies tone + hover in one place. For labelled buttons keep using.lw-btn. - Segmented selection (
.lw-segmented/.lw-segmented-item): a container with one button per option (theme mode, text size). The selected state hangs offaria-pressed— the a11y state is the style hook, there is no--activeclass. Pitfall:.lw-segmented-itemis by contract a square icon field (7×7); for text segments override the size at the call site (w-auto px-3 text-sm) — “size stays per-site utilities”, as with.lw-icon-btn. Shared bar-control height:.lw-segmentedand.lw-select-triggerboth pinh-8.5(34px) — controls standing next to each other in a bar are therefore the same height by construction; new bar controls should carry the same height. - Text fields (
.lw-field): put the classlw-fieldon a native<input>/<textarea>/<input type="date">— it themes through the tokens (tenant-/theme-reactive, like.lw-btn).lw-field--invalidmarks a validation error. Deliberately no wrapper element: a native field already has full semantics, keyboard and a11y — unlike<lw-select>/<lw-button>(which encapsulate non-native elements), a text field only needs styling, not a web component (WC batch ①).ctx.ui.promptuses the same contract. - Checkbox / toggle (
.lw-checkbox/.lw-switch): class contracts on a native<input type="checkbox">—.lw-checkboxthemes the tick/fill viaaccent-color;.lw-switchdraws a toggle switch (track = the input, knob =::before; addrole="switch"in the markup). Native → full keyboard/a11y, no wrapper element. Settings controls use them: thecontrol.kindvaluestoggle(.lw-switch) andtext(.lw-field) alongsideselect/button/component(WC batch ①). - Radio / slider (
.lw-radio/.lw-range): class contracts on a native<input type="radio">(accent-color, grouped vianame) and<input type="range">(tinted thumb via the vendor pseudo-element). Native → arrow keys/a11y, no custom element. The settingscontrol.kindslider(a number withmin/max/step) renders.lw-range+ a value readout (WC batch ③). Arole="slider"needs an accessible name (aria-label). - Progress (
.lw-progress/<lw-progress-ring>): the bar =.lw-progresson a native<progress value max>(class contract;role="progressbar"+ a11y are native, only fill/track are tinted). The ring =<lw-progress-ring value max size>as a custom element (there is no native circular element; it shows the percentage in the centre;sizeis a CSS length such as2.5rem[default] — a bare number is invalid and silently discarded by the browser). Both need an accessible name (aria-label/aria-labelledby, WCAGaria-progressbar-name). Indeterminate =<lw-spinner>(WC batch ②). - Badge (
.lw-badge): a status/count pill on a<span>(class contract, pure presentation, no custom element). Tints.lw-badge--brand/--success/--dangeralongside the neutral base (WC batch ②). - Divider / collapsible (
.lw-divider/.lw-collapsible): class contracts on a native<hr>(implicitrole="separator";.lw-divider--verticalfor a vertical line) and a native<details>/<summary>— native expand/collapse semantics, keyboard and a11y without JS; the class tints header + body (.lw-collapsible-body) and replaces the marker with a chevron that rotates on[open](WC batch ④). - The pattern for future
<lw-*>primitives: wherever a host building block should also be usable by plugins, its appearance lives as a.lw-*CSS class contract intheme.css(the Bootstrap principle: styles only), and the Angular component/directive is the thin wrapper on top. That keeps the contract consumable across the Nx boundary without a@loom/uiimport kit (refining the boundary).
Busy indicator
<lw-spinner size="1.5rem" [label]="…" />— a pure CSS spinner oncurrentColor(brand colour by default). Used by the progress dialog (ctx.ui.progress/withProgress), and anywhere else for “this is running”.
Rich text / Markdown
<lw-markdown source="…">— a framework-agnostic custom element (like<lw-tooltip>/<lw-select>/<lw-menu>): it renders Markdown as sanitised HTML (viamarked+ DOMPurify) with Tailwind Typography styling. No formatting logic of your own. Usable in the weaver body by tag as well (no@loom/shellimport; setCUSTOM_ELEMENTS_SCHEMA). Angular property binding[source]and plain HTMLsource="…"drive the same render. It uses theprose-lwutility (intheme.css), which maps the--tw-prose-*colours onto our--lw-*tokens → it flips automatically in dark mode (noprose-invert/dark:).- For formattable text in dialogs/about/plugin content, always use
<lw-markdown>instead of hand-written HTML.
Popups from clipped regions (overflow-hidden)
Bar/panel headers have overflow-hidden. An absolutely positioned popup is clipped there.
The fix: position: fixed + CSS anchor positioning — anchor-name on the trigger, position-anchor +
position-area on the popup. Example: <lw-select> (libs/core/shell/src/lib/elements/select/lw-select.element.ts
sets anchor-name/position-anchor; the position-area lives in libs/core/shell/src/lib/styles/theme.css).
Caution — a transform ancestor beats position: fixed: if an ancestor has a transform (e.g. a
CDK virtual-scroll row with translateY), it becomes the containing block for fixed → the popup
positions relative to it and is clipped by its overflow after all. Only the browser top layer
escapes that: <lw-tooltip> uses a [popover] (showPopover) for it and positions the bubble
in JS (at the cursor on mouse hover, otherwise at the element — no CSS anchor positioning). If a
popup must be a child of a potentially transformed ancestor, take the same route; otherwise the
body-appended <lw-menu> (MenuService) or position: fixed is enough.
Your own CSS classes
Hand-written CSS in components is named with an lw- prefix (.lw-tooltip-bubble, .lw-scrim).
The ESLint guardrail ignores ^lw- — those are not Tailwind utilities.
Guardrail
eslint-plugin-better-tailwindcss runs in the nx lint gate over **/*.html:
no-unregistered-classes(error) — reports mistyped/non-existent utilities (ignores^lw-and Angular structure likeng-*).enforce-consistent-class-order(warn) — stable class order,--fix-able.
Entry point for class resolution: apps/loom-testbed/src/styles.css
(configured in eslint.config.mjs).