Files
paseo/docs/unistyles.md
T
91da0bac4d Choose which orchestration skills get installed (#2680)
* feat(settings): choose which orchestration skills to install

The skills row installed the whole bundle or nothing. A settings control
next to it now opens a sheet where you keep every bundled skill or pick a
subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it.

"All skills" reads the bundle directory instead of a hardcoded catalog, so
a skill added in a later release installs itself without another code
change. Retired bundle names stay in a separate cleanup list so old copies
on disk are still removed but never become selectable.

The selection persists under Electron user data, separate from install
state, so uninstalling does not erase the choice — a later install brings
back exactly the chosen set. Installs with no saved selection read as
"all", which is what they already had.

* Restore skill selection after failed save

* fix(settings): don't save a skill selection that never reached disk

A failed save wrote the preference, converged, then wrote the old preference
back. That leaves a window where a selection the user was told did not save
is the one on disk, and a crash inside it makes the window permanent.
Converge first and persist only on success — nothing to roll back, and a
failed save now leaves disk and preference exactly as Cancel would.

Resolving the bundle path when the command handlers are wired also broke
daemon-manager's tests: the packaged branch reads process.resourcesPath,
which is undefined outside Electron. Resolve targets per command instead —
registering handlers should not touch the environment.

* fix(settings): make skills convergence single-writer and confirm removals

Startup auto-update and the settings commands were separate writers over the
same skill directories. Either could apply a plan computed from a selection the
other had already replaced — narrow the selection while startup is repairing
drift and the repair puts the removed skill back. There is now one controller
that owns scan, apply, and persist, and every entry point queues through it, so
concurrent callers see a consistent result instead of an interleaved one.

Save also had no recovery once it had converged: if the selection could not be
committed afterwards, disk was changed while the preference still said the old
thing, and the next startup silently undid the change. Save now puts the
managed directories back to what the committed selection describes before it
reports the failure, so a failed Save lands where Cancel would.

Deselecting a skill deletes its directory in all three agent homes, including
files the user put inside it. That is now confirmed like every other
destructive action, naming the skills and the directories. Adding skills or
saving an unchanged selection still saves without a prompt, and a skill the
host has not installed yet deletes nothing so it does not prompt either.

* fix(settings): confirm removal for skills installed in only some agent homes

The removal confirmation read an `add` op as "not on disk yet". An `add` op
means "missing from at least one target", so a skill sitting in ~/.claude but
not the other two looked absent, and deselecting it deleted that directory —
with anything the user had put inside it — without asking.

The status snapshot now carries the installed skill names directly, taken from
union presence across the three agent homes, and the confirmation reads that.
`ops` keeps its one meaning.

A host that predates the field cannot say what is on disk, so the app bridge
assumes the saved selection is installed. Asking about a directory that turns
out to be absent is harmless; the reverse is not.

* fix(settings): warn about every skill directory a save deletes

The confirmation asked "was this selected before and is it selected now",
which is a different question from the one the host answers. The host
converges to the desired set and deletes every managed directory outside it,
so a skill on disk that the saved preference never mentioned — reinstalled by
hand, restored by a sync, left behind by drift — was deleted with no warning,
user files included.

The predicate now mirrors the host rule: everything installed that the draft
will not keep, regardless of what was saved before. It moved out of the sheet
into its own module so the rule has one owner and can be tested directly.
Saving with nothing to delete still does not prompt.

* fix(settings): a failed save keeps files the user added inside a skill

Save converges before it commits, and convergence deletes whole skill
directories. When the commit then failed, the recovery re-installed the
committed selection — which rebuilds the bundled files and nothing else, so
notes, hooks, and anything else the user had put inside a deselected skill
were gone for good. Cancel does not do that, and a failed save is supposed to
land where Cancel would.

Save now runs as a transaction. Before converging it stages a copy of every
managed skill directory across the three agent homes, next to the tree so the
copy stays on one filesystem. Committing drops the staging; failing anywhere
puts each directory back exactly as it was, deletes the ones the candidate had
created, and then drops the staging. Staging left by a process that died
mid-save is cleaned up by the next one.

The controller only sees begin/commit/rollback; the filesystem work lives in
skills-transaction.ts.

* fix(settings): make skill removal confirmation authoritative

* fix(settings): verify skill convergence before commit

* fix(settings): preserve concurrent skill edits on rollback

* fix(settings): preserve staged skill metadata

* fix(settings): stage skill deletions atomically

* fix(settings): stage removals per filesystem

* fix(settings): restore staged skill symlinks

* fix(settings): canonicalize skill transaction roots

* fix(settings): keep automatic skill updates non-destructive

* fix(settings): report committed skill saves accurately

* fix(settings): keep skill updates non-destructive

* fix(settings): route skill removals through selection

* fix(settings): close skill selection edge cases

* fix(settings): preserve skill data during maintenance

* fix(settings): hide unavailable skill install

* fix(desktop): keep install policy at command boundary

* fix(desktop): restore staged skill directories atomically

* test(app): retry transient skills sandbox cleanup

* fix(desktop): recover externally deleted skill stages

* fix(desktop): preserve conflicting skill rollback data

* fix(desktop): generalize skill rollback conflicts

* fix(desktop): preserve skill rollback collisions

* fix(desktop): recover quarantined skill backups

* fix(app): inset adaptive sheet content on compact

The compact Choose skills sheet rendered edge to edge: the All skills card
touched both screen edges, the section label was glued to it, and the skill
list ran to the edges too. Desktop was correct.

AdaptiveModalSheet expressed its content inset through
BottomSheetScrollView.contentContainerStyle. That is a library prop, not the
style prop Unistyles registers, and gorhom's scroller was never rewritten by
the Babel plugin, so on web the emitted class was never applied and the
padding and gap resolved to nothing. The desktop card uses a real ScrollView,
which Unistyles does track, which is why only compact was affected.

The inset now lives on a View the sheet owns, rendered by every presentation,
so one definition covers compact and desktop and a scroller only ever gets
theme-free layout. The caller-facing prop is contentStyle, since it no longer
lands on a scroll content container.

Choose skills drops its ad-hoc label for SettingsSection, so the label carries
the app's section rhythm instead of sitting equidistant between two cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(desktop): prevent skill rollback data loss

Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:28:32 +02:00

27 KiB
Raw Blame History

Unistyles Gotchas

This app uses react-native-unistyles v3 for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the Babel plugin rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.

That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.

STOP — useUnistyles() Is Banned

Do not call useUnistyles(). Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched. The library authors themselves strongly advise against it:

We strongly recommend not using this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.

We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to every Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with theme as the only changed input every cycle. It also breaks every downstream useMemo/memo boundary that includes a derived theme value.

Reviewers MUST reject PRs that introduce a new useUnistyles() call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.

Use these alternatives in order:

1. StyleSheet.create((theme) => ...) — default

Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.

const styles = StyleSheet.create((theme) => ({
  container: {
    backgroundColor: theme.colors.surface0,
    padding: theme.spacing[4],
  },
}));

<View style={styles.container} />;

If you are reading a theme value just to feed it back into a style prop, you almost certainly want this and not the hook.

2. Hard-coded constants for genuinely static values

If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing baseColors, theme-name constants, or type Theme is fine when the value is intentionally static.

3. withUnistyles(Component) for third-party props

When a third-party component takes a non-style prop that must be theme-reactive (e.g. BlurView.tint, Image.tintColor, navigator option props, bottom-sheet backgroundStyle), wrap that single component with withUnistyles. Only the wrapper re-renders, not the surrounding tree.

const ThemedBlur = withUnistyles(BlurView);
<ThemedBlur tint={theme.colors.surface0} />;

(Mind the > * child-selector leak documented further down.)

4. There is no "last resort"

There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.

How Updates Propagate

For standard React Native components, the Unistyles Babel plugin rewrites imports such as View, Text, Pressable, and ScrollView to Unistyles-aware component factories. On native, those factories borrow the component ref and register the style prop with the ShadowRegistry. The upstream "Why my view doesn't update?" guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.

The important detail: the automatic native path tracks props.style. It does not generally track every prop that happens to carry style-like values.

Do Not Materialize Styles At Module Scope

Never read a Unistyles style property into a module-level constant. This includes cached arrays:

// Wrong: evaluated while the app may still be using the temporary system theme.
const ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];

