Mobile Phase A: responsive editor (bottom-sheet panels, collapsed topbar, touch ergonomics) #9

Merged
jknapp merged 2 commits from mobile-phase-a into main 2026-07-13 14:31:24 +00:00
10 changed files with 852 additions and 64 deletions
Showing only changes of commit 979331b12d - Show all commits
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Site Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+7 -1
View File
@@ -5,6 +5,7 @@ import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types';
import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile';
interface CanvasProps {
device: DeviceMode;
@@ -112,13 +113,18 @@ export const EmptyCanvasHint: React.FC = () => {
isDragging: state.events.dragged.size > 0,
};
});
const isMobile = useIsMobile();
if (!isEmpty || isDragging) return null;
return (
<div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden />
<span>Drag blocks from the left panel, or pick a Template to start.</span>
<span>
{isMobile
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
: 'Drag blocks from the left panel, or pick a Template to start.'}
</span>
</div>
);
};
+20 -3
View File
@@ -1,12 +1,14 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel';
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile';
import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -21,7 +23,21 @@ function loadShowGuides(): boolean {
}
export const EditorShell: React.FC = () => {
const isMobile = useIsMobile();
const [device, setDevice] = useState<DeviceMode>('desktop');
// Phase A: default the canvas to the "mobile" preview width the first
// time we detect a mobile viewport, so the frame fits without the user
// having to reach for the device switcher (now tucked in TopBar's
// overflow menu on mobile). Only fires once, and only if the device is
// still at its initial default -- it must not fight a device the user
// has already picked (e.g. from the overflow menu) on a later re-render.
const mobileDeviceAppliedRef = useRef(false);
useEffect(() => {
if (isMobile && !mobileDeviceAppliedRef.current) {
mobileDeviceAppliedRef.current = true;
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
}
}, [isMobile]);
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
// so the choice survives a reload. Lifted here (rather than owned by
// TopBar or Canvas alone) because the toggle button lives in TopBar but
@@ -67,12 +83,13 @@ export const EditorShell: React.FC = () => {
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
<LeftPanel />
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
</div>
<RightPanel />
{!isMobile && <RightPanel />}
</div>
{isMobile && <MobilePanelBar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
* CSS and this hook must agree on where mobile chrome kicks in. */
export const MOBILE_BREAKPOINT_PX = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
function computeIsMobile(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
try {
return window.matchMedia(MOBILE_QUERY).matches;
} catch {
// Fall through to the width check below (e.g. some older/embedded
// WebViews expose a matchMedia that throws instead of omitting it).
}
}
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
}
/**
* Reports whether the viewport is at or below the mobile editor breakpoint.
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
* above the breakpoint, so this is the single source of truth both React
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
*
* Falls back to a `resize` listener + `window.innerWidth` when
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
* hook degrades gracefully rather than throwing.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window.matchMedia === 'function') {
let mql: MediaQueryList | null = null;
try {
mql = window.matchMedia(MOBILE_QUERY);
} catch {
mql = null;
}
if (mql) {
const handleChange = () => setIsMobile(mql!.matches);
handleChange();
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handleChange);
return () => mql!.removeEventListener('change', handleChange);
}
// Safari < 14 only supports the deprecated addListener/removeListener pair.
const legacyMql = mql as MediaQueryList & {
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
};
legacyMql.addListener?.(handleChange);
return () => legacyMql.removeListener?.(handleChange);
}
}
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile;
}
+66
View File
@@ -0,0 +1,66 @@
import React, { useEffect } from 'react';
export interface BottomSheetProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
/**
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components
* (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged,
* as the mobile replacement for the desktop fixed left/right panel columns.
* See `MobilePanelBar`, which owns which sheet (if any) is open.
*
* Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases.
*/
export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="mobile-sheet-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="mobile-sheet" role="dialog" aria-modal="true" aria-label={title}>
<div className="mobile-sheet-handle-row">
<span className="mobile-sheet-handle" aria-hidden="true" />
</div>
<div className="mobile-sheet-header">
<span className="mobile-sheet-title">{title}</span>
<button
type="button"
className="mobile-sheet-close"
onClick={onClose}
aria-label={`Close ${title}`}
>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="mobile-sheet-body">{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { BottomSheet } from './BottomSheet';
import { BlocksPanel } from '../left/BlocksPanel';
import { PagesPanel } from '../left/PagesPanel';
import { LayersPanel } from '../left/LayersPanel';
import { AssetsPanel } from '../left/AssetsPanel';
import { GuidedStyles } from '../right/GuidedStyles';
type MobileTab = 'blocks' | 'pages' | 'layers' | 'assets' | 'styles';
const TABS: { id: MobileTab; label: string; icon: string }[] = [
{ id: 'blocks', label: 'Blocks', icon: 'fa-cube' },
{ id: 'pages', label: 'Pages', icon: 'fa-file-o' },
{ id: 'layers', label: 'Layers', icon: 'fa-sitemap' },
{ id: 'assets', label: 'Assets', icon: 'fa-image' },
{ id: 'styles', label: 'Styles', icon: 'fa-paint-brush' },
];
/**
* Mobile replacement for the desktop LeftPanel/RightPanel column layout
* (Phase A). A fixed bottom tab bar (Blocks/Pages/Layers/Assets/Styles)
* toggles a single `BottomSheet` at a time, hosting the SAME panel
* component the desktop side panels use -- unchanged, so behavior/state
* stays identical, only the chrome around it differs. Tapping the active
* tab again, tapping the backdrop, the sheet's close button, or Escape all
* close the open sheet.
*
* "Styles" hosts `GuidedStyles`, which already falls back to the
* site-design panel when nothing is selected and switches to the
* per-type style panel once something is -- this is the natural place to
* edit a tapped element's styling (Phase B deepens the tap-to-select flow;
* this component only wires up the sheet, per the Phase A brief).
*/
export const MobilePanelBar: React.FC = () => {
const [activeTab, setActiveTab] = useState<MobileTab | null>(null);
const handleTabClick = (tab: MobileTab) => {
setActiveTab((current) => (current === tab ? null : tab));
};
const activeLabel = TABS.find((t) => t.id === activeTab)?.label ?? '';
return (
<>
<BottomSheet open={activeTab !== null} onClose={() => setActiveTab(null)} title={activeLabel}>
{activeTab === 'blocks' && <BlocksPanel />}
{activeTab === 'pages' && <PagesPanel />}
{activeTab === 'layers' && <LayersPanel />}
{activeTab === 'assets' && <AssetsPanel />}
{activeTab === 'styles' && <GuidedStyles />}
</BottomSheet>
<nav className="mobile-tab-bar" aria-label="Editor panels">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`mobile-tab-btn${activeTab === tab.id ? ' active' : ''}`}
onClick={() => handleTabClick(tab.id)}
aria-pressed={activeTab === tab.id}
>
<i className={`fa ${tab.icon}`} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</nav>
</>
);
};
+8 -2
View File
@@ -253,8 +253,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</div>
<button
onClick={onClose}
className="template-modal-close-btn"
style={closeButtonStyle}
title="Close"
aria-label="Close"
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = '#71717a'; }}
>
@@ -262,11 +264,15 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
</button>
</div>
{/* Filter Tabs */}
<div style={tabBarStyle}>
{/* Filter Tabs -- horizontally scrollable (tabBarStyle sets
overflowX: 'auto') so the row never wraps/overflows the modal at
narrow widths; template-modal-tab-btn below keeps each tab from
shrinking below a comfortable touch target on mobile. */}
<div className="template-modal-tabbar" style={tabBarStyle}>
{TABS.map((tab) => (
<button
key={tab.value}
className="template-modal-tab-btn"
onClick={() => setActiveTab(tab.value)}
style={{
...tabStyle,
+155 -54
View File
@@ -4,9 +4,11 @@ import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
@@ -33,6 +35,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const [overflowOpen, setOverflowOpen] = useState(false);
const isMobile = useIsMobile();
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -122,6 +126,156 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
};
}, []);
// Extracted so both the desktop inline Preview button and the mobile
// overflow-menu "Preview" item can trigger the exact same flow.
const handlePreview = useCallback(() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
headCode: design.headCode,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}, [query, headerPage, footerPage, whpConfig, design]);
// Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
// Preview, Head Code, the guides toggle, and the device switcher moved
// into the "⋯" overflow menu. This keeps desktop's JSX/behavior below
// completely untouched.
if (isMobile) {
return (
<nav className="topbar topbar-mobile">
<div className="topbar-left">
{isWHP && (
<a
href={whpConfig!.backUrl}
className="topbar-btn icon-only back-btn"
aria-label="Back to Panel"
data-tooltip="Back"
>
<i className="fa fa-arrow-left" />
</a>
)}
</div>
<div className="topbar-right">
<button
className="topbar-btn icon-only"
onClick={() => actions.history.undo()}
disabled={!canUndo}
aria-label="Undo"
data-tooltip="Undo"
>
<i className="fa fa-undo" />
</button>
<button
className="topbar-btn icon-only"
onClick={() => actions.history.redo()}
disabled={!canRedo}
aria-label="Redo"
data-tooltip="Redo"
>
<i className="fa fa-repeat" />
</button>
<span className="topbar-divider" />
<button
className="topbar-btn icon-only primary"
onClick={handleSave}
disabled={saveStatus === 'saving'}
aria-label="Save Draft"
data-tooltip="Save"
>
<i className={`fa ${saveStatus === 'saving' ? 'fa-spinner fa-spin' : 'fa-save'}`} />
</button>
{isWHP && (
<button
className="topbar-btn icon-only publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
aria-label="Publish to live site"
data-tooltip="Publish"
>
<i className={`fa ${publishStatus === 'publishing' ? 'fa-spinner fa-spin' : 'fa-globe'}`} />
</button>
)}
<button
type="button"
className={`topbar-btn icon-only overflow-toggle${overflowOpen ? ' active' : ''}`}
onClick={() => setOverflowOpen((v) => !v)}
aria-label="More options"
aria-expanded={overflowOpen}
aria-haspopup="menu"
data-tooltip="More"
>
<i className="fa fa-ellipsis-h" />
</button>
</div>
<TopBarOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
device={device}
onDeviceChange={onDeviceChange}
showGuides={showGuides}
onToggleGuides={onToggleGuides}
onOpenTemplates={() => setTemplateModalOpen(true)}
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
onPreview={handlePreview}
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
/>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
</nav>
);
}
return (
<nav className="topbar">
<div className="topbar-left">
@@ -182,60 +336,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
>
<i className="fa fa-code" />
</button>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
headCode: design.headCode,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}}>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
<i className="fa fa-eye" />
</button>
@@ -0,0 +1,110 @@
import React, { useEffect, useRef } from 'react';
import { DeviceMode } from '../../types';
export interface TopBarOverflowMenuProps {
open: boolean;
onClose: () => void;
device: DeviceMode;
onDeviceChange: (device: DeviceMode) => void;
showGuides: boolean;
onToggleGuides: () => void;
onOpenTemplates: () => void;
onOpenHeadCode: () => void;
onPreview: () => void;
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
* re-implemented here so the mobile menu reuses the exact same
* lock/cap-aware button the desktop topbar shows. */
sitesmithNode: React.ReactNode;
}
/**
* Mobile topbar "⋯" overflow menu (Phase A). At ≤768px, TopBar collapses to
* just the back arrow, Undo/Redo, Save/Publish, and this menu -- everything
* else that's inline on desktop (Templates, Sitesmith, Preview, Head Code,
* the guides toggle, and the device-width switcher) moves in here so the
* collapsed bar never overlaps or pushes controls off-screen.
*/
export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
open,
onClose,
device,
onDeviceChange,
showGuides,
onToggleGuides,
onOpenTemplates,
onOpenHeadCode,
onPreview,
sitesmithNode,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEsc);
};
}, [open, onClose]);
if (!open) return null;
const runAndClose = (fn: () => void) => () => {
fn();
onClose();
};
return (
<div ref={menuRef} className="topbar-overflow-menu" role="menu">
<div className="topbar-overflow-section">
<span className="topbar-overflow-label">Preview width</span>
<div className="device-switcher">
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
<button
key={d}
type="button"
className={`device-btn ${device === d ? 'active' : ''}`}
onClick={() => onDeviceChange(d)}
title={d.charAt(0).toUpperCase() + d.slice(1)}
aria-label={d.charAt(0).toUpperCase() + d.slice(1)}
aria-pressed={device === d}
>
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
</button>
))}
</div>
</div>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenTemplates)}>
<i className="fa fa-th-large" aria-hidden="true" /> Templates
</button>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onPreview)}>
<i className="fa fa-eye" aria-hidden="true" /> Preview
</button>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
<i className="fa fa-code" aria-hidden="true" /> Head Code
</button>
<button
type="button"
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
role="menuitem"
onClick={runAndClose(onToggleGuides)}
aria-pressed={showGuides}
>
<i className={`fa ${showGuides ? 'fa-th' : 'fa-th-large'}`} aria-hidden="true" />
{' '}{showGuides ? 'Hide Guides' : 'Show Guides'}
</button>
<div className="topbar-overflow-sitesmith" onClick={onClose}>
{sitesmithNode}
</div>
</div>
);
};
+344 -3
View File
@@ -31,6 +31,9 @@
--radius-lg: 8px;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
/* Phase A (mobile): fixed bottom tab bar height, used to size
.editor-container and the safe-area padding math below. */
--mobile-tabbar-height: 58px;
}
/* --------------------------------------------------------------------------
@@ -1377,13 +1380,351 @@ body {
}
@media (max-width: 768px) {
/* ------------------------------------------------------------------------
Phase A -- Mobile Editor Chrome
Gated entirely behind this breakpoint; desktop styles above are
untouched. See useIsMobile() (same 768px breakpoint) for the React-side
equivalent that swaps LeftPanel/RightPanel for MobilePanelBar, and
collapses TopBar.
------------------------------------------------------------------------ */
/* Side panels are not rendered on mobile (EditorShell swaps them for the
bottom tab bar) -- kept as a defensive fallback in case either is ever
mounted at this width. */
.panel-left,
.panel-right {
display: none;
}
.topbar-btn span,
.topbar-domain {
display: none;
/* --------------------------------------------------------------------
Viewport sizing -- dynamic viewport height accounts for mobile
browsers' collapsing URL bar; the unconditional 100vh set earlier in
this file is the fallback for browsers without dvh support.
-------------------------------------------------------------------- */
.editor-app {
height: 100vh;
height: 100dvh;
padding-top: env(safe-area-inset-top, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
.editor-container {
height: calc(100vh - var(--topbar-height) - var(--mobile-tabbar-height) - env(safe-area-inset-bottom, 0px));
height: calc(100dvh - var(--topbar-height) - var(--mobile-tabbar-height) - env(safe-area-inset-bottom, 0px));
}
/* Full-width canvas, safe-area-aware bottom padding so content never
sits flush against the fixed bottom tab bar. */
.editor-canvas {
padding: 12px;
}
/* --------------------------------------------------------------------
Topbar collapse (TopBar.tsx renders a distinct `.topbar-mobile` markup
branch -- this is purely the styling for it)
-------------------------------------------------------------------- */
.topbar-mobile {
padding: 0 8px;
padding-left: calc(8px + env(safe-area-inset-left, 0px));
padding-right: calc(8px + env(safe-area-inset-right, 0px));
gap: 4px;
}
.topbar-mobile .topbar-left {
flex: 0 0 auto;
}
.topbar-mobile .topbar-right {
flex: 1;
justify-content: flex-end;
gap: 4px;
}
.topbar-mobile .topbar-btn.icon-only {
width: 44px;
height: 44px;
padding: 0;
justify-content: center;
touch-action: manipulation;
}
.topbar-mobile .topbar-divider {
margin: 0 2px;
}
.topbar-btn.overflow-toggle.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
border-color: var(--color-accent);
}
/* --------------------------------------------------------------------
"⋯" overflow menu (TopBarOverflowMenu.tsx)
-------------------------------------------------------------------- */
.topbar-overflow-menu {
position: fixed;
top: calc(var(--topbar-height) + env(safe-area-inset-top, 0px) + 6px);
right: calc(8px + env(safe-area-inset-right, 0px));
z-index: 10000;
min-width: 220px;
max-width: calc(100vw - 16px);
max-height: calc(100dvh - var(--topbar-height) - 24px);
overflow-y: auto;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.topbar-overflow-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px 10px;
border-bottom: 1px solid var(--color-border);
margin-bottom: 4px;
}
.topbar-overflow-label {
font-size: 12px;
color: var(--color-text-muted);
white-space: nowrap;
}
.topbar-overflow-section .device-switcher {
height: 44px;
}
.topbar-overflow-section .device-btn {
width: 44px;
height: 42px;
touch-action: manipulation;
}
.topbar-overflow-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 8px 10px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
touch-action: manipulation;
}
.topbar-overflow-item:hover,
.topbar-overflow-item:active {
background: var(--color-bg-hover);
}
.topbar-overflow-item.active {
color: var(--color-accent);
}
.topbar-overflow-sitesmith {
padding: 6px 4px 0;
}
.topbar-overflow-sitesmith .sitesmith-btn {
width: 100%;
justify-content: center;
min-height: 44px;
}
/* --------------------------------------------------------------------
Bottom tab bar (MobilePanelBar.tsx)
-------------------------------------------------------------------- */
.mobile-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
/* Above .mobile-sheet-backdrop (400) -- the tab bar must stay clickable
even while a sheet is open so tapping a different tab swaps sheets
directly, instead of requiring a close-then-reopen round trip. The
backdrop itself stops short of the tab bar (see its `bottom` offset
below) so this is mostly belt-and-suspenders for the boundary. */
z-index: 500;
display: flex;
height: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
background: var(--color-bg-surface);
border-top: 1px solid var(--color-border);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
.mobile-tab-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 44px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
background: transparent;
border: none;
touch-action: manipulation;
}
.mobile-tab-btn i {
font-size: 17px;
}
.mobile-tab-btn.active {
color: var(--color-accent);
}
/* --------------------------------------------------------------------
Bottom sheet (BottomSheet.tsx)
-------------------------------------------------------------------- */
.mobile-sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
/* Stop above the bottom tab bar (rather than a full-viewport `inset: 0`)
so the tab bar is never covered/blocked by the backdrop -- tapping a
different tab while a sheet is open swaps sheets directly instead of
being swallowed by the overlay sitting on top of it. */
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.5);
z-index: 400;
display: flex;
align-items: flex-end;
}
.mobile-sheet {
width: 100%;
max-height: 65vh;
max-height: 65dvh;
background: var(--color-bg-surface);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
border: 1px solid var(--color-border);
border-bottom: none;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mobile-sheet-handle-row {
display: flex;
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
}
.mobile-sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--color-border-light);
}
.mobile-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px 10px 16px;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.mobile-sheet-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.mobile-sheet-close {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--color-text-muted);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
touch-action: manipulation;
}
.mobile-sheet-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 16px;
}
/* --------------------------------------------------------------------
Touch ergonomics inside sheets -- 16px inputs kill iOS's auto-zoom on
focus; touch-action speeds up tap response on chrome that doesn't need
native scroll-gesture handling.
-------------------------------------------------------------------- */
.mobile-sheet-body button,
.mobile-sheet-body .block-item {
touch-action: manipulation;
}
.mobile-sheet-body input,
.mobile-sheet-body select,
.mobile-sheet-body textarea,
.guided-input,
.control-input,
.control-select,
.topbar input,
.topbar textarea,
.topbar select {
font-size: 16px;
}
.mobile-sheet-body .settings-tabs button {
min-height: 44px;
}
.mobile-sheet-body .color-preset {
width: 40px;
height: 40px;
}
/* --------------------------------------------------------------------
Templates modal: category tabs stay horizontally scrollable and never
shrink below a comfortable touch target; close button enlarged to the
44px minimum (overriding its 32px inline style).
-------------------------------------------------------------------- */
.template-modal-tabbar {
gap: 8px;
-webkit-overflow-scrolling: touch;
}
.template-modal-tab-btn {
flex-shrink: 0;
min-height: 44px;
padding: 8px 16px !important;
touch-action: manipulation;
}
.template-modal-close-btn {
width: 44px !important;
height: 44px !important;
}
}