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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/genui/a2ui-playground/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export default defineConfig({
render: './src/render.tsx',
},
},
html: {
title: 'Lynx A2UI Playground',
},
output: {
assetPrefix: process.env.ASSET_PREFIX,
copy: [
Expand Down
31 changes: 29 additions & 2 deletions packages/genui/a2ui-playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';

import { ProtocolSwitch } from './components/ProtocolSwitch.js';
import { AIChatPage } from './pages/AIChatPage.js';
Expand Down Expand Up @@ -33,11 +33,28 @@ function parseHash(hash: string): Route {
return { tab: 'chat' };
}

type Theme = 'light' | 'dark';

function getSystemTheme(): Theme {
try {
return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches
? 'dark'
: 'light';
} catch {
return 'light';
}
}
Comment on lines +38 to +46

export function App() {
const [route, setRoute] = useState<Route>(() =>
parseHash(window.location.hash)
);
const [protocol, setProtocol] = useState<ProtocolVersion>(DEFAULT_PROTOCOL);
const [theme, setTheme] = useState<Theme>(getSystemTheme);

useLayoutEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useEffect(() => {
const onHashChange = () => {
Expand Down Expand Up @@ -71,7 +88,7 @@ export function App() {
return (
<div className='appShell'>
<div className='topBar'>
<span className='brand'>A2UI Playground</span>
<span className='brand'>Lynx A2UI Playground</span>

<nav className='tabNav'>
{TABS.map((t) => (
Expand All @@ -94,6 +111,16 @@ export function App() {
<div className='protocolLabel'>Protocol</div>
<ProtocolSwitch value={protocol} onChange={setProtocol} />
</div>

<button
type='button'
className='themeToggle'
onClick={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? '\u2600' : '\u263E'}
</button>
</div>

<div className='appBody'>
Expand Down
10 changes: 1 addition & 9 deletions packages/genui/a2ui-playground/src/components/MobilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,7 @@ export function MobilePreview(props: { src: string }) {
return (
<div className='phoneWrap'>
<div className='phoneFrame'>
<div className='phoneScreen'>
<div className='phoneStatusBar'>
<div className='phoneNotch' />
</div>
<iframe className='phoneIframe' title='preview' src={props.src} />
<div className='phoneHomeIndicator'>
<div className='phoneHomeBar' />
</div>
</div>
<iframe className='phoneIframe' title='preview' src={props.src} />
</div>
</div>
);
Expand Down
41 changes: 39 additions & 2 deletions packages/genui/a2ui-playground/src/pages/DemosPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) {
const [speed, setSpeed] = useState(1);
const [showSimTooltip, setShowSimTooltip] = useState(false);
const [jsonEdited, setJsonEdited] = useState(false);
const [previewMode, setPreviewMode] = useState<'phone' | 'full'>('phone');

const baseUrl = window.location.href.replace(/#.*$/, '');
const rspeedyDevUrl = useRspeedyDevUrl();
Expand Down Expand Up @@ -374,6 +375,28 @@ export function DemosPage(props: { protocol: ProtocolVersion }) {
</div>
)
: null}
<div className='previewModeSwitch'>
<button
type='button'
className={previewMode === 'phone'
? 'previewModeBtn active'
: 'previewModeBtn'}
onClick={() => setPreviewMode('phone')}
Comment on lines +378 to +384
title='Phone frame'
>
Phone
</button>
<button
type='button'
className={previewMode === 'full'
? 'previewModeBtn active'
: 'previewModeBtn'}
onClick={() => setPreviewMode('full')}
title='Full panel'
>
Full
</button>
</div>
</div>
{isSimulated
? (
Expand Down Expand Up @@ -416,9 +439,23 @@ export function DemosPage(props: { protocol: ProtocolVersion }) {
</div>
)
: null}
<div className='previewPanelBody'>
<div
className={previewMode === 'full'
? 'previewPanelBody previewPanelBodyFull'
: 'previewPanelBody'}
>
{renderUrl
? <MobilePreview src={renderUrl} />
? (
previewMode === 'phone'
? <MobilePreview src={renderUrl} />
: (
<iframe
className='previewFullIframe'
title='preview'
src={renderUrl}
/>
)
)
: (
<div className='previewEmpty'>
<div className='previewEmptyIcon'>▶</div>
Expand Down
150 changes: 81 additions & 69 deletions packages/genui/a2ui-playground/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,21 @@
"Liberation Mono", monospace;
}

@media (prefers-color-scheme: dark) {
:root {
--geist-foreground: #ededed;
--geist-background: #0a0a0a;
--geist-secondary: #888;
--geist-border: #333;
--geist-border-hover: #555;
--geist-surface: #111;
--geist-accent: #fff;
--geist-accent-foreground: #000;
--geist-error-light: #2a0000;
--geist-code-bg: #0a0a0a;
--geist-code-fg: #e8e8e8;
--geist-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--geist-shadow-md: 0 4px 14px rgba(0, 0, 0, 0.3);
--geist-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] {
--geist-foreground: #ededed;
--geist-background: #0a0a0a;
--geist-secondary: #888;
--geist-border: #333;
--geist-border-hover: #555;
Comment on lines +33 to +38
--geist-surface: #111;
--geist-accent: #fff;
--geist-accent-foreground: #000;
--geist-error-light: #2a0000;
--geist-code-bg: #0a0a0a;
--geist-code-fg: #e8e8e8;
--geist-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--geist-shadow-md: 0 4px 14px rgba(0, 0, 0, 0.3);
--geist-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
}

/* ── Reset & Base ── */
Expand Down Expand Up @@ -120,6 +118,28 @@ a {
flex: 1;
}

.themeToggle {
width: 28px;
height: 28px;
border: none;
border-radius: var(--geist-radius-sm);
background: transparent;
color: var(--geist-secondary);
font-size: 15px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--geist-transition);
flex-shrink: 0;
padding: 0;
}

.themeToggle:hover {
color: var(--geist-foreground);
background: var(--geist-surface);
}

/* ── Tab Navigation ── */
.tabNav {
display: flex;
Expand Down Expand Up @@ -255,60 +275,18 @@ a {
.phoneFrame {
width: 300px;
height: 560px;
border-radius: 36px;
background: var(--geist-foreground);
padding: 10px;
box-shadow:
var(--geist-shadow-lg),
inset 0 0 0 2px rgba(255, 255, 255, 0.1);
}

.phoneScreen {
width: 100%;
height: 100%;
border-radius: 24px;
border: 6px solid var(--geist-border);
background: #fff;
border-radius: 28px;
overflow: hidden;
display: flex;
flex-direction: column;
}

.phoneStatusBar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

.phoneNotch {
width: 80px;
height: 24px;
background: var(--geist-foreground);
border-radius: 0 0 16px 16px;
box-shadow: var(--geist-shadow-md);
}

.phoneIframe {
flex: 1;
width: 100%;
height: 100%;
border: 0;
}

.phoneHomeIndicator {
height: 20px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

.phoneHomeBar {
width: 120px;
height: 4px;
border-radius: 2px;
background: #d1d1d1;
display: block;
}

/* ── Preview Panels (shared) ── */
Expand All @@ -324,25 +302,48 @@ a {
.previewPanelHeader {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--geist-border);
background: var(--geist-background);
flex-shrink: 0;
flex-wrap: wrap;
}

.previewPanelTitle {
font-size: 13px;
font-weight: 600;
}

.previewPanelMeta {
.previewModeSwitch {
display: inline-flex;
padding: 2px;
border: 1px solid var(--geist-border);
border-radius: 6px;
background: var(--geist-surface);
flex-shrink: 0;
margin-left: auto;
}

.previewMetaTags {
display: flex;
gap: 4px;
.previewModeBtn {
padding: 2px 10px;
border: 0;
border-radius: 4px;
background: transparent;
font-size: 12px;
font-weight: 500;
cursor: pointer;
color: var(--geist-secondary);
transition: all var(--geist-transition);
}

.previewModeBtn:hover {
color: var(--geist-foreground);
}

.previewModeBtn.active {
background: var(--geist-accent);
color: var(--geist-accent-foreground);
}

.previewPanelBody {
Expand All @@ -354,6 +355,17 @@ a {
overflow: auto;
}

.previewPanelBodyFull {
padding: 0;
}

.previewFullIframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}

/* ── Simulation Controls Bar ── */
.simulationBar {
display: flex;
Expand Down
Loading