// Right: each style proxy is read when this view renders.
<View style={[settingsStyles.row, settingsStyles.rowBorder]} />;

Paseo starts with adaptive themes, then applies the persisted theme after async settings load. A module-level read can therefore materialize the light style before a persisted dark theme is active. If the view mounts after that theme change, React Native receives the stale light object; Unistyles registers the node for future changes but does not retroactively replace its initial props. Settings dividers once rendered light #e4e4e7 inside a dark #252B2A card for exactly this reason.

Render-time array syntax is intentional and exempt from the app's JSX array-allocation lint rule. Keep the entries separate so each retains its Unistyles metadata. If composition is needed outside JSX, create the array inside the component or in a useMemo that first runs when the component mounts—never at module evaluation time.

useUnistyles() is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from UnistylesRuntime to re-render a component; issue #817 is a useful reminder of that invariant.

Dynamic Pixel Styles On Web

Avoid feeding changing pixel values such as { top, left }, { maxHeight }, or { minWidth } into the style prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to #unistyles-web; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.

Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.

Inline Style Escape Hatch

When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with inlineUnistylesStyle.

import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";

const styles = StyleSheet.create({
  thumb: {
    position: "absolute",
  },
});

<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;

This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.

Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.

When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, FloatingSurface.frameStyle and FloatingScrollView.style own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.

Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry unistyles_* metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip unistyles_* metadata before sending it through inlineUnistylesStyle.

