Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/theming-lib-and-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@mobile-reality/mdma-renderer-react': minor
'@mobile-reality/mdma-renderer-react-native': minor
---

Add opt-in theming to both renderers via a `theme` prop on `MdmaDocument`.

- Pass `"light"`, `"dark"`, `"auto"` (follows the OS preference), or a full `MdmaTheme` token object; omit it for the unchanged default light look.
- Web renderer: `styles.css` is now driven by `--mdma-*` CSS variables, so themes apply via `data-theme`/inline variables and can be overridden directly in CSS. Exposes `MdmaThemeProvider`, `useMdmaTheme`, `lightTheme`, `darkTheme`, and helpers.
- Web renderer: a `MdmaDocument` with no `theme` prop now inherits the theme from an ancestor `MdmaThemeProvider`, so one provider can theme a whole app; an explicit `theme` prop still wins.
- Web renderer: a custom `MdmaTheme` object now also selects the light/dark base (by its `background` luminance) so the stylesheet's internal derived colors (heading text, code backgrounds, …) match — a custom *dark* theme no longer renders near-black text on a dark surface.
- Web renderer: interactive states — button hover (`--mdma-color-*-hover`) and the input focus ring — now derive from the base tokens via `color-mix`, so a custom `primary` gets a coherent matching hover/focus automatically instead of the built-in purple.
- React Native renderer: `theme="auto"` now follows the OS color scheme via `useColorScheme()`.
- Both renderers share the same `MdmaTheme` token shape, so a custom theme object is portable between web and native.
7 changes: 7 additions & 0 deletions .changeset/unify-rn-palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@mobile-reality/mdma-renderer-react-native': minor
---

Unify the React Native renderer's built-in `lightTheme`/`darkTheme` palettes with the web renderer's, so MDMA content looks consistent across web and native. The native defaults were previously a blue-primary palette; they now match the web renderer's purple-primary light/dark themes exactly (same `colors`, and `fontSize` `small`/`title` aligned). Pass a custom `MdmaTheme` to `MdmaDocument`/`MdmaThemeProvider` to override.

