Skip to content

feat(ads): refresh installed design-system tokens, layers, and theming - #527

Merged
astyfx merged 10 commits into
mainfrom
fix/ads-theme-parity
Sep 14, 2026
Merged

astyfx merged 10 commits into
mainfrom
fix/ads-theme-parity

Conversation

@astyfx

@astyfx astyfx commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

Refresh the installed design-system copy to the current ADS source (589cfc4c): explicit --ads-* token names, StyleX compiled per origin so design-system and product rules occupy separate cascade layers, the xstyle host style channel, theme targets, and a three-way merge of the vendored components this host already customized.

Changes

Features

  • ads: compile StyleX per origin (x vs p) so design-system, brand, and product rules occupy ads / ads-theme / product layers
  • ads: add the xstyle host style channel and a check:style-channel CI guard
  • ads: install theming targets (ads-<target> plus data-variant / data-size / data-tone / data-density) so a brand recipe has a stable address

Fixes

  • ads: keep PortalProductThemeScope from re-applying mode/density token groups on every portal, which would replace the host's runtime palette inside dialogs, menus, popovers, drawers, selects, and tooltips
  • ads: keep control/lightbox-only files at their original install baseline so the next upgrade does not merge against a revision that was never applied

Refactors / Chores

  • ads: rename hashed StyleX token keys to explicit --ads-* custom properties so origin prefixes cannot break token references
  • ads: re-sync tokens, themes, breakpoints, theming modules, and the vendored component closures this host has already customized
  • ads: record sourceRevision on the primary manifest so the next upgrade has a verifiable merge base

Breaking Changes

  • ads: StyleX compositions must reach ADS components through xstyle, not className; check:style-channel fails the latter
  • ads: overlay style objects no longer export a surface key — compose recipes/overlay-surface instead
  • ads: badgeStyles.root / .outlineBase moved to recipes/status-chip; empty-state icon media is now an IconTile

Test Evidence

Local, this session:

  • bun run typecheck passed
  • bun run typecheck:lens-main passed
  • check:reliability-gates, check:doc-paths, check:max-lines-ratchet, check:switch-exhaustiveness, check:style-channel, check:design-system passed

Local, prior to this publication (clean tree, no further edits):

  • bun run test:isolated passed (609 files)
  • bun run build and bun run build:desktop passed
  • Browser: cascade layers register in order; an ads-theme rule beats an ADS Button's own padding-inline; a product-layer rule still wins over ads-theme; a live button with both origin classes resolves --ads-button-tone-fill to the theme's --destructive; an open popup's portal wrapper does not carry the mode theme class and --ads-color-accent still reads the host --primary

Not run here: check:licenses — this worktree has no license-checker package under node_modules. CI installs from the lockfile. No dependency was added.

Notes

Stave runtime themes remain the theme authority. defineProductTheme / ProductThemeProvider are installed but not mounted: user-installable JSON themes cannot be expressed as a build-time CSS generator. ads-theme ships empty.

21 files that belong only to the control and lightbox manifests (AppShell and sidebar parts, Lightbox, Checkbox, RadioGroup, NativeSelect, CappedViewport, and the agent-surface / ToolRun / Thinking / Citation group) were not re-synced. Their recorded integrity stays at the original install baseline.

Perceptible visual changes from upstream: tooltip dwell moved from a CSS variable to a JS rest timer; EmptyMedia variant="icon" now renders IconTile's hairline rim; warning/success hues were retuned with the token re-sync.

Host modifications that survive the merge are listed in src/components/ads/PROVENANCE.md (157 of 212 installed files are byte-identical to ADS 589cfc4c).

Rename every ADS design token from a StyleX-hashed `defineVars` key to an
explicit CSS custom property name, so `vars.colorText` becomes
`vars["--ads-color-text"]` and the emitted variable is `--ads-color-text`
instead of a hash. This is the prerequisite for compiling ADS and product
code under different StyleX class name prefixes: a prefix renames generated
CSS variables as well as classes, so a product-origin file would otherwise
reference a token variable that only the ADS-origin sheet defines.

Re-sync `tokens.stylex.ts` and `themes.stylex.ts` from the current ADS source
in the same change, keeping this copy's Pretendard Variable sans stack, and
install the `breakpoints.stylex.ts` module that upstream split out of
`tokens.stylex.ts`. `AppShell.shell.styles.ts` imports breakpoints from the
defining module because a StyleX condition key cannot resolve through the
re-export.

