waypoint-expo-template 1.0.0

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/App.tsx ADDED
@@ -0,0 +1,61 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { StatusBar } from 'expo-status-bar';
4
+ import { getAppParams, onHostMessage, postToHost, buildRouteManifest, reportRoutes, type HostMessage } from './src/lib/previewBridge';
5
+ import { HomeScreen } from './src/screens/HomeScreen';
6
+
7
+ // Add each new screen's id here as you create one (see src/lib/previewBridge.ts
8
+ // and the workflow-preview skill for the accompanying *.preview.md manifest).
9
+ type ScreenRoute = 'home';
10
+
11
+ export default function App() {
12
+ const [params] = useState(() => getAppParams());
13
+ const initialRoute = (params.route?.replace(/^\//, '') || 'home') as ScreenRoute;
14
+ const [currentScreen, setCurrentScreen] = useState<ScreenRoute>(initialRoute);
15
+
16
+ useEffect(() => {
17
+ postToHost({ type: 'expo-app:ready', appId: params.appId ?? undefined });
18
+ // Required — without this the preview canvas falls back to a static
19
+ // source scan that cannot see this navigation pattern's links and
20
+ // derives differently-cased route paths from screen filenames. Keep
21
+ // this call as you add screens; only buildRouteManifest() itself needs
22
+ // editing.
23
+ reportRoutes(buildRouteManifest(params.appId));
24
+ return onHostMessage((message: HostMessage) => {
25
+ if (message.type === 'expo-app:navigate' && typeof message.route === 'string') {
26
+ setCurrentScreen(message.route.replace(/^\//, '') as ScreenRoute);
27
+ }
28
+ });
29
+ }, [params]);
30
+
31
+ // Navigation helper — updates state AND notifies host.
32
+ const navigateTo = (screen: ScreenRoute) => {
33
+ setCurrentScreen(screen);
34
+ postToHost({ type: 'expo-app:navigation-change', route: `/${screen}` });
35
+ };
36
+
37
+ const renderScreen = () => {
38
+ switch (currentScreen) {
39
+ case 'home':
40
+ return <HomeScreen />;
41
+ default:
42
+ // Hard fail — never silently render a fallback screen for an
43
+ // unmatched route. A route reaching here is a real bug (a case is
44
+ // missing for a route reportRoutes/navigateTo/the host actually
45
+ // named) and must surface immediately, not render an unrelated
46
+ // screen with no visible error.
47
+ throw new Error(`Unhandled ScreenRoute: "${currentScreen}" — no case in App.tsx's renderScreen() switch.`);
48
+ }
49
+ };
50
+
51
+ return (
52
+ <View style={styles.container}>
53
+ <StatusBar style="auto" />
54
+ {renderScreen()}
55
+ </View>
56
+ );
57
+ }
58
+
59
+ const styles = StyleSheet.create({
60
+ container: { flex: 1, backgroundColor: '#ffffff' },
61
+ });
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # waypoint-expo-template
2
+
3
+ The Expo/React Native preview-app scaffold code-builders' `coder` specialist
4
+ bootstraps every new app from. Published to npm as `waypoint-expo-template`;
5
+ a `bootstrap_app_cli` `run_tool` call in the sandbox does `npm pack
6
+ waypoint-expo-template@latest`, extracts the tarball into `/workspace`,
7
+ substitutes the app's name, and runs `npm install` — real npm install of
8
+ this package's own dependencies, not a copied file tree.
9
+
10
+ ## Why this exists
11
+
12
+ The `coder` specialist has no shell — it cannot run `create-expo-app` or
13
+ `npx expo install` itself. This package **is** the output of those real
14
+ commands (`npx create-expo-app@latest --template blank-typescript`, then
15
+ `npx expo install react-dom react-native-web @expo/metro-runtime`), so the
16
+ agent gets a genuinely correct, currently-supported Expo SDK 57 scaffold
17
+ without needing to run anything — and without anyone hand-picking dependency
18
+ versions, which is how a real session once shipped an incompatible set and
19
+ crashed Metro on startup.
20
+
21
+ Two things are added on top of the stock Expo output — the minimum needed
22
+ for this platform's preview canvas, not a rewrite of it:
23
+
24
+ - `src/lib/previewBridge.ts` — the postMessage IPC contract with the canvas
25
+ host (not something `create-expo-app` produces).
26
+ - `App.tsx` calls `reportRoutes(buildRouteManifest(...))` on mount and hard-
27
+ fails on an unrecognized route, instead of silently rendering a fallback
28
+ screen — both verified, live, to be the difference between a working
29
+ preview and one that renders the same screen for every route with the
30
+ canvas showing no navigation edges at all.
31
+
32
+ Everything else — `package.json`, `app.json`, `index.ts`, `tsconfig.json`,
33
+ `assets/` — is unmodified `create-expo-app` output.
34
+
35
+ ## What you get after bootstrapping
36
+
37
+ ```
38
+ package.json # name substituted to the app's own name
39
+ app.json # expo.name/expo.slug substituted; web.output: "single"
40
+ index.ts # @expo/metro-runtime imported first (required for HMR)
41
+ tsconfig.json
42
+ assets/ # Expo's default icon set
43
+ App.tsx # single "home" route; add cases as screens are added
44
+ src/
45
+ lib/previewBridge.ts # host protocol — only buildRouteManifest() should change
46
+ screens/
47
+ HomeScreen.tsx # replace with real content
48
+ HomeScreen.preview.md
49
+ ```
50
+
51
+ From there, the agent writes the app's actual business logic — screens,
52
+ ports, navigation — following the `expo-app` and `workflow-preview` skills.
53
+
54
+ ## Publishing
55
+
56
+ CI publishes automatically (`.github/workflows/expo-template.yml`) on every
57
+ push to `main` that touches this package, whenever `package.json`'s version
58
+ differs from what's on npm — bump the version and push; no manual publish
59
+ step, and the bootstrap tool always resolves whatever is currently tagged
60
+ `latest`, so it never needs a code change when a new version ships.
61
+
62
+ To test a change locally before pushing:
63
+
64
+ ```
65
+ cd packages/expo-template
66
+ npm install
67
+ npx tsc --noEmit
68
+ ```
package/app.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "expo": {
3
+ "name": "expo-template",
4
+ "slug": "expo-template",
5
+ "version": "1.0.0",
6
+ "orientation": "portrait",
7
+ "icon": "./assets/icon.png",
8
+ "userInterfaceStyle": "light",
9
+ "ios": {
10
+ "supportsTablet": true
11
+ },
12
+ "android": {
13
+ "adaptiveIcon": {
14
+ "backgroundColor": "#E6F4FE",
15
+ "foregroundImage": "./assets/android-icon-foreground.png",
16
+ "backgroundImage": "./assets/android-icon-background.png",
17
+ "monochromeImage": "./assets/android-icon-monochrome.png"
18
+ },
19
+ "predictiveBackGestureEnabled": false
20
+ },
21
+ "web": {
22
+ "favicon": "./assets/favicon.png",
23
+ "output": "single"
24
+ }
25
+ }
26
+ }
Binary file
Binary file
Binary file
package/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Must be the first import — pulls Metro's Fast Refresh client into the web
2
+ // bundle. Without it the bundle contains no HMR client at all and edits
3
+ // never reach the running preview (verified: the bundle grows measurably
4
+ // and gains an HMRClient once this import is present).
5
+ import '@expo/metro-runtime';
6
+ import { registerRootComponent } from 'expo';
7
+
8
+ import App from './App';
9
+
10
+ // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
11
+ // It also ensures that whether you load the app in Expo Go or in a native build,
12
+ // the environment is set up appropriately
13
+ registerRootComponent(App);
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "waypoint-expo-template",
3
+ "version": "1.0.0",
4
+ "description": "Expo preview-app scaffold for code-builders' coder specialist — bootstrapped into a fresh sandbox workspace via npm, not vendored/copied. See src/lib/previewBridge.ts and App.tsx for the Waypoint-specific host IPC wiring; everything else is unmodified `create-expo-app --template blank-typescript` output plus the web-enablement packages `npx expo install` adds.",
5
+ "main": "index.ts",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "dependencies": {
10
+ "@expo/metro-runtime": "~57.0.15",
11
+ "expo": "~57.0.20",
12
+ "expo-status-bar": "~57.0.1",
13
+ "react": "19.2.3",
14
+ "react-dom": "19.2.3",
15
+ "react-native": "0.86.3",
16
+ "react-native-web": "^0.21.2"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "~19.2.2",
20
+ "typescript": "~6.0.3"
21
+ },
22
+ "scripts": {
23
+ "start": "expo start",
24
+ "android": "expo start --android",
25
+ "ios": "expo start --ios",
26
+ "web": "expo start --web",
27
+ "compile": "node scripts/compile-preview.js"
28
+ }
29
+ }
package/preview.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "waypoint-expo-template-workflow",
3
+ "version": "1",
4
+ "preset": "iphone-16-pro",
5
+ "nodes": [
6
+ {
7
+ "id": "preview-home",
8
+ "type": "devicePreview",
9
+ "position": {
10
+ "x": 0,
11
+ "y": 0
12
+ },
13
+ "data": {
14
+ "type": "devicePreview",
15
+ "route": "/home",
16
+ "label": "Home",
17
+ "isInitial": true,
18
+ "presetId": "iphone-16-pro",
19
+ "hideControls": true,
20
+ "actionPorts": [],
21
+ "action": {
22
+ "after": [
23
+ {
24
+ "stepId": "await-nav-home",
25
+ "actionType": "step/await-navigation",
26
+ "semantics": {
27
+ "label": "Await navigation from Home",
28
+ "description": "Pause and wait for user to trigger a navigation event from this screen"
29
+ },
30
+ "config": {
31
+ "screenId": "home",
32
+ "route": "/home"
33
+ }
34
+ }
35
+ ]
36
+ },
37
+ "apiCalls": []
38
+ }
39
+ }
40
+ ],
41
+ "edges": []
42
+ }
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * compile-preview.js
5
+ *
6
+ * Generic compiler script that consolidates co-located *.preview.md files
7
+ * from src/screens into:
8
+ * 1. preview.json - UI Preview graph for ReactFlow / Storybook canvas
9
+ * 2. workflow.md - Standard Workflow DSL that compiles cleanly with workflow-engine CLI
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { createRequire } from 'module';
15
+ import { execSync } from 'child_process';
16
+
17
+ const require = createRequire(path.join(process.cwd(), 'package.json'));
18
+ let yaml;
19
+ try {
20
+ yaml = require('js-yaml');
21
+ } catch {
22
+ try {
23
+ const rootRequire = createRequire(path.resolve(process.cwd(), '..', '..', 'package.json'));
24
+ yaml = rootRequire('js-yaml');
25
+ } catch {
26
+ const engineRequire = createRequire(path.resolve(process.cwd(), '..', '..', 'packages', 'workflow-engine', 'package.json'));
27
+ yaml = engineRequire('js-yaml');
28
+ }
29
+ }
30
+
31
+ const PROJECT_ROOT = process.cwd();
32
+ const SCREENS_DIR = path.join(PROJECT_ROOT, 'src', 'screens');
33
+ const PREVIEW_JSON_FILE = path.join(PROJECT_ROOT, 'preview.json');
34
+ const WORKFLOW_MD_FILE = path.join(PROJECT_ROOT, 'workflow.md');
35
+
36
+ // Potential workflow-engine CLI locations. The sandbox image bakes the CLI
37
+ // at $HARNESS_TOOLS_BIN_DIR/cli.cjs (see code-builders/harness/Dockerfile) —
38
+ // checked first since that's the only candidate that actually exists inside
39
+ // a sandbox pod; the monorepo-relative candidates only resolve when this
40
+ // script runs from a real checkout (e.g. local dev outside the container).
41
+ const WORKFLOW_ENGINE_CLI_CANDIDATES = [
42
+ ...(process.env.HARNESS_TOOLS_BIN_DIR
43
+ ? [path.join(process.env.HARNESS_TOOLS_BIN_DIR, 'cli.cjs')]
44
+ : []),
45
+ path.resolve(PROJECT_ROOT, '..', '..', 'packages', 'workflow-engine', 'bin', 'cli.cjs'),
46
+ path.resolve(PROJECT_ROOT, '..', '..', '..', 'waypoint', 'packages', 'workflow-engine', 'bin', 'cli.cjs'),
47
+ path.resolve(PROJECT_ROOT, 'node_modules', 'workflow-engine', 'bin', 'cli.cjs'),
48
+ ];
49
+
50
+ const PRESET = {
51
+ id: 'iphone-16-pro',
52
+ width: 393,
53
+ height: 852,
54
+ };
55
+
56
+ function getAppName() {
57
+ const pkgPath = path.join(PROJECT_ROOT, 'package.json');
58
+ if (fs.existsSync(pkgPath)) {
59
+ try {
60
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
61
+ if (pkg.name) return pkg.name;
62
+ } catch {}
63
+ }
64
+ return path.basename(PROJECT_ROOT);
65
+ }
66
+
67
+ function extractYamlBlock(content) {
68
+ const match = content.match(/```(?:yaml|yml)\s*([\s\S]*?)\s*```/) || content.match(/```json\s*([\s\S]*?)\s*```/);
69
+ if (!match) {
70
+ throw new Error('No ```yaml or ```json block found in preview manifest');
71
+ }
72
+ return yaml.load(match[1]);
73
+ }
74
+
75
+ function buildWorkflowDsl(appName, manifests) {
76
+ const initialScreen = manifests.find((m) => m.isInitial) || manifests[0];
77
+ const wfId = `WF-${appName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
78
+
79
+ const states = [];
80
+ const transitions = [];
81
+
82
+ // A first screen with no authored transitions (typically the only screen)
83
+ // still needs a non-terminal state to act as the workflow's entry point.
84
+ // Emitting it as terminal-only leaves nothing to transition *from*, which
85
+ // fails validation three ways at once: no Human-owned transition (WF0226),
86
+ // the declared capability unused (WF0216), and state-abandoned connected to
87
+ // nothing (ORPHANED_NODE). So it gets both an entry state and a completion
88
+ // state, joined by the outcome transition emitted further down.
89
+ const initialIsTerminal = !initialScreen.transitions || initialScreen.transitions.length === 0;
90
+
91
+ // Single source of truth for "what is screen X's state called?".
92
+ //
93
+ // The state loop below names a terminal screen `state-<id>-complete` and a
94
+ // non-terminal one `state-<id>`. Transition targets must use the identical
95
+ // rule or they point at states that were never emitted. This previously
96
+ // special-cased the literal screen id `home` — so `home` resolved correctly
97
+ // and every *other* terminal destination silently did not, producing
98
+ // WF0222 / EDGE_MISSING_TARGET / ORPHANED_NODE the moment someone added a
99
+ // terminal screen by any other name. Terminality is a property of the
100
+ // manifest (does it declare transitions?), never of the screen's name, so
101
+ // it is derived here and nowhere else.
102
+ const manifestById = new Map(manifests.map((m) => [m.id, m]));
103
+ const resolveTargetState = (targetId) => {
104
+ if (targetId === 'abandoned') return 'state-abandoned';
105
+ const target = manifestById.get(targetId);
106
+ // Unknown ids are left in the `state-<id>` form so the workflow-engine
107
+ // CLI reports the dangling reference against a name the author wrote,
108
+ // rather than one this compiler invented.
109
+ if (!target) return `state-${targetId}`;
110
+ // The initial screen, when terminal, has both an entry and a completion
111
+ // state; anything navigating *to* it means the entry state.
112
+ if (initialIsTerminal && target.id === initialScreen.id) return `state-${targetId}`;
113
+ const targetIsTerminal = !target.transitions || target.transitions.length === 0;
114
+ return targetIsTerminal ? `state-${targetId}-complete` : `state-${targetId}`;
115
+ };
116
+
117
+ for (const m of manifests) {
118
+ const isTerminal = !m.transitions || m.transitions.length === 0;
119
+ if (initialIsTerminal && m.id === initialScreen.id) {
120
+ states.push({ id: `state-${m.id}`, label: m.label, terminal: false });
121
+ states.push({ id: `state-${m.id}-complete`, label: `${m.label} Complete`, terminal: true });
122
+ } else {
123
+ states.push({
124
+ id: isTerminal ? `state-${m.id}-complete` : `state-${m.id}`,
125
+ label: isTerminal ? `${m.label} Complete` : m.label,
126
+ terminal: isTerminal,
127
+ });
128
+ }
129
+
130
+ for (const t of m.transitions || []) {
131
+ if (t.condition) {
132
+ const cond = t.condition;
133
+ const options = (t.branches || []).map((b) => ({
134
+ label: b.label,
135
+ value: b.label.toLowerCase().replace(/\s+/g, '_'),
136
+ target: resolveTargetState(b.to),
137
+ }));
138
+
139
+ transitions.push({
140
+ id: `transition-${cond.id.replace(/^cond-/, '')}`,
141
+ label: cond.label || cond.id,
142
+ owner: 'Human',
143
+ type: 'human',
144
+ from: `state-${m.id}`,
145
+ to: options.map((o) => o.target),
146
+ capabilities: ['CAP-001'],
147
+ actions: [
148
+ {
149
+ action_type: 'step/ask-user',
150
+ semantics: {
151
+ label: cond.label || cond.id,
152
+ description: cond.description || `Evaluate ${cond.label || cond.id}`,
153
+ },
154
+ config: {
155
+ questions: [
156
+ {
157
+ id: cond.id.replace(/^cond-/, ''),
158
+ text: cond.label || cond.id,
159
+ type: 'confirm',
160
+ options,
161
+ },
162
+ ],
163
+ },
164
+ },
165
+ ],
166
+ });
167
+ } else if (t.to) {
168
+ const targetId = resolveTargetState(t.to);
169
+ transitions.push({
170
+ id: `transition-${m.id}-${t.to}`,
171
+ label: `${m.label} to ${t.to}`,
172
+ owner: 'Human',
173
+ type: 'human',
174
+ from: `state-${m.id}`,
175
+ to: [targetId],
176
+ capabilities: ['CAP-001'],
177
+ actions: [
178
+ {
179
+ action_type: 'step/ask-user',
180
+ semantics: {
181
+ label: t.label || `Navigate to ${t.to}`,
182
+ description: `Proceed from ${m.label} to ${t.to}`,
183
+ },
184
+ config: {
185
+ questions: [
186
+ {
187
+ id: `${m.id}_to_${t.to}`,
188
+ text: `Proceed from ${m.label} to ${t.to}?`,
189
+ type: 'confirm',
190
+ },
191
+ ],
192
+ },
193
+ },
194
+ ],
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ states.push({
201
+ id: 'state-abandoned',
202
+ label: 'Onboarding Failed',
203
+ terminal: true,
204
+ });
205
+
206
+ // state-abandoned always exists (WF0205 blocks on a missing failure
207
+ // terminal state), but the synthetic transition *into* it is only valid
208
+ // in some shapes:
209
+ //
210
+ // - Initial screen had no authored transitions: the old code emitted
211
+ // `from: state-<id>` while the state loop had named that state
212
+ // `state-<id>-complete`, so the transition referenced a state that did
213
+ // not exist -> WF0221 TRANSITION_BAD_FROM_STATE / EDGE_MISSING_SOURCE.
214
+ // Simply renaming it would not help, because a terminal state may not
215
+ // have outgoing edges (TERMINAL_STATE_HAS_OUTGOING). The state loop
216
+ // above now emits a non-terminal entry state for this case, and the
217
+ // branch below routes it to completion or abandonment.
218
+ // - A manifest already routes to `abandoned` itself. The known-good
219
+ // reference app (uds/references/expo-preview-app) works exactly this
220
+ // way: its Welcome screen declares a `to: abandoned` branch, and its
221
+ // workflow.md contains no synthetic abandoned transition at all.
222
+ // Adding one would duplicate a path the author already modelled —
223
+ // verified by diffing this compiler's output against that app's
224
+ // committed workflow.md, which matches byte for byte only when the
225
+ // synthetic transition is suppressed.
226
+ const hasAuthoredAbandon = manifests.some((m) =>
227
+ (m.transitions || []).some(
228
+ (t) => t.to === 'abandoned' || (t.branches || []).some((b) => b.to === 'abandoned'),
229
+ ),
230
+ );
231
+
232
+ if (initialIsTerminal) {
233
+ const entryStateId = `state-${initialScreen.id}`;
234
+ const completeStateId = `state-${initialScreen.id}-complete`;
235
+ transitions.push({
236
+ id: `transition-${initialScreen.id}-outcome`,
237
+ label: `${initialScreen.label} outcome`,
238
+ owner: 'Human',
239
+ type: 'human',
240
+ from: entryStateId,
241
+ to: [completeStateId, 'state-abandoned'],
242
+ capabilities: ['CAP-001'],
243
+ actions: [
244
+ {
245
+ action_type: 'step/ask-user',
246
+ semantics: {
247
+ label: `${initialScreen.label} outcome`,
248
+ description: `User finishes or abandons the ${initialScreen.label} flow`,
249
+ },
250
+ config: {
251
+ questions: [
252
+ {
253
+ id: `${initialScreen.id}_outcome`,
254
+ text: `Finished with ${initialScreen.label}?`,
255
+ type: 'confirm',
256
+ options: [
257
+ { label: 'Done', value: 'done', target: completeStateId },
258
+ { label: 'Cancel', value: 'cancel', target: 'state-abandoned' },
259
+ ],
260
+ },
261
+ ],
262
+ },
263
+ },
264
+ ],
265
+ });
266
+ } else if (!hasAuthoredAbandon) {
267
+ const initialStateId = `state-${initialScreen.id}`;
268
+ transitions.push({
269
+ id: `transition-${initialScreen.id}-abandoned`,
270
+ label: `${initialScreen.label} abandoned`,
271
+ owner: 'Human',
272
+ type: 'human',
273
+ from: initialStateId,
274
+ to: ['state-abandoned'],
275
+ capabilities: ['CAP-001'],
276
+ actions: [
277
+ {
278
+ action_type: 'step/ask-user',
279
+ semantics: {
280
+ label: 'Abandon flow',
281
+ description: `User abandons the ${initialScreen.label} flow`,
282
+ },
283
+ config: {
284
+ questions: [
285
+ {
286
+ id: `${initialScreen.id}_abandoned`,
287
+ text: `Abandon the ${initialScreen.label} flow?`,
288
+ type: 'confirm',
289
+ },
290
+ ],
291
+ },
292
+ },
293
+ ],
294
+ });
295
+ }
296
+
297
+ let md = `---
298
+ type: workflow
299
+ id: ${wfId}
300
+ ref: /product/workflows/${wfId}/workflow/workflow.md
301
+ title: ${appName} App Flow
302
+ description: Screen navigation and user interaction workflow for ${appName}
303
+ dsl_version: "1.0"
304
+ workflow_version: 1.0.0
305
+ specification: draft
306
+ relationships:
307
+ implements: [GOAL-001]
308
+ uses: []
309
+ depends_on: []
310
+ constrained_by: []
311
+ decided_by: []
312
+ ---
313
+
314
+ ## Workflow Summary
315
+
316
+ \`\`\`yaml
317
+ name: ${appName} App Flow
318
+ description: Screen navigation and user interaction workflow for ${appName}
319
+ \`\`\`
320
+
321
+ ## Business Object
322
+
323
+ \`\`\`yaml
324
+ name: UserProfile
325
+ \`\`\`
326
+
327
+ ## Business Requirements
328
+
329
+ The workflow starts when a mobile user launches the application on the ${initialScreen.label} screen.
330
+
331
+ The user navigates through interactive preview screens to complete their requested flow.
332
+
333
+ Reaching the complete destination screen is the success outcome and abandoning navigation is the failure outcome.
334
+
335
+ ### Objective
336
+ Deliver an interactive mobile screen flow.
337
+
338
+ ### Actors
339
+ - Mobile App User
340
+
341
+ ## Review Model
342
+
343
+ \`\`\`yaml
344
+ type: approval_required
345
+ revision_loop:
346
+ enabled: false
347
+ rejection:
348
+ terminal: true
349
+ \`\`\`
350
+
351
+ ## Capability Discovery
352
+
353
+ ### CAP-001: Mobile Navigation
354
+ \`\`\`yaml
355
+ id: CAP-001
356
+ name: Mobile Navigation
357
+ purpose: Route user between mobile application screens
358
+ inputs: [user_id]
359
+ outputs: [screen_route]
360
+ dependencies: []
361
+ \`\`\`
362
+
363
+ ## Inputs & Outputs
364
+
365
+ \`\`\`yaml
366
+ inputs:
367
+ - user_id
368
+ outputs:
369
+ - user_profile
370
+ \`\`\`
371
+
372
+ ## States
373
+
374
+ `;
375
+
376
+ for (const st of states) {
377
+ md += `### ${st.label}\n\n\`\`\`yaml\nid: ${st.id}\nlabel: ${st.label}\nterminal: ${st.terminal}\n\`\`\`\n\n`;
378
+ }
379
+
380
+ md += `## Rules & Logic
381
+ - Follow forward navigation transitions
382
+
383
+ ## Gaps & Clarifications
384
+ - None
385
+
386
+ ## Extension Points
387
+ - None
388
+
389
+ ## Transition Contracts
390
+
391
+ `;
392
+
393
+ for (const tr of transitions) {
394
+ md += `### ${tr.label}\n\n\`\`\`yaml\n${yaml.dump(tr).trim()}\n\`\`\`\n\n`;
395
+ }
396
+
397
+ return md;
398
+ }
399
+
400
+ function compilePreviews() {
401
+ if (!fs.existsSync(SCREENS_DIR)) {
402
+ console.error(`[compile-preview] Directory not found: ${SCREENS_DIR}`);
403
+ process.exit(1);
404
+ }
405
+
406
+ const appName = getAppName();
407
+ const files = fs
408
+ .readdirSync(SCREENS_DIR)
409
+ .filter((f) => f.endsWith('.preview.md') || f.endsWith('.preview.mdx'))
410
+ .sort();
411
+
412
+ console.log(`[compile-preview] Found ${files.length} preview manifest(s) in: ${SCREENS_DIR}`);
413
+
414
+ const manifests = [];
415
+ for (const file of files) {
416
+ const filePath = path.join(SCREENS_DIR, file);
417
+ try {
418
+ const content = fs.readFileSync(filePath, 'utf-8');
419
+ const manifest = extractYamlBlock(content);
420
+ manifests.push(manifest);
421
+ } catch (err) {
422
+ console.warn(`[compile-preview] Skipping ${file}: ${err.message}`);
423
+ }
424
+ }
425
+
426
+ // Sort manifests: initial screens first, then alphabetical
427
+ manifests.sort((a, b) => {
428
+ if (a.isInitial && !b.isInitial) return -1;
429
+ if (!a.isInitial && b.isInitial) return 1;
430
+ return a.id.localeCompare(b.id);
431
+ });
432
+
433
+ const nodes = [];
434
+ const edges = [];
435
+ const conditionMap = new Map();
436
+
437
+ // 1. Build DevicePreview nodes & edges for preview.json
438
+ for (const m of manifests) {
439
+ const actionPorts = (m.ports || []).map((port) => ({
440
+ id: port.id,
441
+ label: port.label,
442
+ top: typeof port.relativeTop === 'number' ? Math.round(port.relativeTop * PRESET.height) : (port.top || 0),
443
+ }));
444
+
445
+ nodes.push({
446
+ id: `preview-${m.id}`,
447
+ type: 'devicePreview',
448
+ position: { x: 0, y: 0 },
449
+ data: {
450
+ type: 'devicePreview',
451
+ route: m.path || `/${m.id}`,
452
+ label: m.label || m.id,
453
+ isInitial: !!m.isInitial,
454
+ presetId: PRESET.id,
455
+ hideControls: true,
456
+ actionPorts,
457
+ action: {
458
+ after: [
459
+ {
460
+ stepId: `await-nav-${m.id}`,
461
+ actionType: 'step/await-navigation',
462
+ semantics: {
463
+ label: `Await navigation from ${m.label || m.id}`,
464
+ description: `Pause and wait for user to trigger a navigation event from this screen`,
465
+ },
466
+ config: {
467
+ screenId: m.id,
468
+ route: m.path || `/${m.id}`,
469
+ },
470
+ },
471
+ ],
472
+ },
473
+ apiCalls: m.api_calls || [],
474
+ },
475
+ });
476
+
477
+ for (const t of m.transitions || []) {
478
+ if (t.condition) {
479
+ const cond = t.condition;
480
+ if (!conditionMap.has(cond.id)) {
481
+ conditionMap.set(cond.id, {
482
+ id: cond.id,
483
+ type: 'transition',
484
+ position: { x: 0, y: 0 },
485
+ data: {
486
+ type: 'transition',
487
+ label: cond.label || cond.id,
488
+ transitionType: cond.transitionType || 'conditional',
489
+ description: cond.description || '',
490
+ action: {
491
+ after: [
492
+ {
493
+ stepId: `${cond.id}-action`,
494
+ actionType: 'step/condition',
495
+ semantics: {
496
+ label: cond.label || cond.id,
497
+ description: cond.description || `Evaluate ${cond.label || cond.id}`,
498
+ },
499
+ config: {
500
+ condition: cond.id,
501
+ },
502
+ },
503
+ ],
504
+ },
505
+ },
506
+ });
507
+ }
508
+
509
+ edges.push({
510
+ id: `e-preview-${m.id}-${cond.id}`,
511
+ source: `preview-${m.id}`,
512
+ target: cond.id,
513
+ sourceHandle: t.from,
514
+ type: 'straight',
515
+ });
516
+
517
+ for (const branch of t.branches || []) {
518
+ if (branch.to !== 'abandoned') {
519
+ edges.push({
520
+ id: `e-${cond.id}-preview-${branch.to}`,
521
+ source: cond.id,
522
+ target: `preview-${branch.to}`,
523
+ type: 'straight',
524
+ });
525
+ }
526
+ }
527
+ } else if (t.to) {
528
+ const transNodeId = `transition-${m.id}-${t.to}`;
529
+ nodes.push({
530
+ id: transNodeId,
531
+ type: 'transition',
532
+ position: { x: 0, y: 0 },
533
+ data: {
534
+ type: 'transition',
535
+ label: t.label || `${m.label || m.id} to ${t.to}`,
536
+ action: {
537
+ after: [],
538
+ },
539
+ },
540
+ });
541
+
542
+ edges.push({
543
+ id: `e-preview-${m.id}-${transNodeId}`,
544
+ source: `preview-${m.id}`,
545
+ target: transNodeId,
546
+ sourceHandle: t.from,
547
+ type: 'straight',
548
+ });
549
+
550
+ edges.push({
551
+ id: `e-${transNodeId}-preview-${t.to}`,
552
+ source: transNodeId,
553
+ target: `preview-${t.to}`,
554
+ type: 'straight',
555
+ });
556
+ }
557
+ }
558
+ }
559
+
560
+ for (const condNode of conditionMap.values()) {
561
+ nodes.push(condNode);
562
+ }
563
+
564
+ // 2. Write preview.json
565
+ const previewGraph = {
566
+ name: `${appName}-workflow`,
567
+ version: '1',
568
+ preset: PRESET.id,
569
+ nodes,
570
+ edges,
571
+ };
572
+
573
+ fs.writeFileSync(PREVIEW_JSON_FILE, JSON.stringify(previewGraph, null, 2), 'utf-8');
574
+ console.log(`[compile-preview] ✅ Generated: ${PREVIEW_JSON_FILE}`);
575
+
576
+ // 3. Write workflow.md
577
+ const workflowMd = buildWorkflowDsl(appName, manifests);
578
+ fs.writeFileSync(WORKFLOW_MD_FILE, workflowMd, 'utf-8');
579
+ console.log(`[compile-preview] ✅ Generated: ${WORKFLOW_MD_FILE}`);
580
+
581
+ // 4. Validate with workflow-engine CLI if available
582
+ const cliPath = WORKFLOW_ENGINE_CLI_CANDIDATES.find((p) => fs.existsSync(p));
583
+ if (cliPath) {
584
+ try {
585
+ console.log(`[compile-preview] 🔍 Validating workflow.md with workflow-engine CLI...`);
586
+ execSync(`node "${cliPath}" "${WORKFLOW_MD_FILE}" --workflow`, {
587
+ encoding: 'utf-8',
588
+ });
589
+ console.log(`[compile-preview] ✅ workflow-engine CLI check: PASS`);
590
+ } catch (err) {
591
+ console.error(`[compile-preview] ❌ workflow-engine CLI validation failed:\n${err.stdout || err.message}`);
592
+ process.exit(1);
593
+ }
594
+ }
595
+
596
+ console.log(`[compile-preview] 🎉 Compilation complete! (${nodes.length} nodes, ${edges.length} edges)`);
597
+ }
598
+
599
+ compilePreviews();
@@ -0,0 +1,103 @@
1
+ import { Platform } from 'react-native';
2
+
3
+ export type HostMessage = {
4
+ type: string;
5
+ appId?: string;
6
+ [key: string]: unknown;
7
+ };
8
+
9
+ export type AppParams = {
10
+ appId: string | null;
11
+ name: string | null;
12
+ tenantId: string | null;
13
+ route: string | null;
14
+ };
15
+
16
+ export type AppRoute = {
17
+ id: string;
18
+ path: string;
19
+ label: string;
20
+ isInitial?: boolean;
21
+ };
22
+
23
+ export type AppNavLink = {
24
+ fromRouteId: string;
25
+ toRouteId: string;
26
+ label?: string;
27
+ };
28
+
29
+ export type AppRouteManifest = {
30
+ appId: string;
31
+ routes: AppRoute[];
32
+ links: AppNavLink[];
33
+ };
34
+
35
+ /**
36
+ * The app's real navigation topology, reported to the host at runtime.
37
+ *
38
+ * Edit this — and only this — as you add screens. Everything else in this
39
+ * file is host-protocol plumbing; leave it as-is.
40
+ */
41
+ export function buildRouteManifest(appId: string | null): AppRouteManifest {
42
+ return {
43
+ appId: appId ?? 'waypoint-expo-template',
44
+ routes: [
45
+ { id: 'home', path: '/home', label: 'Home', isInitial: true },
46
+ ],
47
+ links: [],
48
+ };
49
+ }
50
+
51
+ export function reportRoutes(manifest: AppRouteManifest): void {
52
+ postToHost({ type: 'expo-app:routes', manifest });
53
+ }
54
+
55
+ const isWeb = Platform.OS === 'web';
56
+
57
+ export function getAppParams(): AppParams {
58
+ if (!isWeb) {
59
+ return { appId: null, name: null, tenantId: null, route: null };
60
+ }
61
+ const search = new URLSearchParams(window.location.search);
62
+ const hash = window.location.hash ? window.location.hash.replace(/^#/, '') : null;
63
+ const routeParam = search.get('route') || search.get('screen') || hash;
64
+ return {
65
+ appId: search.get('appId'),
66
+ name: search.get('name'),
67
+ tenantId: search.get('tenant_id'),
68
+ route: routeParam,
69
+ };
70
+ }
71
+
72
+ export function postToHost(payload: HostMessage): void {
73
+ if (!isWeb) {
74
+ return;
75
+ }
76
+ const target = window.parent;
77
+ if (target && target !== window) {
78
+ target.postMessage(payload, '*');
79
+ }
80
+ }
81
+
82
+ export function postNavigationTrigger(from: string, to: string, portId?: string): void {
83
+ postToHost({
84
+ type: 'expo-app:navigation-trigger',
85
+ from,
86
+ to,
87
+ port_id: portId,
88
+ });
89
+ }
90
+
91
+ export function onHostMessage(handler: (message: HostMessage) => void): () => void {
92
+ if (!isWeb) {
93
+ return () => {};
94
+ }
95
+ const listener = (event: MessageEvent): void => {
96
+ if (event.source !== window.parent) {
97
+ return;
98
+ }
99
+ handler(event.data as HostMessage);
100
+ };
101
+ window.addEventListener('message', listener);
102
+ return () => window.removeEventListener('message', listener);
103
+ }
@@ -0,0 +1,22 @@
1
+ ---
2
+ id: home
3
+ label: Home
4
+ path: /home
5
+ isInitial: true
6
+ ---
7
+
8
+ # Home Screen
9
+
10
+ Placeholder starting screen. Replace with the app's real content, and add
11
+ `ports`/`transitions` here as you wire up navigation to new screens.
12
+
13
+ ## Interactive Ports & Navigation
14
+
15
+ ```yaml
16
+ id: home
17
+ label: Home
18
+ path: /home
19
+ isInitial: true
20
+ ports: []
21
+ transitions: []
22
+ ```
@@ -0,0 +1,14 @@
1
+ import { StyleSheet, Text, View } from 'react-native';
2
+
3
+ export function HomeScreen() {
4
+ return (
5
+ <View style={styles.container} testID="screen-home">
6
+ <Text style={styles.text}>Home</Text>
7
+ </View>
8
+ );
9
+ }
10
+
11
+ const styles = StyleSheet.create({
12
+ container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff' },
13
+ text: { fontSize: 18 },
14
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ }
6
+ }
package/workflow.md ADDED
@@ -0,0 +1,144 @@
1
+ ---
2
+ type: workflow
3
+ id: WF-WAYPOINT_EXPO_TEMPLATE
4
+ ref: /product/workflows/WF-WAYPOINT_EXPO_TEMPLATE/workflow/workflow.md
5
+ title: waypoint-expo-template App Flow
6
+ description: Screen navigation and user interaction workflow for waypoint-expo-template
7
+ dsl_version: "1.0"
8
+ workflow_version: 1.0.0
9
+ specification: draft
10
+ relationships:
11
+ implements: [GOAL-001]
12
+ uses: []
13
+ depends_on: []
14
+ constrained_by: []
15
+ decided_by: []
16
+ ---
17
+
18
+ ## Workflow Summary
19
+
20
+ ```yaml
21
+ name: waypoint-expo-template App Flow
22
+ description: Screen navigation and user interaction workflow for waypoint-expo-template
23
+ ```
24
+
25
+ ## Business Object
26
+
27
+ ```yaml
28
+ name: UserProfile
29
+ ```
30
+
31
+ ## Business Requirements
32
+
33
+ The workflow starts when a mobile user launches the application on the Home screen.
34
+
35
+ The user navigates through interactive preview screens to complete their requested flow.
36
+
37
+ Reaching the complete destination screen is the success outcome and abandoning navigation is the failure outcome.
38
+
39
+ ### Objective
40
+ Deliver an interactive mobile screen flow.
41
+
42
+ ### Actors
43
+ - Mobile App User
44
+
45
+ ## Review Model
46
+
47
+ ```yaml
48
+ type: approval_required
49
+ revision_loop:
50
+ enabled: false
51
+ rejection:
52
+ terminal: true
53
+ ```
54
+
55
+ ## Capability Discovery
56
+
57
+ ### CAP-001: Mobile Navigation
58
+ ```yaml
59
+ id: CAP-001
60
+ name: Mobile Navigation
61
+ purpose: Route user between mobile application screens
62
+ inputs: [user_id]
63
+ outputs: [screen_route]
64
+ dependencies: []
65
+ ```
66
+
67
+ ## Inputs & Outputs
68
+
69
+ ```yaml
70
+ inputs:
71
+ - user_id
72
+ outputs:
73
+ - user_profile
74
+ ```
75
+
76
+ ## States
77
+
78
+ ### Home
79
+
80
+ ```yaml
81
+ id: state-home
82
+ label: Home
83
+ terminal: false
84
+ ```
85
+
86
+ ### Home Complete
87
+
88
+ ```yaml
89
+ id: state-home-complete
90
+ label: Home Complete
91
+ terminal: true
92
+ ```
93
+
94
+ ### Onboarding Failed
95
+
96
+ ```yaml
97
+ id: state-abandoned
98
+ label: Onboarding Failed
99
+ terminal: true
100
+ ```
101
+
102
+ ## Rules & Logic
103
+ - Follow forward navigation transitions
104
+
105
+ ## Gaps & Clarifications
106
+ - None
107
+
108
+ ## Extension Points
109
+ - None
110
+
111
+ ## Transition Contracts
112
+
113
+ ### Home outcome
114
+
115
+ ```yaml
116
+ id: transition-home-outcome
117
+ label: Home outcome
118
+ owner: Human
119
+ type: human
120
+ from: state-home
121
+ to:
122
+ - state-home-complete
123
+ - state-abandoned
124
+ capabilities:
125
+ - CAP-001
126
+ actions:
127
+ - action_type: step/ask-user
128
+ semantics:
129
+ label: Home outcome
130
+ description: User finishes or abandons the Home flow
131
+ config:
132
+ questions:
133
+ - id: home_outcome
134
+ text: Finished with Home?
135
+ type: confirm
136
+ options:
137
+ - label: Done
138
+ value: done
139
+ target: state-home-complete
140
+ - label: Cancel
141
+ value: cancel
142
+ target: state-abandoned
143
+ ```
144
+