-
-
-
+ // Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
+ // provided around the whole shell so TopBar's Templates/Head Code modal
+ // state and MobilePanelBar's sheet state live in one place. Desktop
+ // doesn't render MobilePanelBar and TopBar's desktop branch behaves
+ // identically to before (same booleans, just sourced from context).
+
+
+
setShowGuides(!showGuides)}
+ />
+
+ {!isMobile &&
}
+
+
+
+ {!isMobile &&
}
-
+ {isMobile && }
+
-
-
+
);
};
diff --git a/craft/src/hooks/useIsMobile.test.tsx b/craft/src/hooks/useIsMobile.test.tsx
new file mode 100644
index 0000000..ad27519
--- /dev/null
+++ b/craft/src/hooks/useIsMobile.test.tsx
@@ -0,0 +1,153 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import { act } from 'react-dom/test-utils';
+import { useIsMobile } from './useIsMobile';
+
+/**
+ * Mobile-A2 (review item 5): useIsMobile is the single source of truth every
+ * mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
+ * from, so it needs direct coverage of: (a) it reflects matchMedia's
+ * `matches`, (b) the `change` listener is registered AND cleaned up on
+ * unmount (a leaked listener would keep re-rendering an unmounted tree /
+ * leak the component instance), and (c) the legacy Safari<14
+ * addListener/removeListener fallback is used when addEventListener isn't
+ * available.
+ *
+ * DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
+ * useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
+ * (not a project dependency).
+ */
+
+let container: HTMLDivElement;
+let root: Root;
+let originalMatchMedia: typeof window.matchMedia;
+
+function render(ui: React.ReactElement) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => {
+ root = createRoot(container);
+ root.render(ui);
+ });
+}
+
+function unmount() {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+}
+
+/** A minimal fake MediaQueryList. `mode` picks which listener API it
+ * exposes, so tests can force the legacy fallback path. */
+function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
+ const listeners: Array<() => void> = [];
+ const mql: any = {
+ matches,
+ media: '(max-width: 768px)',
+ };
+ if (mode === 'modern') {
+ mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
+ mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
+ const i = listeners.indexOf(fn);
+ if (i !== -1) listeners.splice(i, 1);
+ });
+ } else {
+ mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
+ mql.removeListener = vi.fn((fn: () => void) => {
+ const i = listeners.indexOf(fn);
+ if (i !== -1) listeners.splice(i, 1);
+ });
+ }
+ return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
+}
+
+let probedValue: boolean | null = null;
+const Probe: React.FC = () => {
+ probedValue = useIsMobile();
+ return
;
+};
+
+beforeEach(() => {
+ originalMatchMedia = window.matchMedia;
+ probedValue = null;
+});
+
+afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+});
+
+describe('useIsMobile', () => {
+ test('returns true when the mobile media query matches', () => {
+ const { mql } = makeMql(true);
+ window.matchMedia = vi.fn(() => mql) as any;
+
+ render(
);
+ expect(probedValue).toBe(true);
+ unmount();
+ });
+
+ test('returns false when the mobile media query does not match', () => {
+ const { mql } = makeMql(false);
+ window.matchMedia = vi.fn(() => mql) as any;
+
+ render(
);
+ expect(probedValue).toBe(false);
+ unmount();
+ });
+
+ test('registers the change listener via addEventListener and updates on change', () => {
+ const { mql, fire, listenerCount } = makeMql(false);
+ window.matchMedia = vi.fn(() => mql) as any;
+
+ render(
);
+ expect(probedValue).toBe(false);
+ expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ expect(listenerCount()).toBe(1);
+
+ // Flip the query result and fire the registered 'change' listener --
+ // the hook must re-read mql.matches, not just toggle blindly.
+ mql.matches = true;
+ act(() => {
+ fire();
+ });
+ expect(probedValue).toBe(true);
+
+ unmount();
+ });
+
+ test('cleans up the addEventListener listener on unmount', () => {
+ const { mql, listenerCount } = makeMql(true);
+ window.matchMedia = vi.fn(() => mql) as any;
+
+ render(
);
+ expect(listenerCount()).toBe(1);
+
+ unmount();
+
+ expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ expect(listenerCount()).toBe(0);
+ });
+
+ test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
+ const { mql, fire, listenerCount } = makeMql(false, 'legacy');
+ window.matchMedia = vi.fn(() => mql) as any;
+
+ render(
);
+ expect(probedValue).toBe(false);
+ expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
+ expect(listenerCount()).toBe(1);
+
+ mql.matches = true;
+ act(() => {
+ fire();
+ });
+ expect(probedValue).toBe(true);
+
+ unmount();
+
+ expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
+ expect(listenerCount()).toBe(0);
+ });
+});
diff --git a/craft/src/hooks/useIsMobile.ts b/craft/src/hooks/useIsMobile.ts
new file mode 100644
index 0000000..afaaa16
--- /dev/null
+++ b/craft/src/hooks/useIsMobile.ts
@@ -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
(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/left/AssetsPanel.tsx b/craft/src/panels/left/AssetsPanel.tsx
index 034b2ac..26572c9 100644
--- a/craft/src/panels/left/AssetsPanel.tsx
+++ b/craft/src/panels/left/AssetsPanel.tsx
@@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets';
import { clickableProps } from '../../utils/a11y';
import { copyToClipboard } from '../../utils/clipboard';
+import { useIsMobile } from '../../hooks/useIsMobile';
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
+ const isMobile = useIsMobile();
const fileInputRef = useRef(null);
const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState(null);
@@ -139,7 +141,9 @@ export const AssetsPanel: React.FC = () => {
{assets.length === 0 && (
)}
- {assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'}
+ {assets.length === 0
+ ? (isMobile ? 'Tap to upload images' : 'Drag images here or click to upload')
+ : 'Drop files here to upload'}
{/* Error message */}
diff --git a/craft/src/panels/mobile/BottomSheet.tsx b/craft/src/panels/mobile/BottomSheet.tsx
new file mode 100644
index 0000000..aed4f59
--- /dev/null
+++ b/craft/src/panels/mobile/BottomSheet.tsx
@@ -0,0 +1,70 @@
+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