Verified: typecheck clean, `bun run test:isolated` passes 607 files, and the
built stylesheet carries 1651 `--ads-*` references with no hashed token
definitions and its media queries intact.
… layers

A StyleX atomic class is a pure function of its declaration, so an ADS
component and an app file that write the same declaration produced the same
class and therefore one cascade position. That left no seam between them: a
brand recipe placed below the band could not restyle a component, and placed
above it overruled the product's own call sites. It also made a host style
handed to an ADS component through `className` win or lose per property,
decided by whichever module first emitted the atomic class.

Replace `stylex.vite()` with `adsStylex()` in both Vite configs. ADS modules
keep StyleX's default `x` class name prefix, everything else compiles under
`p`, and each origin's rules are emitted as `@layer ads.priority*` /
`@layer product.priority*` nested inside the positioned `ads` and `product`
layers. The canonical order becomes `reset, theme, base, ads, ads-theme,
product, components, utilities`, stated identically in `index.html`,
`src/globals.css` and the vendored `styles.css`. `ads-theme` ships empty; it
is the seam a brand recipe would occupy.

`src/main.tsx` no longer imports `virtual:stylex:runtime`. That module belongs
to the Vite adapter this plugin replaces, and `adsStylex` injects its own dev
stylesheet and client; left in place it is an unresolvable import that makes
the dev server return 500 on the entry module.

`check:design-system` and the globals stylesheet test now assert the three
origin layer names in order rather than matching a hardcoded `priority1`, so
losing the seam fails instead of passing on two identical strings.

Verified in a browser on the dev server: the document registers `reset, theme,
base, ads, ads-theme, product, components, utilities`, and on a rendered ADS
Button whose own `padding-inline` is 12px, an `ads-theme` rule renders 33px
while the same rule alongside a product-layer rule renders 41px. No media
query was lost (208 to 212 nested rules; the four added are declarations that
now correctly hold one class per origin), and every `--ads-*` token resolves.
`className` on an ADS component is an opaque string channel: the component's
own atomic class and the host's both exist, and the winner is settled by
cascade position rather than at the call site. Splitting StyleX by origin made
that outcome uniform in the host's favour, but it is still invisible where it
is written, and it was previously decided per property — two properties in one
style object could go opposite ways.

`xstyle` is merged last into the part's own `stylex.props()` call, so it
resolves by property name in JavaScript before the cascade is consulted, and
reads the same before and after the origin split.

Add the prop to the vendored parts that lacked it — `Badge`, `Checkbox`, the
six `Card` parts, nine `Dialog` parts, five `Command` parts, two `Select`
parts — and move all 52 product call sites off `className`. `className` stays
where it carries a behaviour hook, a motion class, a test id, or a caller's
forwarded class; those sites keep it and gain `xstyle` alongside.

`Checkbox` routes `xstyle` exactly the way it already routes `className`: to
the control root in `controlOnly` mode, where the control is the outer box the
host sees, and to the label row otherwise. All three migrated Checkbox call
sites are `controlOnly`, so applying it only to the label row would have
dropped their styling.

Add `check:style-channel`, retargeted from the ADS original for a source
install: it scans `src/` minus the vendored directory and resolves each import
specifier against the importing file rather than matching a package name.
Wired into `test:ci`. Verified it fails on a reintroduced `className={sx(...)}`
and passes on the migrated tree.

Verified: typecheck, build, build:desktop, `test:isolated` (608 files) and every
repository gate pass; the composer and the ADS regression preview render in a
browser with no collapsed control and glyphs still on the 14/16px icon ramp.
Install the ADS `theming/` module and `tokens/theme-values.ts`, then re-sync the
27 vendored components this host has never modified. An audit reconstructed the
pristine install baseline for 119 of the 120 manifest entries out of both
repositories' object stores; for these 27 the current content is byte-identical
to that baseline, so there is no host customization to preserve and the upstream
file can be taken whole.

They now emit stable theme targets — an `ads-<target>` class plus `data-variant`,
`data-size`, `data-tone`, `data-density` and `data-ads-slot` — which is the
addressing scheme a brand recipe needs. Nothing styles them yet and the
`ads-theme` cascade layer reserved earlier is still empty.

