The project is divided into three main parts:
-
openbridge-webcomponents — the core Lit web component library
This package contains the implementation of the web components. The components are implemented through the use of the LitElement library.
For each component, there is a corresponding folder in the
packages/openbridge-webcomponents/src/componentsdirectory. The components are implemented in the*.tsfiles and the corresponding*.cssfiles, with a*.stories.tsfile for the storybook. -
openbridge-webcomponents-vue / openbridge-webcomponents-react — auto-generated framework wrappers
Vue, React, Angular, and Svelte wrappers are auto-generated from the web components:
npm run build:wrappers
⚠️ Warning: Do not edit the generated wrapper packages directly. -
vue-demo / react-demo — demo applications
Each component's *.stories.ts file should:
- Use
tags: ['autodocs', '6.0']for documented OpenBridge 6.0 components - Use
tags: ['alpha']for components still in development - Use
tags: ['skip-test']to exclude a story from visual snapshot testing - Export a
Defaultstory and additional stories for key states and variants
All story title and name fields use Title Case with /-separated segments.
This is enforced by the ESLint rule openbridge/storybook-title-case (auto-fixable via --fix).
- Capitalize every word:
Alert List Item,Speed Gauge,Automation Button. - Keep short conjunctions and prepositions lowercase: and, of, or, in, on, at, to, for.
Example:
Message and Alerts,Selection Controls and Switches. - First and last words of a phrase are always capitalized, even if they are conjunctions/prepositions.
- Use spaces, not dashes:
Automation ConfigurationsnotAutomation-configurations. - Parenthesized content is left as-is:
Minimal Height Donut (48px). - Apply the same rules to every segment:
title: 'UI Components/Input Controls/Slider Double'.
Visual snapshot tests are run via Vitest + storybook-addon-vis + Playwright:
# Run all snapshot tests
npm run test-storybook
# Update snapshots interactively (press 'u' in Vitest terminal)
# Or replace baselines wholesale:
npm run update-snapshotsSnapshot baselines are stored in __vis__/linux/__baselines__/ (and __vis__/darwin/__baselines__/ for macOS).
The CSS files are post-processed by PostCSS.
There is one global CSS file for the palettes, variables.css, which contains the color palettes for the components.
All other CSS code should be kept in the *.css files in the component folders.
Most mixins are defined in src/mixins/ and auto-loaded via postcss-mixins (configured in postcss.config.mjs); the style mixin used for elevation variants is defined inline in postcss.config.mjs. All mixins are available globally in component CSS — no @import is needed.
Every interactive component uses a two-layer DOM structure: an invisible outer touch target and a visible inner visual target.
┌─────────────────────────────────┐ ← outer wrapper (touch target, e.g. 48px)
│ │ detects :hover, :active, :focus-visible
│ ┌─────────────────────────┐ │
│ │ visible-wrapper │ │ ← inner element (visual target, e.g. 32px)
│ │ receives bg / border │ │ styled surface the user sees
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────┘
The outer layer is sized to meet minimum touch-target accessibility requirements (default 48 × 48 px). The inner layer is the drawn surface — smaller, with border-radius, background, and border. The space between them is transparent but clickable.
Token naming convention:
| Layer | Token pattern | Example |
|---|---|---|
| Touch target | --{namespace}-{component}-touch-target-size |
--ui-components-button-touch-target-size |
| Visual target | --{namespace}-{component}-visual-size (or -visual-target-size) |
--ui-components-button-visual-size |
These tokens are defined per size variant (see Size Variants below), so scaling is automatic.
CSS pattern:
.wrapper {
height: var(--ui-components-button-touch-target-size); /* 48px */
min-width: var(--ui-components-button-touch-target-size);
display: flex;
align-items: center;
justify-content: center;
/* transparent — no border or background */
@mixin style style=flat visibleWrapperClass=.visible-wrapper;
}
.visible-wrapper {
height: var(--ui-components-button-visual-size); /* 32px */
border-radius: var(--ui-components-button-border-radius);
/* background and border injected by @mixin style */
}HTML (Shadow DOM):
<button class="wrapper">
<div class="visible-wrapper">
<slot name="icon"></slot>
<span class="label">Click me</span>
</div>
</button>Non-interactive components (e.g. progress-bar, instrument displays) do not use the two-layer pattern — they have no touch target and simply fill their container.
The style mixin (defined in postcss.config.mjs) generates background, border, and cursor rules for six interaction states at build time.
| Parameter | Required | Values | Description |
|---|---|---|---|
style |
Yes | flat, normal, raised, amplified, indent, selected |
Elevation variant — determines which color variable family is used |
visibleWrapperClass |
No | CSS selector (e.g. .visible-wrapper) |
Targets the inner visual element; if omitted, styles apply directly to the element with the mixin |
noClick |
No | (flag) | Emits only the enabled state — no hover, active, focus, or disabled rules. Used for display-only sub-parts |
For @mixin style style=normal visibleWrapperClass=.visible-wrapper the mixin expands to:
| State | Selector | What it sets |
|---|---|---|
| Enabled | & .visible-wrapper |
border-color: var(--normal-enabled-border-color), background-color: var(--normal-enabled-background-color), cursor: pointer |
| Activated | &.activated .visible-wrapper |
border-color: var(--normal-activated-border-color), … |
| Hover | @media (hover:hover) { &:hover .visible-wrapper } |
Uses color-mix() with --obc-can-hover for smooth hover control |
| Pressed | &:active .visible-wrapper |
border-color: var(--normal-pressed-border-color), … |
| Focus-visible | &:focus-visible .visible-wrapper |
outline-color: var(--border-focus-color), outline-width: var(--global-size-spacing-border-weight-focusframe) |
| Disabled | &:disabled .visible-wrapper, &.disabled .visible-wrapper |
cursor: not-allowed, color: var(--on-normal-disabled-color) |
It also sets cursor: pointer on & itself and outline: none on &:focus (visible outline only on :focus-visible).
The mixin references color variables that follow a consistent pattern:
| Purpose | Pattern | Example |
|---|---|---|
| Surface background | --{variant}-{state}-background-color |
--flat-enabled-background-color |
| Surface border | --{variant}-{state}-border-color |
--raised-hover-border-color |
| Text / icon on surface | --on-{variant}-{role}-color |
--on-normal-active-color, --on-flat-neutral-color, --on-raised-disabled-color |
Where:
- variant =
flat,normal,raised,amplified,indent,selected(orintegration-normal, etc.) - state =
enabled,activated,hover,pressed,focused,disabled - role =
active(primary text/icon),neutral(secondary),disabled
All these variables are defined per theme in variables.css.
Use noClick when a sub-element needs the design system's colors but is not itself interactive (e.g. a checkbox's inner box, a table header, an indent track):
.checkbox-box {
@mixin style style=indent noClick visibleWrapperClass=.checkbox-box;
}This emits only the enabled-state border and background — no hover, active, focus, or disabled rules.
The mixin always generates an .activated rule. Toggle it programmatically via classMap:
<div class=${classMap({wrapper: true, activated: this.checked})}></div>/* Touch target — no visible styling */
.wrapper {
height: var(--ui-components-button-touch-target-size);
min-width: var(--ui-components-button-touch-target-size);
display: flex;
align-items: center;
justify-content: center;
}
.wrapper.flat {
@mixin style style=flat visibleWrapperClass=.visible-wrapper;
.button-text { color: var(--on-flat-active-color); }
}
.wrapper.normal {
@mixin style style=normal visibleWrapperClass=.visible-wrapper;
.button-text { color: var(--on-normal-active-color); }
}
.wrapper.raised {
@mixin style style=raised visibleWrapperClass=.visible-wrapper;
.button-text { color: var(--on-raised-active-color); }
}
/* Visual target */
.visible-wrapper {
height: var(--ui-components-button-visual-size);
border-radius: var(--ui-components-button-border-radius);
display: flex;
align-items: center;
justify-content: center;
}Defined in src/main.css:
html { --obc-can-hover: 1; }The @mixin style hover state uses color-mix() to blend hover colors based on this variable:
background-color: color-mix(in srgb,
var(--flat-hover-background-color) calc(var(--obc-can-hover) * 100%),
var(--base-background-color));1→ full hover feedback (default)0→ hover colors are invisible (100% base color)
This is wrapped in @media (hover:hover), so touch-only devices never see hover styles regardless of this value.
Three alert-level mixins in src/mixins/alert.css provide interaction states for alarm-colored buttons:
| Mixin | Color variable prefix | Usage |
|---|---|---|
@mixin alert-alarm $wrapperClass |
--alarm-* |
Highest severity |
@mixin alert-critical $wrapperClass |
--critical-* |
Critical severity |
@mixin alert-caution $wrapperClass |
--caution-* |
Caution/warning severity |
Each generates enabled, hover, active, and focus-visible states — same pattern as @mixin style but with alarm-specific color families.
variables.css registers four CSS @property values used for blink animation:
@property --alarm-blink-on { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --alarm-blink-off { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --warning-blink-on { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --warning-blink-off { syntax: "<number>"; inherits: true; initial-value: 0; }A shared @keyframes warning-blink orchestrates two blink rates:
- Alarm blinks 4× per cycle (fast)
- Warning blinks 2× per cycle (slow)
Components apply the animation by binding opacity to these properties:
.blinking.alert-type-alarm .visible-wrapper { opacity: var(--alarm-blink-on); }
.blinking.alert-type-alarm .blink { opacity: var(--alarm-blink-off); }| File | Mixin | Purpose |
|---|---|---|
src/mixins/card.css |
@mixin card |
Card surface: border-radius: 8px, background: var(--container-global-color), box-shadow: var(--shadow-floating) |
src/mixins/outline-inward.css |
@mixin outline-inward $wrapperClass |
Focus outline with outline-offset: -2px applied on :focus-visible |
src/mixins/base-input-field.css |
Several (base-input-field-wrapper, base-input-field-label, etc.) |
Shared input field chrome: labels, helper text, error borders, disabled states |
src/mixins/scrollbar.css |
@mixin scrollbar |
Custom scrollbar styling via ::-webkit-scrollbar-* pseudo-elements. Uses --obc-scrollbar-* variables |
All font mixins are defined in src/mixins/fonts.css. Each sets font-family, font-weight, font-size, line-height, and font-feature-settings from design-token variables.
For buttons, labels, body text, and headings:
| Mixin | Typical use |
|---|---|
@mixin font-button |
Button labels |
@mixin font-button-two-line |
Two-line button labels |
@mixin font-button-l |
Large button labels |
@mixin font-label |
Secondary labels, captions |
@mixin font-label-active |
Active/selected labels |
@mixin font-body |
Body text |
@mixin font-body-active |
Bold body text |
@mixin font-overline |
Overline text |
@mixin font-overline-new |
Updated overline text |
@mixin font-subtitle |
Subtitles |
@mixin font-title |
Titles |
For numeric readouts, units, and scale labels in gauges and instruments:
| Mixin | Typical use |
|---|---|
@mixin font-instrument-value-small-active |
Small active numeric value |
@mixin font-instrument-value-small-neutral |
Small neutral numeric value |
@mixin font-instrument-value-regular-active |
Regular active numeric value |
@mixin font-instrument-value-regular-neutral |
Regular neutral numeric value |
@mixin font-instrument-value-m-active |
Medium active numeric value |
@mixin font-instrument-value-m-neutral |
Medium neutral numeric value |
@mixin font-instrument-value-enhanced-active |
Enhanced active numeric value |
@mixin font-instrument-value-enhanced-neutral |
Enhanced neutral numeric value |
@mixin font-instrument-label |
Instrument labels and units |
@mixin font-instrument-unit |
Unit suffixes (%, °, kn) |
@mixin font-instrument-tick-mark |
Scale tick labels |
@mixin font-instrument-tick-mark-active |
Active scale tick labels |
For automation readouts and state labels:
| Mixin | Typical use |
|---|---|
@mixin font-automation-value-small |
Small automation readout (on) |
@mixin font-automation-value-small-off |
Small automation readout (off) |
@mixin font-automation-value-regular |
Regular automation readout (on) |
@mixin font-automation-value-regular-off |
Regular automation readout (off) |
@mixin font-automation-value-enhanced |
Enhanced automation readout |
| Mixin | Purpose |
|---|---|
@mixin font-overlay-outline-shadow |
Text shadow for legibility on map/video overlays |
All size-dependent tokens are defined four times in variables.css, once per size class:
| Class | Touch target | Visual target | Icon size |
|---|---|---|---|
:root, .obc-component-size-regular |
48 px | 32 px | 24 px |
.obc-component-size-medium |
56 px | 40 px | 32 px |
.obc-component-size-large |
72 px | 56 px | 40 px |
.obc-component-size-xl |
(larger) | (larger) | (larger) |
Each class overrides the same variable names (--global-size-spacing-touch-target-min, --global-size-spacing-visual-target-min, --global-size-spacing-icon-icon-size-regular, all --ui-components-* sizing tokens, typography tokens, etc.) with scaled values.
Usage: Apply the size class on a parent element; all descendant components automatically resize via CSS variable inheritance:
<div class="obc-component-size-large">
<obc-button label="Bigger"></obc-button> <!-- 72px touch, 56px visual -->
</div>Components should never reference a specific size class internally — they consume the tokens and let the ancestor decide.
Four theme blocks in variables.css override hundreds of color variables:
:root, :root[data-obc-theme="day"] { /* default */ }
:root[data-obc-theme="dusk"] { /* ... */ }
:root[data-obc-theme="night"] { /* ... */ }
:root[data-obc-theme="bright"] { /* ... */ }Set data-obc-theme on <html> or any ancestor to switch themes:
<html data-obc-theme="night">Every theme overrides the same variable names (--element-active-color, --container-global-color, --flat-enabled-background-color, etc.), so components reference variables directly and are theme-agnostic.
These patterns appear across most components and can be used as a baseline when creating new ones:
| Pattern | Usage | Where |
|---|---|---|
* { box-sizing: border-box; } |
Prevents padding from expanding elements | Top of most component CSS files |
user-select: none |
Prevents text selection on interactive elements | .wrapper of all buttons/controls |
appearance: none; border: none; background: none; |
Resets native <button> styling |
.wrapper when using <button> as the outer element |
:host { display: block; } |
Block-level components (tables, modals, lists) | Component host |
:host { display: inline-block; } |
Inline interactive elements (buttons) | Component host |
:host { display: inline-flex; } |
Charts and inline containers | Component host |
* { -webkit-tap-highlight-color: transparent; } |
Removes blue tap flash on mobile | Auto-injected by PostCSS plugin in postcss.config.mjs |
Slotted icons are constrained to the component's icon-size token:
.visible-wrapper ::slotted([slot="icon"]) {
width: var(--ui-components-icon-button-icon-size);
height: var(--ui-components-icon-button-icon-size);
}The icons are exported to webcomponents in the packages/openbridge-webcomponents/src/icons directory.
They are exported from figma by running: npm run download:icons.
To create a new component, use the new:component script:
npm run new:componentThis will create the needed files, using the default template.
Boolean properties and parameters must use positive (affirmative) names so that the default value is false:
showLabelsinstead ofhideLabelsautoAtSetpointinstead ofdisableAutoAtSetpointhasBarinstead ofhideBar
This avoids double-negation confusion (e.g. if (!disableFoo)) and aligns with the Lit/HTML convention where an absent boolean attribute means false.
Booleans that default to true must use attribute: false to remove the HTML attribute and only allow the JavaScript property:
@property({type: Boolean, attribute: false}) autoAtSetpoint = true;Framework wrappers (React, Vue, etc.) always set values via properties, so removing the attribute has no effect on wrapper consumers.
See AGENTS.md § 2 for the full rule and examples.
Instrument components are based on SVG. Typically they are implemented by copying the SVG code from Figma and pasting it into the component file. Note that these modifications should be done:
- Change the colors of the svg with css variables such as
fill: var(--element-active-color); - Make stroke non-scaling by adding
vector-effect="non-scaling-stroke"to the svg tag.
The component file is also more readable if the SVG is splitted up into smaler elements with its own javascript variables. Then later compiled together.
Code quality and visual precision are very important in this project (even half a pixel matters!). For that reason, try to avoid AI hallucinations as much as possible:
- provide precise context (list all the file names, enums, examples, etc.)
- ask the AI to read the files it is working on into memory (for broader exploration, let it use subagents)
- instruct the AI to ask clarifying questions before it begins
- use the latest AI models (the difference can be huge between a paid or latest model compared to free/older ones)
- keep the AI on a short leash