create-proto 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-proto",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Scaffold a new Proto prototype: `npm create proto@latest myapp`. Describe a screen, watch your prototype run natively on iPhone — designer-first, paired with Claude Code.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "fs-extra": "^11.2.0",
46
46
  "qrcode-terminal": "^0.12.0",
47
47
  "validate-npm-package-name": "^5.0.1",
48
- "@sherizan/proto-cli": "^0.8.9"
48
+ "@sherizan/proto-cli": "^0.8.17"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/fs-extra": "^11.0.4",
@@ -1,57 +1,51 @@
1
1
  # Prototo Project — Agent Instructions
2
2
 
3
- You're the design tool inside a Prototo project. The designer prompts you in plain language; you generate native iOS screens. The iOS Simulator is the canvas. Designers never touch files.
3
+ You're the design tool inside a Prototo project. The designer prompts in plain language; you build native iOS screens. The iOS Simulator is the canvas. Designers never touch files: they prompt, you write.
4
4
 
5
5
  ## Read first
6
- - `DESIGN.md` — design tokens and the project's decisions
7
- - `/screens/` — what already exists
6
+ - `DESIGN.md` — tokens and the project's decisions. Keep it alive: update it when colors, type, spacing, shape or accent change, and add a one-line entry to its Screens section for every new screen.
7
+ - `/screens/` — what already exists.
8
8
 
9
9
  ## Building blocks (use whatever fits)
10
10
 
11
- **Native iOS** — the easiest path to system-feel UI. Apple handles Liquid Glass, SF Symbols, accessibility, dynamic type:
11
+ **Native iOS first** (Apple handles Liquid Glass, SF Symbols, accessibility, Dynamic Type):
12
+ - `expo-router/unstable-native-tabs` — native `UITabBar` (shape below). Never build a custom tab bar.
13
+ - `expo-router` `Stack` with `headerLargeTitle: true` + a per-route `title`. Don't add `headerTransparent` or `headerBlurEffect`: iOS 26 paints the glass itself and those props break large titles.
14
+ - `expo-symbols` `SymbolView` for SF Symbols (private-use codepoints in `Text` don't render).
15
+ - `@expo/ui/swift-ui` (`Button`, `Toggle`, `Form`, `Section`…) · `expo-glass-effect` `GlassView`.
12
16
 
13
- - `expo-router/unstable-native-tabs` — native `UITabBar`
14
- - `expo-router` `Stack` with `headerLargeTitle: true` + `title` set per route — native large-title nav bar. Don't add `headerTransparent` or `headerBlurEffect` — iOS 26's UINavigationBar paints Liquid Glass automatically and those props break the large-title rendering / cause overlapping effects.
15
- - `expo-symbols` `SymbolView` — SF Symbol icons
16
- - `@expo/ui/swift-ui` — `Button`, `Toggle`, `Form`, `Section`, etc.
17
- - `expo-glass-effect` `GlassView` — Liquid Glass surfaces
18
-
19
- **Prototo primitives** in `/components/proto` — small set of themed fallbacks. Props cheat sheet (source is read-only; only read a file if something here doesn't match):
17
+ **Prototo primitives** from `../components/proto` (read-only; open a source file only if this table doesn't match):
20
18
 
21
19
  | Primitive | Props |
22
20
  |---|---|
23
- | `Screen` | `scrollable?: boolean` (default true) |
24
- | `Stack` | `gap?`, `padding?`, `align?: 'start'\|'center'\|'end'` (unset = full-width stretch), `style?` |
25
- | `Row` | `gap?`, `align?: 'start'\|'center'\|'end'` (default 'start'), `style?` |
21
+ | `Screen` | `scrollable?` (default true), `gradient?` (true = light accent wash rising from the bottom, or a colour) |
22
+ | `Stack` | `gap?`, `padding?`, `align?: 'start'\|'center'\|'end'` (unset = stretch), `style?` |
23
+ | `Row` | `gap?`, `align?` (default 'start'), `style?` |
26
24
  | `Text` | `size?: 'title'\|'headline'\|'body'\|'caption'\|'label'`, `color?: 'primary'\|'secondary'\|'accent'\|'destructive'`, `style?` |
27
- | `Card` | `glass?: boolean`, `padding?: number` |
28
- | `Button` | `label: string`, `variant?: 'primary'\|'secondary'\|'ghost'\|'destructive'`, `onPress?`, `disabled?`, `icon?`, `style?`, `textStyle?` |
29
- | `Toggle` | `label: string`, `value: boolean`, `onChange?(value)` |
30
- | `Slider` | `value: number`, `onChange?`, `min?`, `max?`, `step?`, `label?` |
31
- | `Stepper` | `label: string`, `value: number`, `onChange(value)`, `min?`, `max?`, `step?` |
32
- | `Divider` | `label?: string` |
33
- | `Input` | React Native `TextInputProps` passthrough |
34
- | `Modal` | `title: string`, `visible: boolean`, `onClose?` |
25
+ | `Card` | `glass?` (iOS 26 material, plain View on older iOS), `padding?` |
26
+ | `Button` | native Liquid Glass. `label`, `variant?: 'primary'\|'secondary'\|'ghost'\|'destructive'`, `onPress?`, `disabled?`, `systemImage?` (SF Symbol name), `image?` (picture URL for a brand mark) |
27
+ | `Toggle` / `Slider` / `Stepper` | native SwiftUI, accent-tinted. `Toggle`: `label`, `value`, `onChange?` · `Slider`: `value`, `onChange?`, `min?`, `max?`, `step?`, `label?` · `Stepper`: `label`, `value`, `onChange`, `min?`, `max?`, `step?` |
28
+ | `Divider` | `label?` |
29
+ | `Input` | RN `TextInputProps` |
30
+ | `Modal` | native bottom sheet, sizes to its content. `title`, `visible`, `onClose?` |
31
+ | `Picker` | native segmented control. `options: string[]`, `value`, `onChange?`, `label?`, `variant?: 'segmented'\|'menu'\|'wheel'` |
32
+ | `DatePicker` | native compact picker. `value: Date`, `onChange?`, `label?`, `mode?: 'date'\|'time'\|'dateTime'`, `min?`, `max?` |
33
+ | `Menu` | native pull-down. `label`, `systemImage?`, `items: { label, onPress?, destructive?, systemImage? }[]` |
34
+ | `Alert` | native alert, controlled. `title`, `message?`, `visible`, `onClose?`, `actions: { label, onPress?, destructive?, cancel? }[]` |
35
35
  | `Lottie` | `source`, `autoPlay?`, `loop?`, `style?` |
36
- `Toggle`, `Slider`, and `Stepper` render real native SwiftUI (`@expo/ui`) on iOS, tinted with the app accent. Card's `glass={true}` uses `expo-glass-effect`'s native iOS 26 material; on older iOS it falls back to a plain View.
37
-
38
- **Prototo motion + graphics** — four subpath modules in `/components/proto` cover animation and drawing. Pick by what the prompt actually asks for:
39
-
40
- - `../components/proto/motion` — `Motion.View` + `Motion.Pressable`. **Default for transitions.** Native platform animations (CAAnimation / ObjectAnimator) with zero JS overhead. Reach for this for "fade in", "slide up", "scale on tap", "animate when this state changes". Driven by `react-native-ease`.
41
- - `../components/proto/gestures` — `AnimatedView`, `useSharedValue`, `useAnimatedStyle`, `withSpring`, `Gesture`, `GestureDetector`, etc. Use **only** when the animation must read gesture state, scroll position, or interpolate continuously: "drag this card", "swipe to delete", "parallax this header". Driven by `react-native-reanimated` + `react-native-gesture-handler`.
42
- - `../components/proto/lottie` — `Lottie` component. Plays `.json` files dropped into `/assets/lottie/`. The designer brings the animation file (LottieFiles / After Effects export); you wire it: `<Lottie source={require('../assets/lottie/<name>.json')} />`. Defaults to `autoPlay` and `loop`. Driven by `lottie-react-native`.
43
- - `../components/proto/canvas` — `Canvas`, `Path`, `Circle`, `Rect`, `LinearGradient`, etc. For custom drawing that doesn't fit RN's box model: confetti bursts, custom charts, badge shapes. Driven by `@shopify/react-native-skia`.
44
- - `../components/proto/svg` — `Svg`, `Path`, `Circle`, `Rect`, `G`, `LinearGradient`, etc. For vector icons, logos, and illustrations. You can also import an SVG file directly: `import Logo from '../assets/logo.svg'` then `<Logo width={120} height={40} />`. Driven by `react-native-svg`. Use this for static vector art; reach for `canvas` only when you need to animate or compute the drawing.
45
36
 
46
- Never import `react-native-ease`, `react-native-reanimated`, `lottie-react-native`, `@shopify/react-native-skia`, or `react-native-svg` directly in a screen — always route through the `../components/proto/<subpath>` module above. If `motion` can't express what's needed, fall back to `gestures`.
37
+ **Motion + graphics** — always import through these subpaths, never the underlying library:
38
+ - `../components/proto/motion` — `Motion.View`, `Motion.Pressable`. **Default** for fades, slides, scale on tap, state-change animations.
39
+ - `../components/proto/gestures` — `AnimatedView`, `useSharedValue`, `useAnimatedStyle`, `withSpring`, `Gesture`, `GestureDetector`. Only when animation follows a drag, swipe, scroll or continuous value.
40
+ - `../components/proto/lottie` — plays designer-supplied files: `<Lottie source={require('../assets/lottie/<name>.json')} />`.
41
+ - `../components/proto/svg` — static vector art; `import Logo from '../assets/logo.svg'` works too.
42
+ - `../components/proto/canvas` — computed or animated drawing (confetti, charts, custom shapes).
47
43
 
48
- **Device sensors** — `expo-sensors` (`Accelerometer`, `Gyroscope`, `DeviceMotion`, `Magnetometer`, `Barometer`, `Pedometer`) for prototypes that react to tilting, shaking, or steps. Always guard with `isAvailableAsync()` on the class you use and render a still fallback when it's false: the Simulator has no motion hardware, so nothing ever fires there. Motion only comes alive on a real iPhone — tell the designer to `npx proto share` and open the link in the Prototo app to feel it. Call `requestPermissionsAsync()` before subscribing to `DeviceMotion` or `Pedometer`.
44
+ **Sensors** — `expo-sensors` (`Accelerometer`, `Gyroscope`, `DeviceMotion`, `Magnetometer`, `Barometer`, `Pedometer`). Guard with `isAvailableAsync()` and render a still fallback: the Simulator never fires motion, only a real iPhone does (via a published link in the Prototo app). Call `requestPermissionsAsync()` before `DeviceMotion` or `Pedometer`.
49
45
 
50
- **Custom** — when none of the above fit, write the component you need with React Native. Put shared ones in `/components/shared/`. The designer's vision wins; primitives are starting points, not constraints.
46
+ **Custom** — when nothing fits, write it with React Native in `/components/shared/`. The designer's vision wins.
51
47
 
52
- ## Adding a library
53
-
54
- To add any npm package (a font, an icon set, a utility), run `npx proto add <package>` — never `npm install` / `pnpm add` directly (and `proto` alone isn't on PATH — always `npx proto`). `proto add` installs through `expo install`, which picks the version that matches this project and resolves dependencies cleanly, so the project doesn't break. If the package needs native code this Prototo doesn't bundle, `proto add` will say so — that feature won't appear on the device until the Proto team ships an updated Prototo.
48
+ **Adding a package** — `npx proto add <package>` only (never `npm install` / `pnpm add`; bare `proto` isn't on PATH). It says when a package needs native code Prototo doesn't bundle.
55
49
 
56
50
  ## File layout
57
51
 
@@ -66,9 +60,12 @@ To add any npm package (a font, an icon set, a utility), run `npx proto add <pac
66
60
 
67
61
  A new screen `screens/Settings.tsx` needs:
68
62
  - `app/settings.tsx` re-exporting it (`import Settings from '../screens/Settings'; export default function SettingsRoute() { return <Settings />; }`)
69
- - A title set in `app/_layout.tsx`: `<Stack.Screen name="settings" options={{ title: 'Settings' }} />`
63
+ - A title in `app/_layout.tsx`: `<Stack.Screen name="settings" options={{ title: 'Settings' }} />`
64
+ - Route filenames are lowercase kebab-case.
65
+
66
+ **Linking screens** — Prototo draws the prototype's flow (Export flow) from the code, so write navigation where it can read it: `router.push('/settings')` inline in the `onPress`, or `<Link href="/settings">`, in the screen file itself (or in that screen's `<Stack.Screen options>` for a header button). A handler defined elsewhere, a link inside a shared component, or a `[param]` route still works, but that arrow won't start at its button.
70
67
 
71
- **Tabs (NativeTabs)** — exact shape for this project's pinned `expo-router` (the flat `Icon`/`Label` imports you may know do NOT exist here; they're nested under `Trigger`):
68
+ **NativeTabs** — exact shape for this project's `expo-router` (`Icon`/`Label` are nested under `Trigger`, not flat imports):
72
69
 
73
70
  ```tsx
74
71
  import { NativeTabs } from 'expo-router/unstable-native-tabs';
@@ -80,106 +77,34 @@ export default function Layout() {
80
77
  <NativeTabs.Trigger.Icon sf="house.fill" />
81
78
  <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
82
79
  </NativeTabs.Trigger>
83
- <NativeTabs.Trigger name="profile">
84
- <NativeTabs.Trigger.Icon sf="person.fill" />
85
- <NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
86
- </NativeTabs.Trigger>
87
80
  </NativeTabs>
88
81
  );
89
82
  }
90
83
  ```
91
84
 
92
- **Root-layout changes need a cold restart.** Swapping the navigator in `app/_layout.tsx` (Stack ↔ NativeTabs) does NOT apply via Fast Refresh, and the stale UI in a screenshot can look plausible. After editing `_layout.tsx`, call the `reload_app` MCP tool, then screenshot.
93
- - Route filenames are lowercase kebab-case.
94
-
95
- ## DESIGN.md is alive
96
-
97
- When the designer asks to change colors, typography, spacing, shape, accent, or anything design-systemy, update `DESIGN.md` too. It's the project's source of truth, and other tools (and future you) will read it.
98
-
99
- When you add a new screen, add a one-line entry to `DESIGN.md`'s Screens section.
100
-
101
- ## One palette, one place
102
-
103
- The theme colors live in `DESIGN.md` and the proto tokens — read them with `useTheme()` from `../components/proto`. When a design needs custom brand colors, fonts, or constants beyond the theme (e.g. a specific gradient or accent set), define them **once** in a single shared module (`/components/shared/theme.ts`) and import it everywhere that needs them. Never paste the same color/font constants inline into more than one screen — duplicated palettes drift out of sync. If you find a palette already inlined in a screen, lift it into the shared module and import it back.
104
-
105
- ## Light, dark, and accessibility
106
-
107
- - **Dark mode is automatic.** `useTheme()` returns the right palette for the device's light or dark setting and re-renders when it flips. So use theme colors (`theme.surface.*`, `theme.text.*`, `theme.border.*`) instead of hardcoded hex/rgba, and the screen adapts for free. Custom brand colors in the shared theme module won't auto-adapt — if a design needs a dark variant of a brand color, define both and pick with the same light/dark signal. To pin a scheme for a prototype, set `colorScheme: 'light' | 'dark'` in `proto.config.js`.
108
- - **Text already scales** with the device's text-size setting (iOS Dynamic Type). Don't disable it. Lay out so text can grow a couple of steps without clipping — avoid fixed heights on text containers.
109
- - **Accessibility floors** live in `a11y` from `../components/proto`: tap targets ≥ `a11y.minTapTarget` (44pt), text contrast ≥ `a11y.minTextContrast`. After visual changes, the `proto shot` check (below) is where you confirm contrast holds — in both light and dark.
110
-
111
- ## Check your work visually
112
-
113
- You can't see the Simulator by default, so after any visual change, look at it:
114
-
115
- 1. Run `proto shot` — it captures the running Simulator to `.proto/last-shot.png`.
116
- 2. Read that image and inspect it for real defects: overlapping elements, low contrast / unreadable text, clipping, cramped or uneven spacing, off-center layout, wrong colors.
117
- 3. If something's off, fix it and capture again. Iterate until it looks right — don't make the designer be your QA.
118
-
119
- Do this especially after layout, color, typography, or spacing changes. If `proto shot` reports no preview is running, the designer needs to run `proto start` first.
120
-
121
- ## Proto MCP tools
122
-
123
- When the designer runs `proto start`, a local MCP server (`prototo`) connects automatically — no setup. It gives you four tools that close the feedback loop, so you can see what you built instead of asking the designer to relay it. (Under Codex, the tools appear once the designer trusts the project — Codex asks on first open. If the prototo tools are missing, say so and ask the designer to relaunch Codex and trust the project; meanwhile `proto shot` still works.)
124
-
125
- **At the start of any fix session** (the designer says something is broken, red, or not working):
126
-
127
- 1. Call `get_metro_errors` first — it returns the current build failures and runtime crashes from the running prototype, with the raw error text and the screen involved. Don't ask the designer to paste or describe an error before checking it.
128
-
129
- **After every screen write:**
130
-
131
- 1. Call `compile_check` with the screen name — it type-checks the project and reports any problems in plain language. Fix anything it surfaces before moving on.
132
- 2. Call `get_simulator_screenshot` — it returns what the prototype actually renders right now. Inspect it for the same defects as above.
133
-
134
- **After editing `app/_layout.tsx` or any navigator:** call `reload_app` — root-layout changes don't Fast-Refresh, and a stale screenshot looks plausible.
135
-
136
- Never assume a screen rendered correctly — check the screenshot. Never ask the designer to describe an error you can catch with `get_metro_errors` or `compile_check`. If a tool says the Simulator isn't running (or Metro reports clean while the designer still sees an error), the designer needs to run `proto start` first.
137
-
138
- (`get_simulator_screenshot` is the automated form of the `proto shot` loop above — prefer the tool when it's available.)
139
-
140
- ## Mock vs real data
141
-
142
- When a screen shows placeholder numbers that aren't yet wired to a real source, wrap them in `mock()` from `../components/proto` — `const conditions = mock({ wave: '0.8m' })`. It returns the value unchanged, so nothing breaks; it just makes stubbed data obvious and greppable so fake numbers never ship believing they're real. When you wire the value to a live source (a `fetch`), drop the `mock()` wrapper. (Don't use code comments to mark mock data — generated screens stay comment-free.)
143
-
144
- ### Making it real
145
-
146
- When the designer says "use real data", follow this shape so every screen handles loading and failure the same way:
147
-
148
- 1. Keep the `mock()` value as the **starting state** — it's what shows before the fetch resolves and if the network fails. Drop the `mock()` wrapper from the live value once wired.
149
- 2. Fetch in an effect, guarding against the screen unmounting:
150
-
151
- ```tsx
152
- const [data, setData] = useState(FALLBACK);
153
- const [loading, setLoading] = useState(true);
154
- useEffect(() => {
155
- let alive = true;
156
- fetch(URL)
157
- .then((r) => r.json())
158
- .then((json) => { if (alive) setData(shape(json)); })
159
- .catch(() => {})
160
- .finally(() => { if (alive) setLoading(false); });
161
- return () => { alive = false; };
162
- }, []);
163
- ```
164
-
165
- 3. While `loading`, show a skeleton or the fallback — never a blank screen. On error, keep the fallback (the `.catch` above already does this); don't surface a raw error to the designer.
166
- 4. Put fetch + shaping logic in `/components/shared/<name>Data.ts`, not inline in the screen.
167
-
168
- **Keyless APIs** (no key, CORS-open, good for prototypes): Open-Meteo (weather/marine/air), REST Countries, Open Library, PokéAPI, Art Institute of Chicago, TheMealDB, Wikipedia REST. Prefer these so "make it real" stays a one-prompt step. If a source needs a key, tell the designer that key goes in `proto.config.js`, nowhere else.
169
-
170
- ## Sharing your prototype
171
-
172
- Run `proto share` to publish the prototype and get a permanent `prototo.app/p/<token>` link. Recipients open it on iPhone with the free **Prototo** app (the link page walks them through installing it) and the prototype runs **natively on their device** — real gestures, haptics, and Liquid Glass, with `motion`/`gestures`/`canvas`/`svg`/`lottie`, live data, and custom logic exactly as they run on the designer's Simulator. There's nothing to dumb down — build whatever the designer asks for and it all shares.
173
-
174
- Two limits worth knowing: the prototype must be built on the **current Prototo runtime** — when `npx proto start` or `npx proto share` says the project is on an older runtime, run `npx proto upgrade` (it updates Prototo and moves the project to the current runtime; never run Expo or npm commands for this yourself), then `npx proto share` again so the existing link opens on the new Prototo app — and it can only use native modules Prototo bundles — `npx proto add` tells you when a package needs native code that isn't available.
175
-
176
- ## When modifying
177
-
178
- Read the file first, then make precise, targeted edits to the parts that change. Keep edits scoped — don't rewrite a whole file when a few lines change.
179
-
180
- ## Avoid
181
-
182
- - Custom tab bars — `expo-router/unstable-native-tabs` is strictly better (real Liquid Glass, real SF Symbols, system blur).
183
- - SF Symbol private-use Unicode codepoints (`''`, `''`) in plain Text — they don't render. Use `expo-symbols` `SymbolView` or pass the symbol name to a native component.
184
- - Editing `/components/proto/`, `.proto/`, `app.config.js`, `babel.config.js`, `metro.config.js`.
185
- - Telling the designer to open or edit a file manually. They prompt; you write.
85
+ ## Theme, dark mode, accessibility
86
+ - Colors come from `useTheme()` (`theme.surface.*`, `theme.text.*`, `theme.border.*`): light/dark is automatic, so no hardcoded hex. `colorScheme: 'light' | 'dark'` in `proto.config.js` pins a scheme.
87
+ - Custom brand colors, fonts or constants live **once** in `/components/shared/theme.ts` (define light + dark variants if needed). Never paste a palette into more than one screen; lift any you find.
88
+ - Text scales with Dynamic Type: never disable it, avoid fixed heights on text containers.
89
+ - `a11y` from `../components/proto`: tap targets ≥ `a11y.minTapTarget` (44pt), contrast ≥ `a11y.minTextContrast`, in light and dark.
90
+
91
+ ## Check your work (Proto MCP tools)
92
+ The `prototo` MCP server connects while the preview runs. (Under Codex it appears once the designer trusts the project; if it's missing, ask them to relaunch Codex and trust it.)
93
+ - **Something's broken / red?** Call `get_metro_errors` first. Never ask the designer to paste an error.
94
+ - **After every screen write:** `compile_check` with the screen name, fix what it reports, then `get_simulator_screenshot` and inspect for overlap, clipping, low contrast, cramped or uneven spacing, wrong colors. Iterate until it looks right; don't make the designer your QA.
95
+ - **After editing `app/_layout.tsx` or any navigator:** `reload_app` first. Root layouts don't Fast-Refresh and a stale screenshot looks plausible.
96
+ - No MCP? `npx proto shot` writes `.proto/last-shot.png`; read it. If the tools say nothing is running, ask the designer to restart the preview.
97
+
98
+ ## Data
99
+ - Placeholder values: wrap in `mock()` from `../components/proto` (`mock({ wave: '0.8m' })`). It returns the value unchanged and marks it as fake. No code comments for this.
100
+ - "Use real data": keep the mock value as the starting state and fallback, fetch in an effect with an `alive` guard, show a skeleton or the fallback while loading, keep the fallback on error (never surface a raw error). Fetch + shaping logic goes in `/components/shared/<name>Data.ts`.
101
+ - Keyless APIs: Open-Meteo, REST Countries, Open Library, PokéAPI, Art Institute of Chicago, TheMealDB, Wikipedia REST. A key goes in `proto.config.js`, nowhere else.
102
+
103
+ ## Sharing
104
+ A published link (`prototo.app/p/<token>`) runs the prototype natively in the free Prototo iPhone app, with everything above: gestures, haptics, glass, motion, live data. Build what the designer asks; nothing needs dumbing down. Limits: only native modules Prototo bundles (`npx proto add` tells you), and the project must be on the current Prototo runtime. If a command says it's older, run `npx proto upgrade` (never Expo or npm commands for this), then publish again so the existing link opens on the new app.
105
+
106
+ ## Rules
107
+ - Read a file, then make targeted edits; don't rewrite whole files for small changes.
108
+ - Generated screens have no code comments.
109
+ - Never edit `/components/proto/`, `.proto/`, `app.config.js`, `babel.config.js`, `metro.config.js`.
110
+ - Never tell the designer to open or edit a file.
@@ -0,0 +1,79 @@
1
+ import { useEffect } from 'react';
2
+ import { Alert as RNAlert, Platform } from 'react-native';
3
+ import { Alert as SwiftUIAlert, Button as SwiftUIButton, Host, Text as SwiftUIText } from '@expo/ui/swift-ui';
4
+ import { frame, opacity } from '@expo/ui/swift-ui/modifiers';
5
+
6
+ export type AlertAction = {
7
+ label: string;
8
+ onPress?: () => void;
9
+ destructive?: boolean;
10
+ cancel?: boolean;
11
+ };
12
+
13
+ export type AlertProps = {
14
+ title: string;
15
+ message?: string;
16
+ visible: boolean;
17
+ onClose?: () => void;
18
+ actions: AlertAction[];
19
+ };
20
+
21
+ const roleOf = (action: AlertAction) =>
22
+ action.destructive ? 'destructive' : action.cancel ? 'cancel' : undefined;
23
+
24
+ /**
25
+ * Apple's own alert. Controlled by `visible`; onClose fires after any action or dismiss.
26
+ */
27
+ export function Alert({ title, message, visible, onClose, actions }: AlertProps) {
28
+ useEffect(() => {
29
+ if (Platform.OS === 'ios' || !visible) return;
30
+ // ponytail: iOS-first product; Android uses the system alert, which closes itself.
31
+ RNAlert.alert(
32
+ title,
33
+ message,
34
+ actions.map((action) => ({
35
+ text: action.label,
36
+ style: roleOf(action) ?? 'default',
37
+ onPress: () => {
38
+ action.onPress?.();
39
+ onClose?.();
40
+ },
41
+ })),
42
+ { onDismiss: onClose },
43
+ );
44
+ }, [visible]);
45
+
46
+ if (Platform.OS !== 'ios') return null;
47
+
48
+ return (
49
+ <Host matchContents>
50
+ <SwiftUIAlert
51
+ title={title}
52
+ isPresented={visible}
53
+ onIsPresentedChange={(open) => {
54
+ if (!open) onClose?.();
55
+ }}
56
+ >
57
+ {/* ponytail: SwiftUI hangs the alert off a trigger view; an invisible one keeps it controlled by `visible`. */}
58
+ <SwiftUIAlert.Trigger>
59
+ <SwiftUIText modifiers={[frame({ width: 0, height: 0 }), opacity(0)]}> </SwiftUIText>
60
+ </SwiftUIAlert.Trigger>
61
+ {message ? (
62
+ <SwiftUIAlert.Message>
63
+ <SwiftUIText>{message}</SwiftUIText>
64
+ </SwiftUIAlert.Message>
65
+ ) : null}
66
+ <SwiftUIAlert.Actions>
67
+ {actions.map((action) => (
68
+ <SwiftUIButton
69
+ key={action.label}
70
+ label={action.label}
71
+ role={roleOf(action)}
72
+ onPress={action.onPress}
73
+ />
74
+ ))}
75
+ </SwiftUIAlert.Actions>
76
+ </SwiftUIAlert>
77
+ </Host>
78
+ );
79
+ }
@@ -1,52 +1,85 @@
1
- import { Pressable, View, type TextStyle, type ViewStyle } from 'react-native';
2
- import type { ReactNode } from 'react';
3
- import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';
1
+ import { Platform, Pressable, type ViewStyle } from 'react-native';
2
+ import { useContext, type ComponentProps } from 'react';
3
+ import { Host, Button as SwiftUIButton, Image as SwiftUIImage, Label as SwiftUILabel } from '@expo/ui/swift-ui';
4
+ import {
5
+ buttonStyle,
6
+ controlSize,
7
+ disabled as disabledModifier,
8
+ frame,
9
+ resizable,
10
+ tint,
11
+ } from '@expo/ui/swift-ui/modifiers';
4
12
  import * as Haptics from 'expo-haptics';
5
13
  import { useTheme, useAccent } from './useTheme';
6
14
  import { Text } from './Text';
15
+ import { RowContext } from './Row';
7
16
 
8
17
  export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
18
+ export type SFSymbol = NonNullable<ComponentProps<typeof SwiftUIButton>['systemImage']>;
9
19
 
10
20
  export type ButtonProps = {
11
21
  label: string;
12
22
  variant?: ButtonVariant;
13
23
  onPress?: () => void;
14
24
  disabled?: boolean;
15
- icon?: ReactNode;
16
- style?: ViewStyle;
17
- textStyle?: TextStyle;
25
+ systemImage?: SFSymbol;
26
+ image?: string;
18
27
  };
19
28
 
20
- export function Button({
21
- label,
22
- variant = 'primary',
23
- onPress,
24
- disabled = false,
25
- icon,
26
- style,
27
- textStyle,
28
- }: ButtonProps) {
29
+ const STYLE = {
30
+ primary: 'glassProminent',
31
+ secondary: 'glass',
32
+ ghost: 'borderless',
33
+ destructive: 'glassProminent',
34
+ } as const;
35
+
36
+ /**
37
+ * Apple's own button: Liquid Glass on iOS 26 (SwiftUI `buttonStyle`), tinted with the
38
+ * prototype's accent. `systemImage` is an SF Symbol name shown before the label; `image` is a
39
+ * picture URL (https or data) for a brand mark instead.
40
+ */
41
+ export function Button({ label, variant = 'primary', onPress, disabled = false, systemImage, image }: ButtonProps) {
29
42
  const theme = useTheme();
30
43
  const accent = useAccent();
31
- const scale = useSharedValue(1);
32
-
33
- const animated = useAnimatedStyle(() => ({
34
- transform: [{ scale: scale.value }],
35
- }));
44
+ const inRow = useContext(RowContext);
36
45
 
37
- const handlePressIn = () => {
38
- if (disabled) return;
39
- scale.value = withTiming(0.96, { duration: 80 });
40
- };
41
- const handlePressOut = () => {
42
- if (disabled) return;
43
- scale.value = withTiming(1, { duration: 120 });
44
- };
45
46
  const handlePress = () => {
46
47
  Haptics.selectionAsync().catch(() => {});
47
48
  onPress?.();
48
49
  };
49
50
 
51
+ if (Platform.OS === 'ios') {
52
+ return (
53
+ // ponytail: a fixed-height host, like Slider, fills a Stack; matchContents measures the
54
+ // button unconstrained (SwiftUI fixedSize) so inside a Row it hugs its label instead.
55
+ <Host
56
+ matchContents={inRow ? { horizontal: true } : undefined}
57
+ style={inRow ? { height: 50 } : { alignSelf: 'stretch', height: 50 }}
58
+ >
59
+ <SwiftUIButton
60
+ role={variant === 'destructive' ? 'destructive' : undefined}
61
+ onPress={handlePress}
62
+ modifiers={[
63
+ buttonStyle(STYLE[variant]),
64
+ controlSize('large'),
65
+ tint(variant === 'destructive' ? theme.text.destructive : accent),
66
+ disabledModifier(disabled),
67
+ ]}
68
+ >
69
+ {/* ponytail: the frame goes on the label so the glass stretches with it; SwiftUI's
70
+ `.infinity` has no JSON form, a huge maxWidth stretches the same way. */}
71
+ <SwiftUILabel
72
+ title={label}
73
+ systemImage={systemImage}
74
+ icon={image ? <SwiftUIImage uiImage={image} modifiers={[resizable(), frame({ width: 18, height: 18 })]} /> : undefined}
75
+ modifiers={[frame({ maxWidth: 100000 })]}
76
+ />
77
+ </SwiftUIButton>
78
+ </Host>
79
+ );
80
+ }
81
+
82
+ // ponytail: iOS-first product; Android keeps a plain themed Pressable.
50
83
  const palette: Record<ButtonVariant, { bg: string; fg: string }> = {
51
84
  primary: { bg: accent, fg: '#FFFFFF' },
52
85
  secondary: { bg: theme.surface.secondary, fg: theme.text.primary },
@@ -54,8 +87,7 @@ export function Button({
54
87
  destructive: { bg: theme.text.destructive, fg: '#FFFFFF' },
55
88
  };
56
89
  const { bg, fg } = palette[variant];
57
-
58
- const baseStyle: ViewStyle = {
90
+ const style: ViewStyle = {
59
91
  backgroundColor: bg,
60
92
  borderRadius: theme.radius.button,
61
93
  paddingVertical: theme.space.sm + 4,
@@ -64,17 +96,11 @@ export function Button({
64
96
  justifyContent: 'center',
65
97
  opacity: disabled ? 0.5 : 1,
66
98
  };
67
-
68
99
  return (
69
- <Pressable disabled={disabled} onPressIn={handlePressIn} onPressOut={handlePressOut} onPress={handlePress}>
70
- <Animated.View style={[baseStyle, animated, style]}>
71
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.space.sm }}>
72
- {icon}
73
- <Text size="label" style={[{ color: fg }, textStyle]}>
74
- {label}
75
- </Text>
76
- </View>
77
- </Animated.View>
100
+ <Pressable disabled={disabled} onPress={handlePress} style={style}>
101
+ <Text size="label" style={{ color: fg }}>
102
+ {label}
103
+ </Text>
78
104
  </Pressable>
79
105
  );
80
106
  }
@@ -0,0 +1,52 @@
1
+ import { Platform } from 'react-native';
2
+ import { Host, DatePicker as SwiftUIDatePicker } from '@expo/ui/swift-ui';
3
+ import { datePickerStyle, tint } from '@expo/ui/swift-ui/modifiers';
4
+ import { useAccent } from './useTheme';
5
+ import { Text } from './Text';
6
+
7
+ export type DatePickerMode = 'date' | 'time' | 'dateTime';
8
+
9
+ export type DatePickerProps = {
10
+ value: Date;
11
+ onChange?: (date: Date) => void;
12
+ label?: string;
13
+ mode?: DatePickerMode;
14
+ min?: Date;
15
+ max?: Date;
16
+ };
17
+
18
+ const COMPONENTS = {
19
+ date: ['date'],
20
+ time: ['hourAndMinute'],
21
+ dateTime: ['date', 'hourAndMinute'],
22
+ } as const;
23
+
24
+ /**
25
+ * Apple's own compact date picker (label left, tappable value right).
26
+ */
27
+ export function DatePicker({ value, onChange, label, mode = 'date', min, max }: DatePickerProps) {
28
+ const accent = useAccent();
29
+
30
+ if (Platform.OS !== 'ios') {
31
+ // ponytail: iOS-first product; Android shows the value read-only.
32
+ return (
33
+ <Text size="body">
34
+ {label ? `${label}: ` : ''}
35
+ {value.toLocaleString()}
36
+ </Text>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <Host style={{ alignSelf: 'stretch', height: 44 }}>
42
+ <SwiftUIDatePicker
43
+ title={label}
44
+ selection={value}
45
+ range={{ start: min, end: max }}
46
+ displayedComponents={[...COMPONENTS[mode]]}
47
+ onDateChange={(next) => onChange?.(next)}
48
+ modifiers={[datePickerStyle('compact'), tint(accent)]}
49
+ />
50
+ </Host>
51
+ );
52
+ }
@@ -0,0 +1,62 @@
1
+ import { Alert as RNAlert, Platform } from 'react-native';
2
+ import { Host, Menu as SwiftUIMenu, Button as SwiftUIButton } from '@expo/ui/swift-ui';
3
+ import { tint } from '@expo/ui/swift-ui/modifiers';
4
+ import { useAccent } from './useTheme';
5
+ import { Button, type SFSymbol } from './Button';
6
+
7
+ export type MenuItem = {
8
+ label: string;
9
+ onPress?: () => void;
10
+ destructive?: boolean;
11
+ systemImage?: SFSymbol;
12
+ };
13
+
14
+ export type MenuProps = {
15
+ label: string;
16
+ systemImage?: SFSymbol;
17
+ items: MenuItem[];
18
+ };
19
+
20
+ /**
21
+ * Apple's own pull-down menu: a button that opens a list of actions.
22
+ */
23
+ export function Menu({ label, systemImage, items }: MenuProps) {
24
+ const accent = useAccent();
25
+
26
+ if (Platform.OS !== 'ios') {
27
+ // ponytail: iOS-first product; Android lists the actions in a system alert.
28
+ return (
29
+ <Button
30
+ label={label}
31
+ variant="ghost"
32
+ onPress={() =>
33
+ RNAlert.alert(
34
+ label,
35
+ undefined,
36
+ items.map((item) => ({
37
+ text: item.label,
38
+ onPress: item.onPress,
39
+ style: item.destructive ? 'destructive' : 'default',
40
+ })),
41
+ )
42
+ }
43
+ />
44
+ );
45
+ }
46
+
47
+ return (
48
+ <Host matchContents>
49
+ <SwiftUIMenu label={label} systemImage={systemImage} modifiers={[tint(accent)]}>
50
+ {items.map((item) => (
51
+ <SwiftUIButton
52
+ key={item.label}
53
+ label={item.label}
54
+ systemImage={item.systemImage}
55
+ role={item.destructive ? 'destructive' : undefined}
56
+ onPress={item.onPress}
57
+ />
58
+ ))}
59
+ </SwiftUIMenu>
60
+ </Host>
61
+ );
62
+ }
@@ -1,6 +1,8 @@
1
- import { Modal as RNModal, View } from 'react-native';
1
+ import { Modal as RNModal, Platform, View, useWindowDimensions } from 'react-native';
2
2
  import { SafeAreaView } from 'react-native-safe-area-context';
3
3
  import type { ReactNode } from 'react';
4
+ import { BottomSheet, Group, Host, RNHostView } from '@expo/ui/swift-ui';
5
+ import { presentationDragIndicator } from '@expo/ui/swift-ui/modifiers';
4
6
  import { useTheme } from './useTheme';
5
7
  import { Text } from './Text';
6
8
 
@@ -12,25 +14,45 @@ export type ModalProps = {
12
14
  };
13
15
 
14
16
  /**
15
- * Modal presented as iOS 26's native page sheet (UISheetPresentationController):
16
- * native spring-in, backdrop dim, the screen behind receding, and swipe-to-dismiss
17
- * — no hand-rolled overlay. onClose fires on both the swipe-dismiss (onRequestClose)
18
- * and programmatic close.
17
+ * Apple's own bottom sheet (SwiftUI `sheet` with a grabber), sized to its content.
18
+ * onClose fires on swipe-dismiss and on programmatic close.
19
19
  */
20
20
  export function Modal({ title, visible, onClose, children }: ModalProps) {
21
21
  const theme = useTheme();
22
+ const { width } = useWindowDimensions();
23
+
24
+ const body = (
25
+ // ponytail: the hosted RN view is measured in both axes, so it gets the window width by hand
26
+ // (sheets are edge to edge on iPhone; on iPad this over-measures and the sheet clips).
27
+ <View style={{ width, padding: theme.space.lg, paddingBottom: theme.space.xl, gap: theme.space.md }}>
28
+ <Text size="headline">{title}</Text>
29
+ {children}
30
+ </View>
31
+ );
32
+
33
+ if (Platform.OS === 'ios') {
34
+ return (
35
+ <Host matchContents>
36
+ <BottomSheet
37
+ isPresented={visible}
38
+ onIsPresentedChange={(open) => {
39
+ if (!open) onClose?.();
40
+ }}
41
+ fitToContents
42
+ >
43
+ <Group modifiers={[presentationDragIndicator('visible')]}>
44
+ <RNHostView matchContents>{body}</RNHostView>
45
+ </Group>
46
+ </BottomSheet>
47
+ </Host>
48
+ );
49
+ }
50
+
51
+ // ponytail: iOS-first product; Android keeps the page sheet.
22
52
  return (
23
- <RNModal
24
- visible={visible}
25
- animationType="slide"
26
- presentationStyle="pageSheet"
27
- onRequestClose={onClose}
28
- >
53
+ <RNModal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
29
54
  <SafeAreaView style={{ flex: 1, backgroundColor: theme.surface.primary }} edges={['bottom']}>
30
- <View style={{ flex: 1, padding: theme.space.lg, gap: theme.space.md }}>
31
- <Text size="headline">{title}</Text>
32
- {children}
33
- </View>
55
+ {body}
34
56
  </SafeAreaView>
35
57
  </RNModal>
36
58
  );
@@ -0,0 +1,57 @@
1
+ import { Platform, Pressable } from 'react-native';
2
+ import { Host, Picker as SwiftUIPicker, Text as SwiftUIText } from '@expo/ui/swift-ui';
3
+ import { pickerStyle, tag, tint } from '@expo/ui/swift-ui/modifiers';
4
+ import { useAccent, useTheme } from './useTheme';
5
+ import { Row } from './Row';
6
+ import { Text } from './Text';
7
+
8
+ export type PickerVariant = 'segmented' | 'menu' | 'wheel';
9
+
10
+ export type PickerProps = {
11
+ options: string[];
12
+ value: string;
13
+ onChange?: (value: string) => void;
14
+ label?: string;
15
+ variant?: PickerVariant;
16
+ };
17
+
18
+ /**
19
+ * Apple's own picker: segmented control by default, or a pull-down menu / wheel.
20
+ */
21
+ export function Picker({ options, value, onChange, label, variant = 'segmented' }: PickerProps) {
22
+ const theme = useTheme();
23
+ const accent = useAccent();
24
+
25
+ if (Platform.OS === 'ios') {
26
+ return (
27
+ <Host style={{ alignSelf: 'stretch', height: variant === 'wheel' ? 200 : 36 }}>
28
+ <SwiftUIPicker
29
+ label={label}
30
+ selection={value}
31
+ onSelectionChange={(next) => onChange?.(String(next))}
32
+ modifiers={[pickerStyle(variant), tint(accent)]}
33
+ >
34
+ {options.map((option) => (
35
+ <SwiftUIText key={option} modifiers={[tag(option)]}>
36
+ {option}
37
+ </SwiftUIText>
38
+ ))}
39
+ </SwiftUIPicker>
40
+ </Host>
41
+ );
42
+ }
43
+
44
+ // ponytail: iOS-first product; Android shows a plain row of options.
45
+ return (
46
+ <Row gap={theme.space.md} align="center">
47
+ {label ? <Text size="body">{label}</Text> : null}
48
+ {options.map((option) => (
49
+ <Pressable key={option} onPress={() => onChange?.(option)}>
50
+ <Text size="label" color={option === value ? 'accent' : 'secondary'}>
51
+ {option}
52
+ </Text>
53
+ </Pressable>
54
+ ))}
55
+ </Row>
56
+ );
57
+ }
@@ -1,5 +1,8 @@
1
1
  import { View, type ViewProps } from 'react-native';
2
- import type { ReactNode } from 'react';
2
+ import { createContext, type ReactNode } from 'react';
3
+
4
+ // Lets controls know they sit in a row, so they hug their content instead of filling the width.
5
+ export const RowContext = createContext(false);
3
6
 
4
7
  export type RowProps = {
5
8
  gap?: number;
@@ -22,7 +25,7 @@ export function Row({ gap = 0, align = 'start', style, children }: RowProps) {
22
25
  style,
23
26
  ]}
24
27
  >
25
- {children}
28
+ <RowContext.Provider value>{children}</RowContext.Provider>
26
29
  </View>
27
30
  );
28
31
  }
@@ -1,10 +1,12 @@
1
1
  import { KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native';
2
2
  import { SafeAreaView } from 'react-native-safe-area-context';
3
3
  import type { ReactNode } from 'react';
4
- import { useTheme } from './useTheme';
4
+ import { LinearGradient } from 'expo-linear-gradient';
5
+ import { useTheme, useAccent } from './useTheme';
5
6
 
6
7
  export type ScreenProps = {
7
8
  scrollable?: boolean;
9
+ gradient?: boolean | string;
8
10
  children?: ReactNode;
9
11
  };
10
12
 
@@ -21,10 +23,24 @@ export type ScreenProps = {
21
23
  * Background lives on the outermost element so it covers bounce / inset areas.
22
24
  * For Liquid Glass surfaces inside (cards, sheets), use Card with glass={true}
23
25
  * — it wraps expo-glass-effect's GlassView, iOS 26's native material.
26
+ *
27
+ * gradient: a light wash of the accent (or the given colour) rising from the bottom edge and
28
+ * fading out towards the top. Sits behind the content.
24
29
  */
25
- export function Screen({ scrollable = true, children }: ScreenProps) {
30
+ export function Screen({ scrollable = true, gradient, children }: ScreenProps) {
26
31
  const theme = useTheme();
32
+ const accent = useAccent();
27
33
  const padding = theme.space.md;
34
+ const washColor = gradient === true ? accent : gradient;
35
+ // A 6-digit hex fades from its own zero-alpha; plain 'transparent' greys out mid-way.
36
+ const washStart = washColor && /^#[0-9a-f]{6}$/i.test(washColor) ? `${washColor}00` : 'transparent';
37
+ const wash = washColor ? (
38
+ <LinearGradient
39
+ pointerEvents="none"
40
+ colors={[washStart, washColor]}
41
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0.1 }}
42
+ />
43
+ ) : null;
28
44
 
29
45
  if (scrollable) {
30
46
  return (
@@ -33,6 +49,7 @@ export function Screen({ scrollable = true, children }: ScreenProps) {
33
49
  contentContainerStyle={{ padding, gap: padding }}
34
50
  contentInsetAdjustmentBehavior="automatic"
35
51
  >
52
+ {wash}
36
53
  {children}
37
54
  </ScrollView>
38
55
  );
@@ -43,6 +60,7 @@ export function Screen({ scrollable = true, children }: ScreenProps) {
43
60
  style={{ flex: 1, backgroundColor: theme.surface.primary }}
44
61
  edges={['top', 'bottom', 'left', 'right']}
45
62
  >
63
+ {wash}
46
64
  <KeyboardAvoidingView
47
65
  style={{ flex: 1 }}
48
66
  behavior={Platform.OS === 'ios' ? 'padding' : undefined}
@@ -3,7 +3,7 @@ export { Stack, type StackProps } from './Stack';
3
3
  export { Row, type RowProps } from './Row';
4
4
  export { Text, type TextProps, type TextSize, type TextColor } from './Text';
5
5
  export { Card, type CardProps } from './Card';
6
- export { Button, type ButtonProps, type ButtonVariant } from './Button';
6
+ export { Button, type ButtonProps, type ButtonVariant, type SFSymbol } from './Button';
7
7
  export { Toggle, type ToggleProps } from './Toggle';
8
8
  export { Slider, type SliderProps } from './Slider';
9
9
  export { Stepper, type StepperProps } from './Stepper';
@@ -11,6 +11,10 @@ export { Divider, type DividerProps } from './Divider';
11
11
  export { Input, type InputProps } from './Input';
12
12
  export { Lottie, type LottieProps } from './Lottie';
13
13
  export { Modal, type ModalProps } from './Modal';
14
+ export { Picker, type PickerProps, type PickerVariant } from './Picker';
15
+ export { DatePicker, type DatePickerProps, type DatePickerMode } from './DatePicker';
16
+ export { Menu, type MenuProps, type MenuItem } from './Menu';
17
+ export { Alert, type AlertProps, type AlertAction } from './Alert';
14
18
  export { useTheme, useAccent } from './useTheme';
15
19
  export { ProtoConfigProvider, useProtoConfig } from './ProtoConfigContext';
16
20
  export { mock } from './mock';
@@ -16,6 +16,7 @@ const FADE_MS = 350;
16
16
  type Dot = { id: number; x: number; y: number };
17
17
  type InspectRequest = { id: number; x: number; y: number };
18
18
  type NavigateRequest = { id: number; path: string };
19
+ type LinksRequest = { id: number };
19
20
 
20
21
  // Flow view: open the route the desktop asked for, then confirm. expo-router is
21
22
  // required at run time (every Prototo project has it; this file's own package
@@ -23,7 +24,18 @@ type NavigateRequest = { id: number; path: string };
23
24
  function navigateTo(req: NavigateRequest) {
24
25
  let ok = false;
25
26
  try {
26
- const { router } = require('expo-router') as { router: { navigate: (href: string) => void } };
27
+ const { router } = require('expo-router') as {
28
+ router: {
29
+ navigate: (href: string) => void;
30
+ canDismiss?: () => boolean;
31
+ dismissAll?: () => void;
32
+ };
33
+ };
34
+ // unwind the current stack first: navigating from a sheet to the screen
35
+ // under it pushed a second copy, and the flow walk (#89) wants each screen
36
+ // once, in its resting place. Only when there is something to dismiss: an
37
+ // unhandled POP_TO_TOP shows a red dev toast that lands in the screenshots.
38
+ if (router.canDismiss?.()) router.dismissAll?.();
27
39
  router.navigate(req.path);
28
40
  ok = true;
29
41
  } catch {
@@ -94,6 +106,137 @@ function inspectAt(view: View | null, req: InspectRequest) {
94
106
  // falls back to a label-only reference
95
107
  }
96
108
  }
109
+ // Flow export (#89): every tappable on the mounted screen, with where it sits,
110
+ // so the flow's arrows can start at the button. A `<Link href>` names its
111
+ // target outright; a `router.push` inside an onPress can't be read here, so
112
+ // its debug stack goes along and `proto flow` matches it to the source. Walks
113
+ // the fiber tree from the renderer's roots (DevTools hook again), measures the
114
+ // first host view under each candidate. Screens under the top one stay mounted
115
+ // (a stack keeps its history, tabs keep every tab) and measure to the same
116
+ // place, so `proto flow` keeps only the links whose source is the screen's own
117
+ // file. Fails open: no answer = no anchors.
118
+ type Fiber = DebugFiber & {
119
+ tag?: number;
120
+ memoizedProps?: { href?: unknown; onPress?: unknown } | null;
121
+ stateNode?: unknown;
122
+ child?: Fiber | null;
123
+ sibling?: Fiber | null;
124
+ return?: Fiber | null;
125
+ alternate?: Fiber | null;
126
+ };
127
+ type Hook = {
128
+ renderers: Map<number, Renderer>;
129
+ getFiberRoots?: (id: number) => Set<{ current: Fiber }>;
130
+ };
131
+ type Measurable = {
132
+ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void;
133
+ };
134
+ type FoundLink = {
135
+ href?: string;
136
+ stacks: string[];
137
+ frame: { x: number; y: number; w: number; h: number };
138
+ };
139
+ const MAX_LINKS = 60;
140
+
141
+ // Fabric keeps the public instance a level or two under the fiber's stateNode.
142
+ function measurableOf(node: unknown): Measurable | null {
143
+ const sn = node as { canonical?: { publicInstance?: unknown }; publicInstance?: unknown } | null;
144
+ for (const c of [sn, sn?.canonical?.publicInstance, sn?.publicInstance]) {
145
+ if (c && typeof (c as Measurable).measureInWindow === 'function') return c as Measurable;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ function hostViewOf(fiber: Fiber): Measurable | null {
151
+ for (let f: Fiber | null | undefined = fiber, i = 0; f && i < 8; f = f.child, i++) {
152
+ if (f.tag === 5) return measurableOf(f.stateNode);
153
+ }
154
+ return null;
155
+ }
156
+
157
+ function collectLinks(req: LinksRequest) {
158
+ const post = (links: FoundLink[]) =>
159
+ fetch('http://127.0.0.1:3001/links/result', {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({ id: req.id, links }),
163
+ }).catch(() => {});
164
+ try {
165
+ const hook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: Hook })
166
+ .__REACT_DEVTOOLS_GLOBAL_HOOK__;
167
+ if (!hook) {
168
+ void post([]);
169
+ return;
170
+ }
171
+ const { width, height } = Dimensions.get('window');
172
+ const candidates: { fiber: Fiber; href?: string }[] = [];
173
+ const seen = new Set<unknown>();
174
+ for (const id of hook.renderers.keys()) {
175
+ for (const root of hook.getFiberRoots?.(id) ?? []) {
176
+ const stack: Fiber[] = [root.current];
177
+ while (stack.length && candidates.length < MAX_LINKS) {
178
+ const f = stack.pop() as Fiber;
179
+ const props = f.memoizedProps;
180
+ const href =
181
+ typeof props?.href === 'string' && props.href.startsWith('/') ? props.href : undefined;
182
+ if (href || typeof props?.onPress === 'function') {
183
+ // the same onPress rides down Button → Pressable → host; keep the outermost
184
+ const key = href ?? props?.onPress;
185
+ if (!seen.has(key)) {
186
+ seen.add(key);
187
+ candidates.push({ fiber: f, href });
188
+ }
189
+ }
190
+ if (f.sibling) stack.push(f.sibling);
191
+ if (f.child) stack.push(f.child);
192
+ }
193
+ }
194
+ }
195
+ const measured = candidates.map(
196
+ (c) =>
197
+ new Promise<FoundLink | null>((resolve) => {
198
+ const host = hostViewOf(c.fiber);
199
+ if (!host) return resolve(null);
200
+ const measure = () => new Promise<number[]>((r) => host.measureInWindow((...m) => r(m)));
201
+ // measured twice, a beat apart: a frame still moving (a sheet being
202
+ // dismissed, a screen scaling back) is not a place to anchor an arrow
203
+ void (async () => {
204
+ const a = await measure();
205
+ await new Promise((r) => setTimeout(r, 200));
206
+ const b = await measure();
207
+ if (a.some((v, i) => Math.abs(v - (b[i] ?? 0)) > 1)) return resolve(null);
208
+ const [x, y, w, h] = b as [number, number, number, number];
209
+ if (!(w > 0 && h > 0) || y + h <= 0 || y >= height) return resolve(null); // off screen
210
+ const stacks: string[] = [];
211
+ let fiber: DebugFiber | null | undefined = c.fiber;
212
+ for (let i = 0; fiber && i < 12; i++) {
213
+ const s = fiber._debugStack?.stack;
214
+ if (typeof s === 'string') stacks.push(s);
215
+ fiber = fiber._debugOwner;
216
+ }
217
+ // clipped to the screen: a card half below the fold anchors at its visible part
218
+ const [x0, y0, x1, y1] = [
219
+ Math.max(0, x),
220
+ Math.max(0, y),
221
+ Math.min(width, x + w),
222
+ Math.min(height, y + h),
223
+ ];
224
+ resolve({
225
+ ...(c.href ? { href: c.href } : {}),
226
+ stacks,
227
+ frame: { x: x0 / width, y: y0 / height, w: (x1 - x0) / width, h: (y1 - y0) / height },
228
+ });
229
+ })();
230
+ }),
231
+ );
232
+ const timeout = new Promise<null>((r) => setTimeout(() => r(null), 1200));
233
+ void Promise.all(measured.map((m) => Promise.race([m, timeout]))).then((links) =>
234
+ post(links.filter((l): l is FoundLink => l !== null)),
235
+ );
236
+ } catch {
237
+ void post([]);
238
+ }
239
+ }
97
240
  type FadingDot = { key: number; x: number; y: number; opacity: Animated.Value };
98
241
 
99
242
  // Brand-pink fill + white rim: reads on light AND dark content (a white or
@@ -117,6 +260,7 @@ export default function TouchDots({ children }: { children: ReactNode }) {
117
260
  const rootRef = useRef<View>(null);
118
261
  const inspected = useRef(0);
119
262
  const navigated = useRef(0);
263
+ const linked = useRef(0);
120
264
 
121
265
  // Poll `proto start`'s local server for the record flag (the Simulator
122
266
  // shares the host loopback). Any failure just means "not recording".
@@ -130,6 +274,7 @@ export default function TouchDots({ children }: { children: ReactNode }) {
130
274
  recording?: boolean;
131
275
  inspect?: InspectRequest;
132
276
  navigate?: NavigateRequest;
277
+ links?: LinksRequest;
133
278
  };
134
279
  if (!alive) return;
135
280
  setRecording(body.recording === true);
@@ -141,6 +286,10 @@ export default function TouchDots({ children }: { children: ReactNode }) {
141
286
  navigated.current = body.navigate.id;
142
287
  navigateTo(body.navigate);
143
288
  }
289
+ if (body.links && body.links.id !== linked.current) {
290
+ linked.current = body.links.id;
291
+ collectLinks(body.links);
292
+ }
144
293
  } catch {
145
294
  if (alive) setRecording(false);
146
295
  }
@@ -3,6 +3,7 @@ node_modules/
3
3
  .metro-cache/
4
4
  .proto/last-shot.png
5
5
  .proto/metro-errors.json
6
+ .proto/metro-reset
6
7
  .proto/tsconfig.mcp.json
7
8
  dist/
8
9
  *.log
@@ -1,3 +1,5 @@
1
1
  node-linker: hoisted
2
2
  allowBuilds:
3
3
  cloudflared: true
4
+ minimumReleaseAgeExclude:
5
+ - '@sherizan/proto-cli'