Altitude 1.x → 2.x migration guide
Altitude v2 changes the theming model from a single global <style>
mutation to a scoped <al-theme> host. Most consumers can migrate in
two edits + a pnpm upgrade.
TL;DR
- import { register } from '@southleft/al-web-components';
+ import { registerAltitude } from '@southleft/al-web-components';
- register({ elements: [...], suffix: process.env.VERSION });
+ registerAltitude({ mode: 'versioned', suffix: process.env.VERSION }, [...]);
<body>
+ <al-theme brand="altitude" mode="dark">
<al-button>Save</al-button>
+ </al-theme>
</body>
- "react": "18.2.0"
+ "react": "^19"
That’s the migration. The rest of this guide explains why and when.
1. Wrap your tree in <al-theme>
In v1, the <al-theme-switcher> mutated a global <style id="al-tokens-sheet">
node and ALElement regex-stripped that into every shadow root. v2 replaces
both with a scoped <al-theme> element that sets brand/mode/density/contrast
tokens on :host.
Before (v1):
<body>
<al-theme-switcher></al-theme-switcher>
<al-button>Hello</al-button>
</body>
<!-- token bundle injected at runtime via document.head.appendChild -->
After (v2):
<body>
<al-theme brand="altitude" mode="dark">
<al-theme-switcher></al-theme-switcher>
<al-button>Hello</al-button>
</al-theme>
</body>
Multi-brand pages? Nest:
<al-theme brand="altitude" mode="light">
<al-button>Brand A</al-button>
</al-theme>
<al-theme brand="southleft" mode="dark">
<al-button>Brand B</al-button>
</al-theme>
The two buttons compute distinct --al-* values without touching :root.
That is now true and tested — pnpm test:scoped-theming renders exactly this
markup for both brands in one document and asserts the computed values
differ, and .altitude/visual-compare/brands.scoped.png is the picture. Before
2026-07-28-scoped-token-emission-brand-wiring it was aspirational: brand
was typed and documented but no :host([brand]) rule existed, so both buttons
rendered identically.
<al-theme> composes; it does not replace your token sheet
The scoped blocks are deltas over the base :root bundle — each restates
only what differs, which is what keeps all three brand×mode combinations at a
fraction of what restating them in full would cost. Keep dist/css/main.css
(or some :root token bundle) loaded. Without one, a brand’s literal values
still apply while every var(--al-color-*) reference in it dangles.
Two related consequences:
brandandmodeare independent. A brand’s mode-independent identity (type ramp, radii, spacing, border weights) lives in:host([brand='x']), so<al-theme brand="southleft">with nomodestill looks like southleft.- A brand with no build for a mode has no block for it. Southleft is
dark-only, so
<al-theme brand="southleft" mode="light">renders southleft’s shape over the light colour surface.
Legacy fallback — DEPRECATED, removal target 3.0.0
<al-theme-switcher> keeps its v1 behavior — swapping a whole
<style id="al-tokens-sheet"> into document.head — only when it finds no
<al-theme> ancestor. With an ancestor it takes the scoped path
(theme-switcher.ts:109-113), sets brand and mode on that element, and
never touches the document.
Status: deprecated as of this release; scheduled for removal in 3.0.0.
It is kept rather than deleted because a consumer still needs it today:
apps/web-components/index.html uses the switcher with no <al-theme>
wrapper. (A second consumer — the Storybook <al-theme-switcher> stories, which
opted out of the preset decorator precisely so this path stayed exercised — went
with Storybook when it was retired on 2026-08-25.) Wrap your content in
<al-theme> and the fallback becomes unreachable at no cost — since scoped
emission landed, the scoped path now moves both axes, where before it moved
only mode.
To opt out of the legacy path explicitly:
document.querySelector('al-theme-switcher').scopedOnly = true;
Migration for 3.0.0: wrap in <al-theme>, or set scopedOnly = true and
drive brand / mode yourself. Once every consumer is wrapped, the six
?inline bundle imports at theme-switcher.ts:15-20 and the styles/dist/
legacy mirror they are the last reason for can both go.
2. Switch to the explicit registry modes
registerAltitude({ mode, suffix?, prefix? }, elements) replaces the
opaque register({ suffix }) API. The legacy export keeps working —
nothing breaks — but new code should use the explicit form.
| Need | Call |
|---|---|
Standard <al-button> tags |
registerAltitude({ mode: 'stable' }, els) |
| Coexist with another Altitude version | registerAltitude({ mode: 'versioned', suffix: pkg.version }, els) |
| Register manually later | registerAltitude({ mode: 'manual' }, els) |
stable is the default for new apps. versioned is for micro-frontends.
manual is for tests / SSR.
3. Upgrade React (if consuming @southleft/al-react)
- "react": "18.2.0",
- "react-dom": "18.2.0",
+ "react": "^19",
+ "react-dom": "^19",
@southleft/al-react’s @lit/react-backed wrappers handle the rest. R19 + custom
elements still requires the explicit events map on each wrapper for
listeners to bind — every shipped wrapper already declares this.
<ALTheme> — the React theming host
@southleft/al-react now ships a wrapper for <al-theme>, so a React tree can be
themed without dropping to raw custom elements:
import { ALTheme, ALButton } from '@southleft/al-react';
<ALTheme brand="southleft" mode="dark">
<ALButton>Label</ALButton>
</ALTheme>;
Two things to know.
The tag is versioned. @southleft/al-react registers with
suffix: PackageJson.version, so the element in the DOM is
<al-theme-1-0-0>, not <al-theme>. Anything that looks a theme host up by
tag name will not find it — including <al-theme-switcher>, whose
closest('al-theme') walk is hardcoded to the plain tag. Set the axes on
<ALTheme> directly rather than relying on that walk.
Pass only the axes you mean. density and contrast are optional;
omitting one leaves the attribute off, which is how you say “no position on
this axis”. density="comfortable" and omitting density are equivalent.
<ALTheme> is not a bare createComponent wrapper like the other 65. It adds
a layout effect that mirrors the five axes to ATTRIBUTES, because @lit/react
sets reactive properties and every :host([brand='…']) / :host([mode='…'])
rule the tokens live in is an attribute selector. Without it the props are
accepted, the properties are correct, and nothing re-themes. See
libs/al-react/src/components/Theme/Theme.tsx.
Theme presets — RETIRED 2026-08-25
Both Storybooks carried a Preset toolbar dropdown over one shared module.
Storybook was retired on 2026-08-25 and nothing replaced the dropdown, the
parameters.alPreset opt-out, or the test:preset-parity script — the checker
and its root alias were both deleted. Set the axes on <ALTheme> /
<al-theme> directly.
The preset data survived the move, at libs/al-web-components/theme-presets.ts
— four brand × mode pairs plus the vocabulary of the other axes. It is not a
consumer-facing API; its two readers are the story fixture and apps/home’s
stats generator.
4. New props on existing components
<al-theme>props:brand,mode(light|dark),density(compact|cozy|comfortable),contrast(normal|more),motion(full|reduced — falls back toprefers-reduced-motion).<al-theme-switcher>addsscopedOnlyto suppress the legacy global-style fallback.
4b. Icons — Phosphor
Altitude now ships the full Phosphor set (1,512
icons, regular weight, MIT) instead of 37 hand-authored SVGs.
Existing markup keeps working. All 37 <al-icon-*> elements and their
ALIcon* React wrappers still exist and are still exported — they now render
Phosphor artwork, so icons will look different. Phosphor’s regular stroke is
heavier than the old Altitude line work; budget a visual pass. The elements are
deprecated and will be removed in 3.0.
New code should use the name-based API with an explicit registration, which is tree-shakeable and renders synchronously (SSR-safe, no placeholder flash):
import { caretDown } from '@southleft/al-web-components/dist/components/icon/glyphs.js';
import { registerIcons } from '@southleft/al-web-components/dist/components/icon/registry.js';
registerIcons({ 'caret-down': caretDown });
<al-icon name="caret-down" size="sm"></al-icon>
If icon names come from data you don’t control, opt into the loader once — ~13 KB gzipped plus a request per icon, and it cannot render server-side:
import '@southleft/al-web-components/dist/components/icon/lazy.js';
Legacy name → Phosphor name
| 1.x | 2.x | 1.x | 2.x | |
|---|---|---|---|---|
add |
plus |
menu |
list |
|
attachment |
paperclip |
pin |
map-pin-simple-line |
|
chevron-down |
caret-down |
search |
magnifying-glass |
|
chevron-left |
caret-left |
send |
paper-plane-tilt |
|
chevron-right |
caret-right |
settings |
gear |
|
chevron-up |
caret-up |
success |
check-circle |
|
close |
x |
support |
headset |
|
document |
file-text |
warning-triangle |
warning |
|
dots-horizontal |
dots-three |
emoji |
smiley |
|
dots-vertical |
dots-three-vertical |
filter |
funnel |
|
help |
question |
home |
house |
|
layout-masonry |
squares-four |
list |
list-dashes |
Unchanged: bell, bookmark, calendar, check, clock, copy, info,
minus, sign-in, sign-out, star, user, warning-circle.
The full map lives in libs/al-web-components/icons/legacy-aliases.json.
<al-icon name="close"> also still resolves, because the alias map is consulted
when a name isn’t a Phosphor icon.
One deliberate collision.
<al-icon-list>renders the legacy bulleted list (Phosphorlist-dashes), but<al-icon name="list">renders the Phosphor hamburger. Name lookup checks the Phosphor catalog before the alias map, so a legacy name can never shadow a real Phosphor icon. Migrate<al-icon-list>to<al-icon name="list-dashes">.
Icon webfont
The generated icon webfont is removed. The .icon-<name> utility classes and
the iconfont @font-face no longer exist; dist/fonts/iconfont.css ships as an
empty deprecation stub for one minor version so existing @imports don’t 404.
At 1,512 glyphs the base64-inlined font would have been ~275–400 KB. Replace
<span class="icon-close"> with <al-icon name="x">.
5. Tokens you may still rely on
Token names are frozen at the 1.0 alias map
(libs/al-web-components/styles/dist-v5/aliases.json). v2 ships the same
set; the alias map gates deprecation through the 3.0 compat budget.
If you’re authoring against --al-* directly, no change. If you used
var(--al-theme-color-background-neutral-default) etc., those still resolve to
the same values in v2’s default theme.
6. Build / dev tooling
- v2 ships Vite as the library builder (webpack removed) —
^7.1.12today, and Vite 5 when this guide was written. Plus pnpm 9 workspaces and Node 22 LTS. - Sass is on
@use/@forwardwith the modern compiler API — zero deprecation warnings in the build pipeline. - Storybook is gone. v2 originally shipped Storybook 10 on the
@storybook/web-components-vite/@storybook/react-viteframeworks; both Storybooks and their.storybook/directories were deleted on 2026-08-25 and nothing replaced them as a component explorer. If you were consuming Altitude’s Storybook: the documentation site isapps/docs(https://altitude.pages.dev/docs), and the isolated render surface islibs/al-web-components/story-fixture(pnpm run build:story-fixture).*.stories.tsfiles themselves survived and are still authored — the fixture, the docs previews, and the MCP all read them.
7. SSR
@lit-labs/ssr renders the v2 components with Declarative Shadow DOM.
See .altitude/SSR.md for the matrix.
8. CI checklists for consumers
- Replace
register({...})withregisterAltitude({mode:'stable'}, ...). - Wrap your app shell in
<al-theme>. - Update React (and
@lit/react) to v19. - Run the contract validator against your generated/templated HTML
examples — see
scripts/validate-contracts.jsfor the reference implementation.
9. What was removed
getGlobalStyles()regex-strip path insideALElementis gone (T4.3).- The legacy SD v3 pipeline (
tokens-config.js) and thewca(legacy manifest analyzer) are gone (T6.2). The v5 pipeline ships the same byte-identical--al-*output.
10. Layout consolidation — one arrangement primitive
v2 collapses every “where do boxes sit” concern onto a single component,
<al-layout>. Before, arrangement was spread across six layout components and
sixteen components carrying their own arrangement props under nine different
names (orientation, gap, alignment, align, direction, behavior,
justify, verticalAlignment, mediaPosition). “Row or column” alone was
spelled three ways.
The new <al-layout>
<al-layout
variant="constrained|grid|bento" <!-- omit for flow -->
direction="row|column" <!-- default column -->
gap="none|xs|sm|md|lg|xl" <!-- default 16px -->
align="start|center|end|stretch" <!-- cross axis -->
justify="start|center|end|between" <!-- main axis -->
size="sm|md|lg|xl|xxl|full" <!-- constrained: the measure -->
gutter="none|sm|md|lg" <!-- constrained: track width -->
columns="1-12" <!-- grid: column count -->
wrap grow stretchItems responsive fullHeight noCollapse>
Layout owns three orthogonal jobs, chosen by variant:
- flow (no variant) — stack or row content.
- constrained — the page measure. Children sit in a centred content column
capped at
size, with gutter tracks either side. A child markedbleedbreaks out and runs edge-to-edge. This is the “constrained layout” pattern — the page declares its measure once and each child decides whether it lives inside it, so sections no longer each need their own container wrapper. - grid — an N-column grid. Children span with the SAME
al-u-grid__item col:Nclasses the.al-u-gridutility uses; there is only one span system in the design system. - bento — a 12-column auto-row grid for
<al-bento-item>children.
<al-layout variant="constrained" size="xl">
<al-hero bleed></al-hero>
<al-heading tagName="h2">Features</al-heading>
</al-layout>
Two behaviours worth knowing:
<al-layout>’s host isdisplay: contents. Its layout box participates directly in a flex/grid parent, which is what makes it work when projected into a slot. Addgrowwhen it needs to absorb the parent’s free space — required forjustifyto have room to act on inside aspace-betweendialog or popover footer.min-height: 100vhis now opt-in viafullHeight. It used to be unconditional, which made<al-layout>unusable for anything smaller than a page. Every page shell must addfullHeightor it will collapse to content height.
Removed components
| Removed | Replacement |
|---|---|
al-button-group |
<al-layout direction="row"> |
al-layout-container |
<al-layout variant="constrained"> |
al-layout-section |
a plain child of <al-layout> |
al-bento-grid |
<al-layout variant="bento"> |
al-split-content |
<al-layout direction="row" wrap> + a theme class |
variant="sidebar-left|sidebar-right" |
<al-layout variant="grid"> + --al-layout-template |
- <al-button-group alignment="right">
+ <al-layout direction="row" justify="end" grow>
- <al-button-group alignment="center">
+ <al-layout direction="row" justify="center" grow>
- <al-button-group behavior="stacked">
+ <al-layout>
- <al-button-group behavior="stretched">
+ <al-layout direction="row" stretchItems>
- <al-button-group behavior="responsive">
+ <al-layout direction="row" responsive>
- <al-layout-container>
+ <al-layout variant="constrained" size="xl" gutter="sm">
- <al-bento-grid>
+ <al-layout variant="bento">
The sidebar variants are gone
sidebar-left / sidebar-right hardcoded one ratio (40% / 1fr at md). A page
now declares its own track list through --al-layout-template, which inherits
through the shadow boundary into variant="grid". Pair it with noCollapse so
the page owns the responsive story:
- <al-layout variant="sidebar-left" gap="none">
+ <al-layout variant="grid" gap="none" fullHeight noCollapse class="app-shell">
.app-shell { --al-layout-template: 1fr; }
@media all and (min-width: 768px) {
.app-shell {
--al-layout-template: 320px minmax(0, 1fr);
}
}
This is strictly more capable than the old variant — any track list works, not
just 40%/1fr — and it is how al-split-content was retired too: the two-column
media/content band is now theme CSS applied to an <al-layout direction="row" wrap>, because the differing column flex-bases that make it stack intrinsically
are a page-design decision, not a design-system behaviour.
Group components kept their semantics, lost their arrangement
These components still exist — they own real behaviour a layout box cannot
express — but their arrangement props are gone. Nest the slotted content in an
<al-layout> instead.
| Component | Still owns | Removed prop |
|---|---|---|
al-checkbox-group |
<fieldset>/<legend>, field note, required/disabled cascade |
variant="horizontal" |
al-radio-group |
the above + arrow-key roving selection | variant="horizontal" |
al-toggle-button-group |
single-select state, click-outside deselect | orientation, gap |
al-chip-group and al-toast-group have been removed entirely. Replace
<al-chip-group> with <al-layout direction="row" wrap>; the “+N” overflow
counter has no replacement. <al-toast-group> has no replacement either —
position <al-toast> yourself. al-toast’s onToastGroupOpen event is
renamed onToastOpen now that no group exists to name.
- <al-radio-group label="Posted at" variant="horizontal">
- <al-radio>Any time</al-radio>
- <al-radio>Last 24 hours</al-radio>
- </al-radio-group>
+ <al-radio-group label="Posted at">
+ <al-layout direction="row" wrap gap="md">
+ <al-radio>Any time</al-radio>
+ <al-radio>Last 24 hours</al-radio>
+ </al-layout>
+ </al-radio-group>
Renamed for one vocabulary
al-list and al-time-selector-list cannot delegate to <al-layout> — a
layout box between a <ul> and its <li> children would break list semantics —
so they keep their own arrangement but now use Layout’s names.
- <al-list orientation="horizontal">
+ <al-list direction="row">
- <al-time-selector-list orientation="horizontal">
+ <al-time-selector-list direction="row">
(al-split-content is not part of this rename — it was removed entirely; see
the “Removed components” table above for its <al-layout direction="row" wrap>
replacement.)
Note: orientation on the internal menu controller is unchanged — there it
means keyboard navigation direction, not layout.
The rule going forward
If a wrapper would own no behaviour, no ARIA relationship, and no state, it
is not a component — it is <al-layout> with props. Do not add a
direction/gap/align/justify prop to a new component, and do not
hand-roll flex or grid to arrange slotted children.
11. Colour tokens — default became neutral, and every semantic family carries a five-step ramp
Two changes to the tier-2 colour tokens, both breaking.
theme.color.{background,content,border}.default-* → neutral-*
The family that used to be called default is the neutral (grey) ramp, and it
now says so. The step names are unchanged; the bare default token becomes
neutral-default. The Figma variables carry the same rename.
- var(--al-theme-color-background-default)
+ var(--al-theme-color-background-neutral-default)
- var(--al-theme-color-background-default-weak)
+ var(--al-theme-color-background-neutral-weak)
- var(--al-theme-color-background-default-strong)
+ var(--al-theme-color-background-neutral-strong)
- var(--al-theme-color-background-default-bold)
+ var(--al-theme-color-background-neutral-bold)
- var(--al-theme-color-content-default)
+ var(--al-theme-color-content-neutral-default)
- var(--al-theme-color-content-default-weak)
+ var(--al-theme-color-content-neutral-weak)
- var(--al-theme-color-content-default-faint)
+ var(--al-theme-color-content-neutral-faint)
- var(--al-theme-color-border-default)
+ var(--al-theme-color-border-neutral-default)
- var(--al-theme-color-border-default-weak)
+ var(--al-theme-color-border-neutral-weak)
- var(--al-theme-color-border-default-strong)
+ var(--al-theme-color-border-neutral-strong)
A find-and-replace of color-background-default, color-content-default and
color-border-default with the -neutral- form covers every case; the old
names are no longer emitted. (disabled-default, inverse-default,
transparent-default and the <family>-default steps are not part of this —
there default is the step, not the family.) The Southleft brand’s
background.default-stronger override folded into neutral-bold.
Every semantic family now has faint / weak / default / strong / bold
primary, secondary, tertiary, info, success, warning and danger
each expose all five steps for background, content and border. Existing
steps kept their values; the new ones (63 tokens) follow each family’s tier-1
ramp. tertiary is new to tier 2 entirely. Nothing you already use changes
value — this is additive apart from the rename above.
Questions
Open a discussion on the repo, ping @southleft on Twitter, or email
design-system@southleft.com.