Browser support

Target: Baseline 2024 — features available in all major browsers by end of 2024.

Desktop and iPad — on iOS, current Safari only. Popular iPad sizes are supported. Require the current Safari on iOS; users with older releases must upgrade. Phone layouts remain deferred. Ruled 2026-08-06.

Current iOS Safari supports CSS anchor positioning. The affected 2025 releases, before Safari 26, are outside the supported iOS range. Users on those releases must upgrade. Supporting iPad therefore does not require an additional positioning fix or change the polyfill approval requirement below.

Support touch interaction. An iPad normally has neither hover nor keyboard focus. Controls and tooltips available only on :hover are therefore inaccessible to touch users. Include @media (hover: none) behavior when designing them.

The application supports widths of 768 px and above. Narrower application layouts are outside its current support range. 768 px is the narrowest popular iPad in portrait (classic / 10.2″) and it is 48em — lg on Breakpoints, and the app's only live breakpoint, below which the studio's content navigator overlays the reader instead of sitting beside it (public/studio.html, app-content-navigator.mjs). This matches the existing layout breakpoint. The iPad mini (744 px) is below the supported width; supporting it would require a separate decision. Ruled 2026-08-06.

CSS: progressive enhancement

Newer CSS features are fine if guarded by @supports or if the degraded experience is acceptable. Examples already in the codebase:

JS: APIs beyond Baseline need a fallback

No TypeScript

Types come from JSDoc annotations, checked in the IDE and optionally via tsc --noEmit. No build step needed to run the code.

Show code /** * @param {HTMLElement} el * @param {boolean} active */ export function spin(el, active) { /* … */ }

Lit is optional

Lit is allowed as a lightweight convenience for components with complex templates — not as a framework commitment. Most components in this codebase extend HTMLElement directly via UiElement.

Polyfills load from preload.mjs, never from inside a component

No polyfill may be added without the project owner's explicit approval. This applies to library and application changes, regardless of implementation cost. Present the need, alternatives, and costs before adding one.

Consider a polyfill only when a feature falls outside Baseline 2024 and neither an @supports/typeof guard with acceptable reduced functionality nor avoiding the feature meets the need. Guarding a feature and providing reduced functionality normally belongs in the component.

When one is warranted it is loaded in exactly one place: public/preload.mjs, which runs ahead of app.mjs and all component code and is where polyfill load order is controlled centrally. A component never carries its own @supports/typeof branch that reimplements a missing feature — that spreads the workaround across components, requiring separate removals when the feature enters the support target. Ruled 2026-08-04.

Load preload.mjs as the first module script on every page. A polyfill that reflects a property (anchor-name here, the ARIA attributes below) can handle only writes made after installation. Earlier writes have no effect. Component modules claim their anchor names in connectedCallback, and customElements.define() upgrades the static markup already in the page synchronously, so a component listed above preload.mjs sets its anchor name before the shim can handle it. This produces no exception or console message. Browsers with native support remain unaffected; browsers needing the polyfill show the panel in the wrong position. <link rel="modulepreload"> does not satisfy this; it fetches the module without evaluating it, so execution still waits for the first import. A repo-wide check enforces the order, since no page can detect its own violation.

Load preload.mjs only through that script tag; never import it from a module. It runs by itself, ahead of the rest of the page's modules. An import of it from an app entry point or a component hides a page that is missing the script tag: the polyfills would install partway through the module graph, after the modules imported before it had already run. The same check fails on any .mjs that imports it.

One rejected candidate illustrates the implementation cost to consider. OddBird's css-anchor-positioning cannot read anchor() insets nested inside :where(tag) { … }, which is the shape every public/ui/ component uses, so adopting it would require removing that nesting throughout the components. preload.mjs is the place to load a polyfill from, and any required restructuring of component CSS must be included in the proposal for owner approval (2026-08-04). The approval requirement applies to every polyfill; the CSS changes explain the cost of this candidate. Anchored placement is covered instead by ui/lib/anchor-fallback.mjs, which needs no CSS change.

An existing example: ui/lib/aria-reflection.mjs adds ARIA content-attribute reflection to Element.prototype for engines that lack it. preload.mjs imports it unconditionally and the module feature-detects internally, doing nothing where the platform already has the feature — so a current engine parses the module without changing its prototype. Remove a polyfill when its feature enters the project's Baseline support target. A single import makes removal straightforward and avoids conflicts between obsolete shims.

Not to be confused with the x- tag-name prefix (<x-select>), which marks a component standing in for a native element rather than a runtime patch — see Conventions. Pages opt into those components through markup; the runtime-polyfill rules in this section do not apply to them.

See also