diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bffbfeb..d140cd7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- feat(ui): add `ios-widgets` skill for MetaMask Mobile home screen widgets and Live Activities, replacing the repo-local `.cursor/rules/widget-development.mdc` ([#110](https://github.com/MetaMask/skills/pull/110)).
- Add opt-in stale project skill pruning via `--prune-stale` and `SKILLS_PRUNE_STALE=1`.
- docs(testing): document CV stale-press flakiness plus high-leverage assert patterns (migration parity, filter both-sides example, loading/skeleton honesty, RefreshControl + flag overrides) in `mobile-testing` component-view and placement refs.
diff --git a/domains/ui/skills/ios-widgets/references/adding-a-widget.md b/domains/ui/skills/ios-widgets/references/adding-a-widget.md
new file mode 100644
index 00000000..6463e95e
--- /dev/null
+++ b/domains/ui/skills/ios-widgets/references/adding-a-widget.md
@@ -0,0 +1,159 @@
+# Adding a home screen widget
+
+A widget needs both a JS half (layout + registration + data) and a native half
+(a Swift file in the WidgetKit extension target). Both halves are keyed on one
+string: the widget's name.
+
+Work through these in order. Steps 5 to 8 are the ones a JS-only change will
+forget, and their absence shows up as "the widget never appears in the
+gallery".
+
+## 1. Design the props
+
+A flat, JSON-serializable interface. Pre-formatted, pre-translated, pre-masked
+— see **Designing props** in the skill body.
+
+## 2. Layout and registration — `app/core/Widgets/widgets/MyWidget.ios.tsx`
+
+```tsx
+import { Text, VStack } from '@expo/ui/swift-ui';
+import { font, foregroundStyle, padding } from '@expo/ui/swift-ui/modifiers';
+import type { WidgetEnvironment } from 'expo-widgets';
+
+import { createMetaMaskWidget } from '../createMetaMaskWidget.ios';
+import type { WithWidgetTheme } from '../types';
+
+export interface MyWidgetProps {
+ /** Already formatted and privacy-mode aware. Computed by WidgetUpdaterService. */
+ valueDisplay: string;
+ label: string;
+}
+
+function MyWidgetLayout(
+ props: MyWidgetProps & WithWidgetTheme,
+ environment: WidgetEnvironment,
+) {
+ 'widget';
+
+ const { valueDisplay, label, theme } = props;
+ const activeTheme =
+ environment.colorScheme === 'dark' ? theme.dark : theme.light;
+
+ return (
+
+
+ {label}
+
+
+ {valueDisplay}
+
+
+ );
+}
+
+export const MY_WIDGET_NAME = 'MyWidget';
+
+export const MyWidget = createMetaMaskWidget(
+ MY_WIDGET_NAME,
+ MyWidgetLayout,
+);
+```
+
+Keep the layout dumb: destructure, arrange, return. The `'widget'` directive
+must be the first statement in the function body.
+
+## 3. Fallback — `app/core/Widgets/widgets/MyWidget.tsx`
+
+Duplicate `MyWidgetProps`, re-export the same name constant, and register a
+no-op layout through the **extensionless** wrapper:
+
+```tsx
+import { createMetaMaskWidget } from '../createMetaMaskWidget';
+
+export interface MyWidgetProps {
+ valueDisplay: string;
+ label: string;
+}
+
+export const MY_WIDGET_NAME = 'MyWidget';
+
+export const MyWidget = createMetaMaskWidget(
+ MY_WIDGET_NAME,
+ () => undefined,
+);
+```
+
+`.tsx`, not `.ts`, even with no JSX in it. See **Platform split** in the skill
+body — a `.ts` fallback silently shadows the real widget on iOS.
+
+## 4. Data — `app/core/Widgets/WidgetUpdaterService.ts`
+
+Add one `private computeMyWidgetProps()` and one
+`private pushMyWidgetUpdate()`, and call the push method from
+`pushUpdates()`. Every selector read, formatter call, privacy-mode mask, and
+`strings()` lookup belongs here — never in the widget file.
+
+The service already debounces store changes (2s) and skips the native write
+when the newly computed props are `JSON.stringify`-identical to the last push.
+Do not push from anywhere else to work around that.
+
+## 5. Swift file — `ios/ExpoWidgetsTarget/MyWidget.swift`
+
+Copy `BalanceWidget.swift` and replace `name` (must exactly equal
+`MY_WIDGET_NAME`), `configurationDisplayName`, `description`, and
+`supportedFamilies`.
+
+## 6. Bundle entry — `ios/ExpoWidgetsTarget/index.swift`
+
+Add `MyWidget()` to the `WidgetBundle` body. WidgetKit caps one bundle at
+**4 widgets**; a fifth needs a chained nested bundle (see the comments in that
+file).
+
+## 7. Declare it in `app.config.js`
+
+Add an entry to the `expo-widgets` plugin's `widgets` array mirroring the
+Swift file's `name`, `displayName`, `description`, `supportedFamilies`, and
+`contentMarginsDisabled`.
+
+This entry is **never evaluated** — the repo has a checked-in `ios/` directory,
+so `expo prebuild` never runs and the config plugin never executes. It exists
+as the canonical human-readable declaration, the same way `expo-font`'s
+`fonts` array is declared beside a hand-committed `UIAppFonts` list. Keeping
+it in sync matters because a future `expo prebuild` would regenerate
+`ios/ExpoWidgetsTarget/` from it alone.
+
+## 8. Xcode target membership
+
+The new `.swift` file must be a **member of the `ExpoWidgetsTarget` target**,
+not merely present on disk (Xcode → File Inspector → Target Membership).
+`scripts/ios/setup-expo-widgets-target.rb` does not help: it early-returns
+once the target exists.
+
+## 9. Tests
+
+See [`testing.md`](testing.md).
+
+## 10. Verify in a simulator
+
+Full rebuild (widget code lives in a separate native binary, so a Metro reload
+is not enough), then long-press the home screen → **+** → search the widget's
+display name. Check light and dark mode, and that it updates after the
+underlying data changes in-app.
+
+## No analytics step
+
+Adoption is tracked automatically, keyed on the WidgetKit `kind` — which is
+already `MY_WIDGET_NAME`. A new widget is measured the moment its Swift file
+exists and a user places it. There is nothing to add.
diff --git a/domains/ui/skills/ios-widgets/references/live-activities.md b/domains/ui/skills/ios-widgets/references/live-activities.md
new file mode 100644
index 00000000..e887bd0b
--- /dev/null
+++ b/domains/ui/skills/ios-widgets/references/live-activities.md
@@ -0,0 +1,152 @@
+# Adding a Live Activity
+
+A Live Activity is the Lock Screen and Dynamic Island counterpart of a home
+screen widget. Every rule in the skill body applies unchanged: the `'widget'`
+directive and its no-closures consequence, the `.ios.tsx` + `.tsx` pair, both
+theme variants, pre-formatted props.
+
+Three things differ, and they are the whole delta.
+
+## 1. No native work at all
+
+No `.swift` file, no `index.swift` entry, no Xcode target membership, no
+`app.config.js` entry. `expo-widgets`' generic `WidgetLiveActivity()` renderer
+is already in the bundle and `NSSupportsLiveActivities` is already set.
+`createLiveActivity(name, layout)` writes the stringified layout into the
+shared App Group container at **import time**, and the extension reads it back
+by `name` at render time.
+
+A new Live Activity is a pure-JS change and ships over Metro/OTA like any
+other JS. No rebuild is needed to see it in a simulator that already has the
+extension installed — reloading JS is enough, because registration happens at
+import.
+
+## 2. The layout returns an object, not a JSX tree
+
+A widget layout returns one view. A Live Activity layout returns a
+`LiveActivityLayout` whose keys are the presentation regions iOS asks for:
+
+| Key | Where it appears |
+| --- | --- |
+| `banner` | Lock Screen and Notification Center |
+| `bannerSmall` | CarPlay / watchOS (falls back to `banner`) |
+| `compactLeading` | Collapsed Dynamic Island, left of the camera |
+| `compactTrailing` | Collapsed Dynamic Island, right of the camera |
+| `minimal` | Dynamic Island when another app shares it |
+| `expandedLeading` / `expandedTrailing` / `expandedCenter` / `expandedBottom` | Long-pressed Dynamic Island |
+
+The second parameter is a `LiveActivityEnvironment`, not a
+`WidgetEnvironment` — same `colorScheme`, no `widgetFamily`. `expo-widgets`
+does not re-export the type from its package root, so import it from
+`../createMetaMaskLiveActivity.ios`, which derives and re-exports it.
+
+```tsx
+import { Text } from '@expo/ui/swift-ui';
+import { foregroundStyle } from '@expo/ui/swift-ui/modifiers';
+
+import {
+ createMetaMaskLiveActivity,
+ type LiveActivityEnvironment,
+} from '../createMetaMaskLiveActivity.ios';
+import type { WithWidgetTheme } from '../types';
+
+export interface MyActivityProps {
+ valueDisplay: string;
+ /** Semantic, not a resolved color — the layout still has to pick light/dark. */
+ isProfit: boolean;
+}
+
+function MyActivityLayout(
+ props: MyActivityProps & WithWidgetTheme,
+ environment: LiveActivityEnvironment,
+) {
+ 'widget';
+
+ const { valueDisplay, isProfit, theme } = props;
+ const activeTheme =
+ environment.colorScheme === 'dark' ? theme.dark : theme.light;
+ const valueColor = isProfit
+ ? activeTheme.colors.success
+ : activeTheme.colors.error;
+
+ const value = (
+ {valueDisplay}
+ );
+
+ return {
+ banner: value,
+ compactLeading: value,
+ compactTrailing: value,
+ minimal: value,
+ };
+}
+
+export const MY_ACTIVITY_NAME = 'MyActivity';
+
+export const MyActivity = createMetaMaskLiveActivity(
+ MY_ACTIVITY_NAME,
+ MyActivityLayout,
+);
+```
+
+The plain `MyActivity.tsx` fallback mirrors this through
+`../createMetaMaskLiveActivity` with a `() => undefined` layout, same as a
+widget's fallback.
+
+## 3. The lifecycle is feature-owned
+
+`WidgetUpdaterService` exists to fan one debounced Redux snapshot out to
+widgets. A Live Activity is a state machine — start on open, update while
+open, end on close — and its data is often not in Redux at all. So the owning
+feature drives it, from its own service.
+
+That service must:
+
+- Gate on `Platform.OS === 'ios'` and `process.env.MM_WIDGETS_ENABLED === 'true'`.
+- Call `endLiveActivitiesFromPreviousLaunch()` once when it starts (see
+ [Orphaned activities](#orphaned-activities) below).
+- **Throttle, then dedupe.** ActivityKit budgets update frequency, and a price
+ feed ticks far faster than a Lock Screen card is worth redrawing. Throttle
+ the source subscriptions, then skip the write entirely when the newly
+ computed props are `JSON.stringify`-identical to the last push — the same
+ pattern `WidgetUpdaterService` uses.
+- End with `.end('immediate')`.
+
+A Live Activity can only be **started** while the app is foregrounded.
+
+## Orphaned activities
+
+`expo-widgets` renders every Live Activity through a single shared
+`ActivityAttributes` type, discriminating kinds only by a `name` string inside
+the content state. Two consequences:
+
+- **`getInstances()` on any factory returns every live instance app-wide**,
+ regardless of which factory started it, and `update()` rewrites that `name`
+ from the factory the handle came from. Never adopt `getInstances()[0]` — you
+ may silently repurpose another feature's activity.
+- iOS keeps a Live Activity alive after its host app is terminated, but the JS
+ handle needed to end it does not survive. `reconcileLiveActivities.ts`
+ therefore ends **everything**, once per process, at launch — before any
+ feature has started an activity of its own. It is guarded so a second caller
+ cannot end the first caller's freshly started activity.
+
+## Privacy mode
+
+Suppress the activity outright rather than masking the numbers. A Lock Screen
+is readable without unlocking the device. This is a deliberate difference from
+a home screen widget, which masks instead.
+
+## Checklist
+
+1. Design the props — flat, pre-formatted, semantic flags over resolved
+ colors.
+2. `app/core/Widgets/liveActivities/MyActivity.ios.tsx` — props, `'widget'`
+ layout returning a `LiveActivityLayout`, `createMetaMaskLiveActivity(...)`.
+3. `app/core/Widgets/liveActivities/MyActivity.tsx` — no-op fallback, `.tsx`
+ extension.
+4. A feature-owned lifecycle service: `.start()` / `.update()` /
+ `.end('immediate')`, throttled and deduped, gated on platform and flag,
+ calling `endLiveActivitiesFromPreviousLaunch()` once.
+5. Tests — see [`testing.md`](testing.md).
+6. Verify in a simulator. No rebuild needed if the extension is already
+ installed.
diff --git a/domains/ui/skills/ios-widgets/references/testing.md b/domains/ui/skills/ios-widgets/references/testing.md
new file mode 100644
index 00000000..a745d1a7
--- /dev/null
+++ b/domains/ui/skills/ios-widgets/references/testing.md
@@ -0,0 +1,72 @@
+# Testing widgets and Live Activities
+
+A `'widget'`-directive function becomes a **string literal** at build time,
+and `babel.config.tests.js` uses the same preset — so the transform runs under
+Jest too. Importing the module and calling the layout does not execute your
+JSX; it returns a string. React Testing Library cannot help here.
+
+Test each layer at the boundary where it is still real code.
+
+## What to test where
+
+| Layer | What is assertable |
+| --- | --- |
+| `WidgetTheme.ts` | Pure functions: color and typography mapping, spacing scale, font-weight fallback, singleton exports |
+| `createMetaMaskWidget.ios.ts` / `createMetaMaskLiveActivity.ios.ts` | Delegation to `expo-widgets`' `createWidget` / `createLiveActivity` with the right `(name, layout)` |
+| The plain `.ts` fallbacks | Every method is a safe no-op and never throws |
+| A widget's own `*.ios.tsx` | Registration only: the name matches the Swift file, and the layout argument is now a `string` |
+| `WidgetUpdaterService.ts` | All of the real logic: formatting, privacy masking, debounce, redundant-push skipping, cleanup |
+| A Live Activity's lifecycle service | All of the real logic: start/update/end transitions, throttling, dedupe, flag and platform gating |
+
+## The registration test
+
+Small, but it is the regression check that the Babel transform actually ran —
+if someone breaks the preset config, the layout stays a function and the app
+ships a widget that cannot render:
+
+```ts
+expect(createWidget).toHaveBeenCalledWith('MyWidget', expect.any(String));
+```
+
+Assert the name matches the Swift file's `kind`, and that the returned object
+exposes `updateSnapshot` / `reload`. Nothing about rendered UI is assertable
+from this file.
+
+## The logic test
+
+This is where the coverage actually belongs. Mock `ReduxService.store`, the
+selectors, and the widget module (`jest.mock('./widgets/MyWidget', ...)`),
+then drive the captured `store.subscribe` listener with `jest.useFakeTimers()`
+to assert debounce, skip, and cleanup behavior. `WidgetUpdaterService.test.ts`
+is the full worked pattern.
+
+For a Live Activity, the equivalent is the feature's lifecycle service: drive
+its data source, then assert `.start()` / `.update()` / `.end()` calls and
+that a duplicate payload produces no second write.
+
+## Jest infrastructure
+
+Native-module mocks are registered explicitly in `jest.config.js`'s
+`moduleNameMapper` — this project does not rely on the auto-discovered root
+`__mocks__/` convention:
+
+- `app/__mocks__/expo-widgets.ts`
+- `app/__mocks__/@expo/ui/swift-ui.ts`
+- `app/__mocks__/@expo/ui/swift-ui-modifiers.ts`
+
+They exist so the **import statements** at the top of `.ios.tsx` files resolve
+without the real module's throwing `requireNativeModule`. The stubbed
+components are never invoked, since the JSX never executes under Jest.
+
+Importing an `@expo/ui` submodule that has no stub yet throws at require time.
+Add the `moduleNameMapper` entry next to the existing ones and create the
+matching stub in `app/__mocks__/@expo/ui/`.
+
+## Testing the feature flag
+
+`jest.config.js` defaults `MM_WIDGETS_ENABLED` to `'true'` so the enabled path
+is the default under test. Because the value is inlined by
+`transform-inline-environment-variables` at transform time, a test can only
+toggle it at runtime if **both the module under test and its test file** are
+in `babel.config.tests.js`'s inline-env `exclude` list. Add both when a new
+service reads the flag and you want to cover the disabled no-op path.
diff --git a/domains/ui/skills/ios-widgets/references/troubleshooting.md b/domains/ui/skills/ios-widgets/references/troubleshooting.md
new file mode 100644
index 00000000..9acd92ec
--- /dev/null
+++ b/domains/ui/skills/ios-widgets/references/troubleshooting.md
@@ -0,0 +1,81 @@
+# Troubleshooting
+
+Start from the symptom. Most of these are the two-process model asserting
+itself, not a defect in the foundation.
+
+## `ReferenceError` for some variable on device, but the editor was happy
+
+You referenced something outside the layout function's own parameters — a
+closure, an import, a module-scope constant. TypeScript resolves it against
+the module; the JavaScriptCore sandbox never receives the module. Move the
+value into props and compute it in the producer.
+
+## The app crashes on Android after a change under `app/core/Widgets/`
+
+You imported `expo-widgets` or `@expo/ui` from a file without an `.ios.`
+extension, or used an explicit `.ios` suffix on a **value** import from a file
+that is not itself iOS-only. Both put a throwing `requireNativeModule` call in
+the Android bundle. See **Platform split** in the skill body.
+
+## `createWidget` throws `The 2nd argument cannot be cast to type String` at startup
+
+A split pair whose two halves have different extensions — a `.ios.tsx`
+implementation with a `.ts` fallback. Metro resolves the `.ts` no-op first on
+iOS and hands its `() => undefined` layout to the real native `createWidget`.
+Rename the fallback to match the implementation's extension. Jest will not
+catch this.
+
+## The widget never appears in the simulator's widget gallery
+
+Check, in order: the `.swift` file is a **member of the `ExpoWidgetsTarget`
+target** (not just on disk), `index.swift` lists it in the `WidgetBundle`, and
+you did a **full rebuild** — widget code lives in a separate native binary, so
+a Metro reload changes nothing.
+
+## The widget shows stale or blank data
+
+- Confirm `WidgetUpdaterService.initialize()` actually ran. It is called once
+ from `app/store/index.ts` after persisted state rehydrates, and it is a
+ no-op unless `MM_WIDGETS_ENABLED === 'true'`.
+- Confirm the App Group identifier matches **exactly** across
+ `ios/MetaMask/Info.plist`'s `ExpoWidgetsAppGroupIdentifier`,
+ `MetaMask.entitlements`, `MetaMaskDebug.entitlements`, and the extension's
+ `Info.plist` + entitlements. A mismatch fails silently rather than erroring.
+- Remember the ~2s debounce, plus WidgetKit's own daily refresh budget, which
+ throttles renders regardless of how often the app pushes.
+
+## Jest throws trying to `require` an `.ios.tsx` widget file
+
+A `moduleNameMapper` entry is missing for an `@expo/ui` submodule you newly
+imported. Add it beside the existing `@expo/ui/swift-ui` entries in
+`jest.config.js` and create the stub in `app/__mocks__/@expo/ui/`.
+
+## Two entries for the same widget in the gallery
+
+A stale WidgetKit registration from before the extension target's identity
+last changed — not a code defect. Verify with
+`xcrun simctl spawn pluginkit -m -p com.apple.widgetkit-extension`,
+which should show one entry for the extension's bundle id. Erase the simulator
+(`xcrun simctl erase `), wipe DerivedData, and reinstall.
+
+## `pod install` fails: `Unable to find a target named 'ExpoWidgetsTarget'`
+
+The Xcode target is missing, most likely from a `project.pbxproj` merge or
+rebase conflict resolved by taking one side wholesale. Run
+`ruby scripts/ios/setup-expo-widgets-target.rb` (idempotent — safe even if you
+are unsure), then rerun `pod install`.
+
+## A Live Activity is frozen on the Lock Screen after a force-quit
+
+Expected without cleanup: iOS keeps the activity alive but the JS handle to
+end it does not survive the process. The owning service must call
+`endLiveActivitiesFromPreviousLaunch()` once at start. If it already does and
+the card persists, check that the service is reached at all under the current
+platform and feature-flag gating.
+
+## A Live Activity shows another feature's data
+
+Something adopted `getInstances()[0]`. That array is app-wide across every
+kind, and `update()` rewrites the instance's `name` from whichever factory the
+handle came from. Each feature must own the handle returned by its own
+`.start()`.
diff --git a/domains/ui/skills/ios-widgets/repos/metamask-mobile.md b/domains/ui/skills/ios-widgets/repos/metamask-mobile.md
new file mode 100644
index 00000000..6b8560ad
--- /dev/null
+++ b/domains/ui/skills/ios-widgets/repos/metamask-mobile.md
@@ -0,0 +1,66 @@
+---
+repo: metamask-mobile
+parent: ios-widgets
+---
+
+# MetaMask Mobile
+
+This skill installs only for **metamask-mobile**. `expo-widgets` is iOS-only,
+so nothing here has an Android counterpart.
+
+## Canonical source of truth
+
+`docs/widgets/README.md` is the long-form human guide — architecture,
+rationale, possibilities, limitations. This skill is the condensed, actionable
+version. When the two disagree, the repo wins; read the live code before
+proposing a change.
+
+## Where things live
+
+| Path | What |
+| --- | --- |
+| `app/core/Widgets/` | All JS/TS foundation: theme bridge, `createMetaMask*` wrappers, `WidgetUpdaterService`, `reconcileLiveActivities` |
+| `app/core/Widgets/widgets/BalanceWidget.ios.tsx` + `.tsx` | Reference widget — copy its shape |
+| `app/core/Widgets/liveActivities/PerpsPnlLiveActivity.ios.tsx` + `.tsx` | Reference Live Activity |
+| `app/components/UI/Perps/services/PerpsLiveActivityService.ts` | Reference lifecycle driver — the more instructive half; throttle-then-dedupe and privacy suppression both live here |
+| `ios/ExpoWidgetsTarget/` | The WidgetKit app extension target (Swift) |
+| `ios/MetaMask/NativeModules/RCTWidgetInfo/` | `WidgetCenter.getCurrentConfigurations` bridge, used for adoption analytics |
+| `scripts/ios/setup-expo-widgets-target.rb` | Recreates the Xcode target; only needed after a `project.pbxproj` conflict |
+
+## Feature flag
+
+`MM_WIDGETS_ENABLED` gates `WidgetUpdaterService.initialize()` and every Live
+Activity service. It defaults to `'false'` in `builds.yml`'s `_public_envs`
+while the feature is in development, so every shipped build has widgets
+receiving no data.
+
+To work on widgets locally, set `MM_WIDGETS_ENABLED="true"` in `.js.env` and
+**restart Metro** — the value is inlined at transform time by
+`transform-inline-environment-variables`, not read at runtime.
+
+## Adoption analytics are automatic
+
+`initialize()` fire-and-forgets `trackWidgetAdoption()` once per launch,
+reporting which widgets are actually **installed** rather than tapped — a
+passive glanceable widget is looked at, and `expo-widgets` exposes no tap
+signal for a non-interactive widget anyway. It is keyed on the WidgetKit
+`kind`, which already equals each widget's exported name constant, so a new
+widget is covered the moment its Swift file exists. **There is no analytics
+step to add.**
+
+## Repo-specific limitations
+
+- **`MetaMask-Flask` ships without widgets.** `ExpoWidgetsTarget` is embedded
+ only in the `MetaMask` scheme. Shipping on Flask needs a second parallel
+ extension target with its own bundle id, entitlements, and provisioning —
+ not a config change. Open an RFC first.
+- **Device and IPA builds need Apple Developer portal work that does not
+ exist yet** (App Group capability on the existing profiles, two new profiles
+ for the extension's bundle id, `provisioningProfiles` entries in the export
+ options plists, manual signing). See the provisioning section of
+ `docs/widgets/README.md`. Simulator builds, `yarn start:ios`, and E2E are
+ unaffected.
+- **`expo prebuild` never runs here.** The repo has a checked-in `ios/`
+ directory and `app.config.js` nests everything under `expo:`, which
+ `@expo/config` reduces away before any plugin is seen. Every native change
+ is hand-applied to `ios/`.
diff --git a/domains/ui/skills/ios-widgets/skill.md b/domains/ui/skills/ios-widgets/skill.md
new file mode 100644
index 00000000..c22af832
--- /dev/null
+++ b/domains/ui/skills/ios-widgets/skill.md
@@ -0,0 +1,152 @@
+---
+name: ios-widgets
+description: >-
+ Build and review iOS home screen widgets and Live Activities in MetaMask
+ Mobile, on the expo-widgets + @expo/ui foundation in app/core/Widgets/ and
+ ios/ExpoWidgetsTarget/. Use when adding, changing, or reviewing a widget, a
+ Live Activity, a Dynamic Island or Lock Screen surface, WidgetKit/App Group
+ wiring, WidgetUpdaterService data pushes, or widget theming; when a file
+ carries a 'widget' directive or is one half of an .ios.tsx/.tsx
+ platform-split pair; or when debugging a widget that renders stale or blank
+ data, throws ReferenceError only on device, crashes the Android bundle at
+ import time, or never appears in the simulator's widget gallery.
+maturity: stable
+---
+
+# iOS widgets and Live Activities
+
+A widget does not run in your app. It runs in a **separate iOS app extension
+process** (`ExpoWidgetsTarget`), inside an embedded **JavaScriptCore** VM that
+shares no memory, no imports, and no module state with the React Native
+runtime. The only channel between the two is a serialized `props` object
+written to a shared **App Group** container.
+
+Every rule below follows from that one fact. Almost every widget bug is a
+violation of it that TypeScript, ESLint, and Jest all accept.
+
+## When to use
+
+- Adding or changing a home screen widget, a Live Activity, a Lock Screen
+ card, or a Dynamic Island presentation.
+- Reviewing a diff that touches `app/core/Widgets/`, `ios/ExpoWidgetsTarget/`,
+ a `'widget'`-directive function, or an `.ios.tsx`/`.tsx` pair.
+- Debugging a widget that shows stale data, renders blank, throws
+ `ReferenceError` on device only, or takes the Android app down at startup.
+
+**Out of scope:** Android home screen widgets (no equivalent foundation
+exists — `expo-widgets` is iOS-only), push-driven remote updates, and
+interactive widget buttons.
+
+## Hard rules
+
+1. **No closures inside a `'widget'`-directive function.** `babel-preset-expo`
+ replaces the whole function with a **string literal of its own source** at
+ build time. Anything it references that is not one of its own parameters —
+ an import, a module constant, a selector, `strings()`, `Logger`,
+ `console.log` — is `undefined` in the sandbox. Only `@expo/ui/swift-ui`
+ (and `/modifiers`) and plain JS are injected there.
+2. **Every value the layout needs arrives as a prop**, already fetched,
+ formatted, translated, and masked by the caller.
+3. **Platform-split every file that imports `expo-widgets` or `@expo/ui`.**
+ Their JS entry points call a *throwing* `requireNativeModule` /
+ `requireNativeView` at import time, so merely importing one from the
+ Android bundle crashes the app at startup.
+4. **Both halves of a split pair must share the same file extension.** A
+ `.ios.tsx` implementation needs a `.tsx` fallback, even when that fallback
+ contains no JSX (see [Platform split](#platform-split) for why a `.ts`
+ fallback silently wins on iOS).
+5. **Pass both theme variants and resolve inside the layout** via
+ `environment.colorScheme`. Resolving "the current theme" outside means the
+ widget only tracks an OS appearance change on the *next* app-triggered
+ push.
+6. **Never adopt `getInstances()[0]`** for a Live Activity — that array is
+ app-wide across every kind. See
+ [`references/live-activities.md`](references/live-activities.md).
+
+## Platform split
+
+| File | Runs on | Contains |
+| --- | --- | --- |
+| `MyThing.ios.tsx` | iOS | Real implementation; may import `expo-widgets` / `@expo/ui` |
+| `MyThing.tsx` | Everywhere else | No-op fallback with the same exported names and types |
+
+- Import through the **extensionless** path (`from './MyThing'`). Metro picks
+ the `.ios` variant on iOS and the fallback everywhere else; `tsc` resolves
+ the fallback, and each platform file is still type-checked when `tsc` walks
+ it directly.
+- Duplicate small prop interfaces into the fallback rather than importing
+ them from the `.ios` file.
+- An explicit `./MyThing.ios` **value** import bypasses Metro's platform
+ exclusion and ships the module to Android. It is only safe from a file that
+ is itself `.ios`-only. `import type` is erased by Babel and is safe
+ anywhere, but prefer the extensionless path for consistency.
+
+Extension parity (rule 4) is what keeps Metro and Jest in agreement. Metro
+loops `sourceExts` on the outside and platform on the inside, so for a
+`.ios.tsx` + `.ts` pair it finds `MyThing.ts` **before** it ever tries
+`MyThing.ios.tsx` — the no-op shadows the real implementation on iOS, and
+`createWidget` then throws `The 2nd argument cannot be cast to type String` at
+import time. Jest's resolver tries all platform variants first and does not
+reproduce this, so the suite stays green while the app is broken at startup.
+
+## Designing props
+
+The props object is the entire API between the two processes. Make it flat,
+JSON-serializable, and inert:
+
+- **Pre-format and pre-translate.** Currency strings, percentages, dates, and
+ labels arrive rendered — there is no `Intl` config, no formatter, and no
+ i18n catalogue in the sandbox.
+- **Pre-mask.** Privacy mode is applied by the producer. For a Live Activity,
+ prefer suppressing the activity entirely over masking, because the Lock
+ Screen is readable without unlocking the device.
+- **Pass semantic flags, not resolved colors** (`isProfit: boolean`, not
+ `color: '#1c8234'`), so the layout can still pick the right light/dark
+ variant when the OS appearance changes without an app push.
+
+## Theming
+
+Widgets cannot use `useTailwind()`,
+`@metamask/design-system-react-native`, or any NativeWind runtime — none of it
+exists inside the sandbox. Use the serializable `WidgetTheme` snapshot
+(colors, typography `{ size, weight }`, a 4px spacing scale) instead of
+hand-rolling values.
+
+Only **solid** design tokens belong in it: `@expo/ui` reads an 8-digit hex as
+`#AARRGGBB` (SwiftUI's order) while `@metamask/design-tokens`' alpha tokens are
+`#RRGGBBAA`, so an alpha token silently renders the wrong color. Prefer
+`border.default` over `border.muted`, or convert explicitly.
+
+Widgets always follow the **system** appearance, never MetaMask's in-app theme
+override — `colorScheme` comes from WidgetKit and cannot see app state.
+
+## Workflow
+
+| Task | Open |
+| --- | --- |
+| Add or change a home screen widget | [`references/adding-a-widget.md`](references/adding-a-widget.md) |
+| Add or change a Live Activity / Dynamic Island surface | [`references/live-activities.md`](references/live-activities.md) |
+| Write or fix tests for either | [`references/testing.md`](references/testing.md) |
+| A widget renders wrong, stale, or not at all | [`references/troubleshooting.md`](references/troubleshooting.md) |
+
+Read the reference for the task at hand, not all four. A widget and a Live
+Activity share every rule above and differ only in registration, layout shape,
+and who drives the lifecycle.
+
+## Review checklist
+
+Reject a change that does any of these:
+
+- References anything inside a `'widget'`-directive function other than its
+ own parameters and `@expo/ui/swift-ui`(`/modifiers`).
+- Imports `expo-widgets` or `@expo/ui/*` from a file without an `.ios.`
+ extension.
+- Uses an explicit `.ios`-suffixed **value** import from a file that is not
+ itself `.ios`-only.
+- Adds an `.ios.ts(x)` file without a plain fallback counterpart **using the
+ same extension**.
+- Passes one resolved theme instead of both variants, or resolves
+ `colorScheme` outside the layout.
+- Formats, translates, or masks data inside the layout instead of in the
+ producer.
+- Adopts an existing Live Activity instance from `getInstances()`.