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 +61 -0
- package/README.md +68 -0
- package/app.json +26 -0
- package/assets/android-icon-background.png +0 -0
- package/assets/android-icon-foreground.png +0 -0
- package/assets/android-icon-monochrome.png +0 -0
- package/assets/favicon.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/splash-icon.png +0 -0
- package/index.ts +13 -0
- package/package.json +29 -0
- package/preview.json +42 -0
- package/scripts/compile-preview.js +599 -0
- package/src/lib/previewBridge.ts +103 -0
- package/src/screens/HomeScreen.preview.md +22 -0
- package/src/screens/HomeScreen.tsx +14 -0
- package/tsconfig.json +6 -0
- package/workflow.md +144 -0
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
|
|
Binary file
|
package/assets/icon.png
ADDED
|
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
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
|
+
|