Design tokens & the <lw-*> vocabulary
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:theming·ui-primitives. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
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 @loomweaver/shell/styles/shell.css — no Tailwind install needed. Setup: bringing your own CSS framework. npx @loomweaver/cli 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 frame plugin | the frame UI kit: <link rel="stylesheet" href="/frame-kit/lw-frame.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@loomweaver/shellpackage:@import 'tailwindcss';@source '../node_modules/@loomweaver/shell'; /* also scan the shell library's classes */@source './app';@import '@loomweaver/shell/styles/theme.css'; - The
@sourcepath is relative to the stylesheet it stands in and has to reach thenode_modulesthat holds@loomweaver/shell. A project that sits deeper needs one../more per level. With a wrong path Tailwind emits none of the shell’s classes and reports nothing. - Tailwind reports no error for a mistyped or non-existent class: it is silently ignored. The
ESLint guardrail (
no-unknown-classes, see below) catches that. - No
tailwind.config.js: everything is CSS-first. Built via@tailwindcss/postcss(.postcssrc.json). - Tokens + theme: one source,
@loomweaver/shell/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, which 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: 29 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 the host chrome into a promise no release could 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. The host insets nothing of its own, so a surface fills the pane
it is mounted in. A product that wants its surfaces inset asks for it once, with
padding: 'inset' on provideShell, and a
single surface that differs says so with padded. How wide the inset is stays a stylesheet
question, below.
Size against the pane, never against the window
This is the mistake to know about before you write a template. A surface is mounted in a pane, and the user decides how wide that pane is. Splitting the content area halves the pane while the browser window stays exactly as wide as it was, so a layout built on viewport breakpoints keeps a shape that no longer fits and its content runs out of the box meant to hold it.
The workbench therefore gives every surface it renders a sizing reference that tracks the pane, named
surface. You do not arrange it, and it follows the surface wherever the user puts it: the content
pane, a split, a sidebar, a pop-out window.
With Tailwind, use the container variants rather than sm: / lg: / xl::
<!-- wrong: fires on the window, which the user did not resize --><div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<!-- right: fires on the pane the surface was given --><div class="grid gap-4 @sm:grid-cols-2 @2xl:grid-cols-4">The numbers are not the same numbers. A viewport breakpoint counts the rail, any open side panel and the pane’s own padding; the container one counts only the width your content actually has. Pick each from the width at which that layout stops fitting.
Bare @sm: resolves against the nearest reference, which is the workbench’s unless your own template
declares one with @container. Where you need the pane from inside your own nested container, name
it: @2xl/surface:grid-cols-3.
Without Tailwind it is an ordinary container query, and the name is the whole contract:
@container surface (width >= 42rem) { .my-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }}Two things that bite even with the right variants. A grid or flex child does not shrink below its
content unless you say min-w-0. A chart canvas reports a content width, so a card holding one will
hold its column open no matter what the breakpoints say. And a surface presented as an isolated
document needs none of this: its frame is the pane, so ordinary viewport queries inside it are
already pane queries.
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 rail */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. Two of them belong to one slot:.lw-chrome-pictureis the picture a rail entry or a bar button draws for what it stands for, and.lw-chrome-markis the round, bordered slot the initials take when there is no picture. Restyle the slot and both alternatives follow. - The element tags (
lw-shell-rail,lw-shell-panel,lw-address-pane-header,lw-pane-tab-strip, …) reach the structure, but they are not a versioned contract. They move when the chrome is restructured, and nothing warns you. Targeting them is supported in the sense that it works and nothing fights it; it is not supported in the sense that it is promised to keep working.
Two honest limits. The rules do not reach a sandboxed plugin’s surface: that is a separate document, and it receives the shell’s compiled stylesheet and the token push, not yours. 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.
The looks the demo ships are 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 rail 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. There are no dimension tokens today, and there will be none until a second product asks for the same handful, because one data point names a set that fits one design rather than the set products 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 or 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 |
scrim | the backdrop behind a dialog and the mobile drawer, translucent so it follows a retinted surface |
border | default divider/border (border-border) |
scroll-thumb | the scroll indicator, drawn over whatever scrolls |
scroll-track | the groove behind it, transparent by default |
Text
| Utility stem | Meaning |
|---|---|
content | primary text |
content-muted | secondary text (labels, metadata) |
content-faint | tertiary/decorative (placeholders, disabled), still WCAG AA on every surface |
unsaved | the mark that says a tab holds work which is not saved yet, 3:1 as a graphical mark |
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), near-black so it reads on the gold |
info | info (blue) |
on-info | text on info surfaces (info button) |
Tooltip (special case, normally not used directly)
tooltip / tooltip-content are 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 or a tenant can set the brand font through
ctx.contributeTheme or 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, so branding and accessibility stay cleanly
separated.
Host building blocks (<lw-*>) instead of hand work
The shell registers every <lw-*> element when it starts, so inside a running workbench you only
use the tag. Content rendered without a running shell, typically a weaver body under a unit
test, has to register each element it uses itself. An unregistered tag stays in the page as an
unknown element that draws nothing and raises no error. Each has an idempotent registration
function in @loomweaver/shell: defineLwIcon(), defineLwButton(), defineLwTooltip(),
defineLwSelect() (with <lw-option>), defineLwMenu() (with <lw-menu-item>),
defineLwMarkdown(), defineLwNavTree(), defineLwProgressRing() and defineLwSpinner().
-
Icons:
<lw-icon name="add" size="1rem" />. Names are semantic rather than pictorial; Icons is the catalogue, every shipped name with its glyph. Contributable names: a distribution adds its own withprovideIcons({ name: svgRef })(from@loomweaver/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 andjavascript:are removed, and 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>, and usable inside your own weaver body (by tag, no@loomweaver/shellimport; in an Angular weaver setschemas: [CUSTOM_ELEMENTS_SCHEMA]). As the last child of aclass="relative"trigger:<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>Attributes:
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, appearing 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. The element is text only. For hover content with structure, such as badges in a grid that places its own tooltips, put.lw-tooltip-bubbleon an element of your own. The class carries the look and nothing else, so the element stays where the grid puts it. -
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). It is 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:<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>Attributes:
label(accessible name),value(current value),placeholder,disabled;<lw-option>:value, optionallyicon(a name the icon registry knows is drawn as<lw-icon>draws it; anything else, such as a flag emoji, is shown as written), 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. Withcompact, the trigger shows the chosen option by its icon alone and names the choice in its accessible name. An option without an icon still shows its text. -
Sidebar navigation (
<lw-nav-tree>+<lw-nav-group>+<lw-nav-item>): a framework-agnostic custom element (like<lw-select>) for the list of destinations a product puts in a sidebar, declared as light-DOM children. The tree takescurrent, the address on screen, and marks the item that address lies at or under, breaking on segment boundaries. It emitslw-nav-select(detail.path) when the user chooses, and navigates nothing. A group takeslabel,keyandcollapsed; an item takespath,labelandicon, and anything written inside it stays on the row after the label.aria-labelon the tree names it for assistive technology. Labels are shown as given, so supply translated text. A group folds, starts shut where you writecollapsed, and keeps what the user folded under itskeyfor the session and no longer; a group holding nothing is still drawn and offers no fold.forgetLwNavFolds()clears the fold memory, which is what a test between cases needs. The look lives as.lw-nav-*intheme.css. How to build one, from the declaration to the panel that retitles itself, is A navigation tree in the sidebar. -
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). The exception is your own sandbox surface: there the plugin draws<lw-menu>itself (from the frame UI kit, where/frame-kit/lw-elements.global.jsdefines the whole<lw-*>family and/frame-kit/lw-frame.csssupplies the compiled look) and positions it withopenAt(x, y)at the iframe-local cursor, with no cross-frame work and no RPC. -
Buttons in bar/rail: register them declaratively as a
BarButtonItem/rail item (the host renders button + tooltip from data), so you need no button component of your own 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:<button lwButton variant="primary" (click)="save()">Save</button><button lwButton variant="ghost" size="sm" iconOnly aria-label="…"><lw-icon name="close" size="1rem" /></button>Variants
primary·default·success·danger·warning·info·ghost; sizesmd(default) ·sm;iconOnly= square. The directive must be in the component’simports(imports: [LwButton], from@loomweaver/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:<!-- (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>The contract: base class
lw-btn(which already carries the default sizemd: height, padding and 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@loomweaver/plugin-sdk. Defined intheme.css; thelwButtondirective emits exactly these classes (one source of truth). There is nolw-btn--md, becausemdis 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, …), and 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, and 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), because size stays per-site utilities, as with.lw-icon-btn. Shared bar-control height:.lw-segmentedand.lw-select-triggerboth pinh-8.5(34px), so controls standing next to each other in a bar are the same height by construction, and new bar controls should carry the same height. -
Text fields (
.lw-field): put the classlw-fieldon a native<input>/<textarea>/<input type="date">, and 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.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. -
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. 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>. -
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. Inside a.lw-tooltip-bubblethe neutral base takes the bubble’s inverted colours, so it reads in both appearances; a tinted badge keeps its fill. -
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>, which bring 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]. -
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@loomweaver/uiimport kit.
Busy indicator
<lw-spinner size="1.5rem" label="…"></lw-spinner>is a custom element like the others, usable by tag from a plugin and from a sandboxed surface. It draws one.lw-spinner-ring. The element is brand-coloured unless you give it a text colour; the ring class takes the colour around it, so a ring drawn by hand with that class keeps yours. It is what the progress dialog shows, and it fits anywhere else something is running.sizeis a CSS length,1.5remby default. A bare number is discarded by the browser.labelis the accessible name announced withrole="status". Pass a translated string, or leave it out where the text beside it already says what is running.- The ring keeps turning under reduced motion, because a spinner that stands still reads as a hang.
Rich text / Markdown
<lw-markdown source="…">is 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@loomweaver/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 the--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 plus CSS anchor positioning, with anchor-name on the trigger and
position-anchor + position-area on the popup. <lw-select> is the worked example: the element
sets anchor-name and position-anchor, and its position-area lives in 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, with 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-, because those are not Tailwind utilities.
How theme.css is laid out
theme.css is the one entry. It declares the order of the three token layers and imports its parts
from styles/theme/:
| Part | What it holds |
|---|---|
tokens.css | the --lw-* ladder for light and dark, and its mapping onto Tailwind’s --color-* and --font-* |
controls.css | the class contracts on native controls, .lw-btn to .lw-switch |
lw-<element>.css | one look per host element: tooltip, select, menu, navigation tree and progress ring, and prose-lw for <lw-markdown> |
workbench.css | what only the chrome draws: rail marks, the dialog backdrop, drag feedback, pane drop zones, the bar tray and the screen-reader live region |
base.css | what applies to the page itself (below) |
Import theme.css, never one of its parts: the parts are how the file is kept, and they may be
regrouped without notice.
What base.css does to your page:
- Margins and text:
htmlandbodyhave no margin, andbodytakes its font, background and colour from the tokens. - Pointer: a button and anything with
role="button"shows the pointer cursor unless it is disabled. The rule sits in thebaselayer, so anycursor-*utility of yours wins. - Scrollbars: every scrolling area draws a thin indicator in
scroll-thumboverscroll-track, with the standard properties only. Leave::-webkit-scrollbaralone in your own CSS too: styling it makes Chrome and Safari trade the overlay indicator for a classic one that takes width from the content, and a narrow rail cannot spare it. - Motion: under
prefers-reduced-motion, transitions and animations collapse to near zero, except the busy spinner, which is status rather than decoration (Accessibility).
The stylesheet also carries the rule that keeps the CDK’s screen-reader announcements out of sight,
so a distribution needs no stylesheet from @angular/cdk for them.
Guardrail
eslint-plugin-better-tailwindcss runs in the nx lint gate over **/*.html:
no-unknown-classes(error) reports mistyped or non-existent utilities. It ignores^lw-and Angular structure likeng-*.enforce-consistent-class-order(warn) keeps the class order stable, and is--fix-able.
The plugin resolves the class registry from the entry point named in its ESLint settings, which is the stylesheet that imports Tailwind and the shell’s theme.