Skip to content

Commit 65feb54

Browse files
authored
Merge pull request #47 from MobileReality/chore/rn-renderer-docs
Chore/rn renderer docs
2 parents 156675d + d5b1ed8 commit 65feb54

8 files changed

Lines changed: 902 additions & 7 deletions

File tree

demo/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
"@mobile-reality/mdma-parser": "workspace:*",
1313
"@mobile-reality/mdma-prompt-pack": "workspace:*",
1414
"@mobile-reality/mdma-renderer-react": "workspace:*",
15+
"@mobile-reality/mdma-renderer-react-native": "workspace:*",
1516
"@mobile-reality/mdma-runtime": "workspace:*",
1617
"@mobile-reality/mdma-spec": "workspace:*",
1718
"@mobile-reality/mdma-validator": "workspace:*",
1819
"highlight.js": "^11.11.1",
1920
"react": "^19.0.0",
2021
"react-dom": "^19.0.0",
22+
"react-native-web": "^0.19.13",
2123
"recharts": "^3.7.0",
2224
"remark-parse": "^11.0.0",
2325
"unified": "^11.0.0",
@@ -28,6 +30,7 @@
2830
"@types/react": "^19.0.0",
2931
"@types/react-dom": "^19.0.0",
3032
"@vitejs/plugin-react": "^4.3.0",
33+
"react-native": "0.76.9",
3134
"typescript": "^5.7.0",
3235
"vite": "^6.0.0"
3336
}

demo/src/docs/DocsView.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import { useState, useEffect } from 'react';
1+
import { useEffect, useState } from 'react';
22
import { Cli } from './sections/Cli.js';
33
import { COMPONENTS, ComponentPreview, Components } from './sections/Components.js';
44
import { CustomPromptBestPractices } from './sections/CustomPromptBestPractices.js';
55
import { Installation } from './sections/Installation.js';
6+
import { IntegrationAgui } from './sections/IntegrationAgui.js';
7+
import { IntegrationLangchain } from './sections/IntegrationLangchain.js';
8+
import { INTEGRATIONS, Integrations } from './sections/Integrations.js';
69
import { Introduction } from './sections/Introduction.js';
710
import { Mcp } from './sections/Mcp.js';
811
import { PACKAGES, PackageDetail } from './sections/PackageDetail.js';
912
import { Packages } from './sections/Packages.js';
1013
import { PromptMatrix } from './sections/PromptMatrix.js';
11-
import { Integrations, INTEGRATIONS } from './sections/Integrations.js';
12-
import { IntegrationLangchain } from './sections/IntegrationLangchain.js';
13-
import { IntegrationAgui } from './sections/IntegrationAgui.js';
14+
import { ReactNative, ReactNativeSnack } from './sections/ReactNative.js';
1415
import { Usage, UsageHydrationPreview } from './sections/Usage.js';
1516
import { Validator } from './sections/Validator.js';
1617