Also fixes the RN `TableRenderer` and `ChartRenderer` (which renders as a table), whose bodies had no themed background (only the header did) — on a dark theme the rows showed through to whatever was behind them. They now fill their container with `colors.background` (header stays `colors.surface`), matching the web renderer and the other RN card renderers.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,18 @@ function App({ ast, store }) {

> **Note:** The `styles.css` import provides default styling for all MDMA components (forms, tables, callouts, animations, etc.). It's optional — you can write your own styles targeting the `.mdma-*` CSS classes instead.

### Theming

Both renderers accept a `theme` prop on `MdmaDocument`, so theming is entirely opt-in — omit it and you get the default light look. Pass a built-in palette, follow the OS preference, or hand over a full custom token object:

```tsx
<MdmaDocument ast={ast} store={store} theme="dark" /> // built-in dark palette
<MdmaDocument ast={ast} store={store} theme="auto" /> // follows OS light/dark
<MdmaDocument ast={ast} store={store} theme={myTheme} /> // custom MdmaTheme tokens
```

The web (`renderer-react`) and native (`renderer-react-native`) renderers share the same `MdmaTheme` token shape, so a theme object is portable between them. On the web, tokens are applied as `--mdma-*` CSS variables (still fully overridable in your own CSS); on native, renderers read them via `useMdmaTheme()`. See the [Theming guide](docs/guides/theming.md) for the full token reference.

## Packages

| Package | Description |
Expand Down
261 changes: 166 additions & 95 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { MdmaThemeProvider } from '@mobile-reality/mdma-renderer-react';
import logoUrl from '../../assets/logo.svg';
import { DemoThemeContext, type ThemeMode } from './theme-context.js';
import { AgentChatView } from './AgentChatView.js';
import { ChatView } from './ChatView.js';
import { CustomChatView } from './CustomChatView.js';
Expand All @@ -24,6 +26,60 @@ function navigate(to: string) {
window.location.hash = to;
}

// ── Theme (applied to the demo chrome + the rendered MDMA examples) ────────────

const THEME_MODES: ThemeMode[] = ['light', 'dark', 'auto'];
const THEME_ICON: Record<ThemeMode, string> = { light: '☀️', dark: '🌙', auto: '🖥️' };

function useThemeMode(): [ThemeMode, (m: ThemeMode) => void] {
const [mode, setMode] = useState<ThemeMode>(() => {
const saved = window.localStorage.getItem('mdma-demo-theme');
return saved === 'light' || saved === 'dark' || saved === 'auto' ? saved : 'light';
});
useEffect(() => {
window.localStorage.setItem('mdma-demo-theme', mode);
}, [mode]);
return [mode, setMode];
}

/**
* Resolve `'auto'` to a concrete `'light'`/`'dark'` by watching the OS
* preference, so the demo can drive both its chrome and the MDMA examples off a
* single attribute (no `prefers-color-scheme` duplication in the CSS).
*/
function useResolvedTheme(mode: ThemeMode): 'light' | 'dark' {
const [systemDark, setSystemDark] = useState(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemDark(mq.matches);
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
if (mode === 'auto') return systemDark ? 'dark' : 'light';
return mode;
}

function ThemeToggle({ mode, onChange }: { mode: ThemeMode; onChange: (m: ThemeMode) => void }) {
return (
<div className="demo-theme-toggle" aria-label="Example theme">
{THEME_MODES.map((m) => (
<button
key={m}
type="button"
className={`demo-theme-btn ${mode === m ? 'demo-theme-btn--active' : ''}`}
onClick={() => onChange(m)}
title={`${m[0].toUpperCase()}${m.slice(1)} theme for examples`}
aria-pressed={mode === m}
>
<span aria-hidden="true">{THEME_ICON[m]}</span>
</button>
))}
</div>
);
}

// ── Nav config ───────────────────────────────────────────────────────────────

type Route = '/' | '/chat' | '/preview' | '/author' | '/custom' | '/validator' | '/docs';
Expand Down Expand Up @@ -96,6 +152,8 @@ export function App() {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const stars = useGitHubStars('MobileReality/mdma');
const [themeMode, setThemeMode] = useThemeMode();
const resolvedTheme = useResolvedTheme(themeMode);

useEffect(() => {
function handleClick(e: MouseEvent) {
Expand All @@ -107,103 +165,116 @@ export function App() {
return () => document.removeEventListener('mousedown', handleClick);
}, []);

// Drive dark mode from the root element so the whole page (including the body
// background behind transparent panels) picks up the `[data-theme='dark']`
// chrome overrides.
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
return () => document.documentElement.removeAttribute('data-theme');
}, [resolvedTheme]);

return (
<div className="demo-layout">
<header className="demo-header">
<div className="demo-header-left">
<button type="button" className="demo-title-link" onClick={() => navigate('/')}>
<img src={logoUrl} alt="MDMA" className="demo-logo" />
</button>
<span className="demo-subtitle">The open standard for AI-generated interactive UI</span>
</div>
<div className="demo-header-right">
<a
className="demo-ph-badge"
href="https://www.producthunt.com/products/mdma-genui-for-apps?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-mdma-genui-for-apps"
target="_blank"
rel="noopener noreferrer"
>
<img
alt="MDMA - GenUI for apps - Turn AI chat responses into interactive forms and workflows | Product Hunt"
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1162215&theme=light&t=1780988696527"
/>
</a>
<a
className="demo-star-btn"
href="https://github.com/MobileReality/mdma"
target="_blank"
rel="noreferrer"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Star
{stars !== null && <span className="demo-star-count">{stars.toLocaleString()}</span>}
</a>
{route !== '/' && (
<div className="demo-nav" ref={dropdownRef}>
<button
type="button"
className="demo-nav-trigger"
onClick={() => setDropdownOpen((v) => !v)}
>
{labelForPath(route)}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
<DemoThemeContext.Provider value={resolvedTheme}>
<div className="demo-layout" data-theme={resolvedTheme}>
<header className="demo-header">
<div className="demo-header-left">
<button type="button" className="demo-title-link" onClick={() => navigate('/')}>
<img src={logoUrl} alt="MDMA" className="demo-logo" />
</button>
<span className="demo-subtitle">The open standard for AI-generated interactive UI</span>
</div>
<div className="demo-header-right">
<ThemeToggle mode={themeMode} onChange={setThemeMode} />
<a
className="demo-ph-badge"
href="https://www.producthunt.com/products/mdma-genui-for-apps?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-mdma-genui-for-apps"
target="_blank"
rel="noopener noreferrer"
>
<img
alt="MDMA - GenUI for apps - Turn AI chat responses into interactive forms and workflows | Product Hunt"
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1162215&theme=light&t=1780988696527"
/>
</a>
<a
className="demo-star-btn"
href="https://github.com/MobileReality/mdma"
target="_blank"
rel="noreferrer"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Star
{stars !== null && <span className="demo-star-count">{stars.toLocaleString()}</span>}
</a>
{route !== '/' && (
<div className="demo-nav" ref={dropdownRef}>
<button
type="button"
className="demo-nav-trigger"
onClick={() => setDropdownOpen((v) => !v)}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{dropdownOpen && (
<div className="demo-nav-dropdown">
{NAV_GROUPS.map((group) => (
<div key={group.label} className="demo-nav-group">
<div className="demo-nav-group-label">{group.label}</div>
{group.items.map((item) => (
<a
key={item.path}
href={`#${item.path}`}
className={`demo-nav-item ${route === item.path ? 'demo-nav-item--active' : ''}`}
onClick={(e) => {
e.preventDefault();
navigate(item.path);
setDropdownOpen(false);
}}
>
<span className="demo-nav-item-icon">{item.icon}</span>
{item.label}
</a>
))}
</div>
))}
</div>
)}
</div>
{labelForPath(route)}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{dropdownOpen && (
<div className="demo-nav-dropdown">
{NAV_GROUPS.map((group) => (
<div key={group.label} className="demo-nav-group">
<div className="demo-nav-group-label">{group.label}</div>
{group.items.map((item) => (
<a
key={item.path}
href={`#${item.path}`}
className={`demo-nav-item ${route === item.path ? 'demo-nav-item--active' : ''}`}
onClick={(e) => {
e.preventDefault();
navigate(item.path);
setDropdownOpen(false);
}}
>
<span className="demo-nav-item-icon">{item.icon}</span>
{item.label}
</a>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
</header>

<MdmaThemeProvider theme={resolvedTheme} style={{ display: 'contents' }}>
{route === '/' ? (
<HomeView />
) : route.startsWith('/docs') ? (
<DocsView />
) : route === '/validator' ? (
<ValidatorView />
) : route === '/custom' ? (
<CustomChatView />
) : route === '/author' ? (
<ChatView />
) : route === '/preview' ? (
<PreviewView />
) : (
<AgentChatView />
)}
</div>
</header>

{route === '/' ? (
<HomeView />
) : route.startsWith('/docs') ? (
<DocsView />
) : route === '/validator' ? (
<ValidatorView />
) : route === '/custom' ? (
<CustomChatView />
) : route === '/author' ? (
<ChatView />
) : route === '/preview' ? (
<PreviewView />
) : (
<AgentChatView />
)}
</div>
</MdmaThemeProvider>
</div>
</DemoThemeContext.Provider>
);
}
16 changes: 1 addition & 15 deletions demo/src/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,7 @@ export function ChatView({
Describe an interactive document and the AI will generate it, or try an example
flow:
</p>
<select
defaultValue=""
onChange={handleLoadFlow}
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #d1d5db',
background: '#fff',
color: '#374151',
fontSize: '14px',
cursor: 'pointer',
marginTop: '8px',
minWidth: '220px',
}}
>
<select className="chat-example-select" defaultValue="" onChange={handleLoadFlow}>
<option value="" disabled>
Load an example flow…
</option>
Expand Down
Loading