From 1f4f4f7edc410c33e112c90c1436b13598270503 Mon Sep 17 00:00:00 2001 From: Kudo Chien Date: Wed, 24 Jun 2026 21:08:55 +0800 Subject: [PATCH] improve expo-ui skill --- plugins/expo/skills/expo-ui/SKILL.md | 7 +- .../expo-ui/references/jetpack-compose.md | 40 +++- .../skills/expo-ui/references/swift-ui.md | 43 +++- .../skills/expo-ui/scripts/list-components.js | 193 ++++++++++++++++++ 4 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 plugins/expo/skills/expo-ui/scripts/list-components.js diff --git a/plugins/expo/skills/expo-ui/SKILL.md b/plugins/expo/skills/expo-ui/SKILL.md index 77c1fa3..fd59c8c 100644 --- a/plugins/expo/skills/expo-ui/SKILL.md +++ b/plugins/expo/skills/expo-ui/SKILL.md @@ -1,8 +1,9 @@ --- name: expo-ui -description: "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android rendered from React in an Expo or React Native app. Covers universal cross-platform components (Host, Column, Row, Button, Text, List, and more imported from @expo/ui), drop-in replacements for popular React Native community libraries (BottomSheet, DateTimePicker, Slider, Menu, etc.), and platform-specific SwiftUI (@expo/ui/swift-ui) and Jetpack Compose (@expo/ui/jetpack-compose) trees and modifiers. Use when adding or reviewing @expo/ui Host/RNHostView trees, building native-feeling UI where standard React Native components fall short (lists with swipe actions and sections, settings forms with toggles, menus, sheets, pickers, sliders), choosing between universal and platform-specific components, or replacing an RN community UI library with a native @expo/ui equivalent. Not for custom native modules, Expo Router navigation, Reanimated, or data fetching." +description: "Build native UI with the @expo/ui package: real SwiftUI on iOS and Jetpack Compose on Android rendered from React in an Expo or React Native app. Covers universal cross-platform components (Host, Column, Row, Button, Text, List, and more imported from @expo/ui), drop-in replacements for popular React Native community libraries (BottomSheet, DateTimePicker, Slider, Menu, etc.), and platform-specific SwiftUI (@expo/ui/swift-ui, iOS only) and Jetpack Compose (@expo/ui/jetpack-compose, Android only) trees and modifiers. Use when adding or reviewing @expo/ui Host/RNHostView trees, building native-feeling UI where standard React Native components fall short (grouped settings forms with toggles, sections, menus, sheets, pickers, sliders), choosing between universal and platform-specific components, or replacing an RN community UI library with a native @expo/ui equivalent. Not for custom native modules, Expo Router navigation, Reanimated, or data fetching." version: 1.0.0 license: MIT +allowed-tools: "Bash(node *expo-ui/scripts/list-components.js *)" --- # Expo UI (`@expo/ui`) @@ -27,7 +28,9 @@ Work down this list and stop at the first layer that meets the need: 1. **Universal components — start here.** Import from the `@expo/ui` root. One component tree runs unmodified on iOS, Android, and web from a single source (Compose on Android, SwiftUI on iOS, `react-native-web`/`react-dom` on web). No platform file splits. → `./references/universal.md` -2. **Platform-specific (SwiftUI / Jetpack Compose).** Import from `@expo/ui/swift-ui` or `@expo/ui/jetpack-compose`. Use **only** when the universal layer is missing a component or modifier you need, or when you need platform-specific behavior or optimization. **Downside:** you write two trees and split them into `.ios.tsx` / `.android.tsx` files (or branch on `Platform.OS`) — more code to maintain. → `./references/swift-ui.md` and `./references/jetpack-compose.md` +2. **Platform-specific (SwiftUI / Jetpack Compose).** Import from `@expo/ui/swift-ui` or `@expo/ui/jetpack-compose`. Use **only** when the universal layer is missing a component or modifier you need, or when you need platform-specific behavior or optimization. **Downside:** you write two trees and split them into `.ios.tsx` / `.android.tsx` files (or branch on `Platform.OS`) — more code to maintain. + + > **`@expo/ui/swift-ui` is iOS-only. `@expo/ui/jetpack-compose` is Android-only.** Importing either in a file that runs on the other platform will crash at runtime with "Unable to get view config" errors. Isolate platform-specific trees in `.ios.tsx` / `.android.tsx` files placed in `components/` (never inside `app/` — Expo Router does not support platform extensions for route files), or guard with `Platform.OS` in a regular route file. `Host` must always be imported from `@expo/ui` (the universal package root), not from the platform-specific sub-packages. → `./references/swift-ui.md` and `./references/jetpack-compose.md` **Already using an RN community UI library?** `@expo/ui` also ships **drop-in replacements** — API-compatible swaps for popular libraries (`@gorhom/bottom-sheet`, `@react-native-community/datetimepicker`, and more), imported from `@expo/ui/community/`. This is a migration side-path for replacing an existing dependency, not a step in the universal-vs-platform decision above. → `./references/drop-in-replacements.md` diff --git a/plugins/expo/skills/expo-ui/references/jetpack-compose.md b/plugins/expo/skills/expo-ui/references/jetpack-compose.md index a6e2001..1096a57 100644 --- a/plugins/expo/skills/expo-ui/references/jetpack-compose.md +++ b/plugins/expo/skills/expo-ui/references/jetpack-compose.md @@ -1,18 +1,52 @@ # Platform-specific Android UI: `@expo/ui/jetpack-compose` -Use this layer only when the universal `@expo/ui` components don't cover what you need on Android (see `./universal.md` first). This requires a platform-specific tree, typically in an `.android.tsx` file. +> **Android only.** Code that imports from `@expo/ui/jetpack-compose` will crash on iOS with "Unable to get view config" errors. Always place this code in an `.android.tsx` component file or guard it with `Platform.OS === 'android'`. `Host` must be imported from `@expo/ui` (the universal root), not from `@expo/ui/jetpack-compose`. + +Use this layer only when the universal `@expo/ui` components don't cover what you need on Android (see `./universal.md` first). This requires a platform-specific tree. + +### File placement with Expo Router + +**Do not put `.android.tsx` files inside `app/` or `src/app/`.** Expo Router does not support platform-extension suffixes for route files and will throw a "no fallback sibling" Render Error. + +Place platform-specific component files in `components/` (or any directory outside the route tree), then import them from a regular route file: + +``` +src/components/ProductList.android.tsx ← Compose tree lives here +src/app/product-list.tsx ← regular Expo Router route, imports the component +``` + +`src/app/product-list.tsx`: +```tsx +import ProductList from '../components/ProductList'; +export default ProductList; +``` + +Alternatively, keep everything in one regular route file and branch on `Platform.OS`: + +```tsx +// src/app/product-list.tsx +import { Platform } from 'react-native'; +const ComposeList = Platform.OS === 'android' ? require('../components/ProductList.android').default : null; +``` ## Instructions - Expo UI's API mirrors Jetpack Compose's API. Use Jetpack Compose and Material Design 3 knowledge to decide which components or modifiers to use. If you need deeper Jetpack Compose or Material 3 guidance (e.g. which component to pick, layout patterns, theming), spawn a subagent to research [Jetpack Compose](https://developer.android.com/develop/ui/compose/components) and [Material Design 3](https://m3.material.io/) best practices. - Components are imported from `@expo/ui/jetpack-compose`, modifiers from `@expo/ui/jetpack-compose/modifiers`. -- **Always read the `.d.ts` type files** to confirm the exact API before using a component or modifier — read the relevant `{ComponentName}/index.d.ts` from the installed `@expo/ui/jetpack-compose` package in `node_modules`. This is the most reliable source of truth. +- **Before writing any code, run the list-components script** to get the exact components and modifiers available in the installed version: + ```bash + node /scripts/list-components.js # names only (compact) + node /scripts/list-components.js --docs # with one-line descriptions + ``` + (`` is the directory containing this `references/` folder.) +- **Always read the `.d.ts` type files** to confirm prop shapes and signatures — read the relevant `{ComponentName}/index.d.ts` from the installed `@expo/ui/jetpack-compose` package in `node_modules`. This is the most reliable source of truth. - When about to use a component, fetch its docs to confirm the API — https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/{component-name}/index.md - When unsure about a modifier's API, refer to the docs — https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/modifiers/index.md - Every Jetpack Compose tree must be wrapped in `Host`. Use `` for intrinsic sizing, or `` when you need explicit size (e.g. as a parent of `LazyColumn`). Example: ```jsx -import { Host, Column, Button, Text } from "@expo/ui/jetpack-compose"; +import { Host } from "@expo/ui"; // Host always from universal root +import { Column, Button, Text } from "@expo/ui/jetpack-compose"; import { fillMaxWidth, paddingAll } from "@expo/ui/jetpack-compose/modifiers"; diff --git a/plugins/expo/skills/expo-ui/references/swift-ui.md b/plugins/expo/skills/expo-ui/references/swift-ui.md index 1252ecc..480ec90 100644 --- a/plugins/expo/skills/expo-ui/references/swift-ui.md +++ b/plugins/expo/skills/expo-ui/references/swift-ui.md @@ -1,19 +1,56 @@ # Platform-specific iOS UI: `@expo/ui/swift-ui` -Use this layer only when the universal `@expo/ui` components don't cover what you need on iOS (see `./universal.md` first). This requires a platform-specific tree, typically in an `.ios.tsx` file. +> **iOS only.** Code that imports from `@expo/ui/swift-ui` will crash on Android with "Unable to get view config" errors. Always place this code in an `.ios.tsx` component file or guard it with `Platform.OS === 'ios'`. `Host` must be imported from `@expo/ui` (the universal root), not from `@expo/ui/swift-ui`. + +Use this layer only when the universal `@expo/ui` components don't cover what you need on iOS (see `./universal.md` first). This requires a platform-specific tree. + +### File placement with Expo Router + +**Do not put `.ios.tsx` files inside `app/` or `src/app/`.** Expo Router does not support platform-extension suffixes for route files and will throw a "no fallback sibling" Render Error. + +Place platform-specific component files in `components/` (or any directory outside the route tree), then import them from a regular route file: + +``` +src/components/ProfileEditor.ios.tsx ← SwiftUI tree lives here +src/app/profile-editor.tsx ← regular Expo Router route, imports the component +``` + +`src/app/profile-editor.tsx`: +```tsx +import ProfileEditor from '../components/ProfileEditor'; +export default ProfileEditor; +``` + +Alternatively, keep everything in one regular route file and branch on `Platform.OS`: + +```tsx +// src/app/profile-editor.tsx +import { Platform } from 'react-native'; +// import SwiftUI components only when on iOS to avoid Android crash +const SwiftUIForm = Platform.OS === 'ios' ? require('../components/ProfileEditor.ios').default : null; +``` + +Or more simply, put the `Platform.OS` guard and the SwiftUI import in the same route file (safe because Metro only bundles `.ios.tsx` imports on iOS builds when using platform extensions in `components/`). ## Instructions - Expo UI's API mirrors SwiftUI's API. Use SwiftUI knowledge to decide which components or modifiers to use. - Components are imported from `@expo/ui/swift-ui`, modifiers from `@expo/ui/swift-ui/modifiers`. -- **The installed package's TypeScript types (`.d.ts`) are the most reliable source of truth** for the exact API on your SDK version (@expo/ui is versioned with the SDK and its API can change between versions) — read the relevant `{Component}/index.d.ts` from the installed `@expo/ui/swift-ui` package in `node_modules`. Use the docs below as the human-readable reference. +- **Before writing any code, run the list-components script** to get the exact components and modifiers available in the installed version: + ```bash + node /scripts/list-components.js # names only (compact) + node /scripts/list-components.js --docs # with one-line descriptions + ``` + (`` is the directory containing this `references/` folder.) +- **The installed package's TypeScript types (`.d.ts`) are the most reliable source of truth** for prop shapes and signatures — read the relevant `{Component}/index.d.ts` from the installed `@expo/ui/swift-ui` package in `node_modules`. Use the docs below as the human-readable reference. - When about to use a component, fetch its docs to confirm the API — https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/{component-name}/index.md - When unsure about a modifier's API, refer to the docs — https://docs.expo.dev/versions/latest/sdk/ui/swift-ui/modifiers/index.md - Every SwiftUI tree must be wrapped in `Host`. - `RNHostView` is specifically for embedding RN components inside a SwiftUI tree. Example: ```jsx -import { Host, VStack, RNHostView } from "@expo/ui/swift-ui"; +import { Host } from "@expo/ui"; // Host always from universal root +import { VStack, RNHostView } from "@expo/ui/swift-ui"; // platform components from swift-ui import { Pressable } from "react-native"; diff --git a/plugins/expo/skills/expo-ui/scripts/list-components.js b/plugins/expo/skills/expo-ui/scripts/list-components.js new file mode 100644 index 0000000..3a64f7d --- /dev/null +++ b/plugins/expo/skills/expo-ui/scripts/list-components.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * List available @expo/ui components and modifiers installed in a project. + * + * Usage: + * node list-components.js [--docs] + * + * --docs Include a one-line JSDoc description per modifier. + * Omit (default) for a compact names-only list that consumes fewer tokens. + * + * Output goes to stdout. Redirect or capture it to inject into an agent prompt. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const projectPath = process.argv[2]; +const withDocs = process.argv.includes('--docs'); + +if (!projectPath) { + console.error('Usage: node list-components.js [--docs]'); + process.exit(1); +} + +const pkgRoot = path.join(projectPath, 'node_modules', '@expo', 'ui'); +if (!fs.existsSync(pkgRoot)) { + console.error(`@expo/ui not found in ${projectPath}/node_modules`); + process.exit(1); +} + +// Read installed version from package.json +let version = 'unknown'; +try { + const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')); + version = pkg.version || 'unknown'; +} catch (_) {} + +// --------------------------------------------------------------------------- +// Component extraction — parse `export * from './Name'` in an index.d.ts +// --------------------------------------------------------------------------- +// TypeScript type names that are not components — skip anything matching these suffixes +const TYPE_SUFFIX = /(?:Props|Ref|Handle|Params|Config|Options|Type|Types|Value|Values|Colors|Style|Styles|Event|Events|Alignment|Animation|Spec)$/; + +function extractComponents(indexFile) { + if (!fs.existsSync(indexFile)) return []; + const src = fs.readFileSync(indexFile, 'utf8'); + const names = []; + for (const line of src.split('\n')) { + // export * from './ComponentName' or export * from './ComponentName/index' + const m = line.match(/^export \* from ['"]\.\/([^/'"]+)/); + if (m) { + const name = m[1]; + // Skip non-component re-exports (types, utils, state internals) + if (/^(types|utils|index|State|hooks|colors|layout-types|MaterialSymbols)/.test(name)) continue; + if (TYPE_SUFFIX.test(name)) continue; + names.push(name); + } + // export { Name, ... } from './Something' — pick up named re-exports too + const n = line.match(/^export \{([^}]+)\}/); + if (n) { + for (const part of n[1].split(',')) { + // Skip `type Foo` re-exports + if (/^\s*type\s/.test(part)) continue; + const id = part.trim().split(/\s+as\s+/)[0].trim(); + if (id && /^[A-Z]/.test(id) && !TYPE_SUFFIX.test(id)) names.push(id); + } + } + } + return [...new Set(names)].sort(); +} + +// --------------------------------------------------------------------------- +// Modifier extraction — parse `export declare const/function ` in +// a flat modifiers/index.d.ts, optionally with the preceding JSDoc summary. +// --------------------------------------------------------------------------- +function extractModifiers(modifiersFile) { + if (!fs.existsSync(modifiersFile)) return []; + const src = fs.readFileSync(modifiersFile, 'utf8'); + const lines = src.split('\n'); + const results = []; + const seen = new Set(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = line.match(/^export declare (?:const|function) ([a-zA-Z_][a-zA-Z0-9_]*)/); + if (!m) continue; + const name = m[1]; + // Skip type-only helpers and internal symbols + if (/^(is|filter|create|type|export)/.test(name) && name !== 'frame') continue; + if (seen.has(name)) continue; + seen.add(name); + + if (!withDocs) { + results.push({ name }); + continue; + } + + // Find /** that opens the JSDoc block immediately preceding this export, + // then scan forward through the block for the first plain-prose summary. + let jsdocStart = -1; + for (let j = i - 1; j >= 0; j--) { + const jl = lines[j].trim(); + if (jl === '/**') { jsdocStart = j; break; } + if (jl !== '' && jl !== '*/' && !jl.startsWith('*')) break; + } + + let summary = ''; + let deprecated = false; + if (jsdocStart >= 0) { + let inCodeBlock = false; + for (let k = jsdocStart + 1; k < i; k++) { + const kl = lines[k].trim(); + if (kl.startsWith('* ```')) { inCodeBlock = !inCodeBlock; continue; } + if (inCodeBlock) continue; + // Check @deprecated anywhere in the block (may appear after @param) + if (kl.startsWith('* @deprecated')) { deprecated = true; continue; } + // Extract summary from opening prose only — stop at first non-deprecated tag + if (!summary) { + if (kl.startsWith('* @')) continue; // skip tags while looking for prose + if (kl === '*/' || kl === '/**' || kl === '*') continue; + if (kl.startsWith('* ')) { + const text = kl.slice(2).trim(); + if (!text.startsWith('-') && !text.startsWith('<')) { + summary = text.replace(/\.$/, ''); + } + } + } + } + } + results.push({ name, summary, deprecated }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Format helpers +// --------------------------------------------------------------------------- +function formatNames(names) { + return names.join(', '); +} + +function formatModifiers(mods) { + if (!withDocs) return mods.map(m => m.name).join(', '); + const lines = []; + for (const m of mods) { + const dep = m.deprecated ? ' [deprecated]' : ''; + const desc = m.summary ? ` — ${m.summary}` : ''; + lines.push(` ${m.name}${dep}${desc}`); + } + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +const buildRoot = path.join(pkgRoot, 'build'); + +// Universal +const universalComponents = extractComponents(path.join(buildRoot, 'universal', 'index.d.ts')); + +// Swift-UI (iOS only) +const swiftuiComponents = extractComponents(path.join(buildRoot, 'swift-ui', 'index.d.ts')); +const swiftuiModifiers = extractModifiers(path.join(buildRoot, 'swift-ui', 'modifiers', 'index.d.ts')); + +// Jetpack Compose (Android only) +const composeComponents = extractComponents(path.join(buildRoot, 'jetpack-compose', 'index.d.ts')); +const composeModifiers = extractModifiers(path.join(buildRoot, 'jetpack-compose', 'modifiers', 'index.d.ts')); + +const docsNote = withDocs ? ' (with descriptions)' : ' (names only — run with --docs for descriptions)'; +console.log(`@expo/ui ${version}${docsNote}\n`); + +console.log(`@expo/ui — universal (iOS + Android + web)`); +console.log(` Components: ${formatNames(universalComponents)}\n`); + +console.log(`@expo/ui/swift-ui — iOS ONLY (crashes on Android)`); +console.log(` Components: ${formatNames(swiftuiComponents)}`); +if (withDocs) { + console.log(` Modifiers:\n${formatModifiers(swiftuiModifiers)}`); +} else { + console.log(` Modifiers: ${formatModifiers(swiftuiModifiers)}`); +} +console.log(); + +console.log(`@expo/ui/jetpack-compose — Android ONLY (crashes on iOS)`); +console.log(` Components: ${formatNames(composeComponents)}`); +if (withDocs) { + console.log(` Modifiers:\n${formatModifiers(composeModifiers)}`); +} else { + console.log(` Modifiers: ${formatModifiers(composeModifiers)}`); +}