Stave's own theme system stays the single theme authority. It is user-installable
at runtime as validated JSON, which a build-time CSS generator cannot express, so
`defineProductTheme` and `ProductThemeProvider` are installed but unmounted.

That forced one host modification. `PortalProductThemeScope` upstream re-applies
the mode and density token groups on the portal wrapper, because upstream
supports a provider scoped to a region. Here the single root provider already
mirrors those groups onto the document under `syncDocument`, and
`StaveDesignProvider` publishes the host palette mapping over them as inline
custom properties on `<html>` — so a class on the closer portal wrapper would
have replaced the user's saved theme with the design system's stock palette
inside every dialog, menu, popover, drawer, select and tooltip. The groups now
apply only when a brand is actually scoped; attributes, `color-scheme` and the
selection pair still apply unconditionally because they declare no token.

`Select.parts.tsx` takes a partial forward-port of `readOnly` only, because the
re-synced `Select.array.tsx` passes it; the rest of that file awaits its merge.
`Calendar.keyboard.ts` keeps a strict-indexed-access adaptation this repo needs
and upstream does not.

Verified: every repository gate, `test:isolated` (609 files), both builds, and in
a browser that a portalled surface still resolves the host palette
(`--ads-color-accent` reads the theme's `--primary`, not the ADS default).
`tests/ads-portal-theme-scope.test.tsx` fails if the upstream unconditional form
is restored.
Three-way merge `utils/stylex.ts`, `recipes/transition.ts`, `recipes/menu.ts`
and the Button family against the reconstructed pristine install baseline.
Upstream had independently adopted three of this host's extensions verbatim —
the `bar`/`slide` transition recipes, the native-button reset on `menu.item`,
and Button's indicator slot styles — so those collapse to upstream's spelling.

The host modifications that survive: the exported, widened `StyleXValue` and
unfiltered `sx` that `xstyle` arrays depend on, Button's `layout="host"` render
path, `aria-busy` honouring a caller-passed value, and `xstyle` merged ahead of
the disabled and inert expressions so a disabled control's `cursor` and
`opacity` stay authoritative.

Two upstream removals reach product code. `Button` now publishes its theme
identity on `data-variant`/`data-size`/`data-tone`, spread after the caller's
props, so the wrapper's own duplicates were being overwritten before they
reached the DOM and are removed; the element now carries the design system's
spelling. And `buttonDangerToneStyles` is gone — semantic tones are one
implementation driven by `--ads-button-tone-*` custom properties applied as an
inline style, so the class-only `buttonVariants()` path, which has no element to
carry one, declares the same five properties through StyleX instead.

That last one crosses the origin split, so it was measured rather than assumed:
in the live sheet a product-origin class declares `--ads-button-tone-fill` and an
ADS-origin class consumes it for `background-color`, and an element carrying
both paints this theme's `--destructive`.

Verified: every repository gate, `test:isolated` (609 files), both builds.
Three-way merge Badge, Card, Table, Breadcrumb, EmptyState, Plan, StepRail,
DiffViewer, Command and the ToolRun style/parts modules against the pristine
install baseline, and install the siblings they now import: `Command.types`,
`Command.dialog`, `IconTile`, `truncating-label`, `recipes/value-token` and
`recipes/overlay-surface`.

Most host extensions in this group had already been adopted upstream verbatim —
`xstyle` across every part, EmptyState's centred header, Plan's `space8` header
inset, ToolRun's 22rem container arm, and DiffViewer's whole host syntax
highlighter seam — so those hunks collapse to upstream's spelling rather than
being re-applied. What survives as host bytes is the canonical style exports the
product's compound adapters import, and ToolRun's three deltas.

`ToolRun.parts.tsx` was hand-merged with no base: its recorded integrity matches
no blob in either repository, because the bytes installed were an uncommitted
upstream working-tree state. It starts from upstream with the three known host
deltas re-applied and nothing else, confirmed by diff.

`Command.styles.ts` is taken from upstream even though upstream never moved it,
because upstream's `Command.tsx` uses three keys this copy had deleted as
unused. The host's own additions are superseded: the backdrop and popup surface
are now `recipes/overlay-surface`, declaration for declaration, and the keycap
restyle is now a real `Kbd` inside a positioning-only wrapper.

Two product call sites follow intentional upstream removals. `badgeStyles.root`
and `.outlineBase` moved to `recipes/status-chip` with a new size axis, so the
class-only `badgeVariants()` composes the same three keys the component does.
`emptyStateStyles.media` and `.toneNeutral` are gone — the medallion is an
`IconTile` — so `EmptyMedia`'s icon arm delegates to the ADS part, which also
gives it upstream's hairline rim.

Verified: every repository gate, `test:isolated` (609 files), both builds. The
`EmptyMedia` tag change is layout-inert: all 26 call sites are direct children of
the empty header, which is a grid, so an inline child is blockified by spec.
Three-way merge Dialog, Drawer, Popover, Menu, Tooltip, ToastHost and
`overlay-motion.css` against the pristine install baseline, and install the
siblings they now import: `Dialog.styles`, `Menu.styles`, `Menu.density`,
`Menu.merge-class-name`, `Tooltip.group` and `ToastHost.styles`.

Upstream had independently adopted every behavioural host modification in this
group. The `surface`/`popup` split this copy carried is now expressed as
`recipes/overlay-surface` plus a geometry-only `popup`, declaration for
declaration; the flush popover density generalised from `PopoverDensity` to a
three-value `OverlayContentDensity` covering the same need; and the ToastHost
column restructure — the largest host change in the install — turned out to be
byte-identical to upstream's new `ToastHost.styles.ts`, comments included. What
survives as host bytes is four style exports the product's compound adapters
import, and ToastHost's `toastManager` passthrough.

Every file here portals, so all of them now wrap portalled content in
`PortalProductThemeScope`. That module keeps its host modification: it re-applies
the mode and density token groups only when a brand is actually scoped, because
this host already mirrors them onto the document and publishes its own palette
mapping over them. Measured in a browser on a real open popup — the scope
wrapper carries the `contents`, `nativeChrome` and `colorScheme` classes but not
the mode theme class, generates no box, and the popup resolves
`--ads-color-accent` to the theme's `--primary`, identical to the document.

Seven product call sites follow the one forced API change: the `surface` key no
longer exists on these style objects, so they compose the same
`recipes/overlay-surface` keys the components do.

The premise that menu shims would lose their `data-variant` did not hold: the
`menu-item` theme target publishes `density` and `tone` only, so the product
attribute still reaches the DOM. It is now redundant rather than overwritten and
was left in place, since no upstream change forces its removal.

Verified: every repository gate, `test:isolated` (609 files), both builds.
Three-way merge TextField, Textarea, Switch, Toggle, Slider, Select.parts, Tabs,
Tabs.styles and Calendar against the pristine install baseline, and install the
siblings they now import: `Calendar.styles`, `date-range`, `Tabs.session` and
`Tabs.session.styles`.

Upstream had adopted the Tabs `xs` rung whole — the caption type step, the
compact row height with its coarse-pointer floor, `root` align-content and the
line-strip stretch alignment — and moved `TabsSize` into `Tabs.styles.ts`, so the
host version collapses to upstream's. `Select.parts.tsx`'s partial `readOnly`
forward-port from an earlier pass likewise became upstream's own code.

Three host modifications survive because upstream still overwrites where this
copy merges: TextField, Textarea and Switch each compose a caller's
`aria-describedby` with the field anatomy's rather than replacing it, and OR the
caller's `aria-invalid` into the anatomy's. That is accessibility behaviour, not
styling, so it is re-applied on upstream's structure. Calendar keeps its
strict-indexed-access adaptations, one of which moved with `parseDateKey` into
the new `date-range` module.

`merge3.py` reported two files MERGED CLEAN that were not: both sides had added
`xstyle` and the `xs` rung at the same anchor, producing duplicate bindings and a
duplicate `const`. Both were reset to upstream with the host delta re-applied.

`ui/select.tsx` drops a now-overwritten `data-size`: the `select-trigger` target
declares a `size` axis and `{...theme}` is spread after `{...props}`.
`ui/switch.tsx` keeps its `data-size` — the `switch` target declares only a
`tone` axis, so the attribute is untouched, not shadowed.

Verified: every repository gate, `test:isolated` (609 files), both builds, and
all three ARIA merges confirmed by rendering.
Three-way merge `ThemeProvider.tsx`, `styles.css` and `fonts.css`, completing the
re-sync. `ThemeProvider` is now byte-identical to upstream — both remaining
conflicts were prose, because upstream had already adopted the non-destructive
`<html>` restore and the selection token pair this copy carried.

`styles.css` takes upstream's zero-specificity reset: every selector wrapped in
`:where()` at 0,0,0, so a host rule placed in the same layer wins by weight
rather than by whichever sheet the bundler parsed last. Three host rules stay —
the layer statement's comment, the `layout="host"` carve-out on the control
glyph rule, and the `@layer base` default that gives a host-layout Button's glyph
a size without mandating one. `--atelier-motion-tooltip-delay` goes with
upstream's move of the tooltip dwell to a JS rest timer, which is a perceptible
timing change to every tooltip.

`fonts.css` keeps this copy's Pretendard variable dynamic subset — one variable
face across 92 unicode-range slices instead of four static weights — and adopts
the `layer(ads)` annotations, inert for `@font-face` but keeping the sheet in the
package's own origin.

All three manifests now record `sourceRevision`. Every file's integrity is the
hash of the upstream file at that commit, so the merge base for the next upgrade
is one `git show` away and is verifiable against the recorded hash. This
migration had no such anchor: the baseline had to be recovered by scanning both
repositories' object stores for a blob matching each recorded hash, and one file
matched nothing because its recorded bytes were never committed upstream.

`tests/ads-control-chrome.test.ts` now asserts the zero-specificity form and
fails on any unwrapped reset selector, rather than merely checking an element
name appears. Its region slice was also anchored on the `@layer base {` block
instead of the bare phrase, which upstream's new prose had begun matching.

Verified: every repository gate, `test:isolated` (609 files), both builds.
The previous commit advanced every manifest's integrity to ADS `589cfc4c`, but
this migration only re-synced the closure `.ads-source.json` tracks. 21 files
belong exclusively to the control and lightbox manifests — AppShell and its
sidebar parts, Lightbox, Checkbox, RadioGroup, NativeSelect, CappedViewport,
Citation, Thinking, the ToolRun group and the small agent-surface parts — and
they still sit at the upstream content they were installed from plus their host
modifications.

Recording `589cfc4c` as their baseline would claim a merge base that was never
merged: the next upgrade would diff against the wrong side and silently drop
host work. Those entries go back to their original install hashes, and the two
sibling manifests no longer claim a `sourceRevision` at all, since one is only
meaningful when every file it covers was resolved against it. Files shared with
`.ads-source.json` were genuinely re-synced and keep the advanced baseline.

Verified: no sibling-exclusive baseline is advanced, every re-synced entry's
baseline equals upstream at the recorded revision, and typecheck,
`test:isolated` (609 files) and the design-system gate pass.
@astyfx
astyfx requested a review from a team as a code owner September 14, 2026 13:39
@astyfx
astyfx requested review from heath-s and png-1105 and removed request for a team September 14, 2026 13:39
@astyfx
astyfx enabled auto-merge (squash) September 14, 2026 13:40
@upwind-code-us

upwind-code-us Bot commented Sep 14, 2026

Copy link
Copy Markdown

Upwind Upwind Code Scan - ✅ Passed

0 newly introduced vulnerabilities · 0 resolved · 57 total in this PR vs main

Total breakdown: 🔶 6 High | 🟡 48 Medium | 🟢 3 Low

View full analysis in Upwind Console

Scan completed in 11s

Scan history (1 scan)
Commit Scanned at New Resolved Net
7696940 < 2026-09-14 13:40 UTC 0 0 0

Last scanned: 7696940 · 2026-09-14 13:40 UTC

@upwind-code-us

upwind-code-us Bot commented Sep 14, 2026

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Passed

0 newly introduced misconfigurations · 0 resolved · 0 total in this PR vs the default branch

View full analysis in Upwind Console →

Scan completed in 1s

Scan history (2 scans)
Commit Scanned at New Resolved Net
7696940 2026-09-14 13:40 UTC
7696940 < 2026-09-14 13:40 UTC 0 0 0

Last scanned: 7696940 · 2026-09-14 13:40 UTC

@heath-s heath-s left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

@astyfx
astyfx merged commit 1070aaa into main Sep 14, 2026
6 checks passed
@astyfx
astyfx deleted the fix/ads-theme-parity branch September 14, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants