diff --git a/.changeset/theming-lib-and-docs.md b/.changeset/theming-lib-and-docs.md new file mode 100644 index 0000000..550c646 --- /dev/null +++ b/.changeset/theming-lib-and-docs.md @@ -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. diff --git a/.changeset/unify-rn-palette.md b/.changeset/unify-rn-palette.md new file mode 100644 index 0000000..689263b --- /dev/null +++ b/.changeset/unify-rn-palette.md @@ -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. diff --git a/README.md b/README.md index 7689479..7942061 100644 --- a/README.md +++ b/README.md @@ -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 + // built-in dark palette + // follows OS light/dark + // 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 | diff --git a/demo/src/App.tsx b/demo/src/App.tsx index bd5dcb3..6b93966 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -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'; @@ -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 = { light: '☀️', dark: '🌙', auto: '🖥️' }; + +function useThemeMode(): [ThemeMode, (m: ThemeMode) => void] { + const [mode, setMode] = useState(() => { + 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 ( +
+ {THEME_MODES.map((m) => ( + + ))} +
+ ); +} + // ── Nav config ─────────────────────────────────────────────────────────────── type Route = '/' | '/chat' | '/preview' | '/author' | '/custom' | '/validator' | '/docs'; @@ -96,6 +152,8 @@ export function App() { const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); const stars = useGitHubStars('MobileReality/mdma'); + const [themeMode, setThemeMode] = useThemeMode(); + const resolvedTheme = useResolvedTheme(themeMode); useEffect(() => { function handleClick(e: MouseEvent) { @@ -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 ( -
-
-
- - The open standard for AI-generated interactive UI -
-
- - MDMA - GenUI for apps - Turn AI chat responses into interactive forms and workflows | Product Hunt - - - - Star - {stars !== null && {stars.toLocaleString()}} - - {route !== '/' && ( -
- + The open standard for AI-generated interactive UI +
+
+ + + MDMA - GenUI for apps - Turn AI chat responses into interactive forms and workflows | Product Hunt + + + + Star + {stars !== null && {stars.toLocaleString()}} + + {route !== '/' && ( +
+ - {dropdownOpen && ( -
- {NAV_GROUPS.map((group) => ( - - ))} -
- )} -
+ {labelForPath(route)} + + + {dropdownOpen && ( +
+ {NAV_GROUPS.map((group) => ( + + ))} +
+ )} +
+ )} +
+
+ + + {route === '/' ? ( + + ) : route.startsWith('/docs') ? ( + + ) : route === '/validator' ? ( + + ) : route === '/custom' ? ( + + ) : route === '/author' ? ( + + ) : route === '/preview' ? ( + + ) : ( + )} -
- - - {route === '/' ? ( - - ) : route.startsWith('/docs') ? ( - - ) : route === '/validator' ? ( - - ) : route === '/custom' ? ( - - ) : route === '/author' ? ( - - ) : route === '/preview' ? ( - - ) : ( - - )} - + + + ); } diff --git a/demo/src/ChatView.tsx b/demo/src/ChatView.tsx index 50efc1b..8deacbe 100644 --- a/demo/src/ChatView.tsx +++ b/demo/src/ChatView.tsx @@ -128,21 +128,7 @@ export function ChatView({ Describe an interactive document and the AI will generate it, or try an example flow:

- diff --git a/demo/src/docs/DocsView.tsx b/demo/src/docs/DocsView.tsx index 9357f2e..3b6671a 100644 --- a/demo/src/docs/DocsView.tsx +++ b/demo/src/docs/DocsView.tsx @@ -12,6 +12,8 @@ import { PACKAGES, PackageDetail } from './sections/PackageDetail.js'; import { Packages } from './sections/Packages.js'; import { PromptMatrix } from './sections/PromptMatrix.js'; import { ReactNative, ReactNativeSnack } from './sections/ReactNative.js'; +import { ReactWeb } from './sections/ReactWeb.js'; +import { Theming, ThemingPreview, ThemingProvider } from './sections/Theming.js'; import { Usage, UsageHydrationPreview } from './sections/Usage.js'; import { Validator } from './sections/Validator.js'; @@ -26,14 +28,13 @@ interface Section { component?: React.ComponentType; } -const SECTIONS: Section[] = [ +const MAIN_SECTIONS: Section[] = [ { slug: 'introduction', label: 'Introduction', component: Introduction }, { slug: 'packages', label: 'Packages' }, { slug: 'installation', label: 'Installation', component: Installation }, { slug: 'usage', label: 'Usage', component: Usage }, - { slug: 'integrations', label: 'Integrations' }, { slug: 'components', label: 'Components' }, - { slug: 'react-native', label: 'React Native', component: ReactNative }, + { slug: 'theming', label: 'Theming', component: Theming }, { slug: 'validator', label: 'Validator', component: Validator }, { slug: 'mcp', label: 'MCP & Skills', component: Mcp }, { slug: 'cli', label: 'CLI', component: Cli }, @@ -45,6 +46,31 @@ const SECTIONS: Section[] = [ { slug: 'prompt-matrix', label: 'Prompt Matrix', component: PromptMatrix }, ]; +const INTEGRATION_SECTIONS: Section[] = INTEGRATIONS.map((i) => ({ + slug: `integrations/${i.slug}`, + label: i.label, +})); + +const RENDERER_SECTIONS: Section[] = [ + { slug: 'react', label: 'React', component: ReactWeb }, + { slug: 'react-native', label: 'React Native', component: ReactNative }, +]; + +const SECTIONS: Section[] = [...MAIN_SECTIONS, ...INTEGRATION_SECTIONS, ...RENDERER_SECTIONS]; + +/** Grouped page list for the mobile breadcrumb picker. Includes the Packages + * sub-pages (which expand under "Packages" in the desktop sidebar) so they're + * reachable on mobile too. */ +const BREADCRUMB_GROUPS = [ + { label: 'Documentation', items: MAIN_SECTIONS }, + { + label: 'Packages', + items: PACKAGES.map((p) => ({ slug: `packages/${p.slug}`, label: p.label })), + }, + { label: 'Integrations', items: INTEGRATION_SECTIONS }, + { label: 'Renderers', items: RENDERER_SECTIONS }, +]; + function getDocsSlug(): string { const hash = window.location.hash.slice(1); // e.g. /docs/packages/runtime const sub = hash.startsWith('/docs/') ? hash.slice('/docs/'.length) : ''; @@ -59,6 +85,8 @@ export function DocsView() { const [active, setActiveState] = useState(getDocsSlug); const [selectedComponent, setSelectedComponent] = useState('form'); const [usageExampleOpen, setUsageExampleOpen] = useState(false); + // Mobile hamburger: whether the nav drawer is open. + const [navOpen, setNavOpen] = useState(false); useEffect(() => { function sync() { @@ -71,12 +99,15 @@ export function DocsView() { function setActive(slug: string) { navigateDocs(slug); setActiveState(slug); + setNavOpen(false); // close the mobile drawer after navigating } const showComponentsPreview = active === 'components'; const showUsagePreview = active === 'usage' && usageExampleOpen; const showRnPreview = active === 'react-native'; - const showPreview = showComponentsPreview || showUsagePreview || showRnPreview; + const showThemingPreview = active === 'theming'; + const showPreview = + showComponentsPreview || showUsagePreview || showRnPreview || showThemingPreview; const previewEntry = COMPONENTS.find((c) => c.type === selectedComponent) ?? COMPONENTS[0]; const isPackagesActive = active === 'packages' || active.startsWith('packages/'); @@ -121,70 +152,110 @@ export function DocsView() { (s.slug === 'packages' && isPackagesActive) || (s.slug === 'integrations' && isIntegrationsActive); - return ( -
- - -
{renderContent()}
- - {showPreview && ( - + {s.slug === 'integrations' && isIntegrationsActive && ( +
+ {INTEGRATIONS.map((integration) => ( + + ))} +
)}
); + + // Mobile breadcrumb trail: which group the current page is in + its label. + const bcValue = SECTIONS.some((s) => s.slug === active) + ? active + : active.startsWith('packages/') + ? active + : 'introduction'; + const bcItem = BREADCRUMB_GROUPS.flatMap((g) => g.items).find((s) => s.slug === bcValue); + const bcGroup = + BREADCRUMB_GROUPS.find((g) => g.items.some((s) => s.slug === bcValue))?.label ?? + 'Documentation'; + + return ( + +
+ {/* Mobile-only: hamburger toggles the nav drawer; breadcrumb trail below. */} +
+ +
+ Docs + + {bcGroup} + + {bcItem?.label ?? 'Introduction'} +
+
+ + +
{renderContent()}
+ + {showPreview && ( + + )} +
+
+ ); } diff --git a/demo/src/docs/sections/Components.tsx b/demo/src/docs/sections/Components.tsx index a9df8a7..78001d5 100644 --- a/demo/src/docs/sections/Components.tsx +++ b/demo/src/docs/sections/Components.tsx @@ -4,7 +4,6 @@ import type { MdmaRoot } from '@mobile-reality/mdma-spec'; import type { DocumentStore } from '@mobile-reality/mdma-runtime'; import { parseMarkdown } from '../../chat/parse-markdown.js'; import { ChartRenderer } from '../../chart-components.js'; -import { Code } from '../Code.js'; const CUSTOMIZATIONS = { components: { chart: ChartRenderer }, @@ -226,8 +225,8 @@ export function Components({ selected, onSelect }: ComponentsProps) { <>

Components

- 9 built-in component types, rendered out of the box by{' '} - @mobile-reality/mdma-renderer-react. Click a row to preview. + 9 built-in component types defined by the MDMA spec — every renderer (React, React Native) + renders them out of the box. Click a row to preview.

@@ -256,32 +255,6 @@ export function Components({ selected, onSelect }: ComponentsProps) {
- -

Custom Chart Renderer

-

- The built-in chart renderer renders data as a plain table so the library stays lightweight. - To get actual charts, register a custom renderer: -

- {`import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; -import { MyRechartsRenderer } from './MyRechartsRenderer'; - -function App({ ast, store }) { - return ( - - ); -}`} -

- This pattern works for overriding any built-in component — pass a custom React component - under customizations.components.<type>. -

); } diff --git a/demo/src/docs/sections/PackageDetail.tsx b/demo/src/docs/sections/PackageDetail.tsx index 9c92267..1f72c26 100644 --- a/demo/src/docs/sections/PackageDetail.tsx +++ b/demo/src/docs/sections/PackageDetail.tsx @@ -121,16 +121,6 @@ const ast = await processor.run(tree) as MdmaRoot;`} purpose: "Bridges the runtime's action dispatcher and each component's specific state shape. Covers form, button, tasklist, table, callout, approval-gate, and webhook — the components that manage state. Rendering layers such as mdma-renderer-react use these handlers to wire UI events to the runtime without re-implementing interaction logic per renderer.", }, - { - slug: 'renderer-react', - label: 'mdma-renderer-react', - npm: '@mobile-reality/mdma-renderer-react', - dir: 'renderer-react', - tagline: 'React rendering layer for all 9 MDMA component types.', - purpose: - 'Ships accessible, styled React components for every MDMA type plus hooks for reading and dispatching runtime state. Supports a customizations.components prop so you can swap any built-in component for your own renderer — useful for replacing the default chart table with a real charting library. The companion styles.css provides default visual styling and is entirely optional.', - seeAlso: { label: 'Components', slug: 'components' }, - }, { slug: 'prompt-pack', label: 'mdma-prompt-pack', diff --git a/demo/src/docs/sections/Packages.tsx b/demo/src/docs/sections/Packages.tsx index b239cb1..1c2326b 100644 --- a/demo/src/docs/sections/Packages.tsx +++ b/demo/src/docs/sections/Packages.tsx @@ -41,10 +41,6 @@ export function Packages({ onNavigate }: PackagesProps) { navLink('attachables-core', 'mdma-attachables-core', onNavigate), 'Handlers for 7 of the 9 component types — the ones that manage state (form, button, tasklist, table, callout, approval-gate, webhook).', ], - [ - navLink('renderer-react', 'mdma-renderer-react', onNavigate), - 'React rendering layer with components for all 9 MDMA types and hooks for state access.', - ], [ navLink('prompt-pack', 'mdma-prompt-pack', onNavigate), 'System prompts that teach LLMs how to author valid MDMA documents. Ships model-specialised variants for OpenAI, Anthropic, Google, and xAI.', @@ -71,7 +67,7 @@ export function Packages({ onNavigate }: PackagesProps) { ├── mdma-validator Document validation └── mdma-runtime State / events / policy engine └── mdma-attachables-core Component handlers - └── mdma-renderer-react React components + └── mdma-renderer-* Renderers mdma-cli CLI prompt builder + validation mdma-mcp MCP server for AI assistants`} diff --git a/demo/src/docs/sections/ReactNativePreview.tsx b/demo/src/docs/sections/ReactNativePreview.tsx index a2b39c2..956023b 100644 --- a/demo/src/docs/sections/ReactNativePreview.tsx +++ b/demo/src/docs/sections/ReactNativePreview.tsx @@ -1,11 +1,17 @@ -import { MdmaDocument } from '@mobile-reality/mdma-renderer-react-native'; +import { + MdmaDocument, + lightTheme, + darkTheme, + type MdmaTheme, +} from '@mobile-reality/mdma-renderer-react-native'; import { type DocumentStore, createDocumentStore } from '@mobile-reality/mdma-runtime'; import type { MdmaRoot } from '@mobile-reality/mdma-spec'; // Renders live in the browser: `react-native` is aliased to `react-native-web` // (see vite.config.ts), so this is the exact renderer-react-native code running // as a web preview — the "emulator" shown in the docs. No code editor, no API // key; the four responses are pre-parsed and matched by keyword. -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useDemoThemeMode } from '../../theme-context.js'; import { ActivityIndicator, Pressable, @@ -35,6 +41,9 @@ function pick(text: string, n: number) { } export function ReactNativePreview() { + const themeMode = useDemoThemeMode(); + const t = themeMode === 'dark' ? darkTheme : lightTheme; + const styles = useMemo(() => makeStyles(t), [t]); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const countRef = useRef(0); @@ -112,13 +121,13 @@ export function ReactNativePreview() { ) : ( - + Generating… )} {m.done && m.ast && m.store ? ( - + ) : null} @@ -140,7 +149,7 @@ export function ReactNativePreview() { value={input} onChangeText={setInput} placeholder="Message…" - placeholderTextColor="#9ca3af" + placeholderTextColor={t.colors.textMuted} onSubmitEditing={() => send()} returnKeyType="send" /> @@ -156,111 +165,124 @@ export function ReactNativePreview() { ); } -const BLUE = '#2563eb'; -const BORDER = '#e5e7eb'; +/** + * Build the phone-mock chrome styles from the MDMA theme so the preview shell + * (bubbles, bars, chips) tracks the same light/dark palette as the rendered + * MDMA content inside it. `screen` is the area behind the bubbles — a touch + * darker/lighter than the card surfaces. + */ +function makeStyles(t: MdmaTheme) { + const { colors: c } = t; + const screen = themeIsDark(c) ? '#0e1119' : '#f3f4f6'; + return StyleSheet.create({ + page: { flex: 1, backgroundColor: screen }, + flex: { flex: 1 }, -const styles = StyleSheet.create({ - page: { flex: 1, backgroundColor: '#f3f4f6' }, - flex: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: c.background, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + avatar: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: c.primary, + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { color: c.onPrimary, fontWeight: '700', fontSize: 16 }, + title: { fontSize: 15, fontWeight: '700', color: c.text }, + subtitle: { fontSize: 12, color: c.textMuted, marginTop: 1 }, - header: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - paddingHorizontal: 14, - paddingVertical: 12, - backgroundColor: '#ffffff', - borderBottomWidth: 1, - borderBottomColor: BORDER, - }, - avatar: { - width: 34, - height: 34, - borderRadius: 17, - backgroundColor: BLUE, - alignItems: 'center', - justifyContent: 'center', - }, - avatarText: { color: '#ffffff', fontWeight: '700', fontSize: 16 }, - title: { fontSize: 15, fontWeight: '700', color: '#111827' }, - subtitle: { fontSize: 12, color: '#6b7280', marginTop: 1 }, + scroll: { padding: 12, gap: 12 }, + empty: { paddingVertical: 40, paddingHorizontal: 12, gap: 8, alignItems: 'center' }, + emptyTitle: { fontSize: 15, fontWeight: '700', color: c.text, textAlign: 'center' }, + emptyHint: { fontSize: 13, color: c.textMuted, textAlign: 'center', lineHeight: 19 }, - scroll: { padding: 12, gap: 12 }, - empty: { paddingVertical: 40, paddingHorizontal: 12, gap: 8, alignItems: 'center' }, - emptyTitle: { fontSize: 15, fontWeight: '700', color: '#111827', textAlign: 'center' }, - emptyHint: { fontSize: 13, color: '#6b7280', textAlign: 'center', lineHeight: 19 }, + userRow: { alignItems: 'flex-end' }, + user: { + maxWidth: '85%', + backgroundColor: c.primary, + borderRadius: 18, + borderBottomRightRadius: 5, + paddingVertical: 9, + paddingHorizontal: 13, + }, + userText: { color: c.onPrimary, fontSize: 15, lineHeight: 20 }, - userRow: { alignItems: 'flex-end' }, - user: { - maxWidth: '85%', - backgroundColor: BLUE, - borderRadius: 18, - borderBottomRightRadius: 5, - paddingVertical: 9, - paddingHorizontal: 13, - }, - userText: { color: '#ffffff', fontSize: 15, lineHeight: 20 }, + bot: { + alignSelf: 'stretch', + backgroundColor: c.background, + borderRadius: 18, + borderBottomLeftRadius: 5, + borderWidth: 1, + borderColor: c.border, + padding: 12, + }, + botText: { color: c.text, fontSize: 15, lineHeight: 22 }, + typing: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + typingText: { color: c.textMuted, fontSize: 14 }, + doc: { marginTop: 10 }, - bot: { - alignSelf: 'stretch', - backgroundColor: '#ffffff', - borderRadius: 18, - borderBottomLeftRadius: 5, - borderWidth: 1, - borderColor: BORDER, - padding: 12, - }, - botText: { color: '#1f2937', fontSize: 15, lineHeight: 22 }, - typing: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - typingText: { color: '#9ca3af', fontSize: 14 }, - doc: { marginTop: 10 }, + chipsWrap: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + paddingHorizontal: 10, + paddingVertical: 10, + backgroundColor: c.background, + borderTopWidth: 1, + borderTopColor: c.border, + }, + chip: { + paddingVertical: 7, + paddingHorizontal: 13, + borderRadius: 999, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + chipText: { color: c.text, fontSize: 13, fontWeight: '500' }, - chipsWrap: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - paddingHorizontal: 10, - paddingVertical: 10, - backgroundColor: '#ffffff', - borderTopWidth: 1, - borderTopColor: BORDER, - }, - chip: { - paddingVertical: 7, - paddingHorizontal: 13, - borderRadius: 999, - borderWidth: 1, - borderColor: BORDER, - backgroundColor: '#f9fafb', - }, - chipText: { color: '#374151', fontSize: 13, fontWeight: '500' }, + bar: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 10, + paddingVertical: 10, + backgroundColor: c.background, + borderTopWidth: 1, + borderTopColor: c.border, + }, + input: { + flex: 1, + borderWidth: 1, + borderColor: c.border, + borderRadius: 22, + paddingHorizontal: 15, + paddingVertical: 10, + fontSize: 15, + color: c.text, + backgroundColor: c.surface, + }, + send: { + backgroundColor: c.primary, + borderRadius: 22, + paddingHorizontal: 18, + paddingVertical: 10, + }, + sendDisabled: { opacity: 0.45 }, + sendText: { color: c.onPrimary, fontWeight: '600', fontSize: 15 }, + }); +} - bar: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - paddingHorizontal: 10, - paddingVertical: 10, - backgroundColor: '#ffffff', - borderTopWidth: 1, - borderTopColor: BORDER, - }, - input: { - flex: 1, - borderWidth: 1, - borderColor: BORDER, - borderRadius: 22, - paddingHorizontal: 15, - paddingVertical: 10, - fontSize: 15, - backgroundColor: '#f9fafb', - }, - send: { - backgroundColor: BLUE, - borderRadius: 22, - paddingHorizontal: 18, - paddingVertical: 10, - }, - sendDisabled: { opacity: 0.45 }, - sendText: { color: '#ffffff', fontWeight: '600', fontSize: 15 }, -}); +/** The dark palette's background is a dark navy; the light one is white. */ +function themeIsDark(colors: MdmaTheme['colors']): boolean { + return colors.background.toLowerCase() !== '#ffffff'; +} diff --git a/demo/src/docs/sections/ReactWeb.tsx b/demo/src/docs/sections/ReactWeb.tsx new file mode 100644 index 0000000..38d1de6 --- /dev/null +++ b/demo/src/docs/sections/ReactWeb.tsx @@ -0,0 +1,72 @@ +import { Code } from '../Code.js'; + +export function ReactWeb() { + return ( + <> +

React

+

+ @mobile-reality/mdma-renderer-react renders MDMA documents as web UI — every + live example on this site is produced by it. It consumes the headless spec +{' '} + runtime stack unchanged and turns a parsed document into interactive React + components with full state, bindings, actions, policy, audit, and PII redaction. +

+ +

Install

+ {`npm install @mobile-reality/mdma-renderer-react \\ + @mobile-reality/mdma-spec @mobile-reality/mdma-runtime react`} + +

Usage

+

+ Parse a document to an AST + store (with @mobile-reality/mdma-parser), import + the stylesheet once, then hand both to MdmaDocument. +

+ {`import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import { remarkMdma } from '@mobile-reality/mdma-parser'; +import { createDocumentStore } from '@mobile-reality/mdma-runtime'; +import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; +import '@mobile-reality/mdma-renderer-react/styles.css'; + +const processor = unified().use(remarkParse).use(remarkMdma, {}); + +export function Document({ markdown }: { markdown: string }) { + const [doc, setDoc] = useState(null); + useEffect(() => { + (async () => { + const ast = await processor.run(processor.parse(markdown), markdown); + setDoc({ ast, store: createDocumentStore(ast) }); + })(); + }, [markdown]); + + if (!doc) return null; + return ; +}`} + +

Styling & theming

+

+ The imported styles.css is driven by --mdma-* CSS variables. Pass + a theme prop ("light" | "dark" | "auto", or a full token object) + to MdmaDocument — the Theming page has a live editor. +

+ +

Customizing components

+

+ Override any built-in component through customizations.components.<type>. + For example, the built-in chart renderer draws data as a plain table so the library stays + lightweight — swap in a real chart with a custom renderer: +

+ {`import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; +import { MyRechartsRenderer } from './MyRechartsRenderer'; + +function App({ ast, store }) { + return ( + + ); +}`} + + ); +} diff --git a/demo/src/docs/sections/Theming.tsx b/demo/src/docs/sections/Theming.tsx new file mode 100644 index 0000000..cc85db2 --- /dev/null +++ b/demo/src/docs/sections/Theming.tsx @@ -0,0 +1,313 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { + MdmaDocument, + darkTheme, + lightTheme, + type MdmaTheme, +} from '@mobile-reality/mdma-renderer-react'; +import type { MdmaRoot } from '@mobile-reality/mdma-spec'; +import type { DocumentStore } from '@mobile-reality/mdma-runtime'; +import { parseMarkdown } from '../../chat/parse-markdown.js'; +import { useDemoThemeMode } from '../../theme-context.js'; +import { Code } from '../Code.js'; +import { Table } from '../Table.js'; + +/** The five styleable tokens exposed as color pickers in the playground. */ +const TOKENS = [ + { key: 'primary', label: 'Accent' }, + { key: 'background', label: 'Background' }, + { key: 'text', label: 'Text' }, + { key: 'textMuted', label: 'Muted' }, + { key: 'border', label: 'Border' }, +] as const; + +type TokenKey = (typeof TOKENS)[number]['key']; +type Editable = Record; + +function pickEditable(theme: MdmaTheme): Editable { + return { + primary: theme.colors.primary, + background: theme.colors.background, + text: theme.colors.text, + textMuted: theme.colors.textMuted, + border: theme.colors.border, + }; +} + +/** A small doc that exercises all five tokens: card backgrounds + borders + * (background/border), body + header text (text/muted), and the accent + * (primary) on the button, checkbox, and input focus. */ +const PLAYGROUND_DOC = `\`\`\`mdma +type: form +id: play-form +label: "Create account" +onSubmit: play-submit +fields: + - name: full-name + type: text + label: "Full name" + - name: notify + type: checkbox + label: "Email me product updates" +\`\`\` + +\`\`\`mdma +type: button +id: play-button +text: "Get started" +variant: primary +\`\`\` + +\`\`\`mdma +type: table +id: play-table +columns: + - key: plan + label: "Plan" + type: text + - key: seats + label: "Seats" + type: number +data: + - plan: "Starter" + seats: 3 + - plan: "Team" + seats: 12 +\`\`\``; + +interface ThemingCtxValue { + colors: Editable; + setColor: (key: TokenKey, value: string) => void; + reset: () => void; + theme: MdmaTheme; + parsed: { ast: MdmaRoot; store: DocumentStore } | null; +} + +const ThemingContext = createContext(null); + +function useThemingCtx(): ThemingCtxValue { + const ctx = useContext(ThemingContext); + if (!ctx) throw new Error('Theming components must be used within '); + return ctx; +} + +/** Holds the live-editor state so the controls (in the docs content) and the + * preview (in the right-hand panel) share it. */ +export function ThemingProvider({ children }: { children: ReactNode }) { + const mode = useDemoThemeMode(); + const base = mode === 'dark' ? darkTheme : lightTheme; + const [colors, setColors] = useState(() => pickEditable(base)); + const [parsed, setParsed] = useState<{ ast: MdmaRoot; store: DocumentStore } | null>(null); + const cancelRef = useRef(false); + + useEffect(() => { + cancelRef.current = false; + parseMarkdown(PLAYGROUND_DOC).then((result) => { + if (!cancelRef.current) setParsed(result); + }); + return () => { + cancelRef.current = true; + }; + }, []); + + // Reset the pickers to the built-in palette when the light/dark toggle flips. + useEffect(() => { + setColors(pickEditable(mode === 'dark' ? darkTheme : lightTheme)); + }, [mode]); + + const theme: MdmaTheme = useMemo( + () => ({ ...base, colors: { ...base.colors, ...colors } }), + [base, colors], + ); + + const value = useMemo( + () => ({ + colors, + setColor: (key, v) => setColors((c) => ({ ...c, [key]: v })), + reset: () => setColors(pickEditable(base)), + theme, + parsed, + }), + [colors, base, theme, parsed], + ); + + return {children}; +} + +/** The color pickers — rendered inline in the docs content. */ +function ThemingControls() { + const { colors, setColor, reset } = useThemingCtx(); + return ( +
+ {TOKENS.map((t) => ( + + ))} + +
+ ); +} + +/** The live render — shown in the docs right-hand preview panel. */ +export function ThemingPreview() { + const { theme, parsed } = useThemingCtx(); + return ( + <> +
+ Live preview + theme +
+
+
+ {parsed ? ( + + ) : ( + Loading… + )} +
+
+ + ); +} + +export function Theming() { + return ( + <> +

Theming

+

+ Both renderers ship with a polished default look and a first-class theming layer on top of + it. Theming is opt-in: render a document with no theme and you + get the built-in light palette, unchanged. When you want more, switch to a dark palette, + follow the operating-system preference, or hand over a fully custom set of design tokens. +

+

Try it

+

+ Pick colors for five styleable tokens — accent, background + , text, muted, and border. Each one is + written onto a custom MdmaTheme and the components below re-render instantly. + Hover a button or focus the input — the hover and focus states derive from your accent + automatically. Toggle the top-bar theme to reset the pickers to the light or dark palette. +

+ + +

The three modes

+

+ Everything is driven by the theme prop on MdmaDocument: +

+ Default, 'Default light palette — fully backward compatible.'], + ["light", 'The built-in light palette.'], + ["dark", 'The built-in dark palette.'], + ["auto", 'Follows the OS preference (prefers-color-scheme).'], + [MdmaTheme, 'Your custom tokens.'], + ]} + /> + {` // not themed (default light) + // built-in dark + // follow the OS + // custom`} + +

The MdmaTheme token shape

+

A theme is a plain object. This is the whole contract:

+ {`interface MdmaTheme { + colors: { + background: string; + surface: string; + text: string; + textMuted: string; + border: string; + primary: string; + onPrimary: string; + secondary: string; + onSecondary: string; + danger: string; + onDanger: string; + // Callout accents + soft backgrounds + info: string; infoBg: string; + warning: string; warningBg: string; + error: string; errorBg: string; + success: string; successBg: string; + }; + spacing: { xs: number; sm: number; md: number; lg: number }; + radius: { sm: number; md: number }; + fontSize: { small: number; body: number; label: number; title: number }; +}`} +

+ The lightTheme and darkTheme presets are exported from both + renderer packages, so the easiest way to build a custom theme is to spread a preset and + override just what you need (exactly what the playground above does): +

+ {`import { lightTheme, type MdmaTheme } from '@mobile-reality/mdma-renderer-react'; + +const brandTheme: MdmaTheme = { + ...lightTheme, + colors: { ...lightTheme.colors, primary: '#e91e63', onPrimary: '#ffffff' }, + radius: { sm: 10, md: 16 }, +};`} + +

Web renderer

+

+ Import the stylesheet once, then pass a theme: +

+ {`import { MdmaDocument } from '@mobile-reality/mdma-renderer-react'; +import '@mobile-reality/mdma-renderer-react/styles.css'; + +;`} +

+ Under the hood the stylesheet expresses every color, radius, and size as a{' '} + --mdma-* CSS variable scoped to .mdma-document. Built-in palettes + apply via a data-theme attribute; a custom MdmaTheme is written as + inline CSS variables. Because it's all CSS variables, you can theme{' '} + without the prop at all — just set the variables in your own CSS: +

+ {`.mdma-document { + --mdma-color-primary: #e91e63; + --mdma-radius-md: 16px; +}`} +

+ A MdmaDocument with no theme of its own inherits the theme from an + ancestor MdmaThemeProvider, so one provider can theme a whole app. Rendering a + lone <MdmaBlock> outside MdmaDocument? Wrap it so the + variables cascade: +

+ {`import { MdmaThemeProvider, MdmaBlock } from '@mobile-reality/mdma-renderer-react'; + + + +;`} + +

React Native renderer

+

+ Same prop, no stylesheet — each renderer builds its StyleSheet from the + resolved tokens, which any custom component can read with useMdmaTheme(): +

