- Drag blocks from the left panel, or pick a Template to start.
+
+ {isMobile
+ ? <>Tap Blocks below to add content, or pick a Template to start.>
+ : 'Drag blocks from the left panel, or pick a Template to start.'}
+
);
};
diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx
index fb45fb6..848c75d 100644
--- a/craft/src/editor/EditorShell.tsx
+++ b/craft/src/editor/EditorShell.tsx
@@ -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('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)}
/>
-
+ {!isMobile && }
-
+ {!isMobile && }
+ {isMobile && }
(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;
+}
diff --git a/craft/src/panels/mobile/BottomSheet.tsx b/craft/src/panels/mobile/BottomSheet.tsx
new file mode 100644
index 0000000..920920b
--- /dev/null
+++ b/craft/src/panels/mobile/BottomSheet.tsx
@@ -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 = ({ 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 (
+
+ );
+};
diff --git a/craft/src/panels/mobile/MobilePanelBar.tsx b/craft/src/panels/mobile/MobilePanelBar.tsx
new file mode 100644
index 0000000..cd438fb
--- /dev/null
+++ b/craft/src/panels/mobile/MobilePanelBar.tsx
@@ -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(null);
+
+ const handleTabClick = (tab: MobileTab) => {
+ setActiveTab((current) => (current === tab ? null : tab));
+ };
+
+ const activeLabel = TABS.find((t) => t.id === activeTab)?.label ?? '';
+
+ return (
+ <>
+ setActiveTab(null)} title={activeLabel}>
+ {activeTab === 'blocks' && }
+ {activeTab === 'pages' && }
+ {activeTab === 'layers' && }
+ {activeTab === 'assets' && }
+ {activeTab === 'styles' && }
+
+
+
+ >
+ );
+};
diff --git a/craft/src/panels/topbar/TemplateModal.tsx b/craft/src/panels/topbar/TemplateModal.tsx
index 11a27f3..aa90414 100644
--- a/craft/src/panels/topbar/TemplateModal.tsx
+++ b/craft/src/panels/topbar/TemplateModal.tsx
@@ -253,8 +253,10 @@ export const TemplateModal: React.FC = ({ open, onClose }) =
- {/* Filter Tabs */}
-
+ {/* 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. */}
+