diff --git a/packages/genui/a2ui-playground/src/components/MobileTabBar.tsx b/packages/genui/a2ui-playground/src/components/MobileTabBar.tsx new file mode 100644 index 0000000000..a58ac15694 --- /dev/null +++ b/packages/genui/a2ui-playground/src/components/MobileTabBar.tsx @@ -0,0 +1,69 @@ +// 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. + +export type MobilePaneTab = 'edit' | 'preview'; + +interface MobileTabBarProps { + activeTab: MobilePaneTab; + onChange: (tab: MobilePaneTab) => void; + editLabel?: string; +} + +export function MobileTabBar(props: MobileTabBarProps) { + const { activeTab, editLabel = 'Edit', onChange } = props; + return ( + + ); +} diff --git a/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx b/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx index 8b36164770..92f480fcc1 100644 --- a/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx +++ b/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx @@ -592,7 +592,15 @@ export function PreviewPanel(props: PreviewPanelProps) { return; } - if (typeof window !== 'undefined' && window.innerWidth <= 980) { + // Auto-fullscreen on narrow but tab-less screens (721–980px). At ≤720 + // the host page renders a MobileTabBar, and the Preview tab already + // gives the panel the full viewport — auto-fullscreening on top of + // that hides the tab bar and traps the user behind an X button. + if ( + typeof window !== 'undefined' + && window.innerWidth > 720 + && window.innerWidth <= 980 + ) { setIsFullscreen(true); } }, [previewSource]); diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.css b/packages/genui/a2ui-playground/src/pages/AIChatPage.css index 6cf4f693a9..d3ef9d9ec5 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.css +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.css @@ -1435,22 +1435,118 @@ max-width: 100%; } - .chatComposerFooter { - align-items: stretch; - flex-direction: column; + /* Tab-mode: each tab is full screen, no inline split / resize handle. + Active pane is sized via .chatPage[data-active-tab='…'] rules below. */ + .chatPage[data-active-tab="edit"] .previewPanel { + display: none; + } + .chatPage[data-active-tab="preview"] .chatPanel, + .chatPage[data-active-tab="preview"] .conversationPanel { + display: none; + } + .chatPageBody > .panelResizeHandle { + display: none; } - .chatProviderControl { - max-width: none; + /* The active pane fills the body — overrides the desktop sizing baked + into .conversationPanel / .chatPanel / .previewPanel. */ + .chatPage[data-active-tab="edit"] .chatPanel, + .chatPage[data-active-tab="preview"] .previewPanel { + flex: 1; width: 100%; + max-width: none; + min-height: 0; + } + .chatPage[data-active-tab="preview"] .previewPanel { + border-top: none; + border-left: none; } - .chatProviderSelect { - max-width: none; - width: 100%; + /* Conversation panel collapses into a single horizontal strip on + phones: +New Chat is an icon button on the left, conversations + scroll as compact one-line cards on the right. Drops from ~150px + to ~52px of vertical chrome. */ + .chatPage[data-active-tab="edit"] .conversationPanel { + flex-direction: row; + align-items: stretch; + min-height: 0; + max-height: none; + height: 52px; + background: var(--geist-background); + } + .conversationPanelHeader, + .conversationPanelCreateRow { + flex: 0 0 auto; + width: auto; + min-height: 0; + padding: 8px; + border-bottom: none; + border-right: 1px solid var(--geist-border); + background: transparent; + } + .conversationNewButton { + width: 36px; + height: 36px; + padding: 0; + border-radius: 999px; + box-shadow: none; + } + .conversationNewButtonLabel { + display: none; + } + .conversationList { + flex: 1; + align-items: center; + padding: 6px 8px; + gap: 6px; + } + /* Keep the desktop card aesthetic on mobile: rounded rectangle, active + bar on the left, Edit/Del hidden by default and revealed only on the + active card (matching the desktop hover/active behavior). Vertically + center title + actions since the meta/date row is hidden — the + desktop card is a two-line block, the mobile chip is single-line. */ + .conversationListItem { + flex: 0 0 auto; + width: auto; + min-width: 0; + max-width: 220px; + padding: 6px 8px 6px 12px; + align-items: center; + } + .conversationListItemMeta, + .conversationListItemPreview { + display: none; + } + .conversationListItemTitle { + font-size: 12px; + line-height: 1.25; + } + .conversationListItemActions { + opacity: 0; + pointer-events: none; + transform: none; + gap: 4px; + } + .conversationListItem-active .conversationListItemActions, + .conversationListItem:focus-within .conversationListItemActions { + opacity: 1; + pointer-events: auto; } + /* Pill Send button (drops the desktop drop-shadow on mobile). */ .chatSendBtn { - width: 100%; + min-width: 84px; + height: 38px; + padding: 0 14px; + font-size: 13px; + box-shadow: none; + } + + /* Drop the "Create / Online Agent / Describe the UI…" header on + mobile — the chat input area itself communicates the same thing + and this band was eating ~120px of vertical space. (Token-usage + badge lives here too, only relevant for power users on desktop.) */ + .chatHeader { + display: none; } } diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx index e346360d49..39d65e4012 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx @@ -9,6 +9,8 @@ import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConversationListPanel } from '../components/ConversationListPanel.js'; import { CopyToast, useCopyToast } from '../components/CopyToast.js'; import { InstantExamplesStrip } from '../components/InstantExamplesStrip.js'; +import { MobileTabBar } from '../components/MobileTabBar.js'; +import type { MobilePaneTab } from '../components/MobileTabBar.js'; import { PageHeader } from '../components/PageHeader.js'; import { PanelResizeHandle } from '../components/PanelResizeHandle.js'; import { PreviewPanel } from '../components/PreviewPanel.js'; @@ -854,6 +856,9 @@ export function AIChatPage( PreviewPayloadUrls | null >(null); const [isGenerating, setIsGenerating] = useState(false); + const [activeMobileTab, setActiveMobileTab] = useState( + 'edit', + ); const [deleteConversationId, setDeleteConversationId] = useState< string | null >(null); @@ -1665,6 +1670,7 @@ export function AIChatPage(
+ + ); } diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.css b/packages/genui/a2ui-playground/src/pages/DemosPage.css index be1c450537..a4e11b34e5 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.css +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.css @@ -1059,3 +1059,116 @@ html[data-theme="dark"] .detailBackButton:hover { border-top: 1px solid var(--geist-border); } } + +/* Phone-sized tab mode: each tab is full screen, no inline split. */ +@media (max-width: 720px) { + .demosPage[data-active-tab="edit"] .examplesPreviewWrap { + display: none; + } + .demosPage[data-active-tab="preview"] .sidebar, + .demosPage[data-active-tab="preview"] .codePanel { + display: none; + } + .demosPage > .panelResizeHandle { + display: none; + } + + /* Preview pane fills the viewport (minus top nav + bottom tab bar); + overrides the desktop .examplesPreviewWrap min-height: 480px. */ + .demosPage[data-active-tab="preview"] .examplesPreviewWrap { + flex: 1; + width: 100%; + min-height: 0; + } + + /* Sidebar collapses into a single horizontal strip: back-icon on the + left, scenarios scrolling as chips on the right. SCENARIOS heading + hides — its label is redundant when the chips are the only thing in + the strip. Drops the band from ~180px to ~52px. */ + .demosPage[data-active-tab="edit"] .sidebar { + flex-direction: row; + align-items: stretch; + width: 100%; + max-height: none; + min-height: 0; + height: 52px; + overflow: hidden; + background: var(--geist-background); + } + /* Code panel grows to fill the remaining viewport instead of staying + pinned at min-height 280px (the desktop / <=980 default). */ + .demosPage[data-active-tab="edit"] .codePanel { + flex: 1; + min-height: 0; + } + .sidebarTopNav { + flex: 0 0 auto; + padding: 8px 10px; + border-right: 1px solid var(--geist-border); + } + .detailBackLabel { + display: none; + } + .detailBackButton { + min-height: 32px; + padding: 6px 10px; + box-shadow: none; + } + .sidebarSection { + flex: 1; + min-width: 0; + padding: 0; + display: flex; + align-items: center; + } + .sidebarHeading { + display: none; + } + .scenarioList { + flex-direction: row; + gap: 6px; + padding: 6px 10px; + overflow-x: auto; + overflow-y: hidden; + width: 100%; + } + .scenarioItem { + flex: 0 0 auto; + flex-direction: row; + align-items: center; + width: auto; + min-height: 0; + padding: 6px 12px; + border-radius: 999px; + border-color: var(--geist-border); + } + .scenarioItem .scenarioDesc { + display: none; + } + .scenarioItem .scenarioName { + white-space: nowrap; + font-size: 12px; + } + + /* Tighter toolbar so title + Reset/Clear/Render all stay on one row + in the constrained mobile width. */ + .codePanelToolbar { + padding: 6px 10px; + } + .codePanelToolbar .toolbarBtn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + .codePanelToolbar .toolbarBtn.primary { + padding: 0 10px; + } + /* "A2UI Messages" was wrapping to two lines — keep it on one. */ + .codePanelTitle { + white-space: nowrap; + font-size: 12px; + } + .codePanelBadge { + display: none; + } +} diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index a160780b41..829bbc22cf 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -8,6 +8,8 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import './DemosPage.css'; +import { MobileTabBar } from '../components/MobileTabBar.js'; +import type { MobilePaneTab } from '../components/MobileTabBar.js'; import { PanelResizeHandle } from '../components/PanelResizeHandle.js'; import { PreviewPanel } from '../components/PreviewPanel.js'; import { PreviewViewport } from '../components/PreviewViewport.js'; @@ -146,6 +148,7 @@ export function DemosPage(props: { const [jsonEdited, setJsonEdited] = useState(false); const [previewRenderKey, setPreviewRenderKey] = useState(0); const [isPublishingPayload, setIsPublishingPayload] = useState(false); + const [activeMobileTab, setActiveMobileTab] = useState('edit'); const [previewInput, setPreviewInput] = useState(() => initialScenario ? { @@ -624,6 +627,7 @@ export function DemosPage(props: {
); } diff --git a/packages/genui/a2ui-playground/src/styles.css b/packages/genui/a2ui-playground/src/styles.css index f10d67612f..649d51de48 100644 --- a/packages/genui/a2ui-playground/src/styles.css +++ b/packages/genui/a2ui-playground/src/styles.css @@ -1387,11 +1387,10 @@ html[data-theme="dark"] .phoneHomeIndicator { padding: 0 10px; } + /* Logo alone is enough on mobile — drops the "Lynx GenUI Playground" + wordmark that was truncating to "Lynx GenUI Playg…" */ .brand { - max-width: 130px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: none; } .brandLogo { @@ -1416,3 +1415,64 @@ html[data-theme="dark"] .phoneHomeIndicator { padding: 10px; } } + +/* ── Mobile tab bar (per-page Edit/Preview swap) ── + Pages that opt in render as the last child of their + flex-column root, so the bar lands at the bottom naturally without + position: fixed/sticky overlap quirks. */ +.mobileTabBar { + display: none; + flex-shrink: 0; + background: var(--geist-background); + border-top: 1px solid var(--geist-border); + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +.mobileTab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 8px 4px 7px; + background: transparent; + border: none; + color: var(--geist-secondary); + font-size: 11px; + font-weight: 500; + cursor: pointer; + position: relative; + transition: color var(--geist-transition); +} + +.mobileTab:hover { + color: var(--geist-foreground); +} + +.mobileTab.active { + color: var(--geist-foreground); + font-weight: 600; +} + +.mobileTab.active::before { + content: ""; + position: absolute; + top: -1px; + left: 50%; + transform: translateX(-50%); + width: 32px; + height: 2px; + background: var(--geist-foreground); + border-radius: 0 0 999px 999px; +} + +.mobileTabLabel { + letter-spacing: 0.01em; +} + +@media (max-width: 720px) { + .mobileTabBar { + display: flex; + } +}