Card.Delta's label is toned again, matching the design spec it had drifted from: green on the green tint, red on the red. The 2026-09-01 change to --cds-text-primary was made on contrast grounds without checking the spec, which calls for the status colour. Only the success tone falls short of WCAG AA at 14px, measuring 3.99:1 on a white card; danger clears it at 5.15:1. The 3.16:1 the earlier entry cites does not reproduce against the shipped tokens. Success is being resolved by revisiting the tone token rather than by overriding the design.
Before
After
label used the primary text colour
label uses the status solid colour, matching Figma
Added
BottomSheet
scrimOpacity caps how opaque the backdrop may be, so a host can reveal what is behind the sheet without moving it. Until now the only way to lighten the scrim was lowered, which slides the sheet down as well — fine for a sheet that steps aside per action, wrong for one that has to hold still while something lands behind it. The two compose: whichever asks for more relief wins, so a sheet that only ducks is unchanged.
Before
After
—
scrimOpacity
Added
Tokens
--cds-bg-dashboard, the floor the home dashboard sheet paints its widgets on. Named for its one consumer on purpose. In dark it is black: --cds-bg-page sits one grey-ramp step under --cds-bg-surface, so a widget had no edge against the sheet behind it. Light keeps the page grey, which already separates white cards.
Changed
Tokens
Dark --cds-bg-surface moves from grey-05 to grey-06, the DS 4.5 Global/Background/BKGD_02 value. It had been painting the same hex as --cds-input-container, so a field on a dark card had no edge — Input, Select, Textarea, SearchField and DatePicker all disappeared into the surface behind them, and surface="raised" was a no-op in dark for the same reason. Cards now sit one step above the page and one below a field.
Before
After
--cds-bg-surface (dark): #232321, identical to --cds-input-container
--cds-bg-surface (dark): #1b1b18
Added
Tokens
A chip fill/label pair per status tone: --cds-bg-status-* and --cds-text-on-status-*. DS 4.5 renders a status tag as a 10% tint in light and a solid fill with white text in dark, because the tint is invisible on a dark surface. The existing --cds-status-*-solid/-subtle keep their meaning as the status hue and its wash — around 25 consumers read them for error text, borders and icon plates, so they could not carry a per-theme flip.
Changed
Tag
emphasis="subtle" now renders a solid fill with white text in dark, matching the DS 4.5 tag boards. It reads the new chip pair rather than the raw tone hue. In dark this makes subtle and solid emphasis look alike, which is what the design specifies. Neutral loses its .neutral.emphasis-subtle override — its tone entry is already a surface grey.
Before
After
dark subtle: #0089241a fill (10% wash) with #008924 text
dark subtle: #008924 fill with #ffffff text
Changed
Tokens
--cds-status-warning-* stops borrowing --cds-color-risk-fair and uses the DS 4.5 Status/Orange palette, added as --cds-color-orange-01/-02. The borrow was marked provisional "pending a Figma status palette"; that palette now exists. Info and neutral stay provisional — DS 4.5 has no counterpart for either.
Three value repairs found auditing against the DS 4.5 export. Dark --cds-brand-subtle was a hand-lightened #ff7a3d backed by no primitive in either system; it is brand at 15% like light. --cds-text-ai-secondary was grey-02 in both themes where DS 4.5 specifies grey-04. And --cds-color-risk-high duplicated --cds-color-red-01 hex-for-hex, so --cds-risk-high now points at red-01 and the duplicate primitive is gone.
Fixed
Dialog
A dialog with no secondary action drew a bright blue WebKit focus ring around the whole card. Its open effect focuses the safe secondary action, falling back to the dialog root when there isn't one, and the UA ring followed. The root now sets outline: none, matching BottomSheet's sheet; the programmatic focus stays, so VoiceOver still lands on the dialog when it opens.
Before
After
outline: 3px auto rgb(0, 136, 255) on the focused dialog root
outline: none
Fixed
Tag
A label wider than the tag no longer spills past the rounded edge to be cut mid-glyph by whatever ancestor clipped first. The label truncates with an ellipsis inside the tag instead, which needed display: block on it: text-overflow had nothing to act on while it shared an inline-flex rule with the leading slot. Affects any Tag whose text can outgrow its container — fullWidth in a narrow column most of all. Worth a look if you have a label sitting within a few px of its box: an overflow small enough to render intact before now earns a visible ellipsis.
Before
After
Overflowing label ran past the tag and was clipped mid-character
Overflowing label truncates with an ellipsis inside the tag
Fixed
Storybook
Form-control stories no longer render invisibly. --cds-input-container is the same color as the Storybook canvas — cream in light, and grey-04 once a story staged itself on a white surface in dark — so Input, Field, Select, Textarea, SearchField, DatePicker and Autocomplete painted their default fill on an identical background. A stage: 'form-control' story parameter now repaints the canvas itself to the contrasting surface for the active theme, rather than nesting a second canvas inside the first. The Surfaces story on Select, Textarea and SearchField became RaisedOnCanvas, since the two surfaces cannot both read on one canvas; story permalinks changed accordingly.
Input's stories go from 17 to 9, adopting the shape its siblings already use. Six one-state stories collapse into one StateMatrix and two focus stories into Focused; Password/Url/ReadOnlyValidation become Types; SearchSmall joins WithSlots as its sm row; and the two return-key form fixtures collapse into MobileFormFlow, since the fuller one already covered the other. Nine permalinks are gone and Input.mdx points at the merged stories.
Before
After
17 stories, 9 of them referenced by no doc page
9 stories, 8 referenced by Input.mdx
Fixed
Background
Background sets box-sizing: border-box, matching Screen. It pairs width: 100% with the padding props, so inside any parent that constrains its width it overflowed to the right by the full padding — visible as soon as one was nested rather than used as a screen root.
Removed
Textarea
error is gone from Textarea, Select and DatePicker, matching Input. All three rendered their own message while Field rendered a second one, and all three drew the danger border from their own error string, so a Field error left the control unstyled. Each now takes invalid?: boolean instead, replacing the ariaInvalid prop that set the attribute but never the border. RadioGroup's ariaInvalid is renamed to invalid for the same reason.
Before
After
error?: string and ariaInvalid?: boolean on four controls
invalid?: boolean, with the message owned by Field
Changed
Field
Field publishes the whole control contract — id, labelled-by, described-by, required, disabled, invalid — through context, and cloneElement is gone. Cloning only ever reached a direct child that happened to accept those exact props, so a control wrapped in an app component silently dropped them: the data-removal form's TextInput takes no id, which left every htmlFor there pointing at an element that did not exist. Controls now read the wiring themselves through useFieldControl, and their own props still win.
Before
After
React.cloneElement onto a direct child
FieldContext, read by each control
Fixed
Docs
The UsageTable and Do/Dont MDX blocks read their colors, spacing, and radii through a legacy pui custom-property prefix that the shipped tokens never defined, so every one of them fell back to nothing and the guidance cards rendered as plain grey boxes with no green-do or red-dont treatment. All 17 names now use the cds prefix the blocks README already documented.
Before
After
17 custom properties under a prefix nothing declares
the same 17 under the shipped cds prefix
Removed
Input
error is gone from Input; Field is the only owner of validation copy. Both components used to render their own message, and only Input's own error string painted the red ring — so <Field error='…'><Input/></Field> produced red text under a grey field, and the only way to get the ring was to pass the error to both and render the message twice. Callers that showed an error on a bare Input now wrap it in a Field, whose label is optional.
Before
After
error?: string on both Input and Field
error?: string on Field only
Changed
Input
ariaInvalid is replaced by invalid, which drives the visual as well as the attribute. One value now sets data-invalid on the control and aria-invalid on the input, and the ring is keyed off the attribute in CSS, so the treatment and the semantics cannot drift apart. Inside a Field it is inherited and needs no prop; set it directly only where there is no Field — the caller still owns showing the user readable copy somewhere.
Before
After
ariaInvalid?: boolean set the attribute but never the ring
invalid?: boolean sets both, and a parent Field supplies it
Added
Field
Setting error now marks the child control invalid through React context rather than only through the cloned ariaInvalid prop. Context reaches a control nested below a wrapper element, which prop cloning could not, and it removes the reason app code had grown local addressInputError classes to paint the border Field could not reach. Input reads it today; the other form controls follow in the next change.
Before
After
—
FieldContext publishes { invalid }
Added
SectionHeader
trailingAlign accepts baseline, which renders the trailing slot on the title's own line so the two share a baseline. top only matches box tops, leaving smaller text riding high beside a larger title.
sizing prop. hug sizes each option to its own label so an auto-width strip stops ellipsising labels it has room for; fill stays the default, so existing strips are unchanged.
Changed
Screen
Screen now clears and frosts the navigation bar the host draws over the web view without being told to. The root draws a progressive blur the height of --cds-safe-area-top-chrome, gated on a new --cds-top-chrome-frost the host publishes; iOS cannot draw this itself, because its own scroll-edge material resolves from a scroll view no web-view toolbar has. Screen.Body insets by the same token on every screen, taking the larger of it and the device inset rather than stacking them. Both tokens default to zero, so a screen with no host chrome is unchanged. Breaking:underTopChrome is gone — the inset it asked for is now the default. A body whose own first child reaches the top edge takes bleedUnderTopChrome.
Before
After
<Screen.Body underTopChrome> on every screen sitting under the bar
<Screen.Body> — the inset comes free, and the bar it sits under decides the frost
--cds-safe-area-top-overlay is where a position: fixed overlay may start: max(--cds-safe-area-top-chrome, --cds-safe-area-top). The chrome token alone is not enough, because it is 0px wherever the host draws no bar — a surface whose web header reserves that space in normal flow instead. An in-flow element is pushed down by that header; a fixed one is not, so it read the 0px and sat 8px from the viewport top, under the status bar. Flooring against the device inset means a fixed overlay cannot climb above the notch however the host answers. Reach for it rather than --cds-safe-area-top-chrome whenever the element is position: fixed.
Before
After
calc(var(--cds-safe-area-top-chrome) + var(--cds-space-8)), which resolves to 8px on a surface with no native bar
Lines keep a 6-unit inset above the bottom of the svg, mirroring the 12-unit headroom the top has always had. A zero-valued run used to map to the exact bottom edge, rendering the stroke half-clipped and visually touching whatever the consumer laid out beneath the chart.
Before
After
a value of 0 drew on the svg bottom edge, stroke half-clipped
a value of 0 draws 6 units above the edge; peaks are unchanged
Added
Flag
Country flag as a circular mark, keyed by ISO 3166-1 alpha-2 and drawn from vendored artwork (circle-flags, MIT) for the 89 countries the VPN catalog can return. Replaces the flag emoji, which the platform draws glossy and waving at a size and baseline we cannot control, and which made the webview look like a different product from the iOS app — that app has always used circular flag artwork. Carries a hairline ring so white-field flags (Japan, Poland, Finland) do not dissolve into a light surface. code is a plain string because codes arrive from payloads; an unknown one renders nothing, and isFlagCode narrows when a caller wants a fallback.
Before
After
flag emoji rendered by the platform font
<Flag code='nl' size='sm' />
Added
Globe
Orthographic globe visualization, drawn on canvas 2D as either a land-dot field (mode='dots') or a coastline wireframe (mode='wireframe'), with optional location markers and a slow idle drift gated on prefers-reduced-motion. Decorative geography for surfaces that need to say "somewhere in the world" — it is not a readable map. Geometry ships as a baked 3KB land bitmask plus 23KB of simplified coastlines instead of a 169KB polygon set, so there is no runtime point-in-polygon work and no WebGL dependency. Use zoom to show a partial sphere: it draws the globe larger than its canvas so the canvas crops it, which keeps the element inside its own layout box — an oversized canvas in an overflow-hidden wrapper still reports an overflowing box and trips UI audits. Use intensity to carry an active and an idle state on one globe by dimming rather than swapping looks — values around 0.3 read fine, but what actually decides it is how much land the visible face carries — dot density compresses toward the limb, so a faint globe turned to an ocean face shows little but its limb and reads as an arc. Anchor a dim globe on a coordinate rather than letting it drift, so the face always has land on it. The canvas renders up to 3x device pixel ratio rather than the 2x the other canvas visualizations cap at, because a hard-edged dot field looks soft beside crisp text when rendered below the screen's density.
The multiline field drops its brand focus ring and squares up its corner to match Input. It was the only form control still painting a 3px --cds-brand ring on focus — dead weight in a touch-only WebView where the caret and the on-screen keyboard already mark the active field, and a look Input had already abandoned for a plain outline: none. Its 32px corner came from reading Input's pill intent literally: correct on a 56px control, but at 120px tall it read as a lozenge and double-rounded against every container it sat in, most visibly at 32px inside the 24px chat takeover. The placeholder also moves onto --cds-text-secondary at full opacity instead of primary text at a hardcoded 0.3 alpha, which is what Input already does.
Before
After
32px corner, 3px brand focus ring, placeholder at primary/0.3
16px corner, no focus ring, placeholder at --cds-text-secondary
Changed
Tag
The gap between a Tag's dot or leading icon and its label steps from 4px to 8px. At 4px the marker and the first word read as one clump. Card.Delta moves with it, so a status pill and a delta pill sitting side by side space their contents identically.
Before
After
--cds-space-4 (4px) between marker and label
--cds-space-8 (8px)
Added
Glyph
trend-up and trend-down: trend-line glyphs for a figure that moved across time windows. The existing arrows and carets point at a direction without implying a measurement changed, so a comparison had to borrow a navigation glyph.
Fixed
Card
Card.Delta's label renders in --cds-text-primary instead of the status colour. A toned label sat on that status's own subtle tint, measuring 3.16:1 against it — below the 4.5:1 minimum, and not fixable by size, since the label is 14px and large-text rules start higher. The marker and the background tint still carry the tone.
Before
After
label used the status solid colour, 3.16:1 on the subtle tint
label uses the primary text colour; the marker and tint stay toned
Added
Card
Card.Delta takes tone and marker. tone decouples the pill's color from its arrow, for a figure whose sentiment runs against its direction — a falling spam count is down and success. marker='trend' swaps the caret for the new trend-line glyph. Both default to the previous behaviour, so existing deltas are unchanged.
Before
After
direction set the tone: up was always success, down always danger
direction sets the marker; tone is overridable and adds warning and neutral
Fixed
BottomSheet
BottomSheet.Header keeps its 20px gutter regardless of the sheet's padding props. The header renders inside the padded content region, so paddingX={0} — which exists so one child can run edge to edge — was also pulling the title flush to the screen edge. The content region now publishes its resolved inline padding as --sheet-gutter-start/--sheet-gutter-end and the header backs out of it before setting its own.
Before
After
paddingX={0} moved the title to the screen edge along with the body
the title keeps the page gutter; only the body runs edge to edge
Fixed
BottomSheet
The sheet honors prefers-reduced-motion in full. Its own travel is spring-driven and already snapped to target under the setting, but two CSS transitions kept animating regardless — the sheet sliding on lowered, and the scrim fading behind it — so a reduced-motion user still saw the sheet glide and the backdrop cross-fade.
Before
After
lowered slide and scrim fade transitioned at 400ms under prefers-reduced-motion
both transitions disabled under prefers-reduced-motion; the spring already snapped
Added
BottomSheet
lowered (0–1) slides the sheet down by that fraction of its own height and fades the backdrop in step, without dismissing it — for a host that needs to duck the sheet out of the way and bring it back. The scrim moved onto an .overlay::before pseudo-element so it can fade on its own transition while the dismiss spring keeps the overlay opacity.
Before
After
—
lowered?: number
Added
Tokens
New motion token --cds-ease-in-out for travel between two resting positions. --cds-ease-out launches at full speed and --cds-ease-exit arrives at full speed, so a move that starts and ends at rest reads as a snap on either of them.
The sheet takes padding props, resolved against the page gutter the way Screen.Body does: with none set the content region keeps its 20px inset, and paddingX={0} opens it up so a child can run edge to edge. The gutter used to sit on the sheet element itself, outside the content region's overflow-x: hidden, so a full-bleed list was clipped at the gutter no matter what the caller did. Existing sheets render identically — the inset moved to an ancestor of the same content.
Before
After
gutter hardcoded on the sheet; no way to host an edge-to-edge list
<BottomSheet paddingX={0}>
Fixed
BottomSheet
The sheet no longer draws the browser default focus ring. It takes focus on open so VoiceOver lands inside it, which on this touch-only runtime painted a blue outline along its top edge with no keyboard behind it to explain the ring. Focus still moves into the sheet; only the outline is suppressed.
silhouette marks a card as a measurement target for a page silhouette, rendering data-silhouette on the root. Presence-only, so the attribute is omitted rather than set to "false".
Before
After
—
<Card silhouette>
Changed
BottomSheet
A sheet titled itself two ways and only one of them ever got new behaviour. The root title prop rendered one element, BottomSheet.Header rendered another, and the safe-area fix that shipped just before this had to name both selectors to cover them — the second path having been missed once already. Both collapse into one slot pair: BottomSheet.Header is the chrome wrapper, and the new BottomSheet.Title inside it names the sheet, matching how Dialog has always worked. Title must sit inside Header, so exactly one element can be the sheet's topmost and the top inset has one place to live rather than two. Two consequences worth knowing. The title no longer draws a divider beneath it, so every sheet that used the root prop looks different. And because a slot is invisible to the compiler, the old props union that forced every sheet to carry a title or an ariaLabel is gone — ariaLabel is plainly optional now and an unnamed sheet is caught by a dev-time check plus the new @cloakedinc/bottom-sheet-slots lint rule instead of a type error.
Before
After
root title prop or BottomSheet.Header title=..., two elements, divider on one of them
BottomSheet.Header wrapping a BottomSheet.Title, one element, no divider
Added
Screen
Screen.Header.Title takes variant, either 'h4' (the default, unchanged) or 'h5'. h5 is the ramp entry matching the native iOS bar's .font(.headline), so the web fallback bar can match the native one without dropping to a raw Text and losing the title's truncation.
Before
After
always h4; matching the native bar meant a raw <Text variant='h5'> that no longer truncates
<Screen.Header.Title variant='h5'>
Removed
Tokens
The env() fallback every read of a safe-area token carried is gone: Screen and Background now emit var(--cds-safe-area-top) rather than var(--cds-safe-area-top, env(safe-area-inset-top, 0px)). Registering the tokens gave them an initial value, and var() takes its fallback only for a property that is guaranteed-invalid — a registered one never is. Every one of those fallbacks was therefore already unreachable, and reading like a safety net that no longer existed. The tokens still default to env() where they are declared, so nothing renders differently.
--cds-safe-area-top, --cds-safe-area-bottom, and --cds-safe-area-top-chrome are registered with @property as <length>. The host writes these from JS, and an unregistered custom property accepts any string: one bad write (undefinedpx) invalidated every calc() reading the token, which cost Home its scroller inset and the whole screen its scroll. Registered, a non-length write resolves to the 0px initial value, so the same mistake costs one inset. env() still resolves through the registration, so the declared defaults are unchanged.
Before
After
unregistered: a non-length write invalidates every calc() that reads the token
registered <length>: a non-length write falls back to 0px
Added
Glyph
Two names in the icon vocabulary. trash is new artwork from the Figma library. share is a second name for the drawing already registered as send — Figma calls it action/share, so reach for share when the action opens a share sheet. Additive; no existing name changes.
NativeSymbol, the type a native navigation bar control names its glyph with: either a NativeSymbolName or { sf, fallback }, an escape hatch reaching an Apple symbol the vocabulary has no name for while carrying the CDS glyph the web fallback bar draws instead. NATIVE_SYMBOL_NAMES gains share and trash. See docs/adr/0009.
Card.Delta holds one line and its intrinsic width instead of being squeezed by a flex parent. A title sharing its row now wraps to absorb the shortfall, and a value too long for the row ellipsises.
Before
After
the pill shrank and its label wrapped onto a second line
the pill never wraps; the title yields, then the label ellipsises
An ariaLabel prop on Card.Delta, for giving assistive technology the unabbreviated figure when the visible value is compacted. Additive.
Before
After
—
<Card.Delta value='1.8k this week' ariaLabel='Up 1,825 this week' />
Added
Tokens
--cds-bg-inverse-elevated — a fixed-dark surface one step lighter than --cds-bg-inverse (grey-05), for a dark card that reads as elevated over a dark or brand-tinted backdrop (e.g. the identity hero card). Stays dark in both themes. Additive.
Before
After
—
background: var(--cds-bg-inverse-elevated)
Added
SectionHeader
A trailingAlign prop (center by default, or top). Pin the trailing slot to the title line when a description makes the body taller, such as an item count beside a titled section. Additive.
Before
After
—
<SectionHeader trailingAlign='top' … />
Changed
Select
The trigger drops its resting divider border (transparent until focus or error) and uses symmetric 20px horizontal padding, matching Input. The chevron now sits flush with the field edge instead of inset by the old 56px trailing padding.
Before
After
border --cds-divider; padding 0 56px 0 24px
border transparent; padding 0 20px
Added
Autocomplete
A surface prop, forwarded to the search field (default by default, or raised). Pass raised inside a raised form so the field reads as an input instead of blending into the container. Additive.
Before
After
—
<Autocomplete surface='raised' … />
Added
Chip
A readonly shape: a chip with neither onPress nor onRemove renders a span with no focus stop, for a locked member of a chip set that reads as the same family as its removable siblings minus the ×. Use Tag for a standalone marker or status label. Additive.
Before
After
—
<Chip size="md">Kevin Dao</Chip>
Added
Chip
Removable chips accept busy, which swaps the remove icon for a spinner and blocks the button (aria-busy) while a removal is in flight. Additive and default-off.
Before
After
—
Chip busy?: boolean (removable)
Fixed
DatePicker
The selected (centered) wheel value is visible again. The highlight band carried z-index: 1 while the wheel columns were unpositioned, so the opaque band painted over the centered row and blanked its text. The columns now sit above the band via position: relative + z-index: 2.
Added
Dialog
A color prop on Dialog.Description (primary by default, or secondary). Multi-paragraph dialogs rank their copy by color and order: supporting paragraphs drop to secondary text and the consequence the reader must not skim past stays primary and sits last, above the actions. This replaces the short-lived emphasis flag, which needed a literal font weight the type ramp does not carry.
Before
After
<Dialog.Description emphasis>
<Dialog.Description color='secondary'>
Changed
Dialog
Copy is now slotted rather than passed as props: compose Dialog.Title and one Dialog.Description per paragraph inside Dialog.Body. Both slots take plain strings, so a dialog can carry several paragraphs without the surface accepting arbitrary markup — the failure mode a ReactNode description invites. Dialog.Body owns the aria-describedby target, which keeps the accessible description to the copy and excludes the title. @cloakedinc/dialog-slots fails the build on any other child, and a dev-only console error covers what the React 18 element types cannot see.
An optional error prop shows a retryable failure from the last action attempt. It renders in a role="alert" region below the copy and stays out of aria-describedby, so a transient message is announced once instead of joining the dialog's standing description.
Added
HStack / VStack
An optional id prop, matching the one Text already carries, so a stack can be the target of an aria relationship such as aria-describedby without a wrapper element.
Added
Avatar
brandFrame='none' suppresses the hairline ring on a brand avatar. The ring was added to give mixed brand-mark silhouettes one consistent edge, which is the right call when the mark is resolved at runtime and its shape is unknowable — a favicon or brandfetch asset can be a circular badge, a wide rect, or a bare square. It earns nothing on a mark we bundle and have looked at, where it reads as a seam around a logo that already has its own edge. Use none only for that case; anything resolved from a URL keeps the ring. Default stays ring, so every existing brand avatar is unchanged.
Before
After
the brand chip ring was unconditional, escapable only via className
brandFrame='none' on a bundled mark whose silhouette you have checked
Removed
Screen
The padding props on Screen.Header and Screen.Footer. Chrome padding is now a system constant — header 20/12/8, footer 20/16/16 — and a padding on the Screen root reaches only the body. The props were optional with no fallback, so an omitted paddingX rendered a header flush to the screen edge; seven shipped that way, the most recent being a close button whose optical centre sat 12pt from the edge instead of 32pt. The values that were set drifted into four different boxes: 12/8 across the extension, 16/12 copy-pasted through thirteen preview files, and two chat headers at 12 whose icons never lined up with their own body content at 20. Body padding is untouched and stays authorable, because body content genuinely varies. A bespoke host alignment now goes through the slot's style prop.
Screen.Header inset='activity', which swaps the header's 12pt top for 32pt. A Stackflow activity renders chrome with no Screen root above it and no navigation bar to sit under, so it needs the deeper inset; four screens were hand-approximating it with paddingTop={32}. It is the only sanctioned variation on the fixed chrome box.
Before
After
—
<Screen.Header inset='activity'>
Changed
Chip
removeLabel is required on a removable chip. The 'Remove chip' default let a caller ship a row of × buttons that all announce the same name, which is the thing the spec's own accessibility rule tells you not to do. Every call site already passed a value-specific label, so only a test changed.
Before
After
removeLabel?: string, defaulting to 'Remove chip'
removeLabel: string
Added
Chip
A command shape: onPress without selected, plus an optional ariaLabel. It renders the same <button> as a selectable chip but no aria-pressed, because a chip that opens an explainer is not an unselected member of a selection set — which is what a permanent aria-pressed="false" announces. selected is now required on the selectable shape, since its presence is what picks the toggle over the command.
onPress alone renders a plain button; pass selected for a toggle
Fixed
BottomSheet
The top safe-area inset is now the fullScreen presentation's alone, and it covers both ways of titling a sheet. BottomSheet.Header reserved the inset unconditionally, so a bottom-anchored sheet — which never reaches the notch — opened with 116pt of empty space between its drag handle and its title. The sheet's own title prop renders a separate element and had the opposite problem: it never reserved the inset at all, so a fullScreen sheet built that way put its title under the status bar. Both now inset on fullScreen and neither does on sheet. fullScreen also became border-box: the full size sets height: 100vh and the sheet's padding sat outside that, so the sheet measured 918px in an 874px viewport and the bottom-aligned overlay pushed its top edge off-screen — which ate 44px of the very inset this fix reserves. The inset reads --cds-safe-area-top-chrome rather than env(safe-area-inset-top): on iOS the raw env() also grows for the native navigation bar and keeps reporting that height when no bar is drawing, which double-insets the web-header fallback path.
Before
After
every Header padded by env(safe-area-inset-top); the title prop never padded at all
both paths inset on fullScreen only, via --cds-safe-area-top-chrome, and the inset is no longer eaten by a 44px overflow; the call-site paddingTop: 0 workaround is gone
Fixed
ListItem
A row whose only trailing content is a tag keeps the plain row height instead of jumping to the rich 80px accessory height. The trailing region sized any row carrying a tag as a rich row, so a tag row stood 28px taller than the plain value row above it — visible on the broker detail, where "Status" towered over "Sent on". A tag substitutes for the value text, not for a leading tile, so it no longer promotes the row; a tag alongside a chevron or custom trailing still does. Two off-scale SCSS values went with it: the base min-height drops the invented calc(--cds-size-48 + --cds-size-4) for a real --cds-size-48, and the tag inset uses --cds-space-4.
Before
After
a tag-only row rendered at 80px
a tag-only row matches the plain row it sits beside
Removed
Banner
The variant prop, and with it the success, warning and danger treatments. Banner is the informational strip — it renders role="note", which is advisory by definition, so a tone that shouts was always at odds with the part. In practice the tones were doing another component's job: all 39 danger uses were error states, and 33 of those were one copy-pasted screen-level load error in the preview harness. Errors now go to a Card (inline, beside the control that failed), an EmptyState (a whole region that could not load), or a Toast (transient). The prop is gone rather than narrowed to a single legal value, since a required prop with one option only invites the tones back.
Before
After
<Banner variant='danger' eyebrow='Could not load'>
<Card> with danger-toned Text, or <EmptyState>
Added
Banner
A title prop for a sentence-case heading, matching the banner in Design System 4.5. eyebrow was the only heading slot and it forces text-transform: uppercase, which is right for the short status labels every current caller passes ("Could not load") but shouts when given a sentence — a consumer that put one there rendered a full sentence in caps. eyebrow is unchanged. When title is set the message drops from body to body-small, so the heading is the larger of the two; without title the message keeps body and existing callers render exactly as before. The design also shows the title in a heavier weight, which the type ramp cannot express today — every variant except overline is weight 400 — so the title is distinguished by size and color only.
Changed
Accordion
The accordion is now edge to edge and its rows match List.Item. It had baked in a --cds-radius-2xl and painted itself as a rounded island, which made it the one organization container that could not sit inside a Screen.Section — and double-rounded against any Card it was dropped into, as the data-removal FAQ had been doing at 32px inside a 16px card. It now follows ListRoot: full width, surface paint, no radius and no inset of its own, so the enclosing section or card owns shape exactly as it does for a List. Row metrics moved onto List.Item padding='card' at the same time — the 20/16 inset already matched, so this is a 16→12 trigger gap, a --cds-size-48 minimum row height, the shared --cds-divider separator at default width instead of a hairline in --cds-input-container, and the press feedback every interactive List.Item row already had.
Before
After
a rounded island with hairline separators and no press feedback
edge to edge, List.Item row metrics
Added
Accordion
AccordionItem takes a testId, used for the row and, suffixed with -panel, for its panel. The row selector was derived from id, which forced callers whose ids are raw API values to slug them into kebab-case and pass the slug as id — making the open/expand state key lossy, so two ids that normalize alike would expand each other. Callers now pass the raw value as id and name the selector themselves. Items that omit testId keep the derived name, so nothing existing changes.
Added
Accordion
AccordionItem takes a leading slot, rendered before the trigger copy at a fixed width so long titles wrap against it rather than squeezing it. Rows that identify a thing — a broker, an account, a connected service — need its logo or avatar next to the name, and without a slot the only way to get one was to compose it into title, which put the gap and the alignment outside the design system and left every consumer to re-derive them.
Fixed
Accordion
A panel that was already open no longer replays its 500ms expand animation when the parent re-renders. Suppression hung off a single "have we mounted yet" ref that was flipped inside an effect without a re-render, so the panel kept its initial-open state only until something else rendered the accordion — at which point the animation: none override dropped away and a panel the user was already looking at faded in from zero height. It reached any consumer that opens a panel through defaultOpenItemIds/openItemIds and then re-renders — appending to a paginated list, or a parent state change. Suppression is now tracked per item and is sticky until that item actually closes, so reopening still animates.
Before
After
an open panel re-animated on the next unrelated re-render
the animation is suppressed until the item is closed and reopened
Removed
Accordion
The trigger :focus-visible ring. The design system ships only into a touch iOS WKWebView and the sandbox preview of that same UI, neither of which has a keyboard, so the ring was unreachable dead code.
Fixed
Dialog
Two-action dialogs no longer overflow when a label is long. The action row was a 2-column grid whose 1fr tracks cannot shrink below a label’s min-content width, so a label like "Erase everything" pushed past the dialog edge. The row is now flex with wrap-reverse: side by side while both labels fit, stacking with the primary on top (iOS alert convention) when they don’t.
Before
After
long action labels overflowed the dialog
row stacks vertically when labels don’t fit
Fixed
Screen
Screen.Header.Title now truncates to a single line with an ellipsis instead of wrapping. Dynamic titles (e.g. a conversation named after its first message) were wrapping to three lines around the back button and header actions, breaking the fixed nav-bar rhythm; iOS convention is to truncate.
Before
After
long titles wrapped the header chrome
single-line ellipsis truncation
Fixed
Mixins
reset-button now clears the UA focus outline for every consumer of the mixin (Button, Toggle, Dropdown, Tabs, SegmentedControl, ListItemRow, and the rest). The runtime is a touch-only WKWebView with no keyboard, so WebKit's outline: auto ring was dead chrome on all of them — most visibly as a blue band around checkbox-role list rows when focus landed programmatically. Removing it in the shared mixin rather than per component keeps the touch posture from drifting per owner.
Before
After
UA focus ring could paint on any focused reset-button consumer
outline: none in reset-button, mixin-wide
Added
List
List.Item.Selection takes selectionPlacement='leading' to render the check/radio control in the leading slot, replacing the leading icon — the iOS edit-mode treatment for icon-led rows. Without it, select mode on an icon-led list grew a second accessory column at the trailing edge while the icon kept the leading slot, which read as decoration next to the real control. Default stays trailing, so existing selection rows are unchanged.
Before
After
selection control renders at the trailing edge only
selectionPlacement='leading' swaps it into the leading slot
Changed
Screen
Screen.Body's layout is now 'solid' | 'grouped', naming the two screen shapes rather than describing the children: 'solid' flows children down one continuous surface inside the page gutter, 'grouped' is the former 'sectioned'. Behind 'grouped', the page color moved onto a box that holds only the sections and nests inside the padded one. It used to sit on the padded box itself, so it also painted everything that was not a child — the inset underTopChrome reserves for a native bar, bottom clearance, the slack a short screen leaves below its last section. That stayed invisible only while a web Screen.Header covered the top of every such screen; moving them to the native navigation bar exposed it as a page-colored band under the bar, and pulling the scroller slid its edge. Now the page color is a separator and nothing else: every boundary of the scrolling content is a section or the screen surface. The overscrollBottom='var(--cds-bg-page)' pairing this used to require is gone.
Before
After
layout='sectioned', page color on the padded scroll content
layout='grouped', page color on a box that holds only the sections
Added
Text
h3-serif variant (24px), closing a gap against the Figma serif ramp. Figma defines Serif/H2 at 28px and Serif/H3 at 24px, but h2-serif was 24px — so it was really Serif/H3 under the wrong name, and 28px was unreachable. h2-serif is now 28px as specified, and every existing h2-serif call site moved to h3-serif, so nothing changes size. Reach for h2-serif when you want the 28px step.
Before
After
--cds-type-h2-serif-size: 1.5rem
--cds-type-h2-serif-size: 1.75rem
Fixed
Tokens
Realigned every type token line-height and letter-spacing with the Figma typography foundation, which had drifted. The serif ramp and h1 moved to 1.1 leading (h1-serif and h2-serif were 1, h1 was 1.15), joining display-*, h2, and h3, which already sat there; h4 and h5 stay at 1.2. The tracking that Figma specifies as 0 is now actually 0 across display-1, display-2, h1, h2, h3, h2-serif, and link. h1-serif tracking flipped sign to the specified -2%, h4 to +0.5%, and h5 is now SF Pro Medium (weight 510) at -2% instead of Regular. body and body-small already matched. Multi-line headings are the visible change: they gain ~10% leading, so anything that assumed a fixed heading height needs a look.
Before
After
--cds-type-h1-serif-line-height: 1
--cds-type-h1-serif-line-height: 1.1
Fixed
SegmentedControl
Replaced role="radiogroup" plus role="radio" and aria-checked with a list of buttons carrying aria-current. A radiogroup commits every option to radio semantics and radio-group arrow-key behavior, and a tablist promises content panels — neither describes a row of ordinary buttons that re-filters one view on press.
Before
After
role="radiogroup" + role="radio" + aria-checked
role="list" + <li> + <button aria-current>
Added
SegmentedControl
count, dot, disabled, and testId on SegmentedControlItem. The plain {id,label} contract was the single largest source of hand-rolled strips in the app, with more than 7 instances logged in sandbox-preview/WANTED-COMPONENTS.md.
Changed
Chip
ChipProps is now a discriminated union of a selectable shape (onPress with selected), a command shape (onPress alone), and a removable shape (onRemove). A selectable chip renders a real <button> instead of a <span role="button">, so activation is native. The combination of both handlers is a type error: two focus stops would need a <button> inside a <button>. A chip with no handler no longer compiles, because a pill that does nothing is a Tag.
Before
After
one props type with optional onPress, onRemove, and neither
The trailing prop, the :focus-visible rings, and the Enter/Space key handler. trailing existed to serve a chip acting as a menu trigger, which is a Dropdown. The focus affordances are dead code on a touch-only WKWebView.
Added
ChipGroup
A labelled set of chips, rendering role="group" with a required aria-label, plus the wrapping or scrolling row layout every call site was re-implementing. Selection stays with the caller, so single and multiple selection both work without a second component.
Removed
Badge
Deleted in favour of Tag. The name meant two incompatible things across the industry, an anchored notification count on Apple and Material against a free-standing status pill elsewhere, and all 24 call sites here were free-standing markers. An anchored count belongs as a slot on the element it counts. Migration: variant becomes emphasis with the same values, and size is dropped.
Before
After
<Badge tone={t} variant='solid' size='sm'>
<Tag tone={t} emphasis='solid'>
Changed
Tag
Collapsed the six-part Tag.* family into one Tag with tone, emphasis, contrast, dot, leading, and fullWidth. The parts split markers by use case, which produced identical DOM and duplicated the Status tone axis under new names. Product presets such as live or paused now belong in an app wrapper or the recipe table in Tag.mdx.
The size prop. Every marker in Figma is drawn at one height, and Tag.Status’s sm and lg differed only in horizontal inset, so the name described padding rather than size.
Before
After
size='sm' | 'lg'
one 24-unit geometry
Fixed
Tag
Status markers referenced the internal --cds-color-grey-01 and --cds-color-black primitives plus a hand-rolled color-mix, against what Tag.spec.json already declared. A neutral marker now resolves to the surface roles: --cds-bg-subtle for subtle, --cds-bg-inverse for solid, --cds-bg-subtle-inverse under contrast="inverse". contrast retunes only that neutral chrome — a toned marker keeps the status ramp, since those fills are opaque in both themes and already read on a dark surface.
The legacy Label marker. Its variant values were Figma file paths (tag/default, status/card, stat/up) and it duplicated Tag with no consumers. Field owns the real <label htmlFor>, so form labelling is unaffected.
Changed
Card
Card.Delta accepts className and testId, and its geometry now matches the marker family: 4-unit block padding and a 12-unit caret. It is the single home for a trend delta, replacing Tag.Stat.
Before
After
8-unit block padding, 16-unit caret, no className or testId
4-unit block padding, 12-unit caret, className and testId
Added
Tokens
--cds-safe-area-top and --cds-safe-area-bottom are now declared public tokens, defaulting to env(safe-area-inset-*). Screens read these instead of env() directly because on iOS env() also grows for the native navigation bar; the host overrides them on the root element with the device-only inset. They were already read across the extension via a var(--cds-safe-area-*, env(…)) fallback, which masked that the token itself was undeclared — declaring them makes the contract real and satisfies no-unknown-custom-property.
Before
After
read only through a fallback: var(--cds-safe-area-top, env(safe-area-inset-top, 0px))
declared tokens with an env() default the host overrides
Fixed
Background
Background keeps its safe-area insets when the matching padding prop is 0, the same fix already made for Screen.Body. Background's own inset helper mirrors screenSafeArea.ts but had not picked up the zero guard, so <Background safeArea padding={0}> emitted calc(var(…) + 0) — a unitless zero inside calc() is a parse error, and the browser dropped the whole declaration, leaving the content with no inset at all rather than the inset alone. Any non-zero padding is unaffected.
Before
After
<Background safeArea={"both"} padding={0}> emitted calc(var(…) + 0), which the browser discarded
the inset is emitted on its own and the content clears the safe area
Changed
Chip
Selected filter chips now use a solid inverse fill with inverse text instead of a brand border and subtle information fill.
Before
After
brand border + --cds-status-info-subtle fill
--cds-bg-inverse fill + --cds-text-inverse text, no border accent
ariaLabel and ariaHidden props. ariaLabel overrides the announced text when the visual string reads poorly aloud (for example a currency figure); ariaHidden suppresses a duplicate announcement when a labeled sibling already exposes the same text.
Changed
Mixins
The tap-feedback mixin is now pressable. It applies press feedback to any tap target — it suppresses the browser tap flash, dims to 70% and scales to 97% on :active, and drops the scale under reduced motion — but the old name read as belonging to a feedback card, and did get misread that way. Consumers are reset-button (so every interactive List.Item row), a Card rendered as a button, and two app components. Behaviour is unchanged; this is a rename only.
Before
After
@include tap-feedback
@include pressable
Fixed
Banner
The leading icon now sits on the optical center of the message's first line instead of riding high above it. The grid is deliberately align-items: flex-start so a multi-line message keeps its icon with the first line rather than centering it against the whole block, but the icon had no box of its own — a 16px glyph therefore pinned to the top of a ~21px line box. It now gets a first-line-tall box and centers inside it, which is correct for one line and for many. At least one consumer had been papering over this with a local align-items: center override on the banner.
Before
After
a short icon aligned to the top of the first line box
the icon centers against the first line
Added
Screen
Screen.Body takes layout="sectioned" and pairs with a new Screen.Section slot, for the grouped-list rhythm where full-bleed surface slabs are separated by the page color. The body drops its gutter, paints the page color, and gaps its children; each section paints the surface. Six screens had hand-rolled this in two incompatible mechanisms — spacer divs with negative margins, or a local flex-gap wrapper. The negative margins existed only because Screen.Body applies a 20px gutter by default, so every slab had to cancel it before spanning edge to edge; layout="sectioned" defaults that gutter to 0 instead, which removes the cancel at its source (one screen was undoing and re-applying the inset across five layers). Sections paint and position only and add no inline padding of their own, because content components already self-pad to the page gutter — SectionHeader and List.Item both inset 20px. A section is a flex column so a last child's bottom margin cannot collapse through its edge and widen the separator below it.
Before
After
each screen re-derived the cream-gap rhythm, cancelling the body gutter with margin-inline: -20px
Screen.Body layout="sectioned" + Screen.Section
Added
SectionHeader
Accepts PaddingProps, so a container that already owns the inline gutter can pass paddingX={0} instead of cancelling with an inline style. The 20px horizontal default is unchanged — 23 of 29 call sites depend on it, since the convention here is that content self-pads and containers paint. Only the inline axis moved to the prop; the block axis stays size-derived and owned by the component. The default is applied only when the caller sets no horizontal padding at all, so an explicit all-sides padding wins outright rather than losing the inline axis to the injected default.
Before
After
padding: 48px 20px 16px hardcoded, cancellable only via style={{ paddingInline: 0 }}
block axis in CSS, inline axis from PaddingProps with the same 20px default
Added
Screen
Screen.Header.BackButton accepts glyph='close'. The prop was narrowed to arrow-left alone, so a modally-presented screen whose control dismisses rather than pops had to either draw an arrow that misdescribes it or hand-roll an IconButton and lose the safe-back fallback.
Before
After
glyph accepted only 'arrow-left'
glyph accepts 'arrow-left' | 'close', still defaulting to 'arrow-left'
Fixed
Screen
Screen.Body keeps its safe-area and host-chrome insets when the matching padding prop is 0. A zero space token resolves to a bare 0, and a unitless zero inside calc() is a parse error, so the browser dropped the whole padding-top declaration — a Screen.Body asking for paddingTop={0} alongside underTopChrome or safeArea got no inset at all rather than the inset alone, and its content sat under the status bar. Both helpers now emit the bare inset when the padding contributes nothing. Any non-zero padding is unaffected.
Before
After
paddingTop={0} with underTopChrome emitted calc(var(…) + 0), which the browser discarded
the inset is emitted on its own and the content clears the chrome
Added
Screen
Screen.Body takes underTopChrome for content that runs beneath chrome the host draws over the web view — on iOS, the native navigation bar. It reads a new --cds-safe-area-top-chrome custom property that the host publishes, and supersedes the top edge of safeArea rather than adding to it, since both are measured from the same edge. This exists because safeArea resolves to the device inset, while content passing under a native bar has to clear device + bar, and no single constant is right: when the host falls back to a web header in normal flow, that header has already reserved the space and the correct inset is zero. Screens previously hardcoded env(safe-area-inset-top) in their own stylesheets, which was right on the native path and double-inset the fallback path. Unset — no host chrome — resolves to 0px.
Before
After
screens hardcoded padding-top: env(safe-area-inset-top) and could not tell the two paths apart
<Screen.Body underTopChrome> resolves the inset from the host
Fixed
Avatar
An already-decoded bitmap now paints opaque on its first frame instead of fading in. The reveal was written for images that genuinely load after mount, but it also ran for cached ones: the image mounted at opacity: 0 and loaded was only set from the ref callback, so the flip landed a frame later and the 160ms transition played. On the website-search list that made 28 of 29 cached logos fade in together on entry, which read as a flicker. Sources decoded earlier in the session are remembered too, so a remount that loads from cache skips the fade. That memory is only a fade hint — every instance still shows its placeholder until its own bitmap arrives, and still applies its own size and aspect-ratio thresholds. Genuinely uncached images keep the shimmer placeholder and the fade. Images also carry decoding="async" and loading="lazy".
Before
After
every image mounted at opacity 0 and transitioned to 1, cached or not
a bitmap that was already decoded renders with the transition suppressed
Fixed
Tabs
fit='content' no longer scrolls its ancestors to reveal the active tab. scrollIntoView scrolls every scrollable ancestor and reasons in visual coordinates, so a strip mounting inside a router activity — which starts transformed off-screen — read as invisible and made the browser scroll the enclosing panel sideways. The panel then rendered at translateX - scrollLeft for the rest of its push, travelling ~113px past its resting position before snapping back when focus reset the scroll. The reveal now computes inline: 'nearest' from offsetLeft, a layout coordinate, and applies it to the strip alone, and runs with every measurement so a font swap or a widening sibling cannot leave the active tab clipped.
strip.scrollLeft, clamped to the nearest edge of the active tab
Added
Input
New date input type. Renders the platform's native date picker (the iOS wheel in the webview) and yields an ISO YYYY-MM-DD value; the placeholder is platform-owned, so placeholder has no effect for this type. Added for schema-driven forms that collect dates of birth and similar fields.
Changed
Avatar
Brand marks now inset 12% within the circular clip, and the brand chip gets a hairline ring. Real brand assets (favicon-service tiles, brandfetch logos) are frame-filling rounded squares, and measurement against live LinkedIn/YouTube/Reddit marks showed the circular crop cut up to 12.2% of opaque pixels at 0% inset; 12% takes all three to zero. The figure is fit to those measured assets, which carry some internal transparent margin — it is not a geometric guarantee, since a true edge-to-edge square with hard corners would need ~14.65%. Inset within a white chip, a circular badge (Reddit), a wide rounded rect (YouTube), and a square (LinkedIn) share no common silhouette, so the ring gives every shape one consistent edge. Both scoped to the brand mark only — type='image' still fills the circle edge to edge with no ring.
Removed the focus ring: the design system ships only to a touch surface, so a keyboard-only ring had no audience and the touch-triggered :focus-within version rendered permanently on autofocused fields. SearchField inherits the change.
Before
After
:focus-within brand border + glow
no focus ring
Added
Tabs
New fit axis. content sizes each tab to its label and scrolls the strip horizontally, for bucket sets too long to divide evenly; the underline is measured from the active tab rather than derived from tab count, and the active tab is kept on screen on mount and on any activeId change, not only on press. TabItem also gains an optional testId so consumers whose ids are not kebab-case can keep their derived selectors conformant. Defaults to equal, which is unchanged.
Changed
RadioGroup
Breaking: options are now divider-separated List.Item.Selection rows with the control at the trailing edge, replacing the per-option cards. RadioGroup composes List.Item instead of re-implementing a row, so the two treatments can no longer drift. The orientation prop and the RadioGroupOrientation type are removed — full-bleed rows have no horizontal form — as is name, which had no native inputs left to name. required still announces via aria-required.
Before
After
options as 16px-padded cards, leading control, brand border when selected, orientation='vertical' | 'horizontal'
options as 80px (64px without a description) list rows, trailing control, hairline dividers, no orientation
Added
List
List.Item accepts disabled, which dims the row and blocks presses. A disabled selection row keeps its radio/checkbox role and checked state so the choice stays announced. Selection rows with no description also tighten from 80px to 64px, since a single line of copy beside the 32px control left the taller row looking empty.
Changed
Tokens
Breaking (DS-internal): the neutral ramp is renumbered to match the DS 4.5 Figma Colors collection, which has seven grey steps to our six. Neutral/Grey-03 (#c4c2bc) was missing entirely, so every step below it sat one index too low. grey-02 was also carrying a stale value. Primitives are DS-only (ADR 0005) and no app code references them, so no consumer import changes — but any in-flight DS branch touching --cds-color-grey-0* will conflict.
Breaking: --cds-selection-toggle-off is split into --cds-selection-off-container (Checkbox, Radio, RadioGroup) and --cds-toggle-off-container (Toggle). DS 4.5 models these as two variables that only coincide in dark — in light the selection chip is Grey-02 and the toggle track is Grey-03. The single merged token rendered both at #84827e, which was neither. Dark was already correct at white-15.
Before
After
--cds-selection-toggle-off: #84827e (light, both components); #ffffff26 (dark)
--cds-selection-off-container: #e0ddd6 · --cds-toggle-off-container: #c4c2bc (light); both #ffffff26 (dark)
Added
Tokens
--cds-color-white-20 (#ffffff33), the DS 4.5 Neutral/White-20% step. Added so the alpha ramp matches Figma; nothing consumes it yet.
Added
Glyph
Three navigation glyphs from the Cloaked Design System 4.0 icon library: home, family, and cloaked-ai. These are the bottom-tab symbols for Home, Identities, and Automations, which previously had no web equivalent — consumers either hand-rolled an SVG or substituted account for the multi-person family mark. cloaked-ai also removes the need to fall back to the SF symbol iOS uses for the Automations tab.
Before
After
—
Glyph names: home, family, cloaked-ai
Changed
Tag
Compact tags (Label, Status, Live, Stat, Progress) shortened to the DS 4.5 height: minimum height 32px → 24px, Status/Live/Stat vertical padding 8px → 4px, and the outlined Label vertical padding 12px → 4px, so every tag reads at ~24px (Callout unchanged). Completes the deferred Tag portion of the 4.5 component migration.
First-class PII category palette: dedicated --cds-pii-category-{name,email,phone,address,family} tokens plus PII_CATEGORY_IDS / PII_CATEGORY_COLOR_VARS exports, so category color is standardized across platforms instead of re-invented per surface. Distinct from feature/status hues; values provisional pending a Figma collection.
Added
CompositionBar
New visualization: a single horizontal bar of proportional, color-coded segments showing each category's share of the total. Use it over CategoryBars for open-ended counts, where scaling length to the largest value implies a maximum that does not exist.
Removed
BurstGraph
Dropped the center readout (value + caption disc) so the burst is pure category texture; surface any headline count in surrounding copy. Breaking: removed the value and label props.
Before
After
value?: number, label?: ReactNode
items-only (no center readout)
Changed
CategoryBars
Removed the gray track fill behind each bar; rows now show only the colored value bar with no remaining-length background.
Before
After
track background: var(--cds-bg-page)
transparent track
Changed
Menu
Single-select rows now mark selection with a high-contrast bold pill (bg-bold fill, on-bold label + check) instead of a pale blue wash, replacing the provisional info-blue tokens with Cloaked selection language. Row corners tightened from radius-lg to radius-md. Action menus are unaffected.
selected row: --cds-bg-bold fill + --cds-text-on-bold check; radius-md rows
Changed
BottomSheet
Sheet top corners softened from radius-2xl to radius-xl so sheets read less super-rounded, aligning with the tighter direction of the design language.
Before
After
corner radius 32px (radius-2xl)
corner radius 24px (radius-xl)
Added
SectionHeader
Added a contrast prop (default | inverse, default default) for the title, so a section header can read correctly on a tinted or inverted surface instead of being locked to primary text.
Changed
Faq
Faq.Carousel now renders its heading through SectionHeader, aligning the FAQ title with the standard section-header treatment.
Before
After
bespoke h4 title
SectionHeader (h3) title
Changed
List
List.Item description now uses a 1.2 line-height (vs the global body-small 1.1) so the subtitle no longer reads cramped under its title — matches the DS 4.5 list-item spec.
Before
After
description line-height: 1.1 (body-small token)
description line-height: 1.2
Changed
Divider
Section divider now matches the DS 4.5 Divider/Date spec: 32px top / 12px bottom padding (was 8px symmetric) and no bottom hairline on the surface variant — spacing separates groups instead of a line.
Before
After
padding-block 8px; surface variant had a bottom hairline
padding-block 32px top / 12px bottom; borderless
Fixed
List
List.Disclosure no longer draws a separator adjacent to a Divider in its revealed rows, so a section header inside the disclosure is no longer bordered by a doubled hairline. The disclosure trigger now centers its label and chevron so it reads as an action rather than a data row.
Changed
Storybook
Grouped the four iconography pages (Symbol, Icon, IconTile, SFIcon) under Primitives/Iconography; story permalinks/anchors changed accordingly.
New bare-glyph atom ("Symbol" in the design language): renders a registered drawing at 1em/currentColor with no size or color opinion. Exports Glyph, GlyphName, GLYPH_NAMES, GLYPH_GROUPS, and the isGlyphName runtime guard; unregistered names render nothing. Vocabulary contract: a GlyphName value is a "glyph"; "icon" is reserved for rendered elements (Icon, ReactNode slots).
Changed
Icon
Re-layered as a pure opinion over Glyph (token size + semantic color). Breaking type rename: IconName/ICON_NAMES are now GlyphName/GLYPH_NAMES. IconTile composes Glyph directly, matching the iOS layering. Rendered output is unchanged.
Before
After
IconName, ICON_NAMES exported from Icon
GlyphName, GLYPH_NAMES exported from Glyph
Changed
IconTile
Breaking prop rename per the glyph vocabulary contract: the tile takes a glyph name, so the prop is named after what the value is.
Before
After
icon: GlyphName
glyph: GlyphName
Changed
Toast
Breaking prop rename per the glyph vocabulary contract: the tone-disc drawing is a glyph name, not a rendered icon element.
Before
After
icon: GlyphName
glyph: GlyphName
Changed
ListItem
Breaking prop rename per the glyph vocabulary contract. The suffix now signals the type: leadingGlyph takes a glyph name (rendered as a tone IconTile), while trailingIcon keeps its name because it takes a rendered element.
Before
After
leadingIcon?: GlyphName
leadingGlyph?: GlyphName
Changed
Screen
Breaking renames per the glyph vocabulary contract: the header glyph-name union and the back button prop that selects from it. ScreenHeaderIconButtonProps keeps its name (its icon is a rendered element).
Before
After
ScreenHeaderIcon; BackButton icon?: GlyphName
ScreenHeaderGlyph; BackButton glyph?: GlyphName
Added
FloatingActionMenuButton
New primitive: the menu-opening FAB flavor (web counterpart of the iOS FloatingActionMenuButton) — the trigger shares the FAB chrome and opens a private anchored popover of Menu rows.
Added
FloatingActionButton
New primitive: a self-anchoring frosted-glass action circle (web counterpart of the iOS FloatingActionButton), with loading and animated hidden states and a new --cds-fab-container token.
Redesigned as a compact tone-pill. Breaking: tone, icon, and title are now required, children became the optional subtitle, and variant, position, action, onDismiss, and the dismiss button were removed — the host now owns show/hide. Haptic fires once on appear and defaults to off.
Renamed the sidebar group "Figma Toolkit" to "Cloaked Design System"; story permalinks/anchors changed accordingly.
Before
After
figma-toolkit-*
cloaked-design-system-*
Implementation preview
This design-owned implementation is ready to try and review. It does not establish product approval. Imported specifications retain their original review status.