diff --git a/.changeset/lucky-worms-invent.md b/.changeset/lucky-worms-invent.md new file mode 100644 index 0000000000..853d812bb3 --- /dev/null +++ b/.changeset/lucky-worms-invent.md @@ -0,0 +1,3 @@ +--- + +--- diff --git a/packages/genui/a2ui-playground/src/App.tsx b/packages/genui/a2ui-playground/src/App.tsx index 9b192922d3..ffa9296c2b 100644 --- a/packages/genui/a2ui-playground/src/App.tsx +++ b/packages/genui/a2ui-playground/src/App.tsx @@ -15,10 +15,11 @@ import { DemosListPage } from './pages/DemosListPage.js'; import { DemosPage } from './pages/DemosPage.js'; import { OpenUIComponentsPage } from './pages/OpenUIComponentsPage.js'; import { OpenUIDemosPage } from './pages/OpenUIDemosPage.js'; +import { PlaybackPage } from './pages/PlaybackPage.js'; import type { Protocol, ProtocolName } from './utils/protocol.js'; import { DEFAULT_PROTOCOL, getProtocol } from './utils/protocol.js'; -type Tab = 'create' | 'examples' | 'components'; +type Tab = 'create' | 'examples' | 'components' | 'playback'; interface TabDef { id: Tab; @@ -29,6 +30,7 @@ const A2UI_TABS: TabDef[] = [ { id: 'create', label: 'Create' }, { id: 'examples', label: 'Examples' }, { id: 'components', label: 'Components' }, + { id: 'playback', label: 'Playback' }, ]; const OPENUI_TABS: TabDef[] = [ @@ -68,6 +70,9 @@ function parseHash(hash: string): Route { if (rest[0] === 'chat' || rest[0] === 'create') { return { protocol, tab: 'create' }; } + if (rest[0] === 'playback') { + return { protocol, tab: 'playback' }; + } // OpenUI has no create tab, default to examples. if (protocol.name === 'openui') return { protocol, tab: 'examples' }; return { protocol, tab: 'create' }; @@ -162,6 +167,8 @@ export function App() { theme={theme} /> ); + case 'playback': + return ; default: return ; } diff --git a/packages/genui/a2ui-playground/src/demos.ts b/packages/genui/a2ui-playground/src/demos.ts index 99e6a79872..e85908b3c5 100644 --- a/packages/genui/a2ui-playground/src/demos.ts +++ b/packages/genui/a2ui-playground/src/demos.ts @@ -158,3 +158,5 @@ export const SUPPORTED_COMPONENTS = tagsFromMessages([ ...STATIC_DEMOS.flatMap((d) => d.messages as unknown[]), ...DYNAMIC_PRESETS.flatMap((d) => d.messages as unknown[]), ]); + +export const PLAYBACK_SCENARIOS = [...EXTENDED_STATIC_DEMOS]; diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.css b/packages/genui/a2ui-playground/src/pages/AIChatPage.css new file mode 100644 index 0000000000..c8cc1293d4 --- /dev/null +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.css @@ -0,0 +1,156 @@ +/* ═══════════════════════════════════════════ + Create Page + ═══════════════════════════════════════════ */ + +.chatPage { + display: flex; + flex: 1; + overflow: hidden; +} + +.chatPage.resizing { + user-select: none; +} + +.chatPanel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.chatHeader { + padding: 12px 20px; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-background); + flex-shrink: 0; +} + +.chatHeaderTitleRow { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.chatHeaderTitle { + font-size: 15px; + font-weight: 600; + margin: 0; +} + +.constructionBadge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px solid var(--geist-border); + border-radius: 999px; + background: var(--geist-surface); + color: var(--geist-secondary); + font-size: 11px; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + +.chatHeaderSub { + font-size: 12px; + color: var(--geist-secondary); + margin: 2px 0 0; +} + +.chatMessages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.chatMessage { + max-width: 80%; + padding: 10px 14px; + border-radius: var(--geist-radius-lg); + font-size: 14px; + line-height: 1.5; + word-break: break-word; +} + +.chatMessageUser { + align-self: flex-end; + background: var(--geist-accent); + color: var(--geist-accent-foreground); + border-bottom-right-radius: 4px; +} + +.chatMessageAI { + align-self: flex-start; + background: var(--geist-surface); + border: 1px solid var(--geist-border); + border-bottom-left-radius: 4px; +} + +.chatInputArea { + padding: 12px 20px; + border-top: 1px solid var(--geist-border); + background: var(--geist-background); + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.chatInput { + flex: 1; + height: 40px; + padding: 0 14px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + font-size: 14px; + background: var(--geist-background); + color: var(--geist-foreground); + outline: none; + transition: border-color var(--geist-transition); +} + +.chatInput:focus { + border-color: var(--geist-foreground); +} + +.chatInput::placeholder { + color: var(--geist-secondary); +} + +.chatSendBtn { + height: 40px; + padding: 0 20px; + border: none; + border-radius: var(--geist-radius-md); + background: var(--geist-accent); + color: var(--geist-accent-foreground); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity var(--geist-transition); + flex-shrink: 0; +} + +.chatSendBtn:hover { + opacity: 0.85; +} + +.chatSendBtn:active { + transform: scale(0.97); +} + +@media (max-width: 980px) { + .chatPage { + flex-direction: column; + } + + .chatPanel { + min-height: 400px; + flex: 0 0 auto; + } +} diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx index 27ecd75cfa..eb1450bb58 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx @@ -3,6 +3,8 @@ // LICENSE file in the root directory of this source tree. import { useCallback, useEffect, useRef, useState } from 'react'; +import './AIChatPage.css'; + import { useResizablePanels } from '../hooks/useResizablePanels.js'; import type { Protocol } from '../utils/protocol.js'; diff --git a/packages/genui/a2ui-playground/src/pages/ComponentsPage.css b/packages/genui/a2ui-playground/src/pages/ComponentsPage.css new file mode 100644 index 0000000000..be0ab6273b --- /dev/null +++ b/packages/genui/a2ui-playground/src/pages/ComponentsPage.css @@ -0,0 +1,362 @@ +/* ═══════════════════════════════════════════ + Components Page + ═══════════════════════════════════════════ */ + +.compPage { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ── Sidebar ── */ +.compSidebar { + width: 200px; + flex-shrink: 0; + border-right: 1px solid var(--geist-border); + background: var(--geist-background); + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 0; +} + +.compSidebarAll { + display: block; + padding: 6px 12px; + border-radius: var(--geist-radius-sm); + font-size: 13px; + font-weight: 600; + color: var(--geist-secondary); + text-decoration: none; + transition: all var(--geist-transition); + margin-bottom: 4px; +} + +.compSidebarAll:hover { + color: var(--geist-foreground); + background: var(--geist-surface); +} + +.compSidebarAll.active { + color: var(--geist-foreground); + background: var(--geist-surface); +} + +.compSidebarGroup { + margin-bottom: 4px; +} + +.compSidebarGroupLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--geist-secondary); + padding: 10px 12px 4px; +} + +.compSidebarItem { + display: block; + width: 100%; + padding: 5px 12px 5px 20px; + border: 1px solid transparent; + border-radius: var(--geist-radius-sm); + background: transparent; + cursor: pointer; + text-align: left; + font-size: 13px; + font-weight: 500; + color: var(--geist-secondary); + text-decoration: none; + transition: all var(--geist-transition); +} + +.compSidebarItem:hover { + color: var(--geist-foreground); + background: var(--geist-surface); +} + +.compSidebarItem.active { + color: var(--geist-foreground); + background: var(--geist-surface); + border-color: var(--geist-border-hover); +} + +/* ── Content ── */ +.compContent { + flex: 1; + overflow-y: auto; + padding: 32px 40px; +} + +.compName { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.03em; + margin: 0 0 8px; +} + +.compDesc { + color: var(--geist-secondary); + font-size: 14px; + margin: 0 0 24px; + line-height: 1.6; +} + +/* ── Breadcrumb ── */ +.compBreadcrumb { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 20px; + font-size: 13px; +} + +.compBreadcrumbLink { + color: var(--geist-secondary); + text-decoration: none; + transition: color var(--geist-transition); +} + +.compBreadcrumbLink:hover { + color: var(--geist-foreground); +} + +.compBreadcrumbSep { + color: var(--geist-secondary); + opacity: 0.5; +} + +.compBreadcrumbCurrent { + color: var(--geist-foreground); + font-weight: 500; +} + +/* ── Category Badge ── */ +.compCategoryBadge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + border: 1px solid var(--geist-border); + background: var(--geist-surface); + font-size: 12px; + font-weight: 500; + color: var(--geist-secondary); + margin-bottom: 24px; +} + +.compSectionHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.compSectionHeader .compSubheading { + margin: 0; +} + +.compCopyBtn { + height: 26px; + padding: 0 10px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-sm); + background: var(--geist-background); + color: var(--geist-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--geist-transition); +} + +.compCopyBtn:hover { + color: var(--geist-foreground); + border-color: var(--geist-border-hover); +} + +.compUsageEditor { + margin-bottom: 24px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + overflow: hidden; +} + +.compUsageEditor .cm-editor { + height: auto; + max-height: 520px; + font-family: var(--geist-mono); + font-size: 13px; + background: var(--geist-code-bg); +} + +.compUsageEditor .cm-scroller { + max-height: 520px; + overflow: auto; +} + +.compUsageError { + margin: -16px 0 24px; + padding: 8px 12px; + border: 1px solid var(--geist-error); + border-radius: var(--geist-radius-sm); + background: var(--geist-error-light); + color: var(--geist-error); + font-family: var(--geist-mono); + font-size: 12px; +} + +.compPreview { + width: 100%; + height: 360px; + margin-bottom: 24px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + background: var(--geist-surface); + overflow: hidden; +} + +.compPreviewIframe { + display: block; + width: 100%; + height: 100%; + border: 0; + background: var(--geist-background); +} + +.compPreviewInvalid { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--geist-secondary); + font-size: 13px; + text-align: center; +} + +/* ── Category Grid (All Components view) ── */ +.compCategorySection { + margin-bottom: 32px; +} + +.compCategoryTitle { + font-size: 14px; + font-weight: 600; + color: var(--geist-foreground); + margin: 0 0 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.compGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} + +.compGridCard { + display: block; + padding: 16px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-lg); + background: var(--geist-background); + text-decoration: none; + transition: all var(--geist-transition); +} + +.compGridCard:hover { + border-color: var(--geist-border-hover); + box-shadow: var(--geist-shadow-md); +} + +.compGridCardName { + font-size: 15px; + font-weight: 600; + color: var(--geist-foreground); + margin-bottom: 4px; + font-family: var(--geist-mono); +} + +.compGridCardDesc { + font-size: 13px; + color: var(--geist-secondary); + line-height: 1.5; +} + +/* ── Detail subheadings / code / table ── */ +.compSubheading { + font-size: 14px; + font-weight: 600; + margin: 0 0 8px; + color: var(--geist-foreground); +} + +.compCodeBlock { + margin: 0 0 24px; + padding: 16px; + border-radius: var(--geist-radius-md); + background: var(--geist-code-bg); + color: var(--geist-code-fg); + font-family: var(--geist-mono); + font-size: 13px; + line-height: 1.6; + overflow-x: auto; + white-space: pre; +} + +.compTable { + width: 100%; + border-collapse: collapse; + margin-bottom: 24px; + font-size: 13px; +} + +.compTableHeader { + text-align: left; + font-weight: 600; + font-size: 12px; + color: var(--geist-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 8px 12px; + border-bottom: 1px solid var(--geist-border); +} + +.compTableCell { + padding: 8px 12px; + border-bottom: 1px solid var(--geist-border); + vertical-align: top; +} + +.compTableCell:first-child { + font-family: var(--geist-mono); + font-weight: 500; + color: var(--geist-foreground); +} + +.compTableCell:nth-child(2) { + font-family: var(--geist-mono); + font-size: 12px; + color: var(--geist-secondary); +} + +@media (max-width: 980px) { + .compPage { + flex-direction: column; + } + + .compSidebar { + width: 100%; + max-height: 160px; + border-right: none; + border-bottom: 1px solid var(--geist-border); + flex-direction: row; + flex-wrap: wrap; + } + + .compContent { + padding: 20px; + } +} diff --git a/packages/genui/a2ui-playground/src/pages/ComponentsPage.tsx b/packages/genui/a2ui-playground/src/pages/ComponentsPage.tsx index bc8a42a4b8..dc66e90856 100644 --- a/packages/genui/a2ui-playground/src/pages/ComponentsPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/ComponentsPage.tsx @@ -12,6 +12,8 @@ import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js'; import type { Protocol } from '../utils/protocol.js'; import { buildRenderUrl } from '../utils/renderUrl.js'; +import './ComponentsPage.css'; + const jsonExtensions = [json()]; function formatJson(value: unknown): string { diff --git a/packages/genui/a2ui-playground/src/pages/DemosListPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosListPage.tsx index e31f5b19df..4fb3b87344 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosListPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosListPage.tsx @@ -4,6 +4,8 @@ import { useCallback, useMemo } from 'react'; import type { KeyboardEvent } from 'react'; +import './DemosPage.css'; + import { DYNAMIC_PRESETS, EXTENDED_STATIC_DEMOS, diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.css b/packages/genui/a2ui-playground/src/pages/DemosPage.css new file mode 100644 index 0000000000..cd35a1f018 --- /dev/null +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.css @@ -0,0 +1,621 @@ +/* ═══════════════════════════════════════════ + Examples Page + ═══════════════════════════════════════════ */ + +.demosPage { + display: flex; + flex: 1; + overflow: hidden; +} + +.demosPage.resizing { + user-select: none; +} + +.examplePage { + flex: 1; + overflow: auto; + padding: 24px; + background: var(--geist-background); +} + +.exampleMetaRow { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.exampleColumns { + display: flex; + flex-direction: column; + gap: 28px; +} + +.exampleSection { + min-width: 0; +} + +.exampleSectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + padding-bottom: 8px; + border-bottom: 1px solid var(--geist-border); +} + +.exampleSectionTitle { + margin: 0; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--geist-foreground); +} + +.exampleSectionTitleLink { + margin: 0; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; + color: var(--geist-foreground); + text-decoration: none; +} + +.exampleSectionTitleLink:hover { + text-decoration: underline; +} + +.exampleGrid { + column-width: 300px; + column-gap: 14px; +} + +.exampleGridFlow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 14px; + column-gap: 14px; +} + +.exampleGridFlow .exampleCard { + margin-bottom: 0; +} + +.exampleCard { + display: inline-flex; + flex-direction: column; + gap: 10px; + min-height: 0; + padding: 12px; + border: 1px solid var(--geist-border); + border-radius: 18px; + background: var(--geist-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + cursor: pointer; + text-align: left; + transition: + transform var(--geist-transition), + border-color var(--geist-transition), + box-shadow var(--geist-transition), + background var(--geist-transition); + position: relative; + overflow: hidden; + width: 100%; + margin: 0 0 14px; + break-inside: avoid; + page-break-inside: avoid; + -webkit-column-break-inside: avoid; +} + +.exampleCard::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.25), transparent); + pointer-events: none; +} + +.exampleCard:hover { + transform: translateY(-1px); + border-color: var(--geist-border-hover); + box-shadow: var(--geist-shadow-lg); +} + +.exampleCardPreview { + position: relative; + z-index: 1; +} + +.exampleCardPreviewWindow { + height: 240px; + border: 1px solid var(--geist-border); + border-radius: 16px; + overflow: hidden; + background: var(--geist-background); + box-shadow: none; +} + +.exampleCardPreviewFrame { + width: 100%; + height: 100%; + border: 0; + display: block; + pointer-events: none; +} + +.exampleCardTop { + display: flex; + align-items: start; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.exampleCardTitle { + flex: 1; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1.35; + color: var(--geist-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.exampleCardBody { + display: flex; + flex-direction: column; + gap: 0; + padding-top: 2px; +} + +.exampleCardBadge { + flex-shrink: 0; + align-self: flex-start; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid var(--geist-border); + background: rgba(255, 255, 255, 0.7); + color: var(--geist-secondary); + font-size: 10px; + line-height: 1.2; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.detailSidebar { + width: 280px; +} + +.sidebarTopNav { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0 16px; +} + +.detailBackButton { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 8px 14px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 999px; + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.82), + rgba(255, 255, 255, 0.64) + ), + var(--geist-surface); + color: var(--geist-foreground); + font-size: 13px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.01em; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: + background var(--geist-transition), + border-color var(--geist-transition), + transform var(--geist-transition), + box-shadow var(--geist-transition); +} + +.detailBackButton:hover { + border-color: var(--geist-border-hover); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.96), + rgba(255, 255, 255, 0.78) + ), + var(--geist-background); + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08); +} + +.detailBackIcon { + font-size: 15px; + line-height: 1; + color: var(--geist-secondary); +} + +.detailBackLabel { + line-height: 1; +} + +.detailWorkspace { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: + radial-gradient(circle at top right, rgba(0, 0, 0, 0.04), transparent 18%), + var(--geist-background); +} + +.detailWorkspaceFullscreen { + background: var(--geist-background); +} + +.detailHeader { + flex-shrink: 0; + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 16px; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-background); +} + +.detailHeaderCopy { + min-width: 0; +} + +.detailEyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--geist-secondary); + margin-bottom: 8px; +} + +.detailTitle { + font-size: clamp(22px, 3vw, 34px); + font-weight: 750; + letter-spacing: -0.04em; + line-height: 1.05; +} + +.detailDesc { + margin-top: 8px; + max-width: 820px; + color: var(--geist-secondary); + font-size: 14px; + line-height: 1.6; +} + +.detailHeaderMeta { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.detailPanels { + flex: 1; + min-height: 0; + display: flex; + overflow: hidden; +} + +/* ── Sidebar ── */ +.sidebar { + width: 240px; + flex-shrink: 0; + border-right: 1px solid var(--geist-border); + background: var(--geist-background); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.sidebarSection { + padding: 12px; +} + +.sidebarSection + .sidebarSection { + border-top: 1px solid var(--geist-border); +} + +.sidebarHeading { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--geist-secondary); + padding: 0 8px; + margin-bottom: 8px; +} + +.scenarioList { + display: flex; + flex-direction: column; + gap: 4px; +} + +.scenarioItem { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + min-height: 40px; + border: 1px solid transparent; + border-radius: var(--geist-radius-sm); + background: transparent; + cursor: pointer; + text-align: left; + transition: + background var(--geist-transition), + border-color var(--geist-transition), + color var(--geist-transition); + width: 100%; +} + +.scenarioItem:hover { + background: var(--geist-surface); +} + +.scenarioItem.active { + border-color: var(--geist-border-hover); + background: var(--geist-surface); +} + +.scenarioItem.active .scenarioName { + color: var(--geist-foreground); +} + +.scenarioItem.active .scenarioDesc { + color: var(--geist-secondary); +} + +.scenarioName { + font-size: 14px; + font-weight: 500; + color: var(--geist-foreground); +} + +.scenarioTags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.scenarioTag { + font-size: 10px; + font-weight: 500; + color: var(--geist-secondary); + padding: 1px 6px; + border-radius: 4px; + background: var(--geist-surface); + border: 1px solid var(--geist-border); +} + +.scenarioDesc { + font-size: 11px; + color: var(--geist-secondary); + line-height: 1.4; +} + +.sidebarChips { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 8px; +} + +/* ── Code Panel ── */ +.codePanel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.panelResizeHandle { + position: relative; + width: 8px; + flex-shrink: 0; + cursor: col-resize; + background: var(--geist-background); + border-left: 1px solid var(--geist-border); + border-right: 1px solid var(--geist-border); + touch-action: none; + transition: background var(--geist-transition); +} + +.panelResizeHandle::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 2px; + height: 40px; + border-radius: 999px; + background: var(--geist-border-hover); + opacity: 0.35; + transform: translate(-50%, -50%); + transition: + opacity var(--geist-transition), + height var(--geist-transition), + background var(--geist-transition); +} + +.panelResizeHandle:hover, +.panelResizeHandle.active { + background: var(--geist-surface); +} + +.panelResizeHandle:hover::before, +.panelResizeHandle.active::before { + height: 56px; + opacity: 0.9; + background: var(--geist-foreground); +} + +.codePanelToolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-background); + flex-shrink: 0; +} + +.codePanelTitle { + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} + +.codePanelBadge { + font-size: 10px; + font-weight: 500; + padding: 1px 6px; + border-radius: 4px; + background: var(--geist-surface); + border: 1px solid var(--geist-border); + color: var(--geist-secondary); +} + +.toolbarActions { + display: flex; + align-items: center; + gap: 4px; +} + +.toolbarBtn { + height: 28px; + padding: 0 10px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-sm); + background: var(--geist-background); + color: var(--geist-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--geist-transition); + white-space: nowrap; +} + +.toolbarBtn:hover { + color: var(--geist-foreground); + border-color: var(--geist-border-hover); +} + +.toolbarBtn:active { + transform: scale(0.97); +} + +.toolbarBtn.primary { + background: var(--geist-accent); + border-color: var(--geist-accent); + color: var(--geist-accent-foreground); +} + +.toolbarBtn.primary:hover { + opacity: 0.85; +} + +.codeEditor { + flex: 1; + width: 100%; + min-height: 0; + overflow: hidden; +} + +.codeEditor .cm-editor { + height: 100%; + font-family: var(--geist-mono); + font-size: 13px; + background: var(--geist-code-bg); +} + +.codeEditor .cm-scroller { + overflow: auto; +} + +.codeEditor .cm-gutters { + background: var(--geist-code-bg); + border-right: 1px solid var(--geist-border); +} + +.codeError { + padding: 8px 12px; + background: var(--geist-error-light); + border-top: 1px solid var(--geist-error); + color: var(--geist-error); + font-size: 12px; + font-family: var(--geist-mono); + flex-shrink: 0; +} + +@media (max-width: 980px) { + .demosPage { + flex-direction: column; + } + + .sidebar { + width: 100%; + max-height: 180px; + border-right: none; + border-bottom: 1px solid var(--geist-border); + } + + .codePanel { + min-height: 280px; + flex: 0 0 auto; + } + + .panelResizeHandle { + width: 100%; + height: 8px; + cursor: row-resize; + border-left: none; + border-right: none; + border-top: 1px solid var(--geist-border); + border-bottom: 1px solid var(--geist-border); + } + + .panelResizeHandle::before { + width: 48px; + height: 2px; + } + + .panelResizeHandle:hover::before, + .panelResizeHandle.active::before { + width: 64px; + height: 2px; + } + + .previewPanel { + width: 100%; + min-height: 480px; + border-left: none; + border-top: 1px solid var(--geist-border); + } +} diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index 5e1fa1809a..c0d2841531 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -5,6 +5,8 @@ import { json } from '@codemirror/lang-json'; import CodeMirror from '@uiw/react-codemirror'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import './DemosPage.css'; + import { MobilePreview } from '../components/MobilePreview.js'; import { QrCode } from '../components/QrCode.js'; import { diff --git a/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx b/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx index 0516dfdb63..e4de951bc5 100644 --- a/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx @@ -3,6 +3,8 @@ // LICENSE file in the root directory of this source tree. import { useCallback, useEffect, useRef, useState } from 'react'; +import './DemosPage.css'; + import { MobilePreview } from '../components/MobilePreview.js'; import { QrCode } from '../components/QrCode.js'; import { useResizablePanels } from '../hooks/useResizablePanels.js'; diff --git a/packages/genui/a2ui-playground/src/pages/PlaybackPage.css b/packages/genui/a2ui-playground/src/pages/PlaybackPage.css new file mode 100644 index 0000000000..f7fc5a4054 --- /dev/null +++ b/packages/genui/a2ui-playground/src/pages/PlaybackPage.css @@ -0,0 +1,201 @@ +/* ═══════════════════════════════════════════ + Playback Page + ═══════════════════════════════════════════ */ + +.playbackPage { + display: flex; + flex: 1; + overflow: hidden; +} + +.playbackStreamPanel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--geist-border); + background: var(--geist-background); + overflow: hidden; +} + +.playbackPanelHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-background); + flex-shrink: 0; +} + +.playbackPanelTitle { + font-size: 13px; + font-weight: 600; +} + +.playbackPanelBadge { + font-size: 10px; + font-weight: 500; + padding: 1px 6px; + border-radius: 4px; + background: var(--geist-surface); + border: 1px solid var(--geist-border); + color: var(--geist-secondary); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.playbackControls { + display: flex; + align-items: center; + gap: 8px; +} + +.playbackProgress { + flex-shrink: 0; + position: relative; + height: 28px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-surface); + overflow: hidden; +} + +.playbackProgressBar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--geist-foreground); + opacity: 0.06; + transition: width 400ms ease-out; +} + +.playbackProgressLabel { + position: relative; + z-index: 1; + padding: 0 12px; + font-size: 11px; + font-weight: 500; + color: var(--geist-secondary); + font-family: var(--geist-mono); +} + +.playbackStreamBody { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + background: var(--geist-background); +} + +.playbackStreamEmpty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--geist-secondary); + font-size: 14px; + text-align: center; +} + +.playbackPlayIcon { + font-size: 36px; + opacity: 0.25; +} + +.playbackStreamEmptySub { + font-size: 12px; + color: var(--geist-secondary); + opacity: 0.7; +} + +.playbackChunk { + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + overflow: hidden; + background: var(--geist-background); + flex-shrink: 0; +} + +.playbackChunkNew { + animation: chunkAppear 300ms ease-out; + border-color: var(--geist-border-hover); +} + +@keyframes chunkAppear { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.playbackChunkHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.04); + border-bottom: 1px solid var(--geist-border); +} + +.playbackChunkIndex { + font-size: 11px; + font-family: var(--geist-mono); + font-weight: 600; + color: var(--geist-secondary); +} + +.playbackChunkLive { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--geist-success); + padding: 1px 6px; + border-radius: 999px; + background: rgba(0, 112, 243, 0.12); + border: 1px solid rgba(0, 112, 243, 0.2); +} + +.playbackChunkJson { + margin: 0; + padding: 8px 10px; + font-family: var(--geist-mono); + font-size: 12px; + line-height: 1.55; + color: var(--geist-secondary); + white-space: pre-wrap; + word-break: break-word; + max-height: 240px; + overflow-y: auto; +} + +.playbackPreviewPanel { + width: 360px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--geist-surface); + border-left: 1px solid var(--geist-border); +} + +.playbackStatusBadge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--geist-border); + color: var(--geist-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} diff --git a/packages/genui/a2ui-playground/src/pages/PlaybackPage.tsx b/packages/genui/a2ui-playground/src/pages/PlaybackPage.tsx new file mode 100644 index 0000000000..2740d6fa39 --- /dev/null +++ b/packages/genui/a2ui-playground/src/pages/PlaybackPage.tsx @@ -0,0 +1,318 @@ +// 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, useMemo, useRef, useState } from 'react'; + +import './PlaybackPage.css'; + +import { PLAYBACK_SCENARIOS } from '../demos.js'; +import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js'; +import type { Protocol } from '../utils/protocol.js'; + +type PlayState = 'idle' | 'playing' | 'paused' | 'done'; + +const STREAM_DELAY_MS = 800; + +function formatChunk(msg: unknown): string { + return JSON.stringify(msg, null, 2); +} + +export function PlaybackPage(props: { protocol: Protocol }) { + const { protocol } = props; + + const [scenarioId, setScenarioId] = useState( + PLAYBACK_SCENARIOS[0]?.id ?? '', + ); + const [playState, setPlayState] = useState('idle'); + const [currentIndex, setCurrentIndex] = useState(-1); + const [visibleMessages, setVisibleMessages] = useState([]); + const [speed, setSpeed] = useState(1); + const [iframeKey, setIframeKey] = useState(0); + + const timerRef = useRef | null>(null); + const streamBodyRef = useRef(null); + const iframeRef = useRef(null); + const pendingSendRef = useRef<{ msgs: unknown[]; speed: number } | null>( + null, + ); + + const currentScenario = PLAYBACK_SCENARIOS.find((s) => s.id === scenarioId) + ?? PLAYBACK_SCENARIOS[0]; + const messages = Array.isArray(currentScenario?.messages) + ? currentScenario.messages + : []; + + const iframeBaseUrl = useMemo(() => { + const base = window.location.href.replace(/#.*$/, ''); + const url = new URL('render.html', base); + url.searchParams.set('protocol', protocol.name); + url.searchParams.set('demoUrl', DEFAULT_A2UI_DEMO_URL); + return url.toString(); + }, [protocol.name]); + + const sendToIframe = useCallback((msgs: unknown[], spd: number) => { + const win = iframeRef.current?.contentWindow; + if (!win) return; + win.postMessage( + { type: 'INIT_LYNX_VIEW', data: { messages: msgs, speed: spd } }, + '*', + ); + }, []); + + const handleIframeLoad = useCallback(() => { + if (pendingSendRef.current !== null) { + sendToIframe(pendingSendRef.current.msgs, pendingSendRef.current.speed); + pendingSendRef.current = null; + } + }, [sendToIframe]); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const startPlayback = useCallback( + (msgs: unknown[], spd: number) => { + setIframeKey((k) => k + 1); + pendingSendRef.current = { msgs, speed: spd }; + setPlayState('playing'); + setCurrentIndex(0); + setVisibleMessages(msgs.length > 0 ? [msgs[0]] : []); + }, + [], + ); + + useEffect(() => { + if (playState !== 'playing') return; + if (currentIndex >= messages.length - 1) { + setPlayState('done'); + return; + } + const delay = STREAM_DELAY_MS / speed; + timerRef.current = setTimeout(() => { + const next = currentIndex + 1; + setCurrentIndex(next); + setVisibleMessages((prev) => [...prev, messages[next]]); + }, delay); + return clearTimer; + }, [playState, currentIndex, messages, speed, clearTimer]); + + useEffect(() => { + if (!streamBodyRef.current || visibleMessages.length === 0) return; + streamBodyRef.current.scrollTop = streamBodyRef.current.scrollHeight; + }, [visibleMessages]); + + const handleSelectScenario = useCallback( + (id: string) => { + clearTimer(); + setScenarioId(id); + setPlayState('idle'); + setCurrentIndex(-1); + setVisibleMessages([]); + }, + [clearTimer], + ); + + const handlePlay = useCallback(() => { + if (!currentScenario) return; + if (playState === 'paused') { + setPlayState('playing'); + return; + } + clearTimer(); + setVisibleMessages([]); + setCurrentIndex(-1); + setPlayState('idle'); + setTimeout(() => startPlayback(messages, speed), 0); + }, [playState, currentScenario, messages, speed, clearTimer, startPlayback]); + + const handlePause = useCallback(() => { + clearTimer(); + setPlayState('paused'); + }, [clearTimer]); + + const handleRestart = useCallback(() => { + if (!currentScenario) return; + clearTimer(); + setVisibleMessages([]); + setCurrentIndex(-1); + setPlayState('idle'); + setTimeout(() => startPlayback(messages, speed), 0); + }, [currentScenario, messages, speed, clearTimer, startPlayback]); + + const isIdle = playState === 'idle'; + const isPlaying = playState === 'playing'; + const isDone = playState === 'done'; + const isPaused = playState === 'paused'; + const showIframe = !isIdle; + + return ( +
+ + +
+
+ JSONL Stream + JSONL +
+
+ + setSpeed(Number(e.target.value))} + /> + {speed}x + {isPlaying + ? ( + + ) + : ( + + )} + {(isDone || isPaused) && ( + + )} +
+
+ + {!isIdle && messages.length > 0 && ( +
+
+ + {currentIndex + 1} / {messages.length} chunks + {isDone ? ' — complete' : (isPlaying ? ' — streaming…' : '')} + +
+ )} + +
+ {isIdle + ? ( +
+
+
Press play to stream JSONL chunks...
+ {messages.length > 0 && ( +
+ {messages.length} messages · {currentScenario?.title} +
+ )} +
+ ) + : ( + <> + {visibleMessages.map((msg, i) => ( +
+
+ #{i + 1} + {i === visibleMessages.length - 1 && isPlaying && ( + live + )} +
+
+                      {formatChunk(msg)}
+                    
+
+ ))} + + )} +
+
+ +
+
+ Lynx Preview + {(isPlaying || isDone || isPaused) && ( + + {isPlaying + ? 'Streaming' + : (isDone + ? 'Complete' + : 'Paused')} + + )} +
+
+ {showIframe + ? ( +
+
+