+ {`import { MdmaDocument, useMdmaTheme } from '@mobile-reality/mdma-renderer-react-native'; + +; + +function Badge() { + const theme = useMdmaTheme(); + return ; +}`} + + ); +} diff --git a/demo/src/styles.css b/demo/src/styles.css index 0fba458..bb3e21f 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -16,6 +16,40 @@ body { background: #f5f6fa; } +/* ===== Scrollbars (thin, subtle, theme-aware) ===== */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(120, 120, 140, 0.4) transparent; +} +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background-color: rgba(120, 120, 140, 0.35); + border-radius: 8px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background-color: rgba(120, 120, 140, 0.6); +} +::-webkit-scrollbar-corner { + background: transparent; +} +[data-theme="dark"] * { + scrollbar-color: rgba(255, 255, 255, 0.22) transparent; +} +[data-theme="dark"] ::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.18); +} +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.32); +} + /* ===== Home View ===== */ .home-view { flex: 1; @@ -207,6 +241,43 @@ body { gap: 12px; } +/* Segmented light/dark/auto control — themes the rendered MDMA examples. */ +.demo-theme-toggle { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + background: #f5f3fb; + border: 1px solid #d1c9f0; + border-radius: 8px; + flex-shrink: 0; +} + +.demo-theme-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 26px; + padding: 0; + font-size: 14px; + line-height: 1; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} + +.demo-theme-btn:hover { + background: #ede9fe; +} + +.demo-theme-btn--active { + background: #6c5ce7; + box-shadow: 0 1px 2px rgba(108, 92, 231, 0.4); +} + .demo-ph-badge { display: flex; align-items: center; @@ -501,6 +572,275 @@ body { word-break: break-all; } +/* ===== MDMA Theme Tokens ===== + * + * Mirrors packages/renderer-react/styles.css so the demo's MDMA styling is + * driven by the same `--mdma-*` custom properties. The default values encode + * the built-in *light* theme (identical to the demo's previous hardcoded look); + * the `[data-theme='dark']` block and the `prefers-color-scheme` block (for + * `[data-theme='auto']`) swap the palette. `MdmaThemeProvider` sets the + * `data-theme` attribute on each `.mdma-document`. + * + * The trailing "Demo-only component tokens" section covers components the demo + * styles but the library does not (progress / rating / metric widgets, file & + * sensitive-field inputs, sensitive table cells). + */ +.mdma-document, +.mdma-theme-root { + /* Public palette (mirrors the MdmaTheme `colors` shape) */ + --mdma-color-background: #ffffff; + --mdma-color-surface: #f8f9fa; + --mdma-color-text: #333333; + --mdma-color-text-muted: #666666; + --mdma-color-border: #e0e0e0; + --mdma-color-primary: #6c5ce7; + --mdma-color-on-primary: #ffffff; + --mdma-color-secondary: #dfe6e9; + --mdma-color-on-secondary: #2d3436; + --mdma-color-danger: #e74c3c; + --mdma-color-on-danger: #ffffff; + --mdma-color-info: #3498db; + --mdma-color-info-bg: #ebf5fb; + --mdma-color-warning: #f39c12; + --mdma-color-warning-bg: #fef9e7; + --mdma-color-error: #e74c3c; + --mdma-color-error-bg: #fdedec; + --mdma-color-success: #27ae60; + --mdma-color-success-bg: #eafaf1; + + /* Internal derivations (follow the palette; not on the public TS type) */ + --mdma-color-heading: #1a1a2e; + --mdma-color-text-subtle: #555555; + --mdma-color-border-subtle: #f0f0f0; + --mdma-color-primary-hover: color-mix(in srgb, var(--mdma-color-primary) 85%, #000000); + --mdma-color-secondary-hover: color-mix(in srgb, var(--mdma-color-secondary) 88%, #000000); + --mdma-color-danger-hover: color-mix(in srgb, var(--mdma-color-danger) 84%, #000000); + --mdma-color-input-bg: #fafafa; + --mdma-color-focus-ring: color-mix(in srgb, var(--mdma-color-primary) 22%, transparent); + --mdma-color-info-text: #1a5276; + --mdma-color-warning-text: #7d6608; + --mdma-color-error-text: #78281f; + --mdma-color-success-text: #1e8449; + --mdma-color-code-bg: #1e1e2e; + --mdma-color-code-text: #e0e0e0; + --mdma-color-inline-code-bg: #f0f0f5; + --mdma-color-inline-code-text: #c0392b; + --mdma-color-loading-bg: #ffffff; + --mdma-color-loading-border: #c4bfff; + --mdma-color-loading-accent: #6c63ff; + --mdma-color-loading-text: #8b82d4; + --mdma-color-table-header-bg: #f8f9fa; + --mdma-color-row-hover: #f8f9fa; + --mdma-color-blockquote-bg: #f8f8ff; + --mdma-color-chart-bg: rgba(255, 255, 255, 0.02); + --mdma-color-chart-border: rgba(108, 92, 231, 0.15); + --mdma-color-chart-label: #a29bfe; + --mdma-color-chart-axis: #636e72; + --mdma-color-chart-legend: #b2bec3; + --mdma-color-chart-tooltip-bg: #1a1a2e; + --mdma-color-unknown-bg: #fff3cd; + --mdma-color-unknown-border: #ffc107; + --mdma-color-unknown-text: #856404; + + /* Radius scales (mirror the MdmaTheme numeric tokens) */ + --mdma-radius-sm: 6px; + --mdma-radius-md: 10px; + + /* Demo-only component tokens (progress / rating / metric widgets) */ + --mdma-color-widget-strong: #dfe6e9; + --mdma-color-widget-muted: #b2bec3; + --mdma-color-widget-faint: #636e72; + --mdma-color-widget-track: rgba(255, 255, 255, 0.08); + --mdma-color-widget-surface: rgba(255, 255, 255, 0.04); + --mdma-color-widget-border: rgba(255, 255, 255, 0.08); + --mdma-color-widget-positive: #00b894; + --mdma-color-widget-star: #fdcb6e; + + /* Demo-only component tokens (sensitive fields, file inputs, cells) */ + --mdma-color-sensitive-border: #f59e0b; + --mdma-color-sensitive-bg: #fffbeb; + --mdma-color-sensitive-border-focus: #d97706; + --mdma-color-sensitive-focus-ring: rgba(245, 158, 11, 0.15); + --mdma-color-file-text: #374151; + --mdma-color-file-bg: #f3f4f6; + --mdma-color-file-size: #9ca3af; + --mdma-color-file-btn-bg: rgba(108, 92, 231, 0.08); + --mdma-color-file-btn-border: rgba(108, 92, 231, 0.3); + --mdma-color-file-btn-bg-hover: rgba(108, 92, 231, 0.14); + --mdma-color-file-btn-border-hover: rgba(108, 92, 231, 0.5); + --mdma-color-sensitive-cell-text: #000000; + --mdma-color-sensitive-cell-bg: #fef3c7; + --mdma-color-sensitive-cell-bg-hover: #fde68a; +} + +/* ===== Built-in dark palette ===== */ +.mdma-document[data-theme="dark"], +.mdma-theme-root[data-theme="dark"] { + --mdma-color-background: #151a26; + --mdma-color-surface: #1c2333; + --mdma-color-text: #e5e7eb; + --mdma-color-text-muted: #9ca3af; + --mdma-color-border: #2a3140; + --mdma-color-primary: #8b7ff0; + --mdma-color-on-primary: #ffffff; + --mdma-color-secondary: #2a3140; + --mdma-color-on-secondary: #e5e7eb; + --mdma-color-danger: #ef4444; + --mdma-color-on-danger: #ffffff; + --mdma-color-info: #5dade2; + --mdma-color-info-bg: #16283a; + --mdma-color-warning: #f5b041; + --mdma-color-warning-bg: #2a2412; + --mdma-color-error: #ef4444; + --mdma-color-error-bg: #2a1717; + --mdma-color-success: #2ecc71; + --mdma-color-success-bg: #12251a; + + --mdma-color-heading: #f3f4f6; + --mdma-color-text-subtle: #cbd0d8; + --mdma-color-border-subtle: #232a38; + --mdma-color-primary-hover: color-mix(in srgb, var(--mdma-color-primary) 80%, #ffffff); + --mdma-color-secondary-hover: color-mix(in srgb, var(--mdma-color-secondary) 78%, #ffffff); + --mdma-color-danger-hover: color-mix(in srgb, var(--mdma-color-danger) 80%, #ffffff); + --mdma-color-input-bg: #10141d; + --mdma-color-focus-ring: color-mix(in srgb, var(--mdma-color-primary) 35%, transparent); + --mdma-color-info-text: #aed6f1; + --mdma-color-warning-text: #f8c471; + --mdma-color-error-text: #f5b7b1; + --mdma-color-success-text: #a9dfbf; + --mdma-color-code-bg: #0d1017; + --mdma-color-code-text: #e0e0e0; + --mdma-color-inline-code-bg: #232a38; + --mdma-color-inline-code-text: #f19999; + --mdma-color-loading-bg: #1c2333; + --mdma-color-loading-border: #3a3466; + --mdma-color-loading-accent: #8b7ff0; + --mdma-color-loading-text: #a29bfe; + --mdma-color-table-header-bg: #1c2333; + --mdma-color-row-hover: #1c2333; + --mdma-color-blockquote-bg: #1c2333; + --mdma-color-chart-bg: rgba(255, 255, 255, 0.02); + --mdma-color-chart-border: rgba(162, 155, 254, 0.2); + --mdma-color-chart-label: #a29bfe; + --mdma-color-chart-axis: #9ca3af; + --mdma-color-chart-legend: #b2bec3; + --mdma-color-chart-tooltip-bg: #0d1017; + --mdma-color-unknown-bg: #2a2412; + --mdma-color-unknown-border: #b7950b; + --mdma-color-unknown-text: #f8c471; + + /* Demo-only component tokens (progress / rating / metric widgets) */ + --mdma-color-widget-strong: #e5e7eb; + --mdma-color-widget-muted: #9ca3af; + --mdma-color-widget-faint: #6b7280; + --mdma-color-widget-track: rgba(255, 255, 255, 0.08); + --mdma-color-widget-surface: rgba(255, 255, 255, 0.04); + --mdma-color-widget-border: rgba(255, 255, 255, 0.12); + --mdma-color-widget-positive: #00d6a4; + --mdma-color-widget-star: #ffd97d; + + /* Demo-only component tokens (sensitive fields, file inputs, cells) */ + --mdma-color-sensitive-border: #f5b041; + --mdma-color-sensitive-bg: #2a2412; + --mdma-color-sensitive-border-focus: #fbbf24; + --mdma-color-sensitive-focus-ring: rgba(245, 176, 65, 0.3); + --mdma-color-file-text: #cbd5e1; + --mdma-color-file-bg: #1c2333; + --mdma-color-file-size: #9ca3af; + --mdma-color-file-btn-bg: rgba(139, 127, 240, 0.12); + --mdma-color-file-btn-border: rgba(139, 127, 240, 0.35); + --mdma-color-file-btn-bg-hover: rgba(139, 127, 240, 0.2); + --mdma-color-file-btn-border-hover: rgba(139, 127, 240, 0.5); + --mdma-color-sensitive-cell-text: #f8c471; + --mdma-color-sensitive-cell-bg: #3a2f12; + --mdma-color-sensitive-cell-bg-hover: #4a3d18; +} + +/* theme="auto" — same dark palette, gated on the OS preference. */ +@media (prefers-color-scheme: dark) { + .mdma-document[data-theme="auto"], + .mdma-theme-root[data-theme="auto"] { + --mdma-color-background: #151a26; + --mdma-color-surface: #1c2333; + --mdma-color-text: #e5e7eb; + --mdma-color-text-muted: #9ca3af; + --mdma-color-border: #2a3140; + --mdma-color-primary: #8b7ff0; + --mdma-color-on-primary: #ffffff; + --mdma-color-secondary: #2a3140; + --mdma-color-on-secondary: #e5e7eb; + --mdma-color-danger: #ef4444; + --mdma-color-on-danger: #ffffff; + --mdma-color-info: #5dade2; + --mdma-color-info-bg: #16283a; + --mdma-color-warning: #f5b041; + --mdma-color-warning-bg: #2a2412; + --mdma-color-error: #ef4444; + --mdma-color-error-bg: #2a1717; + --mdma-color-success: #2ecc71; + --mdma-color-success-bg: #12251a; + + --mdma-color-heading: #f3f4f6; + --mdma-color-text-subtle: #cbd0d8; + --mdma-color-border-subtle: #232a38; + --mdma-color-primary-hover: color-mix(in srgb, var(--mdma-color-primary) 80%, #ffffff); + --mdma-color-secondary-hover: color-mix(in srgb, var(--mdma-color-secondary) 78%, #ffffff); + --mdma-color-danger-hover: color-mix(in srgb, var(--mdma-color-danger) 80%, #ffffff); + --mdma-color-input-bg: #10141d; + --mdma-color-focus-ring: color-mix(in srgb, var(--mdma-color-primary) 35%, transparent); + --mdma-color-info-text: #aed6f1; + --mdma-color-warning-text: #f8c471; + --mdma-color-error-text: #f5b7b1; + --mdma-color-success-text: #a9dfbf; + --mdma-color-code-bg: #0d1017; + --mdma-color-code-text: #e0e0e0; + --mdma-color-inline-code-bg: #232a38; + --mdma-color-inline-code-text: #f19999; + --mdma-color-loading-bg: #1c2333; + --mdma-color-loading-border: #3a3466; + --mdma-color-loading-accent: #8b7ff0; + --mdma-color-loading-text: #a29bfe; + --mdma-color-table-header-bg: #1c2333; + --mdma-color-row-hover: #1c2333; + --mdma-color-blockquote-bg: #1c2333; + --mdma-color-chart-bg: rgba(255, 255, 255, 0.02); + --mdma-color-chart-border: rgba(162, 155, 254, 0.2); + --mdma-color-chart-label: #a29bfe; + --mdma-color-chart-axis: #9ca3af; + --mdma-color-chart-legend: #b2bec3; + --mdma-color-chart-tooltip-bg: #0d1017; + --mdma-color-unknown-bg: #2a2412; + --mdma-color-unknown-border: #b7950b; + --mdma-color-unknown-text: #f8c471; + + /* Demo-only component tokens (progress / rating / metric widgets) */ + --mdma-color-widget-strong: #e5e7eb; + --mdma-color-widget-muted: #9ca3af; + --mdma-color-widget-faint: #6b7280; + --mdma-color-widget-track: rgba(255, 255, 255, 0.08); + --mdma-color-widget-surface: rgba(255, 255, 255, 0.04); + --mdma-color-widget-border: rgba(255, 255, 255, 0.12); + --mdma-color-widget-positive: #00d6a4; + --mdma-color-widget-star: #ffd97d; + + /* Demo-only component tokens (sensitive fields, file inputs, cells) */ + --mdma-color-sensitive-border: #f5b041; + --mdma-color-sensitive-bg: #2a2412; + --mdma-color-sensitive-border-focus: #fbbf24; + --mdma-color-sensitive-focus-ring: rgba(245, 176, 65, 0.3); + --mdma-color-file-text: #cbd5e1; + --mdma-color-file-bg: #1c2333; + --mdma-color-file-size: #9ca3af; + --mdma-color-file-btn-bg: rgba(139, 127, 240, 0.12); + --mdma-color-file-btn-border: rgba(139, 127, 240, 0.35); + --mdma-color-file-btn-bg-hover: rgba(139, 127, 240, 0.2); + --mdma-color-file-btn-border-hover: rgba(139, 127, 240, 0.5); + --mdma-color-sensitive-cell-text: #f8c471; + --mdma-color-sensitive-cell-bg: #3a2f12; + --mdma-color-sensitive-cell-bg-hover: #4a3d18; + } +} + /* ===== MDMA Document ===== */ .mdma-document { max-width: 720px; @@ -538,9 +878,9 @@ body { .mdma-block-loading { position: relative; overflow: hidden; - background: #fff; - border: 1px dashed #c4bfff; - border-radius: 10px; + background: var(--mdma-color-loading-bg); + border: 1px dashed var(--mdma-color-loading-border); + border-radius: var(--mdma-radius-md); padding: 28px 24px; margin-bottom: 20px; min-height: 80px; @@ -578,7 +918,7 @@ body { display: flex; align-items: center; gap: 10px; - color: #8b82d4; + color: var(--mdma-color-loading-text); font-size: 13px; } @@ -586,7 +926,7 @@ body { width: 18px; height: 18px; border: 2px solid rgba(108, 99, 255, 0.2); - border-top-color: #6c63ff; + border-top-color: var(--mdma-color-loading-accent); border-radius: 50%; animation: mdma-spin 0.8s linear infinite; } @@ -639,7 +979,7 @@ body { margin: 16px 0 8px; font-weight: 600; line-height: 1.3; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-markdown-content h1 { @@ -681,9 +1021,9 @@ body { .mdma-markdown-content blockquote { margin: 0 0 10px; padding: 8px 16px; - border-left: 3px solid #6c63ff; - background: #f8f8ff; - color: #444; + border-left: 3px solid var(--mdma-color-loading-accent); + background: var(--mdma-color-blockquote-bg); + color: var(--mdma-color-text-subtle); } .mdma-markdown-content blockquote p:last-child { @@ -693,9 +1033,9 @@ body { .mdma-markdown-content pre.mdast-code-block { margin: 0 0 10px; padding: 12px 16px; - background: #1e1e2e; - color: #e0e0e0; - border-radius: 6px; + background: var(--mdma-color-code-bg); + color: var(--mdma-color-code-text); + border-radius: var(--mdma-radius-sm); overflow-x: auto; font-size: 13px; line-height: 1.5; @@ -709,21 +1049,21 @@ body { } .mdma-markdown-content code.mdast-inline-code { - background: #f0f0f5; + background: var(--mdma-color-inline-code-bg); padding: 2px 6px; border-radius: 3px; font-size: 0.9em; - color: #c0392b; + color: var(--mdma-color-inline-code-text); } .mdma-markdown-content hr { margin: 16px 0; border: none; - border-top: 1px solid #ddd; + border-top: 1px solid var(--mdma-color-border); } .mdma-markdown-content a { - color: #6c63ff; + color: var(--mdma-color-loading-accent); text-decoration: none; } @@ -734,7 +1074,7 @@ body { .mdma-markdown-content img { max-width: 100%; height: auto; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); } .mdma-markdown-content table.mdast-table { @@ -747,17 +1087,17 @@ body { .mdma-markdown-content table.mdast-table th, .mdma-markdown-content table.mdast-table td { padding: 8px 12px; - border: 1px solid #e0e0e0; + border: 1px solid var(--mdma-color-border); text-align: left; } .mdma-markdown-content table.mdast-table th { - background: #f5f6fa; + background: var(--mdma-color-table-header-bg); font-weight: 600; } .mdma-markdown-content table.mdast-table tbody tr:hover { - background: #f9f9fc; + background: var(--mdma-color-row-hover); } .mdma-markdown-content strong { @@ -770,14 +1110,14 @@ body { .mdma-markdown-content del { text-decoration: line-through; - color: #888; + color: var(--mdma-color-text-muted); } /* ===== Form ===== */ .mdma-form { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; } @@ -786,7 +1126,7 @@ body { font-size: 18px; font-weight: 600; margin-bottom: 16px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-form-field { @@ -797,7 +1137,7 @@ body { display: block; font-size: 13px; font-weight: 600; - color: #444; + color: var(--mdma-color-text-subtle); margin-bottom: 4px; } @@ -812,10 +1152,10 @@ body { width: 100%; padding: 8px 12px; font-size: 14px; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #fafafa; - color: #1a1a2e; + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-sm); + background: var(--mdma-color-input-bg); + color: var(--mdma-color-heading); transition: border-color 0.15s, box-shadow 0.15s; outline: none; } @@ -823,8 +1163,8 @@ body { .mdma-form-field input:focus, .mdma-form-field select:focus, .mdma-form-field textarea:focus { - border-color: #6c5ce7; - box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.15); + border-color: var(--mdma-color-primary); + box-shadow: 0 0 0 3px var(--mdma-color-focus-ring); } /* Sensitive field styles */ @@ -834,14 +1174,14 @@ body { .mdma-form-field--sensitive input, .mdma-form-field--sensitive textarea { - border-color: #f59e0b; - background: #fffbeb; + border-color: var(--mdma-color-sensitive-border); + background: var(--mdma-color-sensitive-bg); } .mdma-form-field--sensitive input:focus, .mdma-form-field--sensitive textarea:focus { - border-color: #d97706; - box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15); + border-color: var(--mdma-color-sensitive-border-focus); + box-shadow: 0 0 0 3px var(--mdma-color-sensitive-focus-ring); } .mdma-sensitive-badge { @@ -873,7 +1213,7 @@ body { font-size: 11px; font-weight: 600; letter-spacing: 0.04em; - color: #6c5ce7; + color: var(--mdma-color-primary); padding: 2px 4px; opacity: 0.7; transition: opacity 0.15s; @@ -893,25 +1233,25 @@ body { .mdma-input--file input[type="file"] { font-size: 13px; - color: #374151; + color: var(--mdma-color-file-text); } .mdma-input--file input[type="file"]::file-selector-button { padding: 7px 14px; font-size: 13px; font-weight: 500; - color: #6c5ce7; - background: rgba(108, 92, 231, 0.08); - border: 1px solid rgba(108, 92, 231, 0.3); - border-radius: 6px; + color: var(--mdma-color-primary); + background: var(--mdma-color-file-btn-bg); + border: 1px solid var(--mdma-color-file-btn-border); + border-radius: var(--mdma-radius-sm); cursor: pointer; margin-right: 10px; transition: background 0.15s, border-color 0.15s; } .mdma-input--file input[type="file"]:hover::file-selector-button { - background: rgba(108, 92, 231, 0.14); - border-color: rgba(108, 92, 231, 0.5); + background: var(--mdma-color-file-btn-bg-hover); + border-color: var(--mdma-color-file-btn-border-hover); } .mdma-file-list { @@ -928,22 +1268,22 @@ body { align-items: center; gap: 6px; font-size: 13px; - color: #374151; - background: #f3f4f6; + color: var(--mdma-color-file-text); + background: var(--mdma-color-file-bg); border-radius: 5px; padding: 4px 10px; } .mdma-file-size { font-size: 11px; - color: #9ca3af; + color: var(--mdma-color-file-size); } /* Table sensitive styles */ .mdma-table-cell--sensitive { cursor: pointer; - color: #000; - background: #fef3c7; + color: var(--mdma-color-sensitive-cell-text); + background: var(--mdma-color-sensitive-cell-bg); padding: 2px 6px; border-radius: 3px; font-family: monospace; @@ -953,7 +1293,7 @@ body { } .mdma-table-cell--sensitive:hover { - background: #fde68a; + background: var(--mdma-color-sensitive-cell-bg-hover); } /* Custom glass input sensitive styles */ @@ -1013,7 +1353,7 @@ body { .mdma-form-field input[type="checkbox"] { width: auto; margin-right: 8px; - accent-color: #6c5ce7; + accent-color: var(--mdma-color-primary); } .mdma-form-submit { @@ -1021,16 +1361,16 @@ body { padding: 8px 20px; font-size: 14px; font-weight: 600; - color: #fff; - background: #6c5ce7; + color: var(--mdma-color-on-primary); + background: var(--mdma-color-primary); border: none; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); cursor: pointer; transition: background 0.15s; } .mdma-form-submit:hover { - background: #5a4bd1; + background: var(--mdma-color-primary-hover); } /* ===== Button ===== */ @@ -1040,7 +1380,7 @@ body { font-size: 14px; font-weight: 600; border: none; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); cursor: pointer; transition: background 0.15s, transform 0.1s; margin-bottom: 20px; @@ -1051,37 +1391,37 @@ body { } .mdma-button--primary { - background: #6c5ce7; - color: #fff; + background: var(--mdma-color-primary); + color: var(--mdma-color-on-primary); } .mdma-button--primary:hover { - background: #5a4bd1; + background: var(--mdma-color-primary-hover); } .mdma-button--secondary { - background: #dfe6e9; - color: #2d3436; + background: var(--mdma-color-secondary); + color: var(--mdma-color-on-secondary); } .mdma-button--secondary:hover { - background: #c8d6db; + background: var(--mdma-color-secondary-hover); } .mdma-button--danger { - background: #e74c3c; - color: #fff; + background: var(--mdma-color-danger); + color: var(--mdma-color-on-danger); } .mdma-button--danger:hover { - background: #c0392b; + background: var(--mdma-color-danger-hover); } /* ===== Tasklist ===== */ .mdma-tasklist { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; } @@ -1090,7 +1430,7 @@ body { font-size: 18px; font-weight: 600; margin-bottom: 12px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-tasklist-items { @@ -1099,7 +1439,7 @@ body { .mdma-tasklist-item { padding: 8px 0; - border-bottom: 1px solid #f0f0f0; + border-bottom: 1px solid var(--mdma-color-border-subtle); } .mdma-tasklist-item:last-child { @@ -1112,25 +1452,25 @@ body { gap: 8px; cursor: pointer; font-size: 14px; - color: #333; + color: var(--mdma-color-text); } .mdma-tasklist-item input[type="checkbox"] { - accent-color: #6c5ce7; + accent-color: var(--mdma-color-primary); width: 16px; height: 16px; } .mdma-tasklist-item input[type="checkbox"]:checked + span { text-decoration: line-through; - color: #999; + color: var(--mdma-color-text-muted); } /* ===== Table ===== */ .mdma-table { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; overflow-x: auto; @@ -1140,7 +1480,7 @@ body { font-size: 18px; font-weight: 600; margin-bottom: 12px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-table table { @@ -1155,32 +1495,32 @@ body { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - color: #666; - background: #f8f9fa; - border-bottom: 2px solid #e0e0e0; + color: var(--mdma-color-text-muted); + background: var(--mdma-color-table-header-bg); + border-bottom: 2px solid var(--mdma-color-border); } .mdma-table td { padding: 10px 12px; font-size: 14px; - border-bottom: 1px solid #f0f0f0; - color: #333; + border-bottom: 1px solid var(--mdma-color-border-subtle); + color: var(--mdma-color-text); } .mdma-table tr:hover td { - background: #f8f9fa; + background: var(--mdma-color-row-hover); } .mdma-table-empty { text-align: center; padding: 20px; - color: #999; + color: var(--mdma-color-text-muted); font-style: italic; } /* ===== Callout ===== */ .mdma-callout { - border-radius: 10px; + border-radius: var(--mdma-radius-md); padding: 16px 20px; margin-bottom: 20px; position: relative; @@ -1188,27 +1528,27 @@ body { } .mdma-callout--info { - background: #ebf5fb; - border-left-color: #3498db; - color: #1a5276; + background: var(--mdma-color-info-bg); + border-left-color: var(--mdma-color-info); + color: var(--mdma-color-info-text); } .mdma-callout--warning { - background: #fef9e7; - border-left-color: #f39c12; - color: #7d6608; + background: var(--mdma-color-warning-bg); + border-left-color: var(--mdma-color-warning); + color: var(--mdma-color-warning-text); } .mdma-callout--error { - background: #fdedec; - border-left-color: #e74c3c; - color: #78281f; + background: var(--mdma-color-error-bg); + border-left-color: var(--mdma-color-error); + color: var(--mdma-color-error-text); } .mdma-callout--success { - background: #eafaf1; - border-left-color: #27ae60; - color: #1e8449; + background: var(--mdma-color-success-bg); + border-left-color: var(--mdma-color-success); + color: var(--mdma-color-success-text); } .mdma-callout-title { @@ -1241,43 +1581,43 @@ body { /* ===== Approval Gate ===== */ .mdma-approval-gate { - background: #fff; - border: 2px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 2px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; } .mdma-approval-gate--pending { - border-color: #f39c12; + border-color: var(--mdma-color-warning); } .mdma-approval-gate--approved { - border-color: #27ae60; - background: #f0faf4; + border-color: var(--mdma-color-success); + background: var(--mdma-color-success-bg); } .mdma-approval-gate--denied { - border-color: #e74c3c; - background: #fdf2f2; + border-color: var(--mdma-color-error); + background: var(--mdma-color-error-bg); } .mdma-approval-gate-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-approval-gate-description { font-size: 14px; - color: #555; + color: var(--mdma-color-text-subtle); margin-bottom: 12px; } .mdma-approval-gate-status { font-size: 13px; - color: #666; + color: var(--mdma-color-text-muted); margin-bottom: 16px; } @@ -1287,13 +1627,13 @@ body { } .mdma-approval-gate--pending .mdma-approval-gate-status strong { - color: #f39c12; + color: var(--mdma-color-warning); } .mdma-approval-gate--approved .mdma-approval-gate-status strong { - color: #27ae60; + color: var(--mdma-color-success); } .mdma-approval-gate--denied .mdma-approval-gate-status strong { - color: #e74c3c; + color: var(--mdma-color-error); } .mdma-approval-gate-actions { @@ -1307,8 +1647,8 @@ body { align-items: center; gap: 10px; padding: 12px 16px; - background: #fff; - border: 1px solid #e0e0e0; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); border-radius: 8px; margin-bottom: 20px; font-size: 13px; @@ -1316,7 +1656,7 @@ body { .mdma-webhook-label { font-weight: 600; - color: #333; + color: var(--mdma-color-text); } .mdma-webhook-status { @@ -1327,8 +1667,8 @@ body { } .mdma-webhook-status--idle { - background: #f0f0f0; - color: #666; + background: var(--mdma-color-surface); + color: var(--mdma-color-text-muted); } .mdma-webhook-status--executing { background: #ffeaa7; @@ -1346,10 +1686,10 @@ body { /* ===== Unknown Component ===== */ .mdma-unknown-component { padding: 12px 16px; - background: #fff3cd; - border: 1px solid #ffc107; + background: var(--mdma-color-unknown-bg); + border: 1px solid var(--mdma-color-unknown-border); border-radius: 8px; - color: #856404; + color: var(--mdma-color-unknown-text); font-size: 13px; margin-bottom: 20px; } @@ -1714,6 +2054,24 @@ body { line-height: 1.6; } +.chat-example-select { + margin-top: 8px; + min-width: 220px; + padding: 8px 12px; + font-size: 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + color: #374151; + cursor: pointer; +} + +[data-theme="dark"] .chat-example-select { + border-color: #2a3140; + background: #1c2333; + color: #e5e7eb; +} + /* ===== Chat Message ===== */ @keyframes chat-msg-enter { from { @@ -2214,44 +2572,44 @@ body { .mdma-progress-label { font-size: 13px; font-weight: 600; - color: #dfe6e9; + color: var(--mdma-color-widget-strong); min-width: 80px; } .mdma-progress-track { flex: 1; height: 12px; - background: rgba(255, 255, 255, 0.08); - border-radius: 6px; + background: var(--mdma-color-widget-track); + border-radius: var(--mdma-radius-sm); overflow: hidden; } .mdma-progress-fill { height: 100%; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); transition: width 0.4s ease; } .mdma-progress-fill--default { - background: #6c5ce7; + background: var(--mdma-color-primary); } .mdma-progress-fill--success { - background: #00b894; + background: var(--mdma-color-widget-positive); } .mdma-progress-fill--warning { - background: #fdcb6e; + background: var(--mdma-color-widget-star); } .mdma-progress-fill--danger { - background: #e74c3c; + background: var(--mdma-color-danger); } .mdma-progress-text { font-size: 13px; font-weight: 600; - color: #b2bec3; + color: var(--mdma-color-widget-muted); min-width: 36px; text-align: right; } @@ -2268,7 +2626,7 @@ body { .mdma-rating-label { font-size: 13px; font-weight: 600; - color: #dfe6e9; + color: var(--mdma-color-widget-strong); margin-right: 4px; } @@ -2283,7 +2641,7 @@ body { font-size: 22px; cursor: pointer; padding: 0 2px; - color: #636e72; + color: var(--mdma-color-widget-faint); transition: color 0.12s, transform 0.12s; line-height: 1; } @@ -2293,12 +2651,12 @@ body { } .mdma-rating-star--filled { - color: #fdcb6e; + color: var(--mdma-color-widget-star); } .mdma-rating-value { font-size: 13px; - color: #b2bec3; + color: var(--mdma-color-widget-muted); margin-left: 4px; } @@ -2309,9 +2667,9 @@ body { flex-direction: column; gap: 4px; padding: 14px 20px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; + background: var(--mdma-color-widget-surface); + border: 1px solid var(--mdma-color-widget-border); + border-radius: var(--mdma-radius-md); min-width: 120px; } @@ -2320,7 +2678,7 @@ body { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - color: #b2bec3; + color: var(--mdma-color-widget-muted); } .mdma-metric-value-row { @@ -2332,13 +2690,13 @@ body { .mdma-metric-value { font-size: 28px; font-weight: 700; - color: #dfe6e9; + color: var(--mdma-color-widget-strong); line-height: 1.1; } .mdma-metric-unit { font-size: 14px; - color: #636e72; + color: var(--mdma-color-widget-faint); } .mdma-metric-trend { @@ -2348,22 +2706,22 @@ body { } .mdma-metric-trend--up { - color: #00b894; + color: var(--mdma-color-widget-positive); } .mdma-metric-trend--down { - color: #e74c3c; + color: var(--mdma-color-danger); } .mdma-metric-trend--flat { - color: #b2bec3; + color: var(--mdma-color-widget-muted); } /* ─── Chart Component ─────────────────────────────────────────────────────── */ .mdma-chart { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(108, 92, 231, 0.15); + background: var(--mdma-color-chart-bg); + border: 1px solid var(--mdma-color-chart-border); border-radius: 14px; padding: 18px; margin-bottom: 20px; @@ -2373,7 +2731,7 @@ body { .mdma-chart-label { font-size: 14px; font-weight: 700; - color: #a29bfe; + color: var(--mdma-color-chart-label); margin-bottom: 12px; } @@ -2388,23 +2746,23 @@ body { display: flex; align-items: center; justify-content: center; - color: #636e72; + color: var(--mdma-color-chart-axis); font-size: 13px; font-style: italic; } /* recharts overrides for MDMA theme */ .mdma-chart .recharts-text { - fill: #636e72; + fill: var(--mdma-color-chart-axis); } .mdma-chart .recharts-legend-item-text { - color: #b2bec3 !important; + color: var(--mdma-color-chart-legend) !important; font-size: 12px; } .mdma-chart .recharts-default-tooltip { - background: #1a1a2e !important; + background: var(--mdma-color-chart-tooltip-bg) !important; border: 1px solid rgba(162, 155, 254, 0.3) !important; border-radius: 8px !important; } @@ -2937,9 +3295,9 @@ body { /* ─── Thinking Component (basic) ─────────────────────────────────────────── */ .mdma-thinking { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); margin: 16px 0; overflow: hidden; } @@ -2952,7 +3310,7 @@ body { cursor: pointer; font-size: 14px; font-weight: 600; - color: #555; + color: var(--mdma-color-text-subtle); user-select: none; list-style: none; } @@ -2966,7 +3324,7 @@ body { font-size: 10px; margin-left: auto; transition: transform 0.2s; - color: #999; + color: var(--mdma-color-text-muted); } .mdma-thinking[open] .mdma-thinking-summary::after { @@ -2978,7 +3336,7 @@ body { width: 8px; height: 8px; border-radius: 50%; - background: #6c5ce7; + background: var(--mdma-color-primary); animation: mdma-thinking-pulse 1.5s ease-in-out infinite; } @@ -3002,9 +3360,9 @@ body { padding: 0 16px 16px; font-size: 13px; line-height: 1.6; - color: #555; + color: var(--mdma-color-text-subtle); white-space: pre-wrap; - border-top: 1px solid #f0f0f0; + border-top: 1px solid var(--mdma-color-border-subtle); padding-top: 12px; } @@ -5003,8 +5361,73 @@ body { } .docs-layout--with-preview .docs-content { - flex: 0 1 860px; + flex: 1 1 0; + min-width: 0; +} + +/* Mobile-only top bar: a hamburger toggle for the nav drawer + a breadcrumb + trail. Hidden on desktop; the mobile media query flips it to `display: flex`. */ +.docs-mobile-bar { + display: none; + align-items: center; + gap: 12px; + padding: 9px 14px; + border-bottom: 1px solid rgba(128, 128, 128, 0.25); + flex-shrink: 0; +} +.docs-menu-toggle { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + font-size: 18px; + line-height: 1; + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 8px; + background: transparent; + color: inherit; + cursor: pointer; +} +.docs-menu-toggle--open { + background: rgba(108, 92, 231, 0.12); + border-color: rgba(108, 92, 231, 0.4); + color: #6c5ce7; +} +.docs-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; min-width: 0; + font-size: 13px; + color: #6b7280; +} +.docs-breadcrumb-sep { + color: #9ca3af; +} +.docs-breadcrumb-group { + font-weight: 600; + color: #374151; +} +.docs-breadcrumb-current { + font-weight: 600; + color: #6c5ce7; +} +[data-theme="dark"] .docs-breadcrumb { + color: #9ca3af; +} +[data-theme="dark"] .docs-breadcrumb-group { + color: #cbd5e1; +} +[data-theme="dark"] .docs-breadcrumb-current { + color: #a29bfe; +} +[data-theme="dark"] .docs-menu-toggle--open { + background: rgba(139, 127, 240, 0.18); + border-color: rgba(139, 127, 240, 0.5); + color: #a29bfe; } .docs-nav { @@ -5028,6 +5451,10 @@ body { padding: 4px 10px 12px; } +.docs-nav-title--group { + margin-top: 16px; +} + .docs-nav-item { display: block; width: 100%; @@ -5266,9 +5693,9 @@ body { .docs-content { flex: 1; + min-width: 0; overflow-y: auto; padding: 40px 48px; - max-width: 860px; } .docs-content h2 { @@ -5417,8 +5844,8 @@ body { } .docs-preview-panel { - flex: 1; - min-width: 260px; + flex: 0 0 500px; + min-width: 0; min-height: 0; align-self: stretch; border-left: 1px solid #e5e7eb; @@ -6164,10 +6591,10 @@ body { /* Give the Snack more room than the component preview: narrower content column, wider preview panel. */ .docs-layout--rn .docs-content { - flex: 0 1 620px; + flex: 1 1 0; } .docs-layout--rn .docs-preview-panel { - min-width: 440px; + flex: 0 0 520px; } .rn-preview-panel { @@ -6212,3 +6639,1335 @@ body { font-size: 13px; color: #6b7280; } + +/* ===== Dark mode (demo chrome) ===== + Additive overrides applied when the app root renders + `.demo-layout[data-theme='dark']`. Each rule restates only color-ish + properties (background / color / border / box-shadow) with dark values; + `[data-theme='dark'] .foo` outspecifies the light `.foo`, so light mode is + untouched. MDMA component styles (`.mdma-*`) and chrome that is already dark + in light mode (header, nav dropdown, docs left nav, dark code blocks) are + intentionally left alone. */ + +/* ── Reset & Base ── */ +[data-theme="dark"] body { + color: #e5e7eb; + background: #0e1119; +} + +/* ── Home View ── */ +[data-theme="dark"] .home-hero-title { + color: #e5e7eb; +} +[data-theme="dark"] .home-card { + background: #151a26; + border-color: #2a3140; +} +[data-theme="dark"] .home-card-label { + color: #e5e7eb; +} +[data-theme="dark"] .home-card-desc { + color: #9ca3af; +} + +/* ── Main content / error ── */ +[data-theme="dark"] .demo-error { + background: #2a1717; + color: #ef4444; + border-color: #7f1d1d; +} + +/* ── Event Log Panel ── */ +[data-theme="dark"] .demo-event-panel { + background: #151a26; + border-left-color: #2a3140; +} +[data-theme="dark"] .demo-event-title { + color: #cbd5e1; + border-bottom-color: #2a3140; +} +[data-theme="dark"] .demo-event-empty { + color: #9ca3af; +} +[data-theme="dark"] .demo-event-entry { + border-bottom-color: #232a38; +} +[data-theme="dark"] .demo-event-time { + color: #9ca3af; +} +[data-theme="dark"] .demo-event-type { + background: #1c2333; + color: #cbd5e1; +} +[data-theme="dark"] .demo-event-detail { + color: #9ca3af; +} + +/* ── Chat Action Log ── */ +[data-theme="dark"] .chat-action-log-toggle { + border-color: #2a3140; + background: #151a26; + color: #cbd5e1; +} +[data-theme="dark"] .chat-action-log-panel { + background: #151a26; + border-left-color: #2a3140; +} +[data-theme="dark"] .chat-action-log-title { + color: #cbd5e1; + border-bottom-color: #2a3140; +} +[data-theme="dark"] .chat-action-log-empty { + color: #9ca3af; +} +[data-theme="dark"] .chat-action-log-entry { + border-bottom-color: #232a38; +} +[data-theme="dark"] .chat-action-log-msg-id { + background: rgba(108, 92, 231, 0.14); + color: #a29bfe; +} + +/* ── Chat Settings Bar ── */ +[data-theme="dark"] .chat-settings-bar { + background: rgba(108, 92, 231, 0.1); + border-bottom-color: #2a3140; +} +[data-theme="dark"] .chat-settings-toggle { + color: #a29bfe; +} +[data-theme="dark"] .chat-settings-toggle::after { + color: #9ca3af; +} +[data-theme="dark"] .chat-settings-toggle:hover { + color: #a29bfe; + background: rgba(108, 92, 231, 0.18); +} +[data-theme="dark"] .chat-settings-toggle[data-alert="true"] { + color: #ef4444; + background: #2a1717; +} +[data-theme="dark"] .chat-settings-toggle[data-alert="true"]:hover { + color: #f87171; + background: #3a1d1d; +} +[data-theme="dark"] .settings-missing-key { + color: #ef4444; + background: #2a1717; + border-color: #7f1d1d; +} +[data-theme="dark"] .ai-preset-btn { + border-color: #2a3140; + background: #1c2333; + color: #cbd5e1; +} +[data-theme="dark"] .ai-preset-btn:hover { + color: #a29bfe; + background: rgba(108, 92, 231, 0.14); +} +[data-theme="dark"] .ai-setting span { + color: #cbd5e1; +} +[data-theme="dark"] .ai-setting input, +[data-theme="dark"] .ai-setting select { + border-color: #2a3140; + background: #1c2333; + color: #e5e7eb; +} +[data-theme="dark"] .ai-setting--toggle input[type="checkbox"] { + background: #2a3140; +} + +/* ── Chat Messages ── */ +[data-theme="dark"] .chat-messages { + background: #0e1119; +} +[data-theme="dark"] .chat-empty-title { + color: #e5e7eb; +} +[data-theme="dark"] .chat-empty-hint { + color: #9ca3af; +} +[data-theme="dark"] .chat-msg-view-toggle { + border-color: #2a3140; + background: #151a26; + color: #cbd5e1; +} +[data-theme="dark"] .chat-msg--user .chat-msg-label { + color: #a29bfe; +} +[data-theme="dark"] .chat-msg--assistant .chat-msg-label { + color: #34d399; +} +[data-theme="dark"] .chat-msg--user .chat-msg-body { + background: rgba(108, 92, 231, 0.14); + border-color: rgba(108, 92, 231, 0.3); +} +[data-theme="dark"] .chat-msg--user .chat-msg-body p { + color: #e5e7eb; +} +[data-theme="dark"] .chat-msg--assistant .chat-msg-body { + background: #151a26; + border-color: #2a3140; +} +[data-theme="dark"] .chat-msg-raw { + color: #cbd5e1; +} +[data-theme="dark"] .agent-raw-toggle { + border-color: rgba(108, 92, 231, 0.3); +} +[data-theme="dark"] .agent-raw-toggle:hover { + background: rgba(108, 92, 231, 0.14); +} +[data-theme="dark"] .chat-msg-editor-reset { + border-color: #2a3140; + color: #9ca3af; +} +[data-theme="dark"] .chat-msg-typing { + color: #9ca3af; +} +[data-theme="dark"] .chat-msg-streaming { + border-top-color: #2a3140; +} +[data-theme="dark"] .chat-error { + background: #2a1717; + color: #ef4444; + border-color: #7f1d1d; +} + +/* ── Chat Input Bar ── */ +[data-theme="dark"] .chat-input-bar { + background: #0e1119; + border-top-color: #2a3140; +} +[data-theme="dark"] .chat-input-container { + border-color: #2a3140; + background: #151a26; +} +[data-theme="dark"] .chat-input { + color: #e5e7eb; +} +[data-theme="dark"] .chat-input::placeholder { + color: #6b7280; +} +[data-theme="dark"] .chat-input-actions { + border-top-color: #232a38; +} +[data-theme="dark"] .chat-input-hint { + color: #6b7280; +} +[data-theme="dark"] .chat-clear-btn { + border-color: #2a3140; + color: #9ca3af; +} +[data-theme="dark"] .chat-clear-btn:hover { + background: #2a1717; +} +[data-theme="dark"] .chat-demo-btn { + border-color: rgba(108, 92, 231, 0.3); +} +[data-theme="dark"] .chat-demo-btn:hover { + background: rgba(108, 92, 231, 0.14); +} +[data-theme="dark"] .chat-send-btn:disabled { + background: #2a3140; +} + +/* ── Playground info (text only; green tint bg reads fine on dark) ── */ +[data-theme="dark"] .playground-info { + color: #cbd5e1; +} + +/* ── Creator custom glass inputs (.ce-*) ── */ +[data-theme="dark"] .ce-glass-input, +[data-theme="dark"] .ce-glass-select, +[data-theme="dark"] .ce-glass-textarea { + color: #e5e7eb; +} +[data-theme="dark"] .ce-glass-input::placeholder, +[data-theme="dark"] .ce-glass-textarea::placeholder { + color: rgba(229, 231, 235, 0.4); +} +/* form-scoped variant (ties the existing `.ce-editable-field .ce-glass-*` + specificity and wins by source order — no `.mdma-*` selector needed) */ +[data-theme="dark"] .ce-editable-field .ce-glass-input, +[data-theme="dark"] .ce-editable-field .ce-glass-select, +[data-theme="dark"] .ce-editable-field .ce-glass-textarea { + color: #e5e7eb; +} +[data-theme="dark"] .ce-glass-input--sensitive { + background: #2a2412 !important; + border-color: #f5b041 !important; +} +[data-theme="dark"] .ce-pii-badge { + color: #f5b041; + background: #2a2412; + border-color: #5c4a1a; +} +[data-theme="dark"] .custom-table-cell--sensitive { + color: #f5b041; + background: #2a2412; +} +[data-theme="dark"] .custom-table-cell--sensitive:hover { + background: #3a3018; +} + +/* ── Custom table override ── */ +[data-theme="dark"] .custom-table td { + color: #e5e7eb; +} +[data-theme="dark"] .custom-table th { + color: #9ca3af; +} + +/* ── Validator ── */ +[data-theme="dark"] .validator-info { + color: #cbd5e1; +} +[data-theme="dark"] .validator-variant-selector { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .validator-variant-btn { + border-color: #2a3140; + background: #151a26; + color: #cbd5e1; +} +[data-theme="dark"] .validator-variant-btn:hover { + background: #222c3d; + border-color: #3a4150; +} +[data-theme="dark"] .validator-side-panel { + background: #151a26; + border-left-color: #2a3140; +} +[data-theme="dark"] .flow-progress-panel { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .flow-progress-panel h3 { + color: #cbd5e1; +} +[data-theme="dark"] .flow-step { + background: #1c2333; + border-color: #2a3140; +} +[data-theme="dark"] .flow-step--done { + background: #12251a; + border-color: #22c55e; +} +[data-theme="dark"] .flow-step--error { + background: #2a1717; + border-color: #ef4444; +} +[data-theme="dark"] .flow-step-num { + background: #222c3d; + color: #9ca3af; +} +[data-theme="dark"] .flow-step-label { + color: #cbd5e1; +} +[data-theme="dark"] .flow-step-badge--done { + background: #12251a; + color: #22c55e; +} +[data-theme="dark"] .flow-step-badge--error { + background: #2a1717; + color: #f87171; +} +[data-theme="dark"] .flow-complete-modal { + background: #151a26; +} +[data-theme="dark"] .flow-complete-modal h2 { + color: #e5e7eb; +} +[data-theme="dark"] .manual-validator { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .manual-validator-header { + color: #cbd5e1; +} +[data-theme="dark"] .manual-validator-badge { + color: #a29bfe; +} +[data-theme="dark"] .manual-validator-textarea { + border-top-color: #2a3140; + border-bottom-color: #2a3140; + background: #1c2333; + color: #e5e7eb; +} +[data-theme="dark"] .manual-validator-textarea:focus { + background: #222c3d; +} +[data-theme="dark"] .manual-validator-textarea::placeholder { + color: #6b7280; +} +[data-theme="dark"] .manual-validator-clear-btn { + border-color: #2a3140; + background: #151a26; + color: #cbd5e1; +} +[data-theme="dark"] .manual-validator-clear-btn:hover { + background: #222c3d; +} +[data-theme="dark"] .ref-card { + background: #1c2333; + border-color: #2a3140; +} +[data-theme="dark"] .ref-card-label { + color: #cbd5e1; +} +[data-theme="dark"] .ref-card-remove { + color: #9ca3af; +} +[data-theme="dark"] .ref-parse-error { + color: #ef4444; +} +[data-theme="dark"] .ref-load-btn { + background: rgba(108, 92, 231, 0.12); + border-color: rgba(108, 92, 231, 0.3); + color: #a29bfe; +} +[data-theme="dark"] .ref-load-btn:hover { + background: rgba(108, 92, 231, 0.2); +} +[data-theme="dark"] .validator-msg-result { + border-bottom-color: #2a3140; +} +[data-theme="dark"] .validator-summary { + border-bottom-color: #2a3140; +} +[data-theme="dark"] .validator-summary--ok { + background: #12251a; +} +[data-theme="dark"] .validator-summary--fail { + background: #2a1717; +} +[data-theme="dark"] .validator-summary--ok .validator-summary-status { + color: #22c55e; +} +[data-theme="dark"] .validator-summary--fail .validator-summary-status { + color: #f87171; +} +[data-theme="dark"] .validator-severity--error { + background: #2a1717; + color: #f87171; +} +[data-theme="dark"] .validator-severity--warning { + background: #2a2412; + color: #f5b041; +} +[data-theme="dark"] .validator-severity--info { + background: #14202a; + color: #60a5fa; +} +[data-theme="dark"] .validator-fix-count { + background: #12251a; + color: #22c55e; +} +[data-theme="dark"] .validator-issues h3 { + color: #cbd5e1; +} +[data-theme="dark"] .validator-issue { + background: #1c2333; + border-color: #2a3140; +} +[data-theme="dark"] .validator-issue--fixed { + background: #12251a; + border-color: #1e3a2a; +} +[data-theme="dark"] .validator-issue-rule { + color: #a29bfe; +} +[data-theme="dark"] .validator-issue-msg { + color: #e5e7eb; +} +[data-theme="dark"] .fixer-settings-panel { + border-bottom-color: #2a3140; + background: #151a26; +} +[data-theme="dark"] .fixer-settings-panel h3 { + color: #cbd5e1; +} +[data-theme="dark"] .fixer-settings-checkbox span { + color: #cbd5e1; +} +[data-theme="dark"] .fixer-settings-field select, +[data-theme="dark"] .fixer-settings-field input { + border-color: #2a3140; + background: #1c2333; + color: #e5e7eb; +} +[data-theme="dark"] .validator-fixing-overlay { + background: rgba(14, 17, 25, 0.85); +} +[data-theme="dark"] .validator-fixing-spinner { + border-color: #2a3140; + border-top-color: #6c5ce7; +} + +/* ── Stepper ── */ +[data-theme="dark"] .stepper-info { + color: #cbd5e1; +} +[data-theme="dark"] .stepper-step { + background: rgba(255, 255, 255, 0.04); +} +[data-theme="dark"] .stepper-step-num { + background: rgba(255, 255, 255, 0.08); + color: #9ca3af; +} + +/* ── Form Creator ── */ +[data-theme="dark"] .creator-layout--split .creator-chat { + border-right-color: #2a3140; +} +[data-theme="dark"] .creator-chat { + background: #151a26; +} +[data-theme="dark"] .creator-chat-empty { + color: #9ca3af; +} +[data-theme="dark"] .creator-chat-error { + background: #2a1717; + color: #f87171; +} +[data-theme="dark"] .creator-msg--assistant .creator-msg-label { + color: #cbd5e1; +} +[data-theme="dark"] .creator-msg-body { + color: #cbd5e1; +} +[data-theme="dark"] .creator-msg--user .creator-msg-body { + background: rgba(108, 92, 231, 0.14); + color: #e5e7eb; +} +[data-theme="dark"] .creator-msg--has-panel:hover { + border-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .creator-msg--active-panel { + background: rgba(108, 92, 231, 0.12); +} +[data-theme="dark"] .creator-panel { + background: #151a26; +} +[data-theme="dark"] .creator-panel-header { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .creator-panel-title { + color: #e5e7eb; +} +[data-theme="dark"] .creator-panel-raw-toggle { + border-color: #2a3140; + background: #1c2333; + color: #9ca3af; +} +[data-theme="dark"] .creator-panel-raw-toggle:hover { + background: #222c3d; + color: #cbd5e1; +} +[data-theme="dark"] .creator-panel-raw-toggle--active { + background: rgba(108, 92, 231, 0.14); + color: #a29bfe; +} +[data-theme="dark"] .creator-panel-source { + color: #cbd5e1; +} +[data-theme="dark"] .creator-panel-empty-title { + color: #9ca3af; +} +[data-theme="dark"] .creator-panel-actions { + border-top-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .creator-reject-btn { + border-color: #2a3140; + background: #1c2333; + color: #9ca3af; +} +[data-theme="dark"] .creator-reject-btn:hover { + background: #2a1717; + color: #f87171; + border-color: #7f1d1d; +} +[data-theme="dark"] .creator-approved { + border-top-color: #2a3140; + background: #151a26; +} +[data-theme="dark"] .creator-approved-header { + color: #9ca3af; +} +[data-theme="dark"] .creator-approved-header:hover { + background: #1c2333; +} +[data-theme="dark"] .creator-approved-card { + background: #1c2333; + border-color: #2a3140; +} +[data-theme="dark"] .creator-approved-label { + color: #cbd5e1; +} +[data-theme="dark"] .creator-prompt-generator { + background: #151a26; +} +[data-theme="dark"] .creator-prompt-generator-header { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .creator-prompt-generator-title { + color: #e5e7eb; +} +[data-theme="dark"] .creator-prompt-back-btn:hover { + background: rgba(108, 92, 231, 0.14); +} +[data-theme="dark"] .creator-prompt-label { + color: #cbd5e1; +} +[data-theme="dark"] .creator-prompt-textarea { + border-color: #2a3140; + background: #1c2333; + color: #e5e7eb; +} +[data-theme="dark"] .creator-prompt-generate-btn:disabled { + background: #2a3140; +} +[data-theme="dark"] .creator-prompt-error { + background: #2a1717; + color: #f87171; +} +[data-theme="dark"] .creator-prompt-output-section { + border-color: #2a3140; +} +[data-theme="dark"] .creator-prompt-output-header { + background: #1c2333; + border-bottom-color: #2a3140; + color: #cbd5e1; +} +[data-theme="dark"] .creator-prompt-output { + background: #1c2333; + color: #e5e7eb; +} +[data-theme="dark"] .creator-prompt-test-bar { + border-top-color: #2a3140; + background: #1c2333; +} + +/* ── Agent Chat ── */ +[data-theme="dark"] .agent-thinking { + border-color: rgba(108, 92, 231, 0.25); + background: rgba(108, 92, 231, 0.06); +} +[data-theme="dark"] .agent-thinking-summary { + color: #a29bfe; + background: rgba(108, 92, 231, 0.1); +} +[data-theme="dark"] .agent-thinking-content { + border-top-color: rgba(108, 92, 231, 0.2); +} +[data-theme="dark"] .agent-thinking-content pre { + color: #cbd5e1; +} +[data-theme="dark"] .agent-text-content p { + color: #e5e7eb; +} +[data-theme="dark"] .agent-text-h1, +[data-theme="dark"] .agent-text-h2, +[data-theme="dark"] .agent-text-h3 { + color: #e5e7eb; +} +[data-theme="dark"] .agent-text-hr { + border-top-color: #2a3140; +} +[data-theme="dark"] .agent-text-table th, +[data-theme="dark"] .agent-text-table td { + border-color: #2a3140; + color: #e5e7eb; +} +[data-theme="dark"] .agent-text-table thead th { + background: #1c2333; +} +[data-theme="dark"] .agent-text-table tbody tr:nth-child(even) td { + background: #171c28; +} +[data-theme="dark"] .agent-text-list li { + color: #e5e7eb; +} +[data-theme="dark"] .agent-inline-code { + background: #1c2333; + border-color: #2a3140; + color: #a29bfe; +} +[data-theme="dark"] .agent-tool-call { + border-color: rgba(34, 197, 94, 0.3); + background: rgba(34, 197, 94, 0.06); +} +[data-theme="dark"] .agent-tool-call-header { + color: #34d399; + border-bottom-color: rgba(34, 197, 94, 0.25); + background: rgba(34, 197, 94, 0.14); +} +[data-theme="dark"] .agent-settings-note--storage { + color: #9ca3af; + background: rgba(34, 197, 94, 0.06); + border-color: rgba(34, 197, 94, 0.25); +} + +/* ── Docs content (right reading pane; left nav stays dark) ── */ +[data-theme="dark"] .docs-package-tagline { + color: #cbd5e1; +} +[data-theme="dark"] .docs-inprogress-item { + background: #1c2333; + border-color: #2a3140; +} +[data-theme="dark"] .docs-inprogress-name { + color: #cbd5e1; +} +[data-theme="dark"] .docs-inprogress-badge { + background: #2a2412; + color: #f5b041; +} +[data-theme="dark"] .docs-intro-footnote, +[data-theme="dark"] .docs-intro-footnote a { + color: #9ca3af; +} +[data-theme="dark"] .docs-runtime-demo { + border-color: #2a3140; + background: #151a26; +} +[data-theme="dark"] .docs-runtime-demo-doc { + border-right-color: #2a3140; +} +[data-theme="dark"] .docs-runtime-demo-log-header { + border-bottom-color: #2a3140; +} +[data-theme="dark"] .docs-runtime-demo-clear:hover { + color: #cbd5e1; +} +[data-theme="dark"] .docs-package-see-also { + background: rgba(108, 92, 231, 0.1); + border-color: rgba(108, 92, 231, 0.25); + color: #cbd5e1; +} +[data-theme="dark"] .docs-content h2 { + color: #e5e7eb; +} +[data-theme="dark"] .docs-content p { + color: #cbd5e1; +} +[data-theme="dark"] .docs-content code { + background: #1c2333; + border-color: #2a3140; + color: #a29bfe; +} +[data-theme="dark"] .docs-table th { + background: #1c2333; + border-color: #2a3140; + color: #cbd5e1; +} +[data-theme="dark"] .docs-table td { + border-color: #2a3140; + color: #cbd5e1; +} +[data-theme="dark"] .docs-table tr:hover td { + background: #1c2333; +} +[data-theme="dark"] .docs-table-row--active td, +[data-theme="dark"] .docs-table-row--active td code { + background: #14202a; + color: #60a5fa; +} +[data-theme="dark"] .docs-table-row-clickable:hover td { + background: #1c2333; +} +[data-theme="dark"] .docs-preview-panel { + border-left-color: #2a3140; + background: #151a26; +} +[data-theme="dark"] .docs-example-toggle { + color: #a5b4fc; + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.3); +} +[data-theme="dark"] .docs-example-toggle:hover { + background: rgba(99, 102, 241, 0.25); +} +[data-theme="dark"] .docs-convo-avatar--user { + background: #222c3d; + color: #cbd5e1; +} +[data-theme="dark"] .docs-convo-bubble { + background: #151a26; + border-color: #2a3140; + color: #e5e7eb; +} +[data-theme="dark"] .docs-convo-msg--user .docs-convo-bubble { + background: rgba(99, 102, 241, 0.12); + border-color: rgba(99, 102, 241, 0.3); +} +[data-theme="dark"] .docs-preview-panel-header { + background: #1c2333; + border-bottom-color: #2a3140; +} +[data-theme="dark"] .docs-preview-panel-label { + color: #e5e7eb; +} +[data-theme="dark"] .docs-preview-panel-type { + background: rgba(99, 102, 241, 0.15); + color: #a5b4fc; +} +[data-theme="dark"] .docs-preview-panel-render { + background: #151a26; + border-color: #2a3140; +} +[data-theme="dark"] .docs-list li { + color: #cbd5e1; +} +[data-theme="dark"] .docs-note { + color: #9ca3af !important; + background: #1c2333; +} +[data-theme="dark"] .docs-do { + background: #12251a; + border-color: rgba(34, 197, 94, 0.3); +} +[data-theme="dark"] .docs-dont { + background: #2a1717; + border-color: rgba(239, 68, 68, 0.3); +} +[data-theme="dark"] .docs-do h4 { + color: #22c55e; +} +[data-theme="dark"] .docs-dont h4 { + color: #ef4444; +} + +/* ── Preview Layout ── */ +[data-theme="dark"] .preview-layout .preview-chat { + border-right-color: #2a3140; +} +[data-theme="dark"] .preview-layout .preview-pane { + background: #151a26; +} +[data-theme="dark"] .preview-layout .preview-pane-header { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .preview-layout .preview-pane-title { + color: #e5e7eb; +} +[data-theme="dark"] .preview-layout .preview-pane-status--idle { + background: #1c2333; + color: #9ca3af; +} +[data-theme="dark"] .preview-layout .preview-pane-status--validating, +[data-theme="dark"] .preview-layout .preview-pane-status--fixing { + background: #2a2412; + color: #f5b041; +} +[data-theme="dark"] .preview-layout .preview-pane-status--ready { + background: #12251a; + color: #22c55e; +} +[data-theme="dark"] .preview-layout .preview-pane-status--invalid { + background: #2a1717; + color: #f87171; +} +[data-theme="dark"] .preview-layout .preview-pane-empty-title { + color: #cbd5e1; +} +[data-theme="dark"] .preview-layout .preview-callout-content { + color: #cbd5e1; +} +[data-theme="dark"] .preview-layout .preview-callout-dismiss:hover { + color: #e5e7eb; +} +[data-theme="dark"] .preview-layout .preview-callout--info { + background: #14202a; + border-color: rgba(37, 99, 235, 0.3); + border-left-color: #3b82f6; +} +[data-theme="dark"] .preview-layout .preview-callout--info .preview-callout-title { + color: #93c5fd; +} +[data-theme="dark"] .preview-layout .preview-callout--success { + background: #12251a; + border-color: rgba(34, 197, 94, 0.3); + border-left-color: #22c55e; +} +[data-theme="dark"] .preview-layout .preview-callout--success .preview-callout-title { + color: #34d399; +} +[data-theme="dark"] .preview-layout .preview-callout--warning { + background: #2a2412; + border-color: rgba(245, 176, 65, 0.3); + border-left-color: #f5b041; +} +[data-theme="dark"] .preview-layout .preview-callout--warning .preview-callout-title { + color: #f5b041; +} +[data-theme="dark"] .preview-layout .preview-callout--error { + background: #2a1717; + border-color: rgba(239, 68, 68, 0.3); + border-left-color: #ef4444; +} +[data-theme="dark"] .preview-layout .preview-callout--error .preview-callout-title { + color: #f87171; +} +[data-theme="dark"] .preview-layout .preview-pane-status--submitted { + background: rgba(99, 102, 241, 0.15); + color: #a5b4fc; +} +[data-theme="dark"] .preview-layout .preview-pane-note--submitted { + background: rgba(99, 102, 241, 0.12); + color: #a5b4fc; + border-color: rgba(99, 102, 241, 0.3); +} +[data-theme="dark"] .preview-layout .agent-tool-call--clickable:hover { + background: rgba(108, 92, 231, 0.1); +} +[data-theme="dark"] .preview-layout .agent-tool-call--active { + background: rgba(108, 92, 231, 0.16); +} +[data-theme="dark"] .preview-layout .preview-pane-note--fixed { + background: #2a2412; + color: #f5b041; + border-color: rgba(245, 176, 65, 0.3); +} +[data-theme="dark"] .preview-layout .preview-pane-note--invalid { + background: #2a1717; + color: #f87171; + border-color: rgba(239, 68, 68, 0.3); +} +[data-theme="dark"] .preview-layout .preview-pane-note--invalid code { + background: rgba(255, 255, 255, 0.08); +} +[data-theme="dark"] .preview-layout .preview-log-toggle { + border-color: #2a3140; + background: #151a26; + color: #cbd5e1; +} +[data-theme="dark"] .preview-layout .preview-log-drawer { + background: #151a26; + border-left-color: #2a3140; +} +[data-theme="dark"] .preview-layout .preview-log-drawer-header { + border-bottom-color: #2a3140; + background: #1c2333; +} +[data-theme="dark"] .preview-layout .preview-log-drawer-title { + color: #e5e7eb; +} +[data-theme="dark"] .preview-layout .preview-log-drawer-count { + background: rgba(99, 102, 241, 0.15); + color: #a5b4fc; +} +[data-theme="dark"] .preview-layout .preview-log-clear { + border-color: #2a3140; + background: #1c2333; + color: #cbd5e1; +} +[data-theme="dark"] .preview-layout .preview-log-clear:hover { + background: #222c3d; +} +[data-theme="dark"] .preview-layout .preview-log-drawer-close:hover { + color: #e5e7eb; +} +[data-theme="dark"] .preview-layout .preview-log-item { + background: #1c2333; + border-color: #2a3140; +} +[data-theme="dark"] .preview-layout .preview-log-item-method { + color: #cbd5e1; +} +[data-theme="dark"] .preview-layout .preview-log-item-status { + background: #12251a; + color: #22c55e; +} +[data-theme="dark"] .preview-layout .preview-log-item-claim { + background: rgba(99, 102, 241, 0.15); + color: #a5b4fc; +} +[data-theme="dark"] .preview-layout .preview-log-item-summary { + color: #cbd5e1; +} + +/* ── React Native docs preview ── */ +[data-theme="dark"] .rn-open-snack { + color: #a5b4fc; + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.3); +} +[data-theme="dark"] .rn-open-snack:hover { + background: rgba(99, 102, 241, 0.25); +} + +/* ===== Light mode (nav bar + side nav) ===== + * The top nav bar and docs side nav are dark-navy by default (that's the + * dark-mode look). In light mode they flip to a light surface so the whole app + * tracks the theme toggle. `data-theme` is always set on by the app. */ + +/* --- Top nav bar --- */ +[data-theme="light"] .demo-header { + background: #ffffff; + color: #1a1a2e; +} +[data-theme="light"] .demo-subtitle { + color: #6b7280; + opacity: 1; +} +[data-theme="light"] .demo-nav-trigger { + background: rgba(108, 92, 231, 0.1); + border-color: rgba(108, 92, 231, 0.3); + color: #4c3fb0; +} +[data-theme="light"] .demo-nav-trigger:hover { + background: rgba(108, 92, 231, 0.18); + border-color: rgba(108, 92, 231, 0.5); +} +[data-theme="light"] .demo-nav-dropdown { + background: #ffffff; + border-color: #e5e7eb; + box-shadow: 0 12px 32px rgba(24, 24, 46, 0.14), 0 0 0 1px rgba(108, 92, 231, 0.06); +} +[data-theme="light"] .demo-nav-group + .demo-nav-group { + border-top-color: rgba(0, 0, 0, 0.07); +} +[data-theme="light"] .demo-nav-group-label { + color: #9ca3af; +} +[data-theme="light"] .demo-nav-item { + color: #374151; +} +[data-theme="light"] .demo-nav-item:hover { + background: rgba(108, 92, 231, 0.1); + color: #1a1a2e; +} +[data-theme="light"] .demo-nav-item--active { + background: rgba(108, 92, 231, 0.12); + color: #6c5ce7; +} +[data-theme="light"] .demo-nav-item--active:hover { + background: rgba(108, 92, 231, 0.18); + color: #4c3fb0; +} +[data-theme="light"] .demo-doc-selector { + background: #f8f9fa; + border-color: #e5e7eb; + color: #1a1a2e; +} +[data-theme="light"] .demo-doc-selector:hover { + background: #f0f0f5; +} +[data-theme="light"] .demo-doc-selector option { + background: #ffffff; + color: #1a1a2e; +} + +/* --- Docs side nav --- */ +[data-theme="light"] .docs-nav { + background: #ffffff; + color: #1a1a2e; + border-right: 1px solid #e5e7eb; +} +[data-theme="light"] .docs-nav-title { + color: #9ca3af; +} +[data-theme="light"] .docs-nav-item { + color: #374151; +} +[data-theme="light"] .docs-nav-item:hover { + background: rgba(108, 92, 231, 0.1); + color: #1a1a2e; +} +[data-theme="light"] .docs-nav-item--active { + background: rgba(108, 92, 231, 0.12); + color: #6c5ce7; +} +[data-theme="light"] .docs-nav-sub-item { + border-left-color: rgba(0, 0, 0, 0.1); + color: #6b7280; +} +[data-theme="light"] .docs-nav-sub-item:hover { + background: rgba(108, 92, 231, 0.08); + color: #374151; + border-left-color: rgba(108, 92, 231, 0.5); +} +[data-theme="light"] .docs-nav-sub-item--active { + background: rgba(108, 92, 231, 0.12); + color: #6c5ce7; + border-left-color: #6c5ce7; +} + +/* ===== Theming playground (docs) ===== */ +.theming-pickers { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 16px; + margin: 8px 0 24px; +} +.theming-picker { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + font-size: 12px; +} +.theming-picker input[type="color"] { + width: 46px; + height: 46px; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 10px; + background: none; + cursor: pointer; +} +.theming-picker input[type="color"]::-webkit-color-swatch-wrapper { + padding: 3px; +} +.theming-picker input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 7px; +} +.theming-picker-label { + font-weight: 600; + color: #374151; +} +.theming-picker-hex { + font-size: 11px; + color: #9ca3af; + font-variant-numeric: tabular-nums; +} +.theming-swatch-reset { + align-self: center; + padding: 6px 14px; + font-size: 13px; + font-weight: 600; + border: 1px solid #d1d5db; + border-radius: 999px; + background: #ffffff; + color: #374151; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} +.theming-swatch-reset:hover { + background: #f3f4f6; +} +[data-theme="dark"] .theming-picker input[type="color"] { + border-color: #2a3140; +} +[data-theme="dark"] .theming-picker-label { + color: #cbd5e1; +} +[data-theme="dark"] .theming-swatch-reset { + background: #1c2333; + border-color: #2a3140; + color: #cbd5e1; +} +[data-theme="dark"] .theming-swatch-reset:hover { + background: #222c3d; +} + +/* ===== Mobile responsive ===== */ + +/* Neutral borders below use rgba(128,128,128,.25) so they read acceptably in + both light and dark themes (never a pure-white line on a dark surface). */ + +@media (max-width: 860px) { + /* Guard against any fixed-width child forcing horizontal page scroll. */ + html, + body { + max-width: 100%; + overflow-x: hidden; + } + + /* ── Header — keep it on one row ─────────────────────────────── */ + .demo-header { + padding: 10px 12px; + } + .demo-header-left { + gap: 8px; + min-width: 0; + } + .demo-header-right { + gap: 8px; + min-width: 0; + } + .demo-logo { + height: 60px; + } + .demo-subtitle { + display: none; + } + .demo-ph-badge { + display: none; + } + /* Keep the nav dropdown inside the viewport. */ + .demo-nav-dropdown { + right: 0; + max-width: calc(100vw - 24px); + } + + /* ── Docs layout → stack vertically ──────────────────────────── */ + .docs-layout { + flex-direction: column; + overflow: auto; + } + /* The side nav collapses into a hamburger-toggled drawer; the mobile bar + (hamburger + breadcrumb) sits at the top. */ + .docs-mobile-bar { + display: flex; + } + .docs-nav { + display: none; + } + .docs-nav--open { + display: flex; + width: 100%; + max-height: 65vh; + overflow-y: auto; + border-right: none; + border-bottom: 1px solid rgba(128, 128, 128, 0.25); + } + /* Flow naturally in the stacked column instead of being an internal + scroll box (the base rule's flex:1 + overflow:auto would clip content). */ + .docs-content { + padding: 24px 18px; + flex: none; + overflow: visible; + } + /* Preview panel drops below the content, full width. */ + .docs-layout--with-preview { + overflow: auto; + } + .docs-preview-panel, + .docs-layout--rn .docs-preview-panel { + flex: none; + width: 100%; + border-left: none; + border-top: 1px solid rgba(128, 128, 128, 0.25); + min-height: 360px; + overflow: visible; + } + .docs-preview-panel-render { + max-width: 100%; + } + /* Runtime demo (doc + log) stacks too. */ + .docs-runtime-demo { + flex-direction: column; + } + .docs-runtime-demo-doc { + border-right: none; + border-bottom: 1px solid rgba(128, 128, 128, 0.25); + } + .docs-runtime-demo-log { + width: 100%; + } + + /* ── Validator → stack ───────────────────────────────────────── */ + .validator-content { + flex-direction: column; + } + .validator-side-panel { + width: 100%; + flex-shrink: 0; + border-left: none; + border-top: 1px solid rgba(128, 128, 128, 0.25); + } + /* Variant selector already scrolls; let it wrap if it prefers. */ + .validator-variant-selector { + flex-wrap: wrap; + } + + /* ── Chat / Author + Custom settings ─────────────────────────── */ + .chat-settings { + padding: 0 12px 12px; + } + .chat-settings-fields { + grid-template-columns: 1fr; + } + .ai-settings-presets { + flex-wrap: wrap; + } + .ai-setting-model-group { + flex-wrap: wrap; + } + .chat-input-bar { + padding: 12px 12px 16px; + } + /* Action-log side panel stacks under the chat instead of sitting beside it. */ + .chat-layout--with-log { + flex-direction: column; + } + .chat-action-log-panel { + width: 100%; + border-left: none; + border-top: 1px solid rgba(128, 128, 128, 0.25); + } + + /* ── Fixed-width side panels / settings — clamp to viewport ───── */ + .demo-event-panel { + width: 100%; + max-width: 100%; + } + .fixer-settings-panel { + max-width: 100%; + } + + /* ── General padding reductions ──────────────────────────────── */ + .home-view { + padding: 28px 16px; + gap: 32px; + } + .home-hero-title { + font-size: 26px; + } + .demo-document-panel { + padding: 20px 16px; + } +} + +@media (max-width: 520px) { + /* Tighten the header further and drop the star count on tiny screens. */ + .demo-header { + padding: 8px 10px; + } + .demo-header-right { + gap: 6px; + } + .demo-logo { + height: 52px; + } + .demo-star-count { + display: none; + } + .demo-star-btn { + padding: 6px 10px; + } + .demo-nav-trigger { + padding: 7px 10px; + } + + .docs-content { + padding: 18px 14px; + } + .home-view { + padding: 20px 12px; + } + /* Preview/agent chat + pane split already stacks at 900px; give the pane a + minimum so it stays usable on phones. */ + .preview-layout .preview-pane { + min-height: 320px; + } +} + +@media (max-width: 640px) { + /* Free room in the chat input footer so the Send button never clips. */ + .chat-input-hint { + display: none; + } +} diff --git a/demo/src/theme-context.ts b/demo/src/theme-context.ts new file mode 100644 index 0000000..9bb40f1 --- /dev/null +++ b/demo/src/theme-context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +/** Theme applied to the MDMA examples shown throughout the demo. */ +export type ThemeMode = 'light' | 'dark' | 'auto'; + +/** + * The web examples inherit the theme through `MdmaThemeProvider`, but the React + * Native preview renders via the separate RN renderer (its own context), so it + * reads the current mode from here instead. + */ +export const DemoThemeContext = createContext('light'); + +export function useDemoThemeMode(): ThemeMode { + return useContext(DemoThemeContext); +} diff --git a/docs/guides/theming.md b/docs/guides/theming.md new file mode 100644 index 0000000..0798ce4 --- /dev/null +++ b/docs/guides/theming.md @@ -0,0 +1,135 @@ +# Theming + +MDMA renderers ship with a polished default look and a first-class theming layer on top of it. Theming is **opt-in**: render a document with no `theme` and you get the built-in light palette, unchanged. When you want more, you can switch to a dark palette, follow the operating-system preference, or hand over a fully custom set of design tokens. + +Both the web renderer (`@mobile-reality/mdma-renderer-react`) and the React Native renderer (`@mobile-reality/mdma-renderer-react-native`) expose the **same `MdmaTheme` token shape**, so a custom theme object is portable between the two. + +## The three modes + +Everything is driven by the `theme` prop on `MdmaDocument`: + +| `theme` value | Result | +| -------------------- | ------------------------------------------------------------- | +| *(omitted)* | Default light palette. No behavior change — fully backward compatible. | +| `"light"` / `"dark"` | The built-in light or dark palette. | +| `"auto"` | Follows the OS preference (`prefers-color-scheme` on web, `useColorScheme()` on native). | +| `MdmaTheme` object | Your custom tokens. | + +```tsx + // not themed (default light) + // built-in dark + // follow the OS +// custom +``` + +## The `MdmaTheme` token shape + +A theme is a plain object. This is the whole contract: + +```ts +interface MdmaTheme { + colors: { + background: string; + surface: string; + text: string; + textMuted: string; + border: string; + primary: string; + onPrimary: string; + secondary: string; + onSecondary: string; + danger: string; + onDanger: string; + // Callout accents + soft backgrounds + info: string; + infoBg: string; + warning: string; + warningBg: string; + error: string; + errorBg: string; + success: string; + successBg: string; + }; + spacing: { xs: number; sm: number; md: number; lg: number }; + radius: { sm: number; md: number }; + fontSize: { small: number; body: number; label: number; title: number }; +} +``` + +The `lightTheme` and `darkTheme` presets are exported from both renderer packages, so the easiest way to build a custom theme is to spread a preset and override just what you need: + +```ts +import { lightTheme, type MdmaTheme } from '@mobile-reality/mdma-renderer-react'; + +const brandTheme: MdmaTheme = { + ...lightTheme, + colors: { ...lightTheme.colors, primary: '#e91e63', onPrimary: '#ffffff' }, + radius: { sm: 10, md: 16 }, +}; +``` + +## Web renderer + +Import the stylesheet once, then pass a `theme`: + +```tsx +import { MdmaDocument, lightTheme } from '@mobile-reality/mdma-renderer-react'; +import '@mobile-reality/mdma-renderer-react/styles.css'; + +; +``` + +### How it works — CSS variables + +The stylesheet expresses every color, radius, and size as a `--mdma-*` CSS custom property scoped to `.mdma-document`. The defaults are the light palette, so importing the CSS with no theme renders exactly as before. + +- `theme="light" | "dark" | "auto"` sets a `data-theme` attribute on the document root; the stylesheet swaps the palette (and `"auto"` is gated behind a `prefers-color-scheme` media query). +- A custom `MdmaTheme` object is written as inline CSS variables on the root, overriding the defaults. + +Because it's all CSS variables, you can also theme **without touching the `theme` prop** — just set the variables in your own CSS: + +```css +.mdma-document { + --mdma-color-primary: #e91e63; + --mdma-radius-md: 16px; +} +``` + +The public color tokens map to `--mdma-color-` (e.g. `onPrimary` → `--mdma-color-on-primary`, `infoBg` → `--mdma-color-info-bg`); numeric scales become `--mdma-radius-*`, `--mdma-spacing-*`, and `--mdma-font-size-*` in pixels. + +### Rendering blocks without `MdmaDocument` + +If you render a lone `` outside of `MdmaDocument`, wrap it in `MdmaThemeProvider` so the variables cascade: + +```tsx +import { MdmaThemeProvider, MdmaBlock } from '@mobile-reality/mdma-renderer-react'; + + + +; +``` + +## React Native renderer + +```tsx +import { MdmaDocument } from '@mobile-reality/mdma-renderer-react-native'; + +; +``` + +`MdmaDocument` wraps its children in an `MdmaThemeProvider`. There is no stylesheet — each renderer builds its `StyleSheet` from the resolved tokens, which any custom component can read with `useMdmaTheme()`: + +```tsx +import { useMdmaTheme } from '@mobile-reality/mdma-renderer-react-native'; + +function Badge() { + const theme = useMdmaTheme(); + return ; +} +``` + +## Notes + +- **Portability.** The `colors`/`spacing`/`radius`/`fontSize` shape is identical across renderers, so a shared theme object can drive both your web and native apps. The built-in `lightTheme`/`darkTheme` *values* differ slightly per platform to match each renderer's native look. +- **Backward compatible.** Existing code that never sets `theme` is unaffected — the default light palette is the previous look. +- **Presentation stays in the renderer.** Themes live entirely in the renderers; the MDMA spec and prompts never describe appearance, so the same document renders under any theme. diff --git a/packages/agui/package.json b/packages/agui/package.json index 0f45a1a..ac3caf4 100644 --- a/packages/agui/package.json +++ b/packages/agui/package.json @@ -67,9 +67,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/attachables-core/package.json b/packages/attachables-core/package.json index 7ab4072..6d9d88f 100644 --- a/packages/attachables-core/package.json +++ b/packages/attachables-core/package.json @@ -31,9 +31,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index b30befd..7a76914 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,15 +1,7 @@ { "name": "@mobile-reality/mdma-cli", "version": "0.2.9", - "keywords": [ - "mdma", - "markdown", - "ai", - "llm", - "interactive-document", - "cli", - "prompt-builder" - ], + "keywords": ["mdma", "markdown", "ai", "llm", "interactive-document", "cli", "prompt-builder"], "type": "module", "bin": { "mdma": "./dist/bin/mdma.js" @@ -68,10 +60,7 @@ "vite": "^6.0.0", "vitest": "^3.2.0" }, - "files": [ - "dist", - "app-dist" - ], + "files": ["dist", "app-dist"], "publishConfig": { "access": "public" }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 35b689f..e21ae8a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -40,9 +40,7 @@ "vitest": "^3.2.0", "zod": "^3.24.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/parser/package.json b/packages/parser/package.json index 004c87e..b07dae8 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -41,9 +41,7 @@ "vitest": "^3.2.0", "zod": "^3.24.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/prompt-pack/package.json b/packages/prompt-pack/package.json index c4070a5..d3351f6 100644 --- a/packages/prompt-pack/package.json +++ b/packages/prompt-pack/package.json @@ -37,9 +37,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/renderer-react-native/README.md b/packages/renderer-react-native/README.md index 704735c..95fee43 100644 --- a/packages/renderer-react-native/README.md +++ b/packages/renderer-react-native/README.md @@ -43,8 +43,13 @@ function Screen({ ast, store }) { ### Theming -`MdmaDocument` wraps its children in an `MdmaThemeProvider`. Pass `theme="light" | "dark"` or a -full `MdmaTheme` token object. Renderers read tokens via `useMdmaTheme()`. +`MdmaDocument` wraps its children in an `MdmaThemeProvider`. Pass `theme="light" | "dark" | "auto"` +(`"auto"` follows the OS color scheme via `useColorScheme()`) or a full `MdmaTheme` token object. +Renderers read tokens via `useMdmaTheme()`. Omit `theme` entirely for the default light palette. + +The `MdmaTheme` token shape is shared with the web renderer +(`@mobile-reality/mdma-renderer-react`), so a custom theme object is portable between the two. +See the repo [Theming guide](../../docs/guides/theming.md) for the full token reference. ### Customizations diff --git a/packages/renderer-react-native/package.json b/packages/renderer-react-native/package.json index 100b6cf..44e70de 100644 --- a/packages/renderer-react-native/package.json +++ b/packages/renderer-react-native/package.json @@ -46,9 +46,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/renderer-react-native/src/components/ChartRenderer.tsx b/packages/renderer-react-native/src/components/ChartRenderer.tsx index 43de1b7..dfaafe3 100644 --- a/packages/renderer-react-native/src/components/ChartRenderer.tsx +++ b/packages/renderer-react-native/src/components/ChartRenderer.tsx @@ -70,9 +70,14 @@ export const ChartRenderer = memo(function ChartRenderer({ ) : ( - + {data.headers.map((h) => ( ; } -function isComponentConfig( - entry: ComponentEntry, -): entry is { +function isComponentConfig(entry: ComponentEntry): entry is { renderer?: ComponentType; elements?: Record; } { diff --git a/packages/renderer-react-native/src/components/TableRenderer.tsx b/packages/renderer-react-native/src/components/TableRenderer.tsx index 05a03ab..837e930 100644 --- a/packages/renderer-react-native/src/components/TableRenderer.tsx +++ b/packages/renderer-react-native/src/components/TableRenderer.tsx @@ -41,9 +41,14 @@ export const TableRenderer = memo(function TableRenderer({ - + {/* Header */} {component.columns.map((col) => ( diff --git a/packages/renderer-react-native/src/theme/MdmaThemeProvider.tsx b/packages/renderer-react-native/src/theme/MdmaThemeProvider.tsx index 1da9dca..0372055 100644 --- a/packages/renderer-react-native/src/theme/MdmaThemeProvider.tsx +++ b/packages/renderer-react-native/src/theme/MdmaThemeProvider.tsx @@ -1,92 +1,8 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { useColorScheme } from 'react-native'; +import { type MdmaTheme, lightTheme, darkTheme } from './tokens.js'; -/** - * Design tokens for the RN renderer. This is the React Native answer to - * `renderer-react`'s `styles.css`: instead of `.mdma-*` classes, every - * renderer reads these tokens and builds `StyleSheet` values from them. - * The default light/dark palettes are chosen to visually track the web look. - */ -export interface MdmaTheme { - colors: { - background: string; - surface: string; - text: string; - textMuted: string; - border: string; - primary: string; - onPrimary: string; - secondary: string; - onSecondary: string; - danger: string; - onDanger: string; - /** Callout variant accents + soft backgrounds. */ - info: string; - infoBg: string; - warning: string; - warningBg: string; - error: string; - errorBg: string; - success: string; - successBg: string; - }; - spacing: { xs: number; sm: number; md: number; lg: number }; - radius: { sm: number; md: number }; - fontSize: { small: number; body: number; label: number; title: number }; -} - -export const lightTheme: MdmaTheme = { - colors: { - background: '#ffffff', - surface: '#f7f7f8', - text: '#1a1a1a', - textMuted: '#6b7280', - border: '#e5e7eb', - primary: '#2563eb', - onPrimary: '#ffffff', - secondary: '#e5e7eb', - onSecondary: '#1a1a1a', - danger: '#dc2626', - onDanger: '#ffffff', - info: '#2563eb', - infoBg: '#eff6ff', - warning: '#d97706', - warningBg: '#fffbeb', - error: '#dc2626', - errorBg: '#fef2f2', - success: '#16a34a', - successBg: '#f0fdf4', - }, - spacing: { xs: 4, sm: 8, md: 12, lg: 16 }, - radius: { sm: 6, md: 10 }, - fontSize: { small: 12, body: 14, label: 15, title: 17 }, -}; - -export const darkTheme: MdmaTheme = { - colors: { - background: '#0b0f19', - surface: '#151a26', - text: '#f3f4f6', - textMuted: '#9ca3af', - border: '#2a3140', - primary: '#3b82f6', - onPrimary: '#ffffff', - secondary: '#2a3140', - onSecondary: '#f3f4f6', - danger: '#ef4444', - onDanger: '#ffffff', - info: '#3b82f6', - infoBg: '#172033', - warning: '#f59e0b', - warningBg: '#2a2312', - error: '#ef4444', - errorBg: '#2a1717', - success: '#22c55e', - successBg: '#12251a', - }, - spacing: { xs: 4, sm: 8, md: 12, lg: 16 }, - radius: { sm: 6, md: 10 }, - fontSize: { small: 12, body: 14, label: 15, title: 17 }, -}; +export { type MdmaTheme, lightTheme, darkTheme } from './tokens.js'; const MdmaThemeContext = createContext(lightTheme); @@ -95,17 +11,23 @@ export function useMdmaTheme(): MdmaTheme { } export interface MdmaThemeProviderProps { - /** A full theme, or `'light'` / `'dark'` for the built-in palettes. */ - theme?: MdmaTheme | 'light' | 'dark'; + /** + * A full theme, or one of the built-in palettes: + * - `'light'` / `'dark'` — the fixed built-in palettes. + * - `'auto'` — follows the OS color scheme via `useColorScheme()`. + */ + theme?: MdmaTheme | 'light' | 'dark' | 'auto'; children: ReactNode; } export function MdmaThemeProvider({ theme = 'light', children }: MdmaThemeProviderProps) { + const colorScheme = useColorScheme(); const resolved = useMemo(() => { + if (theme === 'auto') return colorScheme === 'dark' ? darkTheme : lightTheme; if (theme === 'light') return lightTheme; if (theme === 'dark') return darkTheme; return theme; - }, [theme]); + }, [theme, colorScheme]); return {children}; } diff --git a/packages/renderer-react-native/src/theme/tokens.ts b/packages/renderer-react-native/src/theme/tokens.ts new file mode 100644 index 0000000..ea3c7ef --- /dev/null +++ b/packages/renderer-react-native/src/theme/tokens.ts @@ -0,0 +1,93 @@ +/** + * Design tokens for the RN renderer. This is the React Native answer to + * `renderer-react`'s `styles.css`: instead of `.mdma-*` classes, every + * renderer reads these tokens and builds `StyleSheet` values from them. + * + * The light/dark palettes are kept **identical** to the web renderer's built-in + * themes (`renderer-react`'s `lightTheme`/`darkTheme`) so MDMA content looks the + * same across web and native. Keep the two in sync when either changes. + * + * This module is intentionally free of any `react-native` import so the pure + * token values stay usable (and unit-testable) without an RN runtime. + */ +export interface MdmaTheme { + colors: { + background: string; + surface: string; + text: string; + textMuted: string; + border: string; + primary: string; + onPrimary: string; + secondary: string; + onSecondary: string; + danger: string; + onDanger: string; + /** Callout variant accents + soft backgrounds. */ + info: string; + infoBg: string; + warning: string; + warningBg: string; + error: string; + errorBg: string; + success: string; + successBg: string; + }; + spacing: { xs: number; sm: number; md: number; lg: number }; + radius: { sm: number; md: number }; + fontSize: { small: number; body: number; label: number; title: number }; +} + +export const lightTheme: MdmaTheme = { + colors: { + background: '#ffffff', + surface: '#f8f9fa', + text: '#333333', + textMuted: '#666666', + border: '#e0e0e0', + primary: '#6c5ce7', + onPrimary: '#ffffff', + secondary: '#dfe6e9', + onSecondary: '#2d3436', + danger: '#e74c3c', + onDanger: '#ffffff', + info: '#3498db', + infoBg: '#ebf5fb', + warning: '#f39c12', + warningBg: '#fef9e7', + error: '#e74c3c', + errorBg: '#fdedec', + success: '#27ae60', + successBg: '#eafaf1', + }, + spacing: { xs: 4, sm: 8, md: 12, lg: 16 }, + radius: { sm: 6, md: 10 }, + fontSize: { small: 13, body: 14, label: 15, title: 18 }, +}; + +export const darkTheme: MdmaTheme = { + colors: { + background: '#151a26', + surface: '#1c2333', + text: '#e5e7eb', + textMuted: '#9ca3af', + border: '#2a3140', + primary: '#8b7ff0', + onPrimary: '#ffffff', + secondary: '#2a3140', + onSecondary: '#e5e7eb', + danger: '#ef4444', + onDanger: '#ffffff', + info: '#5dade2', + infoBg: '#16283a', + warning: '#f5b041', + warningBg: '#2a2412', + error: '#ef4444', + errorBg: '#2a1717', + success: '#2ecc71', + successBg: '#12251a', + }, + spacing: { xs: 4, sm: 8, md: 12, lg: 16 }, + radius: { sm: 6, md: 10 }, + fontSize: { small: 13, body: 14, label: 15, title: 18 }, +}; diff --git a/packages/renderer-react-native/tests/theme.test.ts b/packages/renderer-react-native/tests/theme.test.ts index a750e6b..e744771 100644 --- a/packages/renderer-react-native/tests/theme.test.ts +++ b/packages/renderer-react-native/tests/theme.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { lightTheme, darkTheme, type MdmaTheme } from '../src/theme/MdmaThemeProvider.js'; +import { lightTheme, darkTheme, type MdmaTheme } from '../src/theme/tokens.js'; // Structural parity guard: the light and dark palettes must expose exactly the // same token shape, so a renderer styled against one never hits an undefined diff --git a/packages/renderer-react/package.json b/packages/renderer-react/package.json index b6787aa..ce80ef0 100644 --- a/packages/renderer-react/package.json +++ b/packages/renderer-react/package.json @@ -1,16 +1,7 @@ { "name": "@mobile-reality/mdma-renderer-react", "version": "0.4.1", - "keywords": [ - "mdma", - "markdown", - "ai", - "llm", - "interactive-document", - "react", - "forms", - "ui" - ], + "keywords": ["mdma", "markdown", "ai", "llm", "interactive-document", "react", "forms", "ui"], "type": "module", "exports": { ".": { @@ -38,10 +29,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist", - "styles.css" - ], + "files": ["dist", "styles.css"], "publishConfig": { "access": "public" }, diff --git a/packages/renderer-react/src/components/MdmaDocument.tsx b/packages/renderer-react/src/components/MdmaDocument.tsx index be22d46..514fff5 100644 --- a/packages/renderer-react/src/components/MdmaDocument.tsx +++ b/packages/renderer-react/src/components/MdmaDocument.tsx @@ -1,12 +1,17 @@ import type { MdmaRoot, MdmaBlock as MdmaBlockType } from '@mobile-reality/mdma-spec'; import { MDMA_LANG_TAG } from '@mobile-reality/mdma-spec'; import type { DocumentStore } from '@mobile-reality/mdma-runtime'; -import { useRef, useMemo, type ComponentType } from 'react'; +import { useRef, useMemo, useContext, type ComponentType } from 'react'; import { MdmaProvider } from '../context/MdmaProvider.js'; import { ElementOverridesProvider, type ElementOverrides, } from '../context/ElementOverridesContext.js'; +import { + MdmaThemeContext, + resolveThemeProps, + type MdmaThemeInput, +} from '../theme/MdmaThemeProvider.js'; import { MdmaBlock } from './MdmaBlock.js'; import { MdmaBlockLoading } from './MdmaBlockLoading.js'; import { MdastRenderer } from './MdastRenderer.js'; @@ -48,6 +53,12 @@ export interface MdmaDocumentProps { store: DocumentStore; /** Rendering customizations (component renderers + element overrides). */ customizations?: MdmaRenderCustomizations; + /** + * Theme applied to the document root: `'light'` | `'dark'` | `'auto'` + * (follows the OS preference), or a full `MdmaTheme` for custom tokens. + * Omit to use the stylesheet's default (light) look. + */ + theme?: MdmaThemeInput; className?: string; } @@ -148,12 +159,20 @@ function buildPartialThinkingBlock(yaml: string): MdmaBlockType | null { } as MdmaBlockType; } -export function MdmaDocument({ ast, store, customizations, className }: MdmaDocumentProps) { +export function MdmaDocument({ ast, store, customizations, theme, className }: MdmaDocumentProps) { const { renderers, elementOverrides } = useMemo( () => splitComponents(customizations?.components), [customizations?.components], ); + // Resolve our own `theme` prop, or inherit an ancestor `MdmaThemeProvider`'s + // theme when none is given (so a single provider can theme a whole app). + const inheritedTheme = useContext(MdmaThemeContext); + const themeProps = useMemo( + () => (theme !== undefined ? resolveThemeProps(theme) : inheritedTheme), + [theme, inheritedTheme], + ); + // Cache of successfully parsed blocks by component ID. Prevents flickering // during streaming when a block alternates between parsed and pending states // as incomplete YAML temporarily fails to validate. @@ -162,58 +181,64 @@ export function MdmaDocument({ ast, store, customizations, className }: MdmaDocu return ( -
- {ast.children.map((child, index) => { - if (isMdmaBlock(child)) { - // Cache this successfully parsed block - renderedBlocksRef.current.set(child.component.id, child); - return ; - } - // Incomplete MDMA blocks (still streaming or failed validation) - if (isPendingMdmaBlock(child)) { - const pendingYaml = (child as { value?: string }).value; - const pendingId = extractIdFromYaml(pendingYaml); - - // If this block was previously rendered, keep showing the rendered - // version instead of flickering back to the loading skeleton. - const cachedBlock = pendingId ? renderedBlocksRef.current.get(pendingId) : null; - if (cachedBlock) { - return ( - - ); + +
+ {ast.children.map((child, index) => { + if (isMdmaBlock(child)) { + // Cache this successfully parsed block + renderedBlocksRef.current.set(child.component.id, child); + return ; } + // Incomplete MDMA blocks (still streaming or failed validation) + if (isPendingMdmaBlock(child)) { + const pendingYaml = (child as { value?: string }).value; + const pendingId = extractIdFromYaml(pendingYaml); - // Thinking blocks stream their content live instead of showing - // a loading skeleton — build a synthetic block from partial YAML. - const pendingType = extractTypeFromYaml(pendingYaml); - if (pendingType === 'thinking' && pendingYaml) { - const partialBlock = buildPartialThinkingBlock(pendingYaml); - if (partialBlock) { + // If this block was previously rendered, keep showing the rendered + // version instead of flickering back to the loading skeleton. + const cachedBlock = pendingId ? renderedBlocksRef.current.get(pendingId) : null; + if (cachedBlock) { return ( ); } - } - return ; - } - // Render standard Markdown nodes (headings, paragraphs, lists, etc.) - return ( - [0]['node']} - /> - ); - })} -
+ // Thinking blocks stream their content live instead of showing + // a loading skeleton — build a synthetic block from partial YAML. + const pendingType = extractTypeFromYaml(pendingYaml); + if (pendingType === 'thinking' && pendingYaml) { + const partialBlock = buildPartialThinkingBlock(pendingYaml); + if (partialBlock) { + return ( + + ); + } + } + + return ; + } + // Render standard Markdown nodes (headings, paragraphs, lists, etc.) + return ( + [0]['node']} + /> + ); + })} +
+
); diff --git a/packages/renderer-react/src/index.ts b/packages/renderer-react/src/index.ts index 66bfab3..591d64a 100644 --- a/packages/renderer-react/src/index.ts +++ b/packages/renderer-react/src/index.ts @@ -13,6 +13,18 @@ export { type MdmaProviderProps, type DataSources, } from './context/MdmaProvider.js'; +export { + MdmaThemeProvider, + useMdmaTheme, + resolveThemeProps, + themeToCssVars, + lightTheme, + darkTheme, + type MdmaTheme, + type MdmaThemeInput, + type MdmaThemeProviderProps, + type ResolvedThemeProps, +} from './theme/MdmaThemeProvider.js'; export { ElementOverridesProvider, useElementOverride, diff --git a/packages/renderer-react/src/theme/MdmaThemeProvider.tsx b/packages/renderer-react/src/theme/MdmaThemeProvider.tsx new file mode 100644 index 0000000..99bd98d --- /dev/null +++ b/packages/renderer-react/src/theme/MdmaThemeProvider.tsx @@ -0,0 +1,217 @@ +import { createContext, useContext, useMemo, type CSSProperties, type ReactNode } from 'react'; + +/** + * Design tokens for the web renderer. This is the typed mirror of the CSS + * custom properties in `styles.css`: the same token shape the React Native + * renderer exposes (`renderer-react-native`'s `MdmaTheme`), so a theme object + * is portable between the two renderers. + * + * You rarely need to read these tokens directly — the components are styled via + * `.mdma-*` classes that read the matching `--mdma-*` CSS variables. A custom + * theme just rewrites those variables (see {@link MdmaThemeProvider}). + */ +export interface MdmaTheme { + colors: { + background: string; + surface: string; + text: string; + textMuted: string; + border: string; + primary: string; + onPrimary: string; + secondary: string; + onSecondary: string; + danger: string; + onDanger: string; + /** Callout variant accents + soft backgrounds. */ + info: string; + infoBg: string; + warning: string; + warningBg: string; + error: string; + errorBg: string; + success: string; + successBg: string; + }; + spacing: { xs: number; sm: number; md: number; lg: number }; + radius: { sm: number; md: number }; + fontSize: { small: number; body: number; label: number; title: number }; +} + +/** Built-in light palette — matches the default `--mdma-*` values in `styles.css`. */ +export const lightTheme: MdmaTheme = { + colors: { + background: '#ffffff', + surface: '#f8f9fa', + text: '#333333', + textMuted: '#666666', + border: '#e0e0e0', + primary: '#6c5ce7', + onPrimary: '#ffffff', + secondary: '#dfe6e9', + onSecondary: '#2d3436', + danger: '#e74c3c', + onDanger: '#ffffff', + info: '#3498db', + infoBg: '#ebf5fb', + warning: '#f39c12', + warningBg: '#fef9e7', + error: '#e74c3c', + errorBg: '#fdedec', + success: '#27ae60', + successBg: '#eafaf1', + }, + spacing: { xs: 4, sm: 8, md: 12, lg: 16 }, + radius: { sm: 6, md: 10 }, + fontSize: { small: 13, body: 14, label: 15, title: 18 }, +}; + +/** Built-in dark palette — matches the `[data-theme='dark']` block in `styles.css`. */ +export const darkTheme: MdmaTheme = { + colors: { + background: '#151a26', + surface: '#1c2333', + text: '#e5e7eb', + textMuted: '#9ca3af', + border: '#2a3140', + primary: '#8b7ff0', + onPrimary: '#ffffff', + secondary: '#2a3140', + onSecondary: '#e5e7eb', + danger: '#ef4444', + onDanger: '#ffffff', + info: '#5dade2', + infoBg: '#16283a', + warning: '#f5b041', + warningBg: '#2a2412', + error: '#ef4444', + errorBg: '#2a1717', + success: '#2ecc71', + successBg: '#12251a', + }, + spacing: { xs: 4, sm: 8, md: 12, lg: 16 }, + radius: { sm: 6, md: 10 }, + fontSize: { small: 13, body: 14, label: 15, title: 18 }, +}; + +/** + * What can be passed as a `theme`: + * - `'light'` / `'dark'` — the built-in palettes (applied via `data-theme`). + * - `'auto'` — follows the OS `prefers-color-scheme`. + * - a full {@link MdmaTheme} — custom tokens, written inline as CSS variables. + * - `undefined` — no theme; the stylesheet's default (light) tokens apply. + */ +export type MdmaThemeInput = MdmaTheme | 'light' | 'dark' | 'auto'; + +/** + * Nearest resolved theme. Holds the full {@link ResolvedThemeProps} (not just + * the tokens) so a descendant `MdmaDocument` with no `theme` prop of its own can + * inherit the ancestor's `data-theme` / inline variables and re-apply them to + * its own root. The default is the unthemed light look. + */ +const MdmaThemeContext = createContext({ theme: lightTheme }); + +/** The resolved token object for the nearest theme (defaults to {@link lightTheme}). */ +export function useMdmaTheme(): MdmaTheme { + return useContext(MdmaThemeContext).theme; +} + +export { MdmaThemeContext }; + +function camelToKebab(key: string): string { + return key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); +} + +/** + * Convert an {@link MdmaTheme} into the inline CSS-variable style object the + * `.mdma-*` classes read. Numeric scales are emitted as `px`. + */ +export function themeToCssVars(theme: MdmaTheme): CSSProperties { + const vars: Record = {}; + for (const [key, value] of Object.entries(theme.colors)) { + vars[`--mdma-color-${camelToKebab(key)}`] = value; + } + for (const [key, value] of Object.entries(theme.spacing)) { + vars[`--mdma-spacing-${key}`] = `${value}px`; + } + for (const [key, value] of Object.entries(theme.radius)) { + vars[`--mdma-radius-${key}`] = `${value}px`; + } + for (const [key, value] of Object.entries(theme.fontSize)) { + vars[`--mdma-font-size-${camelToKebab(key)}`] = `${value}px`; + } + return vars as CSSProperties; +} + +export interface ResolvedThemeProps { + /** `data-theme` attribute value, when a built-in palette is selected. */ + dataTheme?: 'light' | 'dark' | 'auto'; + /** Inline CSS variables, when a custom theme object is provided. */ + style?: CSSProperties; + /** The resolved token object exposed via {@link useMdmaTheme}. */ + theme: MdmaTheme; +} + +/** + * Resolve a {@link MdmaThemeInput} into the DOM props that apply it: a + * `data-theme` attribute for built-in palettes, or inline CSS variables for a + * custom theme. Shared by {@link MdmaThemeProvider} and `MdmaDocument`. + */ +export function resolveThemeProps(theme: MdmaThemeInput | undefined): ResolvedThemeProps { + if (theme === undefined) return { theme: lightTheme }; + if (theme === 'light') return { dataTheme: 'light', theme: lightTheme }; + if (theme === 'dark') return { dataTheme: 'dark', theme: darkTheme }; + // `auto` follows the OS preference at the CSS layer; the resolved token + // object is a best-effort light default for any JS consumer of useMdmaTheme. + if (theme === 'auto') return { dataTheme: 'auto', theme: lightTheme }; + // A custom theme sets the public tokens inline, but the stylesheet also has + // internal derived vars (heading text, code backgrounds, …) that aren't on + // the public type. Pick the light/dark base by the theme's own background so + // those derived vars match — otherwise a custom *dark* theme would render, + // e.g., near-black heading text on a dark surface. + return { + dataTheme: isDarkTheme(theme) ? 'dark' : 'light', + style: themeToCssVars(theme), + theme, + }; +} + +/** Heuristic: is this theme's background dark? (relative luminance < 50%). */ +function isDarkTheme(theme: MdmaTheme): boolean { + const hex = theme.colors.background.replace('#', ''); + if (hex.length < 6) return false; + const r = Number.parseInt(hex.slice(0, 2), 16); + const g = Number.parseInt(hex.slice(2, 4), 16); + const b = Number.parseInt(hex.slice(4, 6), 16); + if (Number.isNaN(r + g + b)) return false; + return 0.299 * r + 0.587 * g + 0.114 * b < 128; +} + +export interface MdmaThemeProviderProps { + /** `'light'` | `'dark'` | `'auto'`, or a full {@link MdmaTheme}. */ + theme?: MdmaThemeInput; + /** Extra class names for the wrapper element. */ + className?: string; + /** Extra inline styles, merged after the theme's CSS variables. */ + style?: CSSProperties; + children: ReactNode; +} + +/** + * Standalone theme wrapper for rendering MDMA components *without* `MdmaDocument` + * (e.g. a lone ``). Renders a `.mdma-theme-root` element carrying the + * theme so the `--mdma-*` variables cascade to everything inside. When you use + * `MdmaDocument`, pass `theme` to it directly instead — it themes its own root. + */ +export function MdmaThemeProvider({ theme, className, style, children }: MdmaThemeProviderProps) { + const resolved = useMemo(() => resolveThemeProps(theme), [theme]); + return ( +
+ {children} +
+ ); +} diff --git a/packages/renderer-react/styles.css b/packages/renderer-react/styles.css index 685e198..8b2ad89 100644 --- a/packages/renderer-react/styles.css +++ b/packages/renderer-react/styles.css @@ -1,7 +1,213 @@ +/* ===== MDMA Theme Tokens ===== + * + * Every color/radius/size below is driven by a CSS custom property so the look + * can be themed without touching component markup. The default values encode + * the built-in *light* theme, so importing this stylesheet with no theme set + * renders exactly as before — theming is opt-in. + * + * How the knobs are set: + * - No theme → these defaults apply (light). + * - theme="light|dark" → `data-theme` on the root swaps the palette (below). + * - theme="auto" → follows `prefers-color-scheme`. + * - a custom MdmaTheme → `MdmaThemeProvider` writes the public `--mdma-*` + * tokens inline on the root, overriding these. + * + * `--mdma-color-*` names ending without a `-text`/`-bg` suffix that also appear + * on the `MdmaTheme` TS type are the *public* tokens a custom theme can set. + * The rest are internal derivations that follow the light/dark palette. + */ +.mdma-document, +.mdma-theme-root { + /* Public palette (mirrors the MdmaTheme `colors` shape) */ + --mdma-color-background: #ffffff; + --mdma-color-surface: #f8f9fa; + --mdma-color-text: #333333; + --mdma-color-text-muted: #666666; + --mdma-color-border: #e0e0e0; + --mdma-color-primary: #6c5ce7; + --mdma-color-on-primary: #ffffff; + --mdma-color-secondary: #dfe6e9; + --mdma-color-on-secondary: #2d3436; + --mdma-color-danger: #e74c3c; + --mdma-color-on-danger: #ffffff; + --mdma-color-info: #3498db; + --mdma-color-info-bg: #ebf5fb; + --mdma-color-warning: #f39c12; + --mdma-color-warning-bg: #fef9e7; + --mdma-color-error: #e74c3c; + --mdma-color-error-bg: #fdedec; + --mdma-color-success: #27ae60; + --mdma-color-success-bg: #eafaf1; + + /* Internal derivations (follow the palette; not on the public TS type) */ + --mdma-color-heading: #1a1a2e; + --mdma-color-text-subtle: #555555; + --mdma-color-border-subtle: #f0f0f0; + --mdma-color-primary-hover: color-mix(in srgb, var(--mdma-color-primary) 85%, #000000); + --mdma-color-secondary-hover: color-mix(in srgb, var(--mdma-color-secondary) 88%, #000000); + --mdma-color-danger-hover: color-mix(in srgb, var(--mdma-color-danger) 84%, #000000); + --mdma-color-input-bg: #fafafa; + --mdma-color-focus-ring: color-mix(in srgb, var(--mdma-color-primary) 22%, transparent); + --mdma-color-info-text: #1a5276; + --mdma-color-warning-text: #7d6608; + --mdma-color-error-text: #78281f; + --mdma-color-success-text: #1e8449; + --mdma-color-code-bg: #1e1e2e; + --mdma-color-code-text: #e0e0e0; + --mdma-color-inline-code-bg: #f0f0f5; + --mdma-color-inline-code-text: #c0392b; + --mdma-color-loading-bg: #ffffff; + --mdma-color-loading-border: #c4bfff; + --mdma-color-loading-accent: #6c63ff; + --mdma-color-loading-text: #8b82d4; + --mdma-color-table-header-bg: #f8f9fa; + --mdma-color-row-hover: #f8f9fa; + --mdma-color-blockquote-bg: #f8f8ff; + --mdma-color-chart-bg: rgba(255, 255, 255, 0.02); + --mdma-color-chart-border: rgba(108, 92, 231, 0.15); + --mdma-color-chart-label: #a29bfe; + --mdma-color-chart-axis: #636e72; + --mdma-color-chart-legend: #b2bec3; + --mdma-color-chart-tooltip-bg: #1a1a2e; + --mdma-color-unknown-bg: #fff3cd; + --mdma-color-unknown-border: #ffc107; + --mdma-color-unknown-text: #856404; + + /* Radius / spacing / font-size scales (mirror the MdmaTheme numeric tokens) */ + --mdma-radius-sm: 6px; + --mdma-radius-md: 10px; + --mdma-spacing-xs: 4px; + --mdma-spacing-sm: 8px; + --mdma-spacing-md: 12px; + --mdma-spacing-lg: 16px; + --mdma-font-size-small: 13px; + --mdma-font-size-body: 14px; + --mdma-font-size-label: 15px; + --mdma-font-size-title: 18px; +} + +/* ===== Built-in dark palette ===== */ +.mdma-document[data-theme="dark"], +.mdma-theme-root[data-theme="dark"] { + --mdma-color-background: #151a26; + --mdma-color-surface: #1c2333; + --mdma-color-text: #e5e7eb; + --mdma-color-text-muted: #9ca3af; + --mdma-color-border: #2a3140; + --mdma-color-primary: #8b7ff0; + --mdma-color-on-primary: #ffffff; + --mdma-color-secondary: #2a3140; + --mdma-color-on-secondary: #e5e7eb; + --mdma-color-danger: #ef4444; + --mdma-color-on-danger: #ffffff; + --mdma-color-info: #5dade2; + --mdma-color-info-bg: #16283a; + --mdma-color-warning: #f5b041; + --mdma-color-warning-bg: #2a2412; + --mdma-color-error: #ef4444; + --mdma-color-error-bg: #2a1717; + --mdma-color-success: #2ecc71; + --mdma-color-success-bg: #12251a; + + --mdma-color-heading: #f3f4f6; + --mdma-color-text-subtle: #cbd0d8; + --mdma-color-border-subtle: #232a38; + --mdma-color-primary-hover: color-mix(in srgb, var(--mdma-color-primary) 80%, #ffffff); + --mdma-color-secondary-hover: color-mix(in srgb, var(--mdma-color-secondary) 78%, #ffffff); + --mdma-color-danger-hover: color-mix(in srgb, var(--mdma-color-danger) 80%, #ffffff); + --mdma-color-input-bg: #10141d; + --mdma-color-focus-ring: color-mix(in srgb, var(--mdma-color-primary) 35%, transparent); + --mdma-color-info-text: #aed6f1; + --mdma-color-warning-text: #f8c471; + --mdma-color-error-text: #f5b7b1; + --mdma-color-success-text: #a9dfbf; + --mdma-color-code-bg: #0d1017; + --mdma-color-code-text: #e0e0e0; + --mdma-color-inline-code-bg: #232a38; + --mdma-color-inline-code-text: #f19999; + --mdma-color-loading-bg: #1c2333; + --mdma-color-loading-border: #3a3466; + --mdma-color-loading-accent: #8b7ff0; + --mdma-color-loading-text: #a29bfe; + --mdma-color-table-header-bg: #1c2333; + --mdma-color-row-hover: #1c2333; + --mdma-color-blockquote-bg: #1c2333; + --mdma-color-chart-bg: rgba(255, 255, 255, 0.02); + --mdma-color-chart-border: rgba(162, 155, 254, 0.2); + --mdma-color-chart-label: #a29bfe; + --mdma-color-chart-axis: #9ca3af; + --mdma-color-chart-legend: #b2bec3; + --mdma-color-chart-tooltip-bg: #0d1017; + --mdma-color-unknown-bg: #2a2412; + --mdma-color-unknown-border: #b7950b; + --mdma-color-unknown-text: #f8c471; +} + +/* theme="auto" — same dark palette, gated on the OS preference. */ +@media (prefers-color-scheme: dark) { + .mdma-document[data-theme="auto"], + .mdma-theme-root[data-theme="auto"] { + --mdma-color-background: #151a26; + --mdma-color-surface: #1c2333; + --mdma-color-text: #e5e7eb; + --mdma-color-text-muted: #9ca3af; + --mdma-color-border: #2a3140; + --mdma-color-primary: #8b7ff0; + --mdma-color-on-primary: #ffffff; + --mdma-color-secondary: #2a3140; + --mdma-color-on-secondary: #e5e7eb; + --mdma-color-danger: #ef4444; + --mdma-color-on-danger: #ffffff; + --mdma-color-info: #5dade2; + --mdma-color-info-bg: #16283a; + --mdma-color-warning: #f5b041; + --mdma-color-warning-bg: #2a2412; + --mdma-color-error: #ef4444; + --mdma-color-error-bg: #2a1717; + --mdma-color-success: #2ecc71; + --mdma-color-success-bg: #12251a; + + --mdma-color-heading: #f3f4f6; + --mdma-color-text-subtle: #cbd0d8; + --mdma-color-border-subtle: #232a38; + --mdma-color-primary-hover: color-mix(in srgb, var(--mdma-color-primary) 80%, #ffffff); + --mdma-color-secondary-hover: color-mix(in srgb, var(--mdma-color-secondary) 78%, #ffffff); + --mdma-color-danger-hover: color-mix(in srgb, var(--mdma-color-danger) 80%, #ffffff); + --mdma-color-input-bg: #10141d; + --mdma-color-focus-ring: color-mix(in srgb, var(--mdma-color-primary) 35%, transparent); + --mdma-color-info-text: #aed6f1; + --mdma-color-warning-text: #f8c471; + --mdma-color-error-text: #f5b7b1; + --mdma-color-success-text: #a9dfbf; + --mdma-color-code-bg: #0d1017; + --mdma-color-code-text: #e0e0e0; + --mdma-color-inline-code-bg: #232a38; + --mdma-color-inline-code-text: #f19999; + --mdma-color-loading-bg: #1c2333; + --mdma-color-loading-border: #3a3466; + --mdma-color-loading-accent: #8b7ff0; + --mdma-color-loading-text: #a29bfe; + --mdma-color-table-header-bg: #1c2333; + --mdma-color-row-hover: #1c2333; + --mdma-color-blockquote-bg: #1c2333; + --mdma-color-chart-bg: rgba(255, 255, 255, 0.02); + --mdma-color-chart-border: rgba(162, 155, 254, 0.2); + --mdma-color-chart-label: #a29bfe; + --mdma-color-chart-axis: #9ca3af; + --mdma-color-chart-legend: #b2bec3; + --mdma-color-chart-tooltip-bg: #0d1017; + --mdma-color-unknown-bg: #2a2412; + --mdma-color-unknown-border: #b7950b; + --mdma-color-unknown-text: #f8c471; + } +} + /* ===== MDMA Document ===== */ .mdma-document { max-width: 720px; margin: 0 auto; + color: var(--mdma-color-text); + font-size: var(--mdma-font-size-body); } /* ===== Entrance animation for all MDMA children ===== */ @@ -35,9 +241,9 @@ .mdma-block-loading { position: relative; overflow: hidden; - background: #fff; - border: 1px dashed #c4bfff; - border-radius: 10px; + background: var(--mdma-color-loading-bg); + border: 1px dashed var(--mdma-color-loading-border); + border-radius: var(--mdma-radius-md); padding: 28px 24px; margin-bottom: 20px; min-height: 80px; @@ -75,7 +281,7 @@ display: flex; align-items: center; gap: 10px; - color: #8b82d4; + color: var(--mdma-color-loading-text); font-size: 13px; } @@ -83,7 +289,7 @@ width: 18px; height: 18px; border: 2px solid rgba(108, 99, 255, 0.2); - border-top-color: #6c63ff; + border-top-color: var(--mdma-color-loading-accent); border-radius: 50%; animation: mdma-spin 0.8s linear infinite; } @@ -136,7 +342,7 @@ margin: 16px 0 8px; font-weight: 600; line-height: 1.3; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-markdown-content h1 { @@ -178,9 +384,9 @@ .mdma-markdown-content blockquote { margin: 0 0 10px; padding: 8px 16px; - border-left: 3px solid #6c63ff; - background: #f8f8ff; - color: #444; + border-left: 3px solid var(--mdma-color-primary); + background: var(--mdma-color-blockquote-bg); + color: var(--mdma-color-text-subtle); } .mdma-markdown-content blockquote p:last-child { @@ -190,9 +396,9 @@ .mdma-markdown-content pre.mdast-code-block { margin: 0 0 10px; padding: 12px 16px; - background: #1e1e2e; - color: #e0e0e0; - border-radius: 6px; + background: var(--mdma-color-code-bg); + color: var(--mdma-color-code-text); + border-radius: var(--mdma-radius-sm); overflow-x: auto; font-size: 13px; line-height: 1.5; @@ -206,21 +412,21 @@ } .mdma-markdown-content code.mdast-inline-code { - background: #f0f0f5; + background: var(--mdma-color-inline-code-bg); padding: 2px 6px; border-radius: 3px; font-size: 0.9em; - color: #c0392b; + color: var(--mdma-color-inline-code-text); } .mdma-markdown-content hr { margin: 16px 0; border: none; - border-top: 1px solid #ddd; + border-top: 1px solid var(--mdma-color-border); } .mdma-markdown-content a { - color: #6c63ff; + color: var(--mdma-color-primary); text-decoration: none; } @@ -231,7 +437,7 @@ .mdma-markdown-content img { max-width: 100%; height: auto; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); } .mdma-markdown-content table.mdast-table { @@ -244,17 +450,17 @@ .mdma-markdown-content table.mdast-table th, .mdma-markdown-content table.mdast-table td { padding: 8px 12px; - border: 1px solid #e0e0e0; + border: 1px solid var(--mdma-color-border); text-align: left; } .mdma-markdown-content table.mdast-table th { - background: #f5f6fa; + background: var(--mdma-color-table-header-bg); font-weight: 600; } .mdma-markdown-content table.mdast-table tbody tr:hover { - background: #f9f9fc; + background: var(--mdma-color-row-hover); } .mdma-markdown-content strong { @@ -267,35 +473,35 @@ .mdma-markdown-content del { text-decoration: line-through; - color: #888; + color: var(--mdma-color-text-muted); } /* ===== Form ===== */ .mdma-form { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; } .mdma-form-label { - font-size: 18px; + font-size: var(--mdma-font-size-title); font-weight: 600; margin-bottom: 16px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-form-field { - margin-bottom: 16px; + margin-bottom: var(--mdma-spacing-lg); } .mdma-form-field label { display: block; font-size: 13px; font-weight: 600; - color: #444; - margin-bottom: 4px; + color: var(--mdma-color-text-subtle); + margin-bottom: var(--mdma-spacing-xs); } .mdma-form-field input[type="text"], @@ -308,10 +514,10 @@ width: 100%; padding: 8px 12px; font-size: 14px; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #fafafa; - color: #1a1a2e; + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-sm); + background: var(--mdma-color-input-bg); + color: var(--mdma-color-text); transition: border-color 0.15s, box-shadow 0.15s; outline: none; } @@ -319,8 +525,8 @@ .mdma-form-field input:focus, .mdma-form-field select:focus, .mdma-form-field textarea:focus { - border-color: #6c5ce7; - box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.15); + border-color: var(--mdma-color-primary); + box-shadow: 0 0 0 3px var(--mdma-color-focus-ring); } .mdma-form-field textarea { @@ -331,7 +537,7 @@ .mdma-form-field input[type="checkbox"] { width: auto; margin-right: 8px; - accent-color: #6c5ce7; + accent-color: var(--mdma-color-primary); } .mdma-form-submit { @@ -339,16 +545,16 @@ padding: 8px 20px; font-size: 14px; font-weight: 600; - color: #fff; - background: #6c5ce7; + color: var(--mdma-color-on-primary); + background: var(--mdma-color-primary); border: none; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); cursor: pointer; transition: background 0.15s; } .mdma-form-submit:hover { - background: #5a4bd1; + background: var(--mdma-color-primary-hover); } /* ===== Button ===== */ @@ -358,7 +564,7 @@ font-size: 14px; font-weight: 600; border: none; - border-radius: 6px; + border-radius: var(--mdma-radius-sm); cursor: pointer; transition: background 0.15s, transform 0.1s; margin-bottom: 20px; @@ -369,46 +575,46 @@ } .mdma-button--primary { - background: #6c5ce7; - color: #fff; + background: var(--mdma-color-primary); + color: var(--mdma-color-on-primary); } .mdma-button--primary:hover { - background: #5a4bd1; + background: var(--mdma-color-primary-hover); } .mdma-button--secondary { - background: #dfe6e9; - color: #2d3436; + background: var(--mdma-color-secondary); + color: var(--mdma-color-on-secondary); } .mdma-button--secondary:hover { - background: #c8d6db; + background: var(--mdma-color-secondary-hover); } .mdma-button--danger { - background: #e74c3c; - color: #fff; + background: var(--mdma-color-danger); + color: var(--mdma-color-on-danger); } .mdma-button--danger:hover { - background: #c0392b; + background: var(--mdma-color-danger-hover); } /* ===== Tasklist ===== */ .mdma-tasklist { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; } .mdma-tasklist-label { - font-size: 18px; + font-size: var(--mdma-font-size-title); font-weight: 600; margin-bottom: 12px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-tasklist-items { @@ -417,7 +623,7 @@ .mdma-tasklist-item { padding: 8px 0; - border-bottom: 1px solid #f0f0f0; + border-bottom: 1px solid var(--mdma-color-border-subtle); } .mdma-tasklist-item:last-child { @@ -430,35 +636,35 @@ gap: 8px; cursor: pointer; font-size: 14px; - color: #333; + color: var(--mdma-color-text); } .mdma-tasklist-item input[type="checkbox"] { - accent-color: #6c5ce7; + accent-color: var(--mdma-color-primary); width: 16px; height: 16px; } .mdma-tasklist-item input[type="checkbox"]:checked + span { text-decoration: line-through; - color: #999; + color: var(--mdma-color-text-muted); } /* ===== Table ===== */ .mdma-table { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; overflow-x: auto; } .mdma-table-label { - font-size: 18px; + font-size: var(--mdma-font-size-title); font-weight: 600; margin-bottom: 12px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-table table { @@ -473,32 +679,32 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - color: #666; - background: #f8f9fa; - border-bottom: 2px solid #e0e0e0; + color: var(--mdma-color-text-muted); + background: var(--mdma-color-table-header-bg); + border-bottom: 2px solid var(--mdma-color-border); } .mdma-table td { padding: 10px 12px; font-size: 14px; - border-bottom: 1px solid #f0f0f0; - color: #333; + border-bottom: 1px solid var(--mdma-color-border-subtle); + color: var(--mdma-color-text); } .mdma-table tr:hover td { - background: #f8f9fa; + background: var(--mdma-color-row-hover); } .mdma-table-empty { text-align: center; padding: 20px; - color: #999; + color: var(--mdma-color-text-muted); font-style: italic; } /* ===== Callout ===== */ .mdma-callout { - border-radius: 10px; + border-radius: var(--mdma-radius-md); padding: 16px 20px; margin-bottom: 20px; position: relative; @@ -506,27 +712,27 @@ } .mdma-callout--info { - background: #ebf5fb; - border-left-color: #3498db; - color: #1a5276; + background: var(--mdma-color-info-bg); + border-left-color: var(--mdma-color-info); + color: var(--mdma-color-info-text); } .mdma-callout--warning { - background: #fef9e7; - border-left-color: #f39c12; - color: #7d6608; + background: var(--mdma-color-warning-bg); + border-left-color: var(--mdma-color-warning); + color: var(--mdma-color-warning-text); } .mdma-callout--error { - background: #fdedec; - border-left-color: #e74c3c; - color: #78281f; + background: var(--mdma-color-error-bg); + border-left-color: var(--mdma-color-error); + color: var(--mdma-color-error-text); } .mdma-callout--success { - background: #eafaf1; - border-left-color: #27ae60; - color: #1e8449; + background: var(--mdma-color-success-bg); + border-left-color: var(--mdma-color-success); + color: var(--mdma-color-success-text); } .mdma-callout-title { @@ -559,43 +765,43 @@ /* ===== Approval Gate ===== */ .mdma-approval-gate { - background: #fff; - border: 2px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 2px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); padding: 24px; margin-bottom: 20px; } .mdma-approval-gate--pending { - border-color: #f39c12; + border-color: var(--mdma-color-warning); } .mdma-approval-gate--approved { - border-color: #27ae60; - background: #f0faf4; + border-color: var(--mdma-color-success); + background: var(--mdma-color-success-bg); } .mdma-approval-gate--denied { - border-color: #e74c3c; - background: #fdf2f2; + border-color: var(--mdma-color-error); + background: var(--mdma-color-error-bg); } .mdma-approval-gate-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; - color: #1a1a2e; + color: var(--mdma-color-heading); } .mdma-approval-gate-description { font-size: 14px; - color: #555; + color: var(--mdma-color-text-subtle); margin-bottom: 12px; } .mdma-approval-gate-status { font-size: 13px; - color: #666; + color: var(--mdma-color-text-muted); margin-bottom: 16px; } @@ -605,13 +811,13 @@ } .mdma-approval-gate--pending .mdma-approval-gate-status strong { - color: #f39c12; + color: var(--mdma-color-warning); } .mdma-approval-gate--approved .mdma-approval-gate-status strong { - color: #27ae60; + color: var(--mdma-color-success); } .mdma-approval-gate--denied .mdma-approval-gate-status strong { - color: #e74c3c; + color: var(--mdma-color-error); } .mdma-approval-gate-actions { @@ -625,8 +831,8 @@ align-items: center; gap: 10px; padding: 12px 16px; - background: #fff; - border: 1px solid #e0e0e0; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); border-radius: 8px; margin-bottom: 20px; font-size: 13px; @@ -634,7 +840,7 @@ .mdma-webhook-label { font-weight: 600; - color: #333; + color: var(--mdma-color-text); } .mdma-webhook-status { @@ -645,8 +851,8 @@ } .mdma-webhook-status--idle { - background: #f0f0f0; - color: #666; + background: var(--mdma-color-surface); + color: var(--mdma-color-text-muted); } .mdma-webhook-status--executing { background: #ffeaa7; @@ -664,17 +870,17 @@ /* ===== Unknown Component ===== */ .mdma-unknown-component { padding: 12px 16px; - background: #fff3cd; - border: 1px solid #ffc107; + background: var(--mdma-color-unknown-bg); + border: 1px solid var(--mdma-color-unknown-border); border-radius: 8px; - color: #856404; + color: var(--mdma-color-unknown-text); font-size: 13px; margin-bottom: 20px; } .mdma-chart { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(108, 92, 231, 0.15); + background: var(--mdma-color-chart-bg); + border: 1px solid var(--mdma-color-chart-border); border-radius: 14px; padding: 18px; margin-bottom: 20px; @@ -684,7 +890,7 @@ .mdma-chart-label { font-size: 14px; font-weight: 700; - color: #a29bfe; + color: var(--mdma-color-chart-label); margin-bottom: 12px; } @@ -699,32 +905,32 @@ display: flex; align-items: center; justify-content: center; - color: #636e72; + color: var(--mdma-color-text-muted); font-size: 13px; font-style: italic; } /* recharts overrides for MDMA theme */ .mdma-chart .recharts-text { - fill: #636e72; + fill: var(--mdma-color-chart-axis); } .mdma-chart .recharts-legend-item-text { - color: #b2bec3 !important; + color: var(--mdma-color-chart-legend) !important; font-size: 12px; } .mdma-chart .recharts-default-tooltip { - background: #1a1a2e !important; + background: var(--mdma-color-chart-tooltip-bg) !important; border: 1px solid rgba(162, 155, 254, 0.3) !important; border-radius: 8px !important; } /* ─── Thinking Component (basic) ─────────────────────────────────────────── */ .mdma-thinking { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 10px; + background: var(--mdma-color-background); + border: 1px solid var(--mdma-color-border); + border-radius: var(--mdma-radius-md); margin: 16px 0; overflow: hidden; } @@ -737,7 +943,7 @@ cursor: pointer; font-size: 14px; font-weight: 600; - color: #555; + color: var(--mdma-color-text-subtle); user-select: none; list-style: none; } @@ -751,7 +957,7 @@ font-size: 10px; margin-left: auto; transition: transform 0.2s; - color: #999; + color: var(--mdma-color-text-muted); } .mdma-thinking[open] .mdma-thinking-summary::after { @@ -763,7 +969,7 @@ width: 8px; height: 8px; border-radius: 50%; - background: #6c5ce7; + background: var(--mdma-color-primary); animation: mdma-thinking-pulse 1.5s ease-in-out infinite; } @@ -787,8 +993,8 @@ padding: 0 16px 16px; font-size: 13px; line-height: 1.6; - color: #555; + color: var(--mdma-color-text-subtle); white-space: pre-wrap; - border-top: 1px solid #f0f0f0; + border-top: 1px solid var(--mdma-color-border-subtle); padding-top: 12px; } diff --git a/packages/renderer-react/tests/theme.test.ts b/packages/renderer-react/tests/theme.test.ts new file mode 100644 index 0000000..67ab0e8 --- /dev/null +++ b/packages/renderer-react/tests/theme.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { + lightTheme, + darkTheme, + themeToCssVars, + resolveThemeProps, + type MdmaTheme, +} from '../src/theme/MdmaThemeProvider.js'; + +// Structural parity guard: the light and dark palettes must expose exactly the +// same token shape so a class styled against one never reads an undefined +// variable under the other. +function keyShape(theme: MdmaTheme) { + return { + colors: Object.keys(theme.colors).sort(), + spacing: Object.keys(theme.spacing).sort(), + radius: Object.keys(theme.radius).sort(), + fontSize: Object.keys(theme.fontSize).sort(), + }; +} + +describe('MdmaTheme tokens', () => { + it('light and dark themes share the same token shape', () => { + expect(keyShape(lightTheme)).toEqual(keyShape(darkTheme)); + }); + + it('every color token is a defined string in both themes', () => { + for (const theme of [lightTheme, darkTheme]) { + for (const value of Object.values(theme.colors)) { + expect(typeof value).toBe('string'); + expect(value.length).toBeGreaterThan(0); + } + } + }); +}); + +describe('themeToCssVars', () => { + it('maps camelCase tokens to kebab-case --mdma-* variables', () => { + const vars = themeToCssVars(lightTheme) as Record; + expect(vars['--mdma-color-primary']).toBe(lightTheme.colors.primary); + expect(vars['--mdma-color-on-primary']).toBe(lightTheme.colors.onPrimary); + expect(vars['--mdma-color-text-muted']).toBe(lightTheme.colors.textMuted); + expect(vars['--mdma-color-info-bg']).toBe(lightTheme.colors.infoBg); + }); + + it('emits numeric scales with a px unit', () => { + const vars = themeToCssVars(lightTheme) as Record; + expect(vars['--mdma-radius-md']).toBe(`${lightTheme.radius.md}px`); + expect(vars['--mdma-spacing-lg']).toBe(`${lightTheme.spacing.lg}px`); + expect(vars['--mdma-font-size-title']).toBe(`${lightTheme.fontSize.title}px`); + }); +}); + +describe('resolveThemeProps', () => { + it('returns the light defaults with no data-theme when unset', () => { + const r = resolveThemeProps(undefined); + expect(r.dataTheme).toBeUndefined(); + expect(r.style).toBeUndefined(); + expect(r.theme).toBe(lightTheme); + }); + + it('applies built-in palettes via data-theme', () => { + expect(resolveThemeProps('light')).toMatchObject({ dataTheme: 'light', theme: lightTheme }); + expect(resolveThemeProps('dark')).toMatchObject({ dataTheme: 'dark', theme: darkTheme }); + expect(resolveThemeProps('auto').dataTheme).toBe('auto'); + }); + + it('writes a custom theme as inline CSS variables', () => { + const custom: MdmaTheme = { + ...lightTheme, + colors: { ...lightTheme.colors, primary: '#ff0000' }, + }; + const r = resolveThemeProps(custom); + expect((r.style as Record)['--mdma-color-primary']).toBe('#ff0000'); + expect(r.theme).toBe(custom); + }); + + it('picks the light/dark base for a custom theme by its background, so internal vars match', () => { + const lightCustom: MdmaTheme = { ...lightTheme }; + const darkCustom: MdmaTheme = { ...darkTheme }; + expect(resolveThemeProps(lightCustom).dataTheme).toBe('light'); + expect(resolveThemeProps(darkCustom).dataTheme).toBe('dark'); + }); +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a34d785..4792e4c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -36,9 +36,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/spec/package.json b/packages/spec/package.json index f72233a..89822ea 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,16 +1,7 @@ { "name": "@mobile-reality/mdma-spec", "version": "0.3.1", - "keywords": [ - "mdma", - "markdown", - "ai", - "llm", - "interactive-document", - "schema", - "zod", - "types" - ], + "keywords": ["mdma", "markdown", "ai", "llm", "interactive-document", "schema", "zod", "types"], "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", @@ -47,9 +38,7 @@ "typescript": "^5.7.0", "vitest": "^3.2.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/validator/package.json b/packages/validator/package.json index 6703fd4..7eb9bcc 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -35,9 +35,7 @@ "vitest": "^3.2.0", "zod": "^3.24.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" },