Do not register an existing Unistyles style inside another StyleSheet.create either. That also combines two metadata identities into one object. Reuse the original style directly at the component:

// Wrong: sharedStyles.row already carries Unistyles metadata.
const styles = StyleSheet.create({ row: sharedStyles.row });
<View style={styles.row} />;

// Right: one registered style identity reaches the native view.
<View style={sharedStyles.row} />;

This mistake once produced tens of thousands of warnings from retained sidebar rows. Because React Native captures component stacks for warnings, the warning loop itself can consume enough CPU and memory to make the app appear blank.

Main Gotcha: contentContainerStyle

ScrollView.contentContainerStyle is the canonical trap. It looks like a style prop, but it is not the style prop Unistyles registers. How badly that bites depends on who owns the component:

  • A remapped React Native ScrollView still picks the style up — the Babel plugin rewrites the import and the factory tracks contentContainerStyle alongside style. The failure mode here is staleness: on first mount it can paint with the theme that was active at mount time and never repaint. The upstream tutorial calls this out in its ScrollView Background Issue section.
  • A third-party scroller — BottomSheetScrollView, FlashList, anything Unistyles never rewrote — drops the style entirely on web. Nothing throws and the value looks right in JS, but the class Unistyles emitted is never applied, so the padding, gap, or background is simply absent. AdaptiveModalSheet shipped its compact sheet content flush to the screen edges for exactly this reason while the desktop card, which uses a real ScrollView, looked correct.

The second case is why the wrapper-View fix below is not optional styling advice. A themed inset belongs on a View you own; a third-party scroller only gets theme-free layout.

Avoid this pattern when the style depends on the theme:

<ScrollView contentContainerStyle={styles.container} />;

const styles = StyleSheet.create((theme) => ({
  container: {
    flexGrow: 1,
    backgroundColor: theme.colors.surface0,
  },
}));

If app settings later load a persisted theme and call UnistylesRuntime.setTheme, the JS-side style proxy reports the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.

This applies broadly to non-style props that carry theme-dependent values, such as component props named color, trackColor, tintColor, backgroundStyle, handleIndicatorStyle, and other library-specific style props. The 3rd-party view decision algorithm recommends explicit handling for these cases, and issue #1030 shows a related native-prop update edge case around Image.tintColor. Treat these values as React props unless wrapped with withUnistyles.

Fix Patterns

Preferred pattern: put themed backgrounds on a normal wrapper view, and keep contentContainerStyle theme-free.

<View style={styles.container}>
  <ScrollView contentContainerStyle={styles.contentContainer}>{children}</ScrollView>
</View>;

const styles = StyleSheet.create((theme) => ({
  container: {
    flex: 1,
    backgroundColor: theme.colors.surface0,
  },
  contentContainer: {
    flexGrow: 1,
    padding: theme.spacing[4],
  },
}));

This is the pattern used by the settings screen: the screen background lives on a normal View style={styles.container}, while the scroll content container only carries layout.

In practice the wrapper-View pattern is the one we use. Across the app, withUnistyles is now reserved for wrapping leaf components — mostly lucide icons (ThemedActivityIndicator, ThemedChevronDown, …) and small third-party components like MarkdownWithStableRenderer — so they pick up theme-reactive color/tintColor props without re-rendering their parent.

