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.
To run tests locally, you need to have Playwright browsers installed:
# Install Chromium (required for snapshot tests)
npx playwright install --with-deps chromium
# 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). Since snapshot results are highly dependent on the environment (OS, fonts, etc.), it is recommended to use Docker for generating canonical snapshots.
For a consistent testing environment, it is recommended to run tests using Docker. This ensures that snapshots are always generated on the same Linux environment as the CI.
- Docker must be installed and running on your machine.
- On Linux, your user should have permissions to run Docker commands without
sudo. - The
openbridge-webcomponentspackage is built (from repository root):npm ci && cd packages/openbridge-webcomponents && npm run build
The Docker image is based on the Playwright Ubuntu image and contains all dependencies. Run the following command from the repository root:
npm run build:docker-for-storybook-testingYou can run the Storybook tests inside the container. This will mount your local files into the container, allowing it to write snapshot results back to your host machine.
From packages/openbridge-webcomponents:
npm run test-storybook:dockerNote: The script uses --user $(id -u):$(id -g) to ensure that any files created by the container (like snapshot results) are owned by your host user. It uses a temporary directory for visual results (/tmp/openbridge-webcomponents-vis-results) to avoid permission conflicts.
After running the tests in Docker, the results are stored in /tmp/openbridge-webcomponents-vis-results on your host. To update your local baselines with these results:
# From packages/openbridge-webcomponents
# 1. Create results directory if it doesn't exist
mkdir -p __vis__/linux/__results__
# 2. Copy results from temp to package directory
cp -r /tmp/openbridge-webcomponents-vis-results/* __vis__/linux/__results__/
# 3. Run the update script (replaces baselines with results)
npm run update-snapshotsThe 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.
⚠️ src/palettes/variables.cssis generated, not authored. The file is produced by the OpenBridge devtools Figma plugin (published as Figma community plugin1448419213272098259). The plugin'scssvariablescodegen emits the entire file in one go: the four.obc-component-size-*blocks (from theComponent-sizecollection), the* { … }block (typography-primitives+Set-component-corners+component-primitives+ shadow composites), the four:root[data-obc-theme="…"]blocks (from thePalettecollection, with variable alias chains flattened to literalrgb(…)values per theme), and the@property/@keyframes warning-blinkmachinery at the bottom.Do not hand-edit
variables.css. Any local change will be silently overwritten the next time someone pastes new plugin output. To add, rename, or change a token, the workflow is:
- Change the variable in Figma (or, for name normalisation only, in the plugin's
rename()function incode.ts).- Re-run the plugin (Figma → Dev Mode → Inspect → "css variables export").
- Replace
variables.csswholesale with the plugin output and commit.The same plugin also produces:
script/figmavariables.json(via itsvariablescodegen) — aVariableID → token-namelookup consumed byscript/convert-icons.tsto rewrite hex colors back intovar(--…)references in downloaded icons.src/mixins/fonts.cssis regenerated wholesale by the plugin'sfont-exportscodegen — replace the entire file on each regeneration. Hand-curated font mixins that the plugin does not produce live in a sibling file,src/mixins/font-extras.css(currentlyfont-overlay-outline-shadowand thefont-instrument-*-boxfamily used byreadout,readout-list-item,readout-setpoint, andar/poi-header). PostCSS auto-loads every file insrc/mixins/(seepostcss.config.mjs→mixinsDir), so adding new mixins tofont-extras.cssrequires no other wiring. Always runnpm run lint:mixinsafter regeneratingfonts.css— a dropped definition produces an undefined-mixin error rather than silent breakage (@mixin missing;expands to nothing).The audit at
script/check-css-variables.tswill catch consumer CSS that references tokens missing fromvariables.css, but it cannot catch tokens that are missing from Figma itself — those need a designer round-trip.How to run the plugin (browser Figma):
The plugin is a codegen plugin (
"capabilities": ["codegen"],"editorType": ["dev"]in its manifest), so it does not open as a regular plugin window from the Plugins tab. Its output appears inside Dev Mode's Inspect panel.Which Figma file do I open? There are two canonical files, and each codegen reads from a specific one. Running the wrong codegen against the wrong file produces output that looks plausible but is silently broken (e.g.
figmavariables.jsonkeys that no icon will ever match).
Repo target file Codegen language Source Figma file Why this file src/palettes/variables.csscss variables exportOpenBridge 6.1 (main design file, kQMFf24Y1ry43HJWOStqd8)Variable definitions live here — valuesByModeper theme, the per-size-class numbers, etc.src/mixins/fonts.cssFont exportsOpenBridge 6.1 (main design file) Text-style definitions live here. script/figmavariables.jsonvariables mapOpenBridge Icons ( IkDwOtza6OdjLbIdWA7mI7)The map keys are VariableID/<nodeId>where<nodeId>is the icon component node that consumes the variable.convert-icons.tsmatches by that exact key, so the map must be exported from the file the icons live in.The icons file only references palette variables (it does not define them), and the main design file does not contain icon component node IDs — so the two files are not interchangeable for either codegen.
Dev Mode access caveats. Dev Mode requires a Professional / Organization / Enterprise seat — a free seat will not show the codegen UI. If you only have view access on the canonical file, the workflow is:
- Best: ask the design lead for editor / Dev Mode access on the canonical file. Variables in a duplicate drift the moment the original is edited.
- Workaround (drafts duplicate): File → Duplicate to your drafts on the canonical file. Open the draft in Dev Mode and run the codegen there. The duplicate inherits the source file's variables at the moment of the copy. Always re-duplicate before each export run; never let a stale drafts copy linger — you will silently miss any token the design lead added since you last duplicated.
- The icons file (
IkDwOtza6OdjLbIdWA7mI7) is usually shared more openly than the main design file, so you may have Dev Mode on the icons file directly and need the drafts trick only for the main file.Steps once you have the file open in Dev Mode:
- From the community plugin page click Open in… and pick the file (only needed the first time).
- Select any node in the canvas — the codegen panel only renders when something is selected, but for
cssvariables/font-exports/variablesthe output comes from the file's local variables and text styles, not from the selected node.
- Exception: the
variables mapcodegen emits one JSON block per selected node's bindings. To get the full map in one go, select a page-level or large multi-frame node; selecting a single icon yields only the 5–10 bindings on that one icon. If the plugin still chunks the output across multiple codegen runs, paste each block into a scratch file and merge them (Object.assign — same key always maps to the same token, so order does not matter).- In the right sidebar's Inspect tab, scroll to the bottom. The "Codegen Plugin" section is the plugin's output area.
- In that section's header there is a small language dropdown (defaults to
css). Switch it to the codegen you need (see the table above):
css variables export→ replacessrc/palettes/variables.cssFont exports→ replacessrc/mixins/fonts.csswholesale (hand-curated companion mixins live insrc/mixins/font-extras.css)variables map→ replacesscript/figmavariables.jsoncss→ per-node CSS, not used for repo regeneration- Click the copy icon at the top-right of the Codegen Plugin section and paste into the corresponding repo file. Diff carefully before committing.
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 (defined in src/mixins/font-extras.css) |
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"></html>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);
}All icon components live in packages/openbridge-webcomponents/src/icons and are
fully generated from Figma — never hand-edit them. The pipeline is:
script/download-icons.ts(npm run download:icons) fetches every node tagged as an icon from the OpenBridge Figma file via thefigma-apipackage, rasterises each node's vector data to an SVG payload, and writes the raw SVGs toscript/.cache/icons/*.svg(gitignored).script/convert-icons.tswalks the cache and emits one Lit element per icon (src/icons/icon-<kebab-name>.ts, registered as<obi-<kebab-name>>), plus the barrelsrc/icons/index.tsand the runtime registrysrc/icons/names.ts.- For each fill/stroke color the converter looks up the Figma
VariableIDinscript/figmavariables.jsonand rewrites the literal hex intovar(--<token>)so themed icons follow the active palette. Unknown IDs fall back to the literal hex color (see "Unknown variable fallback" below).
| File / env | Tracked? | Notes |
|---|---|---|
packages/openbridge-webcomponents/.env |
No | Must set FIGMA_TOKEN=<personal access token> (read-only scope is sufficient). |
script/figmavariables.json |
Yes | VariableID → token-name map emitted by the obc-figma-plugin variables codegen. Must be exported from the icons file (not the main design file), because the converter matches the binding's per-node-id key. |
script/.cache/icons/*.svg |
No | Per-icon SVG payloads. Cleared on each run. |
script/.cache-figma.json |
No | ~30 MB raw Figma API response cache, reused across runs to avoid re-downloading. |
script/.cache/unknown-variables.json |
No | Written after every npm run download:icons run. Lists VariableIDs referenced by icons but missing from script/figmavariables.json (empty array when fully resolved). |
OBC_USE_CACHE=1 |
n/a | Env var. When set, download-icons reuses the on-disk cache-figma.json and .cache/icons/*.svg instead of calling the Figma API. Useful when iterating on convert-icons.ts or refreshing figmavariables.json without burning API quota. |
Run everything from packages/openbridge-webcomponents/.
- Branch.
git switch -c chore/refresh-figma-iconsoffdevelop. - Token. Ensure
.envcontains a validFIGMA_TOKEN. - Variable map. If the design lead has updated the OpenBridge color tokens,
regenerate
script/figmavariables.jsonfirst. This must come from the OpenBridge Icons Figma file (IkDwOtza6OdjLbIdWA7mI7), not the main design file — the map keys include the icon component node IDs the converter matches against (see the Figma source files matrix). In Dev Mode → Inspect → Codegen Plugin, switch the language dropdown tovariables map, select a page-level or multi-frame node (so the codegen emits the full map, not a 5–10-entry slice for one icon), copy, and replacescript/figmavariables.json. If the plugin chunks the output across multiple runs, paste each block into a scratch file and merge (Object.assign — same key always maps to the same token). - Backup.
cp -r src/icons /tmp/icons-backup-prev— gives you a diff target if you need to recover variable mappings that the new run dropped. - Download + generate.
Expect 2000+ icons to be written. Warnings such as
npm run download:icons
Duplicate icon name <name>indicate Figma-side duplicates that get deduplicated by overwrite — flag them with the design lead. A trailingdoneline means generation succeeded. - Check for unresolved tokens.
If any of these tripwires fires, see "Unknown variable fallback" below. The
grep -rlE 'var\(--undefined\)' src/icons/ | wc -l # must be 0 cat script/.cache/unknown-variables.json # must be [] npm run lint:icons # must report 0 hex leaks
var(--undefined)grep alone is not sufficient — the converter falls back to literal hex rather than emittingvar(--undefined), so silent regressions only show up viaunknown-variables.jsonandlint:icons. - Typecheck & lint.
npx tsc --noEmit npm run lint
- Refresh visual baselines (see "Visual snapshots" below).
- Verify bundle stays under the PWA cap.
vue-demo's service worker is capped at 7 MB per file (workbox.maximumFileSizeToCacheInBytes).Inspect the largest emitted chunk.npm run build --workspace=vue-demo
- Commit. Squash all the regenerated noise into a single
chore: refresh icons from Figma(orfeat:if you also changed the consuming components). Keepscript/figmavariables.jsonand anyscript/convert-icons.tschanges in the same commit.
When convert-icons.ts encounters a VariableID that is not in
figmavariables.json, it falls back to the literal hex color
(fill="#XXXXXX" / stroke="#XXXXXX") rather than emitting
var(--undefined). This keeps the regen unblocked when designers add brand-new
palette tokens that have not been re-exported yet, but it means the resulting
icon will not follow theme switches until the token is added.
Two recovery paths, depending on the cause:
- Token exists in Figma, just missing from the JSON — re-run the
obc-figma-plugin
variablescodegen and overwritescript/figmavariables.json, then re-runnpm run download:icons. Hex fallbacks should disappear. - Token does not yet exist in the palette — accept the hex fallback for this PR, file a follow-up with the design lead to add the missing token, then do a second regen pass once the palette ships.
The script/.cache/unknown-variables.json diagnostic file (written on every
npm run download:icons run) lists every unresolved VariableID. The
parallel npm run lint:icons check (script/check-icon-hex-leaks.ts)
zero-tolerance-fails on any remaining literal hex fill/stroke attribute
in src/icons/*.ts, printing the offending file → attr pairs. Together
the two tripwires force every unbound or fallback color to be acknowledged
in a PR instead of silently producing a non-themed icon. If a future
legitimate exception arises (e.g. a regulatory color that must not follow
the theme), prefer adding an explicit allowlist to
script/check-icon-hex-leaks.ts over reintroducing a sliding budget.
Icon family renames or bucket changes (e.g. the wind family migrating from
wind-true-1 through wind-true-14 to bucket-named
wind-true-{0,1,5,10,15,…,100} and wind-shaft-{0,1,5,…,100}) require code
changes in any component that imports specific icons. The wind indicator is the
canonical worked example because it is the only component that snaps a numeric
sensor value to a discrete icon glyph.
Files to update when an icon family changes:
- The wind icon mapper —
src/navigation-instruments/watch/environment.ts.- Re-import every bucket from the new family with an explicit per-icon
import (not via
icons/index.js) so the PWA can tree-shake — the wind consumers must stay under the 7 MB Workbox cap. - Export the bucket list as a
readonlyarray (e.g.WIND_TRUE_BUCKETS,WIND_SHAFT_BUCKETS). - Provide a snap helper such as
windKnotsToBucket(knots, buckets)that returns the nearest bucket by absolute distance (ties resolve to the lower bucket). Wrap it in family-specific helpers (windKnotsToWindTrueBucket,windKnotsToWindShaftBucket) so consumers do not import the bucket arrays themselves. - Keep any legacy index-based helper that downstream code still needs (the
wind indicator's inline barb glyphs are indexed 1..14, fed by a renamed
windKnotsToShaftTrueLevel).
- Re-import every bucket from the new family with an explicit per-icon
import (not via
- The wind indicator —
src/navigation-instruments/wind-indicator/wind-indicator.ts.- Import the explicit
obi-wind-true-*/obi-wind-shaft-*set. - Use the bucket helpers from
environment.tsto build the tag name (obi-wind-true-${windKnotsToWindTrueBucket(...)}). - Prefer the CSS-color variant of each icon (
instance.iconCss ?? instance.icon) so theme tokens drive the color; thevar(--currentColor)path is the fallback. - Rename any public/private surface (
iconIndex→iconLevel, etc.) and update the matching*.stories.tsargTypesso Storybook controls match.
- Import the explicit
- Wrappers around the indicator (
wind/wind.ts,wind-propulsion/wind-propulsion.ts) usually need no changes — they re-export the indicator's API.
A handful of other components reference specific icons directly
(automation/**, building-blocks/**, etc.). Run a workspace grep for the old
icon names before assuming the wind change is isolated:
grep -rE "obi-<old-family>-|icon-<old-family>-" src/A broad icon refresh almost always disturbs snapshot baselines because dozens of components render icons inside their stories. The strategy:
- First pass — find the drift. Run the full suite without
--update:Expect failures concentrated in components that render icons. Each failure reports the pixel-distance from the baseline.npx vitest run --project storybook
- Inspect a representative diff under
__vis__/linux/__diffs__/<path>/<story>.pngand confirm the change is "just" an icon-color or icon-shape update (not a layout regression). - Update scoped first — for the targeted family (e.g. wind), refresh only
the affected stories so the diff stays reviewable:
npx vitest run --project storybook --update src/navigation-instruments/wind-indicator
- Update everything else that drifted from the icon library churn:
(Repeat per failing path.)
npx vitest run --project storybook --update src/integration-systems/integration-bar
- Verify stable. Re-run the full suite once more without
--updateand confirm zero icon-related failures. Pre-existing chart flakes (e.g.polar-chartChart.js layout timing) are unrelated to icon work and should be triaged separately, not papered over by a baseline refresh. - Linux-only baselines. The repo only ships
__vis__/linux/__baselines__/. On macOS, thedarwindirectory is populated locally but not committed; regenerate Linux baselines inside the Docker container described in § Testing if you cannot rely on a Linux dev box.
PR size warning: A full icon refresh routinely touches 1500–2000 files in
src/icons/plus 100–500 baseline PNGs. Reviewers should focus onscript/convert-icons.ts,script/figmavariables.json, the consuming component(s) (e.g. wind), and a sampling of diff images — not the per-icon noise.
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