@@ -32,6 +33,7 @@ const SECTIONS: Section[] = [
3233
{ slug: 'usage', label: 'Usage', component: Usage },
3334
{ slug: 'integrations', label: 'Integrations' },
3435
{ slug: 'components', label: 'Components' },
36+
{ slug: 'react-native', label: 'React Native', component: ReactNative },
3537
{ slug: 'validator', label: 'Validator', component: Validator },
3638
{ slug: 'mcp', label: 'MCP & Skills', component: Mcp },
3739
{ slug: 'cli', label: 'CLI', component: Cli },
@@ -73,7 +75,8 @@ export function DocsView() {
7375

7476
const showComponentsPreview = active === 'components';
7577
const showUsagePreview = active === 'usage' && usageExampleOpen;
76-
const showPreview = showComponentsPreview || showUsagePreview;
78+
const showRnPreview = active === 'react-native';
79+
const showPreview = showComponentsPreview || showUsagePreview || showRnPreview;
7780
const previewEntry = COMPONENTS.find((c) => c.type === selectedComponent) ?? COMPONENTS[0];
7881

7982
const isPackagesActive = active === 'packages' || active.startsWith('packages/');
@@ -119,7 +122,11 @@ export function DocsView() {
119122
(s.slug === 'integrations' && isIntegrationsActive);
120123

121124
return (
122-
<div className={`docs-layout${showPreview ? ' docs-layout--with-preview' : ''}`}>
125+
<div
126+
className={`docs-layout${showPreview ? ' docs-layout--with-preview' : ''}${
127+
showRnPreview ? ' docs-layout--rn' : ''
128+
}`}
129+
>
123130
<nav className="docs-nav">
124131
<div className="docs-nav-title">Documentation</div>
125132
{SECTIONS.map((s) => (
@@ -171,6 +178,8 @@ export function DocsView() {
171178
<aside className="docs-preview-panel">
172179
{showComponentsPreview ? (
173180
<ComponentPreview key={selectedComponent} entry={previewEntry} />
181+
) : showRnPreview ? (
182+
<ReactNativeSnack />
174183
) : (
175184
<UsageHydrationPreview />
176185
)}
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { Code } from '../Code.js';
2+
import { ReactNativePreview } from './ReactNativePreview.js';
3+
import { RN_RESPONSES } from './rn-responses.js';
4+
5+
// The program shown when the user clicks "Open in Expo Snack" — the same
6+
// interactive chat as the live preview, standalone (imports the published
7+
// packages, includes the Hermes crypto polyfill). RESPONSES are injected as
8+
// JSON via interpolation, so there are no backticks / escaped newlines here.
9+
const SNACK_CODE = `// MDMA — React Native renderer (interactive demo, no API key needed)
10+
if (!global.crypto) global.crypto = {};
11+
if (typeof global.crypto.randomUUID !== "function") {
12+
global.crypto.randomUUID = function () {
13+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (ch) {
14+
var r = (Math.random() * 16) | 0;
15+
var v = ch === "x" ? r : (r & 0x3) | 0x8;
16+
return v.toString(16);
17+
});
18+
};
19+
}
20+
21+
import React, { useState, useRef, useEffect } from "react";
22+
import {
23+
SafeAreaView,
24+
ScrollView,
25+
View,
26+
Text,
27+
TextInput,
28+
Pressable,
29+
ActivityIndicator,
30+
KeyboardAvoidingView,
31+
Platform,
32+
StyleSheet,
33+
} from "react-native";
34+
import { createDocumentStore } from "@mobile-reality/mdma-runtime";
35+
import { MdmaDocument } from "@mobile-reality/mdma-renderer-react-native";
36+
37+
var RESPONSES = ${JSON.stringify(RN_RESPONSES)};
38+
var CHIPS = ["Contact form", "Release checklist", "Adoption table", "Approval gate"];
39+
var BLUE = "#2563eb";
40+
var BORDER = "#e5e7eb";
41+
42+
function pick(text, n) {
43+
var t = text.toLowerCase();
44+
for (var i = 0; i < RESPONSES.length; i++) {
45+
var kws = RESPONSES[i].match;
46+
for (var j = 0; j < kws.length; j++) {
47+
if (t.indexOf(kws[j]) !== -1) return RESPONSES[i];
48+
}
49+
}
50+
return RESPONSES[n % RESPONSES.length];
51+
}
52+
53+
export default function App() {
54+
var [messages, setMessages] = useState([]);
55+
var [input, setInput] = useState("");
56+
var countRef = useRef(0);
57+
var idRef = useRef(0);
58+
var scrollRef = useRef(null);
59+
60+
useEffect(function () {
61+
if (messages.length && scrollRef.current) scrollRef.current.scrollToEnd({ animated: true });
62+
}, [messages]);
63+
64+
function patch(id, next) {
65+
setMessages(function (prev) {
66+
return prev.map(function (m) { return m.id === id ? Object.assign({}, m, next) : m; });
67+
});
68+
}
69+
70+
function stream(id, words, i) {
71+
patch(id, { text: words.slice(0, i).join(" ") });
72+
if (i < words.length) setTimeout(function () { stream(id, words, i + 1); }, 55);
73+
else patch(id, { done: true });
74+
}
75+
76+
function send(raw) {
77+
var text = (raw != null ? raw : input).trim();
78+
if (!text) return;
79+
setInput("");
80+
var r = pick(text, countRef.current);
81+
countRef.current += 1;
82+
var store = createDocumentStore(r.ast);
83+
var bid = idRef.current + 2;
84+
idRef.current += 2;
85+
setMessages(function (prev) {
86+
return prev.concat([
87+
{ id: bid - 1, role: "user", text: text },
88+
{ id: bid, role: "assistant", ast: r.ast, store: store, text: "", done: false },
89+
]);
90+
});
91+
setTimeout(function () { stream(bid, r.reply.split(" "), 1); }, 300);
92+
}
93+
94+
return (
95+
<SafeAreaView style={styles.page}>
96+
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
97+
<View style={styles.header}>
98+
<View style={styles.avatar}><Text style={styles.avatarText}>M</Text></View>
99+
<View>
100+
<Text style={styles.title}>MDMA Assistant</Text>
101+
<Text style={styles.subtitle}>React Native renderer</Text>
102+
</View>
103+
</View>
104+
105+
<ScrollView ref={scrollRef} style={styles.flex} contentContainerStyle={styles.scroll}>
106+
{messages.length === 0 ? (
107+
<View style={styles.empty}>
108+
<Text style={styles.emptyTitle}>Ask for an interactive component</Text>
109+
<Text style={styles.emptyHint}>The reply streams in, then renders natively — fully interactive.</Text>
110+
</View>
111+
) : null}
112+
{messages.map(function (m) {
113+
if (m.role === "user") {
114+
return (
115+
<View key={m.id} style={styles.userRow}>
116+
<View style={styles.user}><Text style={styles.userText}>{m.text}</Text></View>
117+
</View>
118+
);
119+
}
120+
return (
121+
<View key={m.id} style={styles.bot}>
122+
{m.text ? (
123+
<Text style={styles.botText}>{m.text}{m.done ? "" : " ▌"}</Text>
124+
) : (
125+
<View style={styles.typing}>
126+
<ActivityIndicator size="small" color="#9ca3af" />
127+
<Text style={styles.typingText}>Generating…</Text>
128+
</View>
129+
)}
130+
{m.done ? (
131+
<View style={styles.doc}><MdmaDocument ast={m.ast} store={m.store} theme="light" /></View>
132+
) : null}
133+
</View>
134+
);
135+
})}
136+
</ScrollView>
137+
138+
<View style={styles.chipsWrap}>
139+
{CHIPS.map(function (c, i) {
140+
return (
141+
<Pressable key={i} style={styles.chip} onPress={function () { send(c); }}>
142+
<Text style={styles.chipText}>{c}</Text>
143+
</Pressable>
144+
);
145+
})}
146+
</View>
147+
148+
<View style={styles.bar}>
149+
<TextInput style={styles.input} value={input} onChangeText={setInput} placeholder="Message…" placeholderTextColor="#9ca3af" onSubmitEditing={function () { send(); }} returnKeyType="send" />
150+
<Pressable style={[styles.send, !input.trim() && styles.sendDisabled]} onPress={function () { send(); }} disabled={!input.trim()}>
151+
<Text style={styles.sendText}>Send</Text>
152+
</Pressable>
153+
</View>
154+
</KeyboardAvoidingView>
155+
</SafeAreaView>
156+
);
157+
}
158+
159+
var styles = StyleSheet.create({
160+
page: { flex: 1, backgroundColor: "#f3f4f6" },
161+
flex: { flex: 1 },
162+
header: { flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 14, paddingVertical: 12, backgroundColor: "#ffffff", borderBottomWidth: 1, borderBottomColor: BORDER },
163+
avatar: { width: 34, height: 34, borderRadius: 17, backgroundColor: BLUE, alignItems: "center", justifyContent: "center" },
164+
avatarText: { color: "#ffffff", fontWeight: "700", fontSize: 16 },
165+
title: { fontSize: 15, fontWeight: "700", color: "#111827" },
166+
subtitle: { fontSize: 12, color: "#6b7280", marginTop: 1 },
167+
scroll: { padding: 12, gap: 12 },
168+
empty: { paddingVertical: 40, paddingHorizontal: 12, gap: 8, alignItems: "center" },
169+
emptyTitle: { fontSize: 15, fontWeight: "700", color: "#111827", textAlign: "center" },
170+
emptyHint: { fontSize: 13, color: "#6b7280", textAlign: "center", lineHeight: 19 },
171+
userRow: { alignItems: "flex-end" },
172+
user: { maxWidth: "85%", backgroundColor: BLUE, borderRadius: 18, borderBottomRightRadius: 5, paddingVertical: 9, paddingHorizontal: 13 },
173+
userText: { color: "#ffffff", fontSize: 15, lineHeight: 20 },
174+
bot: { alignSelf: "stretch", backgroundColor: "#ffffff", borderRadius: 18, borderBottomLeftRadius: 5, borderWidth: 1, borderColor: BORDER, padding: 12 },
175+
botText: { color: "#1f2937", fontSize: 15, lineHeight: 22 },
176+
typing: { flexDirection: "row", alignItems: "center", gap: 8 },
177+
typingText: { color: "#9ca3af", fontSize: 14 },
178+
doc: { marginTop: 10 },
179+
chipsWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8, paddingHorizontal: 10, paddingVertical: 10, backgroundColor: "#ffffff", borderTopWidth: 1, borderTopColor: BORDER },
180+
chip: { paddingVertical: 7, paddingHorizontal: 13, borderRadius: 999, borderWidth: 1, borderColor: BORDER, backgroundColor: "#f9fafb" },
181+
chipText: { color: "#374151", fontSize: 13, fontWeight: "500" },
182+
bar: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 10, paddingVertical: 10, backgroundColor: "#ffffff", borderTopWidth: 1, borderTopColor: BORDER },
183+
input: { flex: 1, borderWidth: 1, borderColor: BORDER, borderRadius: 22, paddingHorizontal: 15, paddingVertical: 10, fontSize: 15, backgroundColor: "#f9fafb" },
184+
send: { backgroundColor: BLUE, borderRadius: 22, paddingHorizontal: 18, paddingVertical: 10 },
185+
sendDisabled: { opacity: 0.45 },
186+
sendText: { color: "#ffffff", fontWeight: "600", fontSize: 15 },
187+
});
188+
`;
189+
190+
const SNACK_DEPS = [
191+
'@mobile-reality/mdma-renderer-react-native@0.2.1',
192+
'@mobile-reality/mdma-runtime@0.3.1',
193+
'@mobile-reality/mdma-spec@0.3.1',
194+
].join(',');
195+
196+
// Non-embedded Snack URL — opens the full editor (code + device QR) in a new tab.
197+
const SNACK_OPEN_URL = `https://snack.expo.dev/?${new URLSearchParams({
198+
code: SNACK_CODE,
199+
dependencies: SNACK_DEPS,
200+
platform: 'mydevice',
201+
supportedPlatforms: 'mydevice,ios,android,web',
202+
name: 'MDMA — React Native renderer',
203+
}).toString()}`;
204+
205+
export function ReactNative() {
206+
return (
207+
<>
208+
<h2>React Native</h2>
209+
<p>
210+
<code>@mobile-reality/mdma-renderer-react-native</code> renders MDMA documents as native
211+
iOS/Android UI — the native sibling of <code>@mobile-reality/mdma-renderer-react</code>. It
212+
reuses the same headless stack (<code>spec</code> + <code>runtime</code>) unchanged and
213+
reimplements only the view layer with React Native primitives, so state, bindings, actions,
214+
policy, audit, and PII redaction all behave exactly as they do on the web.
215+
</p>
216+
217+
<h2>Install</h2>
218+
<Code lang="bash">{`npm install @mobile-reality/mdma-renderer-react-native \\
219+
@mobile-reality/mdma-spec @mobile-reality/mdma-runtime react react-native`}</Code>
220+
221+
<h2>Usage</h2>
222+
<p>
223+
Parse a document to an AST + store (with <code>@mobile-reality/mdma-parser</code>), then
224+
hand both to <code>MdmaDocument</code>. Interactions dispatch the same store actions as the
225+
web renderer.
226+
</p>
227+
<Code lang="tsx">{`import { unified } from 'unified';
228+
import remarkParse from 'remark-parse';
229+
import { remarkMdma } from '@mobile-reality/mdma-parser';
230+
import { createDocumentStore } from '@mobile-reality/mdma-runtime';
231+
import { MdmaDocument } from '@mobile-reality/mdma-renderer-react-native';
232+
import { ScrollView } from 'react-native';
233+
234+
const processor = unified().use(remarkParse).use(remarkMdma, {});
235+
236+
export function Screen({ markdown }: { markdown: string }) {
237+
const [doc, setDoc] = useState(null);
238+
useEffect(() => {
239+
(async () => {
240+
const ast = await processor.run(processor.parse(markdown), markdown);
241+
setDoc({ ast, store: createDocumentStore(ast) });
242+
})();
243+
}, [markdown]);
244+
245+
if (!doc) return null;
246+
return (
247+
<ScrollView>
248+
<MdmaDocument ast={doc.ast} store={doc.store} theme="dark" />
249+
</ScrollView>
250+
);
251+
}`}</Code>
252+
<p>
253+
Theme with <code>theme="light" | "dark"</code> (or a full token object via{' '}
254+
<code>MdmaThemeProvider</code>), and override any component through the same{' '}
255+
<code>customizations.components.&lt;type&gt;</code> slot the web renderer uses.
256+
</p>
257+
258+
<h2>Try it</h2>
259+
<p>
260+
The panel on the right is the actual <code>renderer-react-native</code> running live (via{' '}
261+
<code>react-native-web</code>) — send a message or tap a suggestion and the reply streams
262+
in, then one of four sample MDMA documents renders and stays interactive. No agent, no API
263+
key. Use <strong>Open in Expo Snack</strong> to see the code and run it on a real device
264+
with Expo Go.
265+
</p>
266+
<p className="rn-snack-note">
267+
The full app (a real MDMA agent with streaming generation) lives at{' '}
268+
<code>demo-native/</code> in the repo — that one needs your own provider key in a{' '}
269+
<code>.env</code> file.
270+
</p>
271+
</>
272+
);
273+
}
274+
275+
/** The live react-native-web preview + a link to the full Snack, rendered in
276+
* the docs preview panel (right side). */
277+
export function ReactNativeSnack() {
278+
return (
279+
<div className="rn-preview-panel">
280+
<a className="rn-open-snack" href={SNACK_OPEN_URL} target="_blank" rel="noreferrer">
281+
Open in Expo Snack ↗
282+
</a>
283+
<div className="rn-phone">
284+
<ReactNativePreview />
285+
</div>
286+
</div>
287+
);
288+
}

0 commit comments

Comments
 (0)