In principle, withUnistyles can also wrap a ScrollView to make contentContainerStyle theme-reactive via its auto-mapping behavior for style and contentContainerStyle. We previously did this on the welcome screen and hit the > * child-selector leak documented below; we have since moved the welcome screen to the wrapper-View pattern. If you find yourself reaching for withUnistyles(ScrollView), treat it as a smell and check whether a wrapper view works first.

The smallest escape hatch is to use useUnistyles() and pass an inline value through React:

const { theme } = useUnistyles();

<ScrollView
  contentContainerStyle={[styles.contentContainer, { backgroundColor: theme.colors.surface0 }]}
/>;

Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.

withUnistyles And The > * Child-Selector Leak

withUnistyles on a component with a theme-dependent style prop works by wrapping the component in a <div style={{display: 'contents'}} className={hash}> and emitting the style under a .hash > * child selector so the styles cascade onto the wrapped component. This is how auto-mapping for style and contentContainerStyle works on web.

The sharp edge: Unistyles hashes styles by value. If withUnistyles receives a style whose value is identical to a style used elsewhere in the app on a plain View, both usages get the same hash — and both CSS rules (the element rule and the > * child rule) are emitted under the same class name. The > * rule then leaks onto the direct children of every View that shares the hash.

Concrete regression we hit: welcome-screen.tsx had const ThemedScrollView = withUnistyles(ScrollView) with style={{ flex: 1, backgroundColor: theme.colors.surface0 }}. panels/agent-panel.tsx had root and container styles with the exact same value. All three collided on class unistyles_j2k2iilhfz, so the browser stylesheet contained:

.unistyles_j2k2iilhfz {
  flex: 1 1 0%;
  background-color: var(--colors-surface0);
}
.unistyles_j2k2iilhfz > * {
  flex: 1 1 0%;
  background-color: var(--colors-surface0);
}

The child-selector rule forced flex:1 and background-color: surface0 onto the Composer's outer Animated.View (a direct child of container), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a surface0 band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips WelcomeScreen after pairing, so the > * rule was never injected there.

Symptoms to watch for:

  • A sibling of a themed panel-background View stretches unexpectedly on web only.
  • Random direct children of a { flex: 1, backgroundColor: surface0 } View pick up an unexpected background.
  • DevTools shows a .unistyles_xxx > * rule you did not write.

Quick confirmation in DevTools console:

[...document.styleSheets]
  .flatMap((s) => [...(s.cssRules || [])])
  .map((r) => r.cssText)
  .filter((t) => t.includes("unistyles") && t.includes("> *"));

Any match beyond benign r-pointerEvents-* > * rules from react-native-web is a leak.

Avoid the bug by preferring the wrapper-View pattern from the previous section whenever possible: put { flex: 1, backgroundColor: surface0 } on a plain View and give the ScrollView a theme-free style/contentContainerStyle. That keeps withUnistyles off the hot path and avoids the hash collision. Only reach for withUnistyles(ScrollView) when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.

Hidden Sheet Content

@gorhom/bottom-sheet can keep BottomSheetModal content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.

We saw this in AdaptiveModalSheet: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:

const { theme } = useUnistyles();

<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>;

Keep layout and typography in StyleSheet.create; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.

The same rule applies to bottom-sheet component props such as backgroundStyle and handleIndicatorStyle: they are library props, not the direct React Native style prop Unistyles registers. Prefer a custom backgroundComponent that calls useUnistyles(), or pass a small inline object from the hook theme.

Memoized Style Objects

When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.

Avoid indirect keys like this:

const { theme, rt } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);

On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.

Prefer the hook theme itself, or explicit theme tokens, as the dependency:

const { theme } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);

If a style factory is cheap, skipping useMemo entirely is also fine.

Static Theme Imports

Do not import theme from @/styles/theme for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.

Wrap the icon (or other leaf component) with withUnistyles instead, so only that node re-renders when the theme changes:

import { ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";

const ThemedChevronDown = withUnistyles(ChevronDown);

const styles = StyleSheet.create((theme) => ({
  icon: { color: theme.colors.foregroundMuted },
}));

<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;

This is the dominant pattern in the app today (see sidebar-workspace-list.tsx, message.tsx, the workspace screens). Reserve useUnistyles() for the last-resort cases described at the top of this file. Importing baseColors, theme-name constants, or type Theme is fine when the value is intentionally static or type-only.

Reanimated Animated.View + Dynamic Styles Crashes

Do not apply StyleSheet.create((theme) => ...) styles to a Reanimated Animated.View. Unistyles wraps styled components in a <UnistylesComponent> and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with Unable to find node on an unmounted component. This was a real iOS sidebar crash on theme toggle (commit 4896cfe9).

Fix: keep static positioning on the Animated.View in plain React Native StyleSheet, and pass theme-dependent values (e.g. backgroundColor) as inline style from useUnistyles() — the inline path is acceptable here because no other escape works:

import { StyleSheet as RNStyleSheet } from "react-native";
import Animated from "react-native-reanimated";
import { useUnistyles } from "react-native-unistyles";

const positionStyles = RNStyleSheet.create({
  sidebar: { position: "absolute", inset: 0, width: 280 },
});

function Sidebar() {
  const { theme } = useUnistyles();
  return (
    <Animated.View
      style={[positionStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surface1 }]}
    />
  );
}

This is one of the rare places useUnistyles() is the right tool: there is no withUnistyles(Animated.View) equivalent, the affected component is small, and the alternative is a crash.

Adaptive Themes And Persisted Settings

Unistyles initialTheme and adaptiveThemes are mutually exclusive. initialTheme can be a string or a synchronous function, but it cannot wait on async storage.

Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:

  1. Unistyles config starts with adaptiveThemes: true.
  2. The device may report system light.
  3. Settings load a persisted non-auto preference, such as dark.
  4. The app calls setAdaptiveThemes(false) and setTheme("dark").

That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. Issue #550 was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.

If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with initialTheme.

Runtime Theme Patching For User Preferences

Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with UnistylesRuntime.updateTheme(name, updater) — not by threading preference reads through components. applyAppearance in packages/app/src/screens/settings/appearance/apply-appearance.ts runs from a ProvidersWrapper effect on settings load/change and loops all six theme keys, returning { ...theme, fontFamily, fontSize, lineHeight, colors.syntax }.

This works without useUnistyles() because every consumer already reads these tokens through StyleSheet.create((theme) => …) (or the withUnistyles/uniProps path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.

Gotchas:

  • Patch all themes, not just the active one. The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs setTheme/setAdaptiveThemes irrelevant. The effect depends on the settings values (not on theme), so it cannot loop.
  • Narrow the discriminated union before spreading. updateTheme's updater returns the theme union; spreading the union widens colorScheme to "light" | "dark", which is assignable to neither concrete member. Branch on t.colorScheme so each branch spreads a single narrowed theme type (no as).
  • lineHeight.diff is the code/diff line-height axis — it is coupled to the code-font-size control (≈ codeFontSize * 1.5). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (Math.round(theme.fontSize.base * 1.4)); routing prose through lineHeight.diff clips text at small code sizes.
  • High-churn draft values (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with inlineUnistylesStyle so per-keystroke values don't grow the #unistyles-web CSS registry.
  • Mounted parsed content uses AppearanceStyleBoundary. Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with packages/app/src/components/appearance-style-boundary.tsx; do not add local "appearance key" props at each callsite.
  • Dynamic font tokens stay widened. fontFamily, fontSize, and lineHeight on commonTheme are annotated string/number (not narrowed by as const) so the updater's return assigns; the platform default stacks live in DEFAULT_UI_FONT_STACK / DEFAULT_MONO_FONT_STACK.

Debugging

To inspect what the Babel plugin sees, temporarily enable debug: true in packages/app/babel.config.js:

[
  "react-native-unistyles/plugin",
  {
    root: "src",
    debug: true,
  },
],

Then rebuild the bundle and look for lines such as:

src/components/welcome-screen.tsx: styles.container: [Theme]

This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.

For paint-layer bugs, use high-contrast probes:

  1. Paint each candidate layer a distinct color, such as root wrapper cyan, ScrollView.style yellow, and contentContainerStyle magenta.
  2. Cold-restart the app, not just Fast Refresh.
  3. Screenshot the simulator and sample pixels to see which color fills the area.
  4. Remove the probes before committing.

The welcome-screen investigation used this approach to prove the white layer was the ScrollView content container.

References