Mantle Eliminate High Contrast Migration Guide
@ngrok/mantle has two themes, light and dark. The light-high-contrast and dark-high-contrast themes are gone, with their stylesheets (@ngrok/mantle/mantle-light-high-contrast.css and @ngrok/mantle/mantle-dark-high-contrast.css) and their Tailwind variants (high-contrast: and dark-high-contrast:). Theme is "system" | "light" | "dark", and ResolvedTheme is "light" | "dark". MantleStyleSheets takes only darkCssUrl and renders one <link> with no media gate and no inline script, so fixMediaScriptContent is gone too. ThemeSwitcher is one component, a light/dark toggle, in place of ThemeSwitcher.Root, ThemeSwitcher.Trigger, and ThemeSwitcher.Content.
TL;DR: Delete every import of the two high-contrast stylesheets and every high-contrast: or dark-high-contrast: class. Pass only darkCssUrl to mantleStyleSheetUrls and MantleStyleSheets. Delete fixMediaScriptContent and the high-contrast <link> tags from a non-React <head>. Replace <ThemeSwitcher.Root><ThemeSwitcher.Trigger {...props} /><ThemeSwitcher.Content /></ThemeSwitcher.Root> with <ThemeSwitcher {...props} />. Replace a Theme submenu that holds ThemeDropdownMenuRadioGroup with one ThemeDropdownMenuItem at the top level of the menu. Users who stored a high-contrast theme need nothing: mantle reads it as light or dark.
Codemod
From a mantle checkout:
In a project that installs @ngrok/mantle:
The rules apply steps 1, 2, and 4:
- They delete each JavaScript import of the two high-contrast stylesheets, with or without
?url. An import whose name other code still reads stays, and its missing module marks that code for manual work. - They delete the
lightHighContrastCssUrlanddarkHighContrastCssUrlentries from amantleStyleSheetUrlscall. - They delete
lightHighContrastCssUrl,darkHighContrastCssUrl,forceTheme,ssrCookie, andnoncefromMantleStyleSheets. - They replace a
ThemeSwitcher.Rootwith no props, oneThemeSwitcher.Trigger, and one self-closingThemeSwitcher.Contentthat has nochildrenprop and no spread. The replacement is<ThemeSwitcher />, which keeps the trigger's props and drops the content's props.
Steps 3 and 5 to 8 stay manual, because a class string, a CSS @import, a theme string literal, a test query, and a menu that holds more than the theme options are not syntax a rule can read. The rules read a part through its import from @ngrok/mantle/theme or @ngrok/mantle/theme-switcher, under its own name or an alias. A function that binds the part's name to a parameter or a local shadows the import, so the rules leave that function alone. The codemod is safe to run twice. Pass --dry-run to print the rewrites first, and run your formatter after the rewrite.
You are an agent migrating a codebase. Do this:
- Find every import of
@ngrok/mantle/mantle-light-high-contrast.cssor@ngrok/mantle/mantle-dark-high-contrast.css: a JavaScript import (often with?url) and a CSS@import. Delete each one. Keep@ngrok/mantle/mantle-dark.css. - Find every
mantleStyleSheetUrlscall and everyMantleStyleSheetselement from@ngrok/mantle/theme, under its own name or an alias. Delete thelightHighContrastCssUrlanddarkHighContrastCssUrlkeys from the call. DeletelightHighContrastCssUrl,darkHighContrastCssUrl,forceTheme,ssrCookie, andnoncefrom the element. KeepforceTheme,ssrCookie, andnonceonPreventWrongThemeFlashScript,ThemeProvider, anduseInitialHtmlThemeProps; those props did not change. - Find every
fixMediaScriptContentcall. Delete it and the<script>that holds its output. In a non-React<head>, also delete the two high-contrast<link>tags, and delete themediaattribute from themantle-dark-styleslink: the dark stylesheet now applies on every page. - Find every
ThemeSwitcher.Rootfrom@ngrok/mantle/theme-switcher. Replace the three parts with<ThemeSwitcher />, and move the props ofThemeSwitcher.Trigger(appearance,label,className,disabled,data-slot,ref) onto it. Drop the props ofThemeSwitcher.Content(align,collisionPadding,className); the toggle has no menu. WhenThemeSwitcher.Roothas props (open,onOpenChange) orThemeSwitcher.Contenthas children, the app needs a menu: build it withDropdownMenuand putThemeDropdownMenuItemin itsDropdownMenu.Content. Rename the typesThemeSwitcherRootProps,ThemeSwitcherTriggerProps, andThemeSwitcherContentPropstoThemeSwitcherProps, or delete them. Replace everyThemeDropdownMenuRadioGroupwithThemeDropdownMenuItem, andThemeDropdownMenuRadioGroupPropswithThemeDropdownMenuItemProps. When the radio group is the only child of aDropdownMenu.SubContent, delete thatDropdownMenu.Suband putThemeDropdownMenuItemwhere the submenu was. Replacearia-labeloraria-labelledbywithlabel, the visible text of the item. The codemod does none of this. - Find every class that starts with
high-contrast:ordark-high-contrast:(high-contrast:bg-black/30,dark-high-contrast:aria-checked:border-accent-400). Delete each one. The variants are gone, so Tailwind no longer generates the class. - Find every
"light-high-contrast"and"dark-high-contrast"string in code:$theme(...)and$resolvedTheme(...)calls,Select.Itemvalues,switchcases, and the keys of aRecord<Theme, …>or aRecord<ResolvedTheme, …>. Delete each one.ThemeandResolvedThemereject them now, so the type checker names every site. - Find every test that queries the old switcher or the old theme list:
getByRole("button", { name: "Change Theme" }), a count of fivemenuitemradioitems, anddata-slot="theme-switcher-trigger". The default accessible name ofThemeSwitchernow names the theme the next click shows ("Switch to dark theme", "Switch to light theme", or "Switch to light theme (system default)"),ThemeDropdownMenuItemis onemenuitemnamed "Theme: Switch to dark theme" or similar, and thedata-slotistheme-switcher. Pass alabeltoThemeSwitcherwhen a test needs a fixed name. - After editing, run the project's formatter, linter, type checker, and tests.
Why two themes? Each high-contrast theme was a second hand-tuned palette for its mode, so every token, component, and chart had to be tuned and tested four times. With two themes, the theme switcher can also be one button: Lea Verou's post on dark mode toggles shows how two visible states can still store
"system","light", and"dark".
Before → after
The root stylesheets
The theme switcher
A theme menu with extra items
A high-contrast class
A non-React head
Edge cases & gotchas
- Stored preferences. A user whose cookie holds
light-high-contrastordark-high-contrastgetslightordark.PreventWrongThemeFlashScriptreads the old value that way before first paint, andThemeProviderrewrites the cookie on mount, so every app on the cookie's domain agrees. An app on an older mantle reads the rewritten cookie as it always did. - The OS contrast setting.
"system"followsprefers-color-schemealone. Aprefers-contrast: moresetting no longer picks a theme. - The flash script writes no cookie. It no longer seeds a
"system"cookie when none is stored, and it no longer migrates a theme fromlocalStorage. A missing cookie reads as"system". - A forced page.
MantleStyleSheetshas noforceTheme. Keep passing the forced theme toPreventWrongThemeFlashScript,ThemeProvider, anduseInitialHtmlThemeProps; those three write the<html>class. On that page,ThemeSwitchershows the forced theme and is disabled, because a click cannot change what the page paints. - A
forced-colorssetting (Windows High Contrast) is an OS feature, not a mantle theme, and keeps working as before.
How to verify
The search finds nothing after the migration. Then run the type checker: it names every Theme literal and every MantleStyleSheets prop the search missed. Run the tests and update the queries step 7 lists.