Conventions

The rules that most affect how components in this library are structured and styled.

Document the library alongside its code

public/ui/ is the shared, app-agnostic layer. Its code and documentation must remain usable when copied into another application.

Nothing in public/ui/, including source comments and documentation, may reference a markdown file. References to application docs, repository style guides, or plugin guides would break when the library is copied elsewhere.

Keep explanations on the relevant page in this documentation tree and link to them from the code, for example /ui/docs/ui-menu.html. The job queue page explains its worker design; this page explains event tiers below. Application-specific material belongs in public/components/ and the application's documentation.

Enforced by a repo-wide check (test/z-style.test.mjs), which fails on any such reference it finds. @tt-about <slug> concept markers are exempt because a slug does not reference a file path. The rules below still apply to them.

Keep the library independent of the application

The same independence applies to vocabulary: no file in public/ui/ names a concept that belongs to the application embedding it — not in a runtime identifier, not in a comment explaining a CSS rule, not in a docs demo's example data, not in a test fixture.

Application-specific examples make it hard for readers to distinguish library behavior from product behavior. Application-specific runtime names can also cause conflicts: two applications on the same origin would share storage if the library gave both the same database name. The job queue therefore accepts its three names from the application.

What to write instead. Explain a CSS rule by what the element is and what the selector cannot otherwise reach, never by which screen uses it. Give a demo the generic version of its example — a role list is Owner / Editor / Viewer, a tree is a table of contents, a document is a document. Where a name genuinely has to differ per application, take it as a parameter and let the app supply it. If those changes cannot make the code reusable, it belongs in public/components/.

Keep each component in one module

A component is one self-contained .mjs file with as few dependencies as possible, so it can be copied into another application and used there. It may import shared helpers from public/ui/lib/ that it genuinely needs, such as inject_styles from UiElement.mjs, and other components it renders. It never imports modules that exist only to hold parts of itself: splitting its styles, keyboard handling or state into sibling files that the component always loads makes nothing lighter, and copying the component then means finding every piece.

When a component file grows too long, simplify it inside the file. Remove duplicated state and parallel code paths, merge symmetric cases, and move explanations of the behaviour to the component's page in this documentation, keeping in the code only the pitfalls a reader needs at that line and a link to the page. <ui-combobox> is an example: its behaviour model is on its page.

Plain HTML, and what a decorator is for

Components and patterns here are used by writing plain HTML. These pages and their demos are where that markup is specified: read the page, copy the shape, and the styling and behaviour follow. The author is expected to write the markup as instructed.

Nothing in the library checks that they did. No component, stylesheet or helper validates a page's markup at runtime, warns about a missing attribute, or withholds its styling until the markup is correct. A rule this documentation states is a rule the author keeps, and a broken one is found by reading the code rather than by the library refusing to work. Write the documentation so the correct markup is the obvious thing to write, rather than adding a guard against the incorrect one.

A decorator is for markup too verbose to hand-write — it adds the ARIA content, it does not police it. tree() takes a nested <ul>/<li> and supplies a whole APG tree view: the roving tabindex, the role="treeitem" rows, the role="group" nesting, the expand/collapse wiring. accordion() does the same for a disclosure region. Each replaces markup an author would otherwise repeat on every node and get subtly wrong. That is the test: reach for a decorator where the hand-written version would be long and repetitive, not where it would merely be forgettable. One attribute on one control is not a case for a decorator.

Tag-name prefixes

ui-
Generic reusable widget. No app-specific knowledge. Examples: <ui-dialog>, <ui-menu>, <ui-icon>.
x-
Polyfill or extension of a native element. Example: <x-select> (fills the gap until <select> customization ships across browsers).
app-
App-specific component. Lives in public/components/, not in the library. Example: <app-settings>.

Hierarchy: sub-components extend the parent's tag with an appended segment: app-settings → app-settings-profile, app-settings-billing.

Context-role words (dialog, panel, card, sheet, popover, menu) don't belong in tag names. The same component should be placeable in any of those contexts; the host carries the role.

File organization

JS naming

Show code // Good export function trigger_status(el, cls) { /* … */ } // Not this style export function triggerStatus(el, cls) { /* … */ }

CSS: private props

Underscore-prefixed custom properties declare component-internal defaults that outer context can override. They are how a component gets variants without a variant API.

Show code .alert { --_accent: var(--red-6); border-inline-start: 3px solid var(--_accent); } .alert.warn { --_accent: var(--orange-6); } /* A parent can override from outside without touching .alert: */ .toolbar .alert { --_accent: var(--brand); }

Published hooks vs. private internals. Underscore-prefixed props (--_accent) are component internals — overridable, but not a stable API. When a component is meant to be re-styled by consumers, expose a small set of un-prefixed, documented hooks named after the component (--switch-track, --switch-track-on, --switch-size, …). Resolve them through the private vars — don't declare the public name on the element. Map each into a private var whose var() fallback is the default (--_track-on: var(--switch-track-on, var(--brand))) and use the private var internally. Declaring --switch-track-on: var(--brand) directly on the element would shadow any value an ancestor sets, silently breaking the override; the indirection lets a value set in any scope (including an inline style on a wrapping <label>) inherit down and take effect, while defaults still re-theme with the app (dark mode included). See Switch · Theming.

CSS: semantic aliases, not raw palette

Components use --surface-*, --text-*, --brand, --border-color — not --stone-4, --indigo-6. This keeps dark-mode overrides in one place (/index.css) rather than sprinkled across every component.

CSS: no utility classes for components

Style via element / contextual selectors. Use --_ private props for variants. The only utility classes in the codebase are the small set in /open-props/extra/utilities.css — see Utilities.

For elements with named variants, Modifiers lists the supported names and explains whether to use a class, an ARIA or native attribute, a data-* value, a bare marker, or a private property without a named variant.

CSS: alpha via color-mix

Compose translucent colors in OKLCH — not RGB hex + opacity or /10 suffixes.

Show code .tint { background: color-mix(in oklch, var(--brand) 10%, transparent); }

Light DOM over Shadow DOM

Prefer light DOM with semantic HTML. Shadow DOM is only for reusable widgets that must not leak styles — which, in this codebase, is almost never the right answer.

Translate user-facing component text

Components normally receive text through the page's markup. When a component must supply its own text, use a catalog key in the form ui.<component>.<what> with an English literal as fallback. Examples include the message required by ElementInternals.setValidity() and the accessible name of a control created by the component. Do not require each page to supply these internal labels through an attribute: the component knows when they are needed and must support the active language.

Use a normal import for lib/l10n.mjs — import {t} from '../lib/l10n.mjs'. It loads its catalog without a top-level await, so importing it does not suspend your module and your customElements.define still runs before the first paint. Imported component dependencies must not use top-level await: such an await suspends every importing module and delays its define until after first paint. When such an await was added to <x-select>'s import chain, 51 WPT subtests failed because customElements.get('x-select') was undefined. Between first paint and catalog readiness, t() returns the raw key. Arrange a re-render through l10n_register_listener(this) if the element renders text. For a string needed only later, such as a validation message, supply the English literal as a fallback.

Show code // The import is plain; the literal covers the window before the catalog arrives import {t} from '../lib/l10n.mjs' static VALUE_MISSING_MESSAGE = 'Please select an item in the list.' static value_missing_message() { const s = t('ui.select.value_missing') return s === 'ui.select.value_missing' ? XSelect.VALUE_MISSING_MESSAGE : s } // Not this — t() at module scope captures the raw key before the catalog lands, // and never revisits it const VALUE_MISSING = t('ui.select.value_missing')

Check whether the returned value equals the key: l10n.mjs returns the raw key for an unknown one, so without it a missing catalog entry would render as ui.select.value_missing in the UI.

Keep copied labels synchronized with the host

Several components copy the host's label to an inner element that the page cannot access — <ui-menu> onto its picker or auto-created trigger, <ui-dialog> onto its inner <dialog> when there is no title to point at, <ui-statusbar> and <ui-notifications> onto the buttons that open their lists. A component that copies a name must observe the attribute it copies from and re-copy it, because the host's value routinely changes after the element has upgraded and the copy would otherwise keep the stale one.

Which attribute is being copied does not change the rule. The first two take the host's aria-label; the last two take a label attribute of their own, because the name they need is for an inner button rather than for the host. Both are a name the page wrote and the component re-published somewhere the page cannot address, so both have to be observed.

A delayed translation can otherwise leave screen-reader labels in the wrong language. apply_static_i18n() awaits the catalog fetch, so a page that does everything the language policy asks — English literal in the markup, data-i18n-attr="aria-label:…" pointing at a key present in both locales — can still expose an English screen-reader label while the visible UI is Swedish. Measured on <ui-menu> in Chromium and Firefox: host Redigeringsmeny, picker Editing menu.

Observe the attribute; do not subscribe to locale changes for this. l10n_register_listener fixes only the translated case, and a live language switch is not a case that arises — the app reloads the page on language-change. Observing catches every late relabel and adds no dependency. And a name the page supplied — a <button> child with its own aria-label — is the page's, never overwritten.

Make component delays adjustable

Define each component delay as an underscore-prefixed static property, rather than a module-level const. This includes search debouncing, submenu hover delays, and fetch timeouts. Tests and subclasses can then adjust the delay without guessing how long to sleep. In the combobox suite, making three delays adjustable reduced elapsed time from 32.8 s to 1.5 s; tests still wait for the work to complete.

Static properties and attributes serve different scopes. The underscore static is class-wide, aimed at tests and subclasses, and is not published API: one assignment changes the delay for every instance in a suite. An attribute is added on top only when a page has a real reason to vary the delay per element — <ui-combobox debounce-ms="400"> for a slower search on one field. Add an attribute only when a page needs per-instance control; every public option requires ongoing support.

Show code // Good — reachable from a test without patching the timer code static _fetch_timeout_ms = 15_000 const timer = setTimeout(() => ctrl.abort(), UiIcon._fetch_timeout_ms) // In the suite UiIcon._fetch_timeout_ms = 10 // Not this — a module-level const is unreachable, so every test that meets it sleeps const FETCH_TIMEOUT_MS = 15_000

For a lib/ module, expose timing through an exported object, such as export const animation_timing = { grace_ms: 100 }, and read animation_timing.grace_ms. Consumers can change the object's properties; they cannot reassign an imported const binding.

After setting a delay to zero, wait for the work to complete through event-loop turns. Do not replace a long sleep with a shorter one: that still depends on machine speed. If a test verifies the delay itself, keep the real delay and wait for it.

Like underscore-prefixed CSS properties, these timing properties are accessible to consumers but are not part of the stable public API.

Validate at boundaries and report internal errors

Don't guard against things that should never happen. If a component always lives inside a <ui-dialog>, call this.closest('ui-dialog').close() without optional chaining — if the parent is missing, that's a bug worth surfacing, not hiding.

Show code // Good — trust the structure this.closest('ui-dialog').close('confirm') // Not this — optional chaining hides structural bugs this.closest('ui-dialog')?.close('confirm')

Guards are appropriate for genuinely optional data (e.g. user?.roles), event-delegation filtering, and conditionally rendered elements.

Component-authoring helpers (UiElement.mjs)

A small set of utilities used by every ui-* / x-* component file. Source: /ui/lib/UiElement.mjs.

ExportSignatureDescription
html (strings, …values) => string Tagged template for multi-line HTML strings (String.raw-equivalent). Triggers HTML syntax highlighting in editors that recognize the tag.
css (strings, …values) => string Tagged template for static _css strings on component classes. Pairs with inject_styles.
inject_styles (klass, root, tag) => void Inject a component's static _css once per root (document.head by default), guarded by a data-…-styles marker so multiple instances don't duplicate the rule.
placement_map constant object Maps popover placement keywords ('top-start', 'bottom-end', …) to CSS position-area values. Used by anchor-positioning popovers/menus.

A backtick inside a comment in one of these templates ends the template. A template literal has no comment context, so a /* … */ in a css block and an <!-- … --> in an html one are text, not comments: the first backtick closes the string, and the prose after it re-parses as JavaScript. Quote a code word with ", or write it plain.

The error may not mention an unterminated string. npm run lint reports Parsing error: Unexpected token … at a word in the comment, which may be some distance from the stray backtick. npm test stops before any suite loads and names the file: a syntax check (scripts/check-syntax.mjs) parses every module first, and reports the offending word with a caret. A run that skips that pre-flight — a scoped node --test, or the browser suite — can produce many downstream failures because much of the application imports this layer: one stray backtick in toast.mjs caused 77 failures and 39 cancellations, making the original file reference difficult to find in the output. In a shared working tree, the syntax check also helps other sessions identify the source of the failure.

Multi-line HTML strings should always use the html tag rather than '…' + '…' concatenation — backticks read as actual HTML and diff cleanly.

inject_styles prepends, so ui.css wins every specificity tie. A component's sheet goes in ahead of the page's stylesheet links, which means a component rule only overrides ui.css by being more specific — at equal weight, source order decides and ui.css is later. Two rules follow: a component that needs to override a shared rule must out-specify it rather than merely restate it, and a base element rule in ui.css must be kept at its lowest workable specificity, since raising it silently disables the component overrides that were sitting just above it. Wrap a qualifier in :where() to broaden a selector without increasing specificity — input:where(:not([type="radio"])) stays at (0,0,1), while the bare :not() would be (0,1,1), because :not() takes its most specific argument's weight.

Typical component scaffolding import { html, css, inject_styles, placement_map } from '/ui/lib/UiElement.mjs' class UiThing extends HTMLElement { static _css = css` ui-thing { display: block; padding: var(--size-2); } ` connectedCallback() { inject_styles(UiThing, document.head, 'ui-thing') this.innerHTML = html` <header>Title</header> <slot></slot> ` } } customElements.define('ui-thing', UiThing)

State & events

Choose the narrowest of these four event mechanisms that reaches every consumer.

1 — state class extends EventTarget
One owner holds the data; views addEventListener on it. The default.
2 — module-level singleton EventTarget
Cross-module state, so no value is threaded through intermediate components.
3 — document event
document.dispatchEvent(new CustomEvent(…)), for "something changed, everyone re-read" across subtrees that share no state-class instance. Overuse makes the graph hard to trace.
4 — direct callback assignment
obj.on_data = e => …. Only where per-event dispatch overhead is measurable (≥15 Hz, render loops).

Storing a value and announcing it are two steps — plain assignment, then this._emit('x-change'). Combining them in a setter would hide the event dispatch. Every subscription added in connectedCallback needs its removal in disconnectedCallback.

See also