diff --git a/src/studio/Toolbar.tsx b/src/studio/Toolbar.tsx index 744df4cac..d915846fe 100644 --- a/src/studio/Toolbar.tsx +++ b/src/studio/Toolbar.tsx @@ -5,6 +5,8 @@ import { CheckCircle2, Play, MessageSquare, Image as ImageIcon, Plug, Brush, Sci import { useNavigate } from '@tanstack/react-router'; import { useOptionalSession } from '../funnel/hooks/useSession'; import { saveProject } from '../funnel/lib/apiClient'; +import { OverflowMenu } from './components/Layout/OverflowMenu'; +import { useIsNarrow } from './hooks/useIsNarrow'; interface ToolbarProps { isModified: boolean; @@ -86,6 +88,11 @@ export function Toolbar({ const navigate = useNavigate(); const [publishState, setPublishState] = useState<'idle' | 'saving' | 'done' | 'error'>('idle'); const [publishedLink, setPublishedLink] = useState(null); + // Below `md` only the two act-on-the-model buttons (Validate / Run) stay on + // the bar; everything else moves into the overflow menu. Previously the + // whole set stayed inline and Run was pushed past the right edge of a + // phone screen, into a scroll region with no visible scrollbar. + const narrow = useIsNarrow(); async function handlePublish() { if (!session) { @@ -115,67 +122,211 @@ export function Toolbar({ } } + const agentButton = enableAgentRail && !agentRailHidden ? ( + + ) : null; + + const connectLink = enableConnect ? ( + + + Connect + + ) : null; + + const myDesignsLink = session ? ( + + My Designs + + ) : null; + + const publishButton = ( + + ); + + const validateButton = ( + + ); + + const runButton = ( + + ); + + const brushButton = ( + + ); + + const sectionButton = ( + + ); + + const referenceButton = referenceImagesPresent ? ( + + ) : null; + + const environmentButton = renderEnvironmentPresent ? ( + + ) : null; + + const inspectorButton = ( + + ); + return (
- {enableAgentRail && !agentRailHidden && ( - - )} - {enableConnect && ( - - - Connect - - )} - {session && ( - - My Designs - + {narrow ? ( + +
+ {agentButton} + {connectLink} + {myDesignsLink} + {publishButton} +
+ {brushButton} + {sectionButton} + {referenceButton} + {environmentButton} + {inspectorButton} +
+ + ) : ( + <> + {agentButton} + {connectLink} + {myDesignsLink} + {publishButton} + )} - {publishedLink && ( Link copied — {publishedLink} @@ -184,110 +335,23 @@ export function Toolbar({ )}
- - - - - {referenceImagesPresent && ( - - )} - {renderEnvironmentPresent && ( - + {validateButton} + {runButton} + {!narrow && ( + <> + {brushButton} + {sectionButton} + {referenceButton} + {environmentButton} + {inspectorButton} + )} -
); diff --git a/src/studio/__tests__/Toolbar.mobile.test.tsx b/src/studio/__tests__/Toolbar.mobile.test.tsx new file mode 100644 index 000000000..26c9f482e --- /dev/null +++ b/src/studio/__tests__/Toolbar.mobile.test.tsx @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +/** @vitest-environment happy-dom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Toolbar } from '../Toolbar'; + +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn() })); +vi.mock('../../funnel/hooks/useSession', () => ({ + useOptionalSession: () => ({ session: { user: { email: 'a@b.c' } }, loading: false }), +})); +vi.mock('../../funnel/lib/apiClient', () => ({ saveProject: vi.fn() })); + +/** Force the narrow-viewport branch (`(max-width: 767px)` matches). */ +function setNarrow(narrow: boolean) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: (query: string) => ({ + matches: narrow, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); +} + +function renderToolbar() { + return render( + , + ); +} + +afterEach(() => { + cleanup(); + setNarrow(false); +}); + +beforeEach(() => { + setNarrow(true); +}); + +describe('Toolbar on a narrow viewport', () => { + it('keeps Validate and Run on the bar', () => { + renderToolbar(); + expect(screen.getByLabelText('Run')).toBeDefined(); + expect(screen.getByLabelText('Validate')).toBeDefined(); + }); + + it('collapses the secondary controls into the overflow menu', () => { + renderToolbar(); + expect(screen.queryByTestId('toolbar-publish')).toBeNull(); + expect(screen.queryByTestId('toolbar-mark')).toBeNull(); + expect(screen.queryByTestId('toolbar-section')).toBeNull(); + expect(screen.queryByTestId('toolbar-inspector')).toBeNull(); + expect(screen.queryByTestId('toolbar-my-designs')).toBeNull(); + + fireEvent.click(screen.getByTestId('toolbar-overflow')); + + expect(screen.getByTestId('toolbar-publish')).toBeDefined(); + expect(screen.getByTestId('toolbar-mark')).toBeDefined(); + expect(screen.getByTestId('toolbar-section')).toBeDefined(); + expect(screen.getByTestId('toolbar-inspector')).toBeDefined(); + expect(screen.getByTestId('toolbar-my-designs')).toBeDefined(); + }); + + it('closes the overflow menu on Escape', () => { + renderToolbar(); + fireEvent.click(screen.getByTestId('toolbar-overflow')); + expect(screen.getByTestId('toolbar-overflow-panel')).toBeDefined(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByTestId('toolbar-overflow-panel')).toBeNull(); + }); + + it('keeps every control on the bar on a wide viewport', () => { + setNarrow(false); + renderToolbar(); + expect(screen.queryByTestId('toolbar-overflow')).toBeNull(); + expect(screen.getByTestId('toolbar-publish')).toBeDefined(); + expect(screen.getByTestId('toolbar-inspector')).toBeDefined(); + }); +}); diff --git a/src/studio/components/Layout/Header.mobile.test.tsx b/src/studio/components/Layout/Header.mobile.test.tsx new file mode 100644 index 000000000..c332ddda4 --- /dev/null +++ b/src/studio/components/Layout/Header.mobile.test.tsx @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +// @vitest-environment happy-dom +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { Header } from './Header'; +import { WorkbenchProvider } from '../../context/WorkbenchContext'; +import { StudioChromeProvider } from '../../context/StudioChromeContext'; + +vi.mock('../../../shared/worker/geometryEngine', async () => { + const actual = await vi.importActual('../../../shared/worker/geometryEngine'); + const mockInstance = { + initialize: vi.fn().mockResolvedValue(true), + executeCode: vi.fn().mockResolvedValue({ geometries: [], sketches: [] }), + }; + return { + ...actual, + exportSTEP: vi.fn().mockResolvedValue(new Blob(['mock data'])), + exportSTL: vi.fn().mockResolvedValue(new Blob(['mock data'])), + init: vi.fn().mockResolvedValue(true), + GeometryEngine: { getInstance: () => mockInstance }, + }; +}); + +/** Force the narrow-viewport branch (`(max-width: 767px)` matches). */ +function setNarrow(narrow: boolean) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: (query: string) => ({ + matches: narrow, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + }); +} + +function renderHeader(headerLeft?: React.ReactNode) { + return render( + + +
+ + , + ); +} + +beforeEach(() => setNarrow(true)); +afterEach(() => { + cleanup(); + setNarrow(false); +}); + +describe('Header on a narrow viewport', () => { + it('moves the instrument clusters into the overflow menu', () => { + renderHeader(); + expect(screen.queryByTestId('view-3d-toggle')).toBeNull(); + expect(screen.queryByTestId('viewport-background-toggle')).toBeNull(); + expect(screen.queryByTestId('viewport-grid-toggle')).toBeNull(); + expect(screen.queryByTitle('Export STEP')).toBeNull(); + + fireEvent.click(screen.getByTestId('header-overflow')); + + expect(screen.getByTestId('view-3d-toggle')).toBeDefined(); + expect(screen.getByTestId('viewport-background-toggle')).toBeDefined(); + expect(screen.getByTestId('viewport-grid-toggle')).toBeDefined(); + expect(screen.getByTitle('Export STEP')).toBeDefined(); + }); + + it('keeps the account slot pinned on the bar', () => { + renderHeader(); + expect(screen.getByTestId('account-slot')).toBeDefined(); + }); + + it('drops the Studio project name only when the route supplies its own title', () => { + renderHeader(); + expect(screen.getByText('Untitled Project')).toBeDefined(); + cleanup(); + + renderHeader(My Bracket); + expect(screen.queryByText('Untitled Project')).toBeNull(); + expect(screen.getByText('My Bracket')).toBeDefined(); + }); + + it('keeps the instruments inline on a wide viewport', () => { + setNarrow(false); + renderHeader(); + expect(screen.queryByTestId('header-overflow')).toBeNull(); + expect(screen.getByTestId('view-3d-toggle')).toBeDefined(); + expect(screen.getByTitle('Export STEP')).toBeDefined(); + }); +}); diff --git a/src/studio/components/Layout/Header.tsx b/src/studio/components/Layout/Header.tsx index 2b817c6dd..faffd364f 100644 --- a/src/studio/components/Layout/Header.tsx +++ b/src/studio/components/Layout/Header.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useWorkbench } from '../../context/WorkbenchContext'; import { Loader2, Download, FileDown, Undo2, Redo2, Box, Grid as GridIcon, Grid3x3, Circle, FolderOpen, Moon, Sun, LayoutGrid, History, RotateCcw } from 'lucide-react'; import { exportSTEP, exportSTL } from '../../../shared/worker/geometryEngine'; @@ -8,8 +8,22 @@ import { formatTooltip, SHORTCUT_HINTS } from '../../../shared/constants/shortcu import { useProject } from '../../context/ProjectContext'; import { useStudioChrome } from '../../context/StudioChromeContext'; import { useUI } from '../../context/UIContext'; +import { COMPACT_HEADER_QUERY, useIsNarrow } from '../../hooks/useIsNarrow'; +import { OverflowMenu } from './OverflowMenu'; import UserMenu from './UserMenu'; +/** Labelled row inside the narrow-viewport overflow menu. Keeps the bar's + * segmented controls intact but gives each cluster a name, since the icons + * alone carry no context once they leave the bar. */ +function MenuRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + export function Header() { const { headerLeft, headerRight } = useStudioChrome(); const { @@ -22,6 +36,9 @@ export function Header() { const [historyOpen, setHistoryOpen] = useState(false); const historyRef = useRef(null); + // Below `lg` the bar cannot hold the instrument cluster next to the route's + // own chrome; the instruments move into a single overflow menu instead. + const narrow = useIsNarrow(COMPACT_HEADER_QUERY); // Close the history menu on outside click / Escape. useEffect(() => { @@ -77,23 +94,206 @@ export function Header() { } }; + const viewModeCluster = ( +
+ + + +
+ ); + + /* Viewport background switcher (dark / light / checkered) */ + const backgroundCluster = ( +
+ + + +
+ ); + + /* Ground grid visibility */ + const gridButton = ( + + ); + + // A route that injects its own header chrome (e.g. /p/:slug shows the + // project title) already names the document, so on a phone the Studio's + // own project-name label is dropped rather than fighting for the same row. + const hideProjectName = narrow && !!headerLeft; + + const exportButtons = (withLabels: boolean) => ( + <> + + + + ); + + const undoRedoButtons = ( + <> + + + + ); + + const historyControl = historyAvailable ? ( +
+ + {historyOpen && ( +
+
+ Revision history +
+ {[...revisions].reverse().map((rev) => ( +
+
+
v{rev.v}
+
{formatRevisionTime(rev.ts)}
+
+ +
+ ))} +
+ )} +
+ ) : null; + return ( -
-
+
+ {/* `overflow-hidden` is load-bearing: without it this group can be + squeezed below its content width and its `shrink-0` children + (title chip, live badge) spill out over the right-hand cluster, + which is what made the phone header look like two rows of + controls stacked on top of each other. */} +
{headerLeft && ( <> -
+
{headerLeft}
)} @@ -103,171 +303,35 @@ export function Header() { {headerRight && ( <>
{headerRight}
-
+ {!narrow &&
} )} - {/* 3D View Mode Toggle */} -
- - - -
- - {/* Viewport background switcher (dark / light / checkered) */} -
- - - -
- - {/* Ground grid visibility */} - - -
- - - - - {historyAvailable && ( -
- - {historyOpen && ( -
-
- Revision history -
- {[...revisions].reverse().map((rev) => ( -
-
-
v{rev.v}
-
{formatRevisionTime(rev.ts)}
-
- -
- ))} -
- )} -
+ {narrow && ( + + {viewModeCluster} + {backgroundCluster} + {gridButton} + + {undoRedoButtons} + {historyControl} + + {exportButtons(true)} + + )} + {!narrow && ( + <> + {viewModeCluster} + {backgroundCluster} + {gridButton} +
+ {undoRedoButtons} + {historyControl} +
+ {exportButtons(false)} + )} - -
- - - {isComputing && }
- {/* Account menu — pinned to the right edge so it never scrolls out of the horizontally-scrollable toolbar. It used to be the last item inside the scrolling instrument cluster, so on narrow viewports it @@ -288,3 +352,4 @@ export function Header() {
); } + diff --git a/src/studio/components/Layout/OverflowMenu.tsx b/src/studio/components/Layout/OverflowMenu.tsx new file mode 100644 index 000000000..5a2b7f86d --- /dev/null +++ b/src/studio/components/Layout/OverflowMenu.tsx @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { MoreHorizontal } from 'lucide-react'; + +export interface OverflowMenuProps { + /** Accessible name for the trigger, e.g. "View and file controls". */ + label: string; + /** Menu body. Rendered as-is inside the dropdown panel. */ + children: ReactNode; + /** Which trigger edge the panel lines up with. Default 'right'. */ + align?: 'left' | 'right'; + testId?: string; +} + +/** + * Narrow-viewport overflow menu for the chrome bars. + * + * The Header and Toolbar are `bar-scroll-x` containers (overflow-x:auto → + * overflow-y:hidden), which would CLIP an in-flow dropdown to the 32-40px bar + * height. So the panel is rendered into a portal on with fixed + * positioning, anchored under the trigger — the same escape hatch `UserMenu` + * already uses for the account dropdown. + * + * The panel stays open while controls inside it are used (most are toggles + * whose effect is visible in the viewport behind); it closes on outside click, + * Escape, or a second press of the trigger. + */ +export function OverflowMenu({ label, children, align = 'right', testId }: OverflowMenuProps) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const [anchor, setAnchor] = useState<{ top: number; left?: number; right?: number } | null>(null); + + const positionPanel = useCallback(() => { + const r = triggerRef.current?.getBoundingClientRect(); + if (!r) return; + setAnchor( + align === 'left' + ? { top: r.bottom + 4, left: Math.max(4, r.left) } + : { top: r.bottom + 4, right: Math.max(4, window.innerWidth - r.right) }, + ); + }, [align]); + + // Anchor up-front in the click handler (not in an effect) so the panel + // never paints at a stale position. + const toggleOpen = () => { + if (!open) positionPanel(); + setOpen(o => !o); + }; + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + const t = e.target as Node; + if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return; + setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + const reposition = () => positionPanel(); + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition, true); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + window.removeEventListener('resize', reposition); + window.removeEventListener('scroll', reposition, true); + }; + }, [open, positionPanel]); + + return ( + <> + + {open && + anchor && + typeof document !== 'undefined' && + createPortal( +
+ {children} +
, + document.body, + )} + + ); +} diff --git a/src/studio/hooks/useIsNarrow.ts b/src/studio/hooks/useIsNarrow.ts new file mode 100644 index 000000000..afb9590bf --- /dev/null +++ b/src/studio/hooks/useIsNarrow.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors +import { useCallback, useSyncExternalStore } from 'react'; + +/** Viewport width below which the fixed-height chrome bars (Header, Toolbar) + * can no longer show their full control set. Matches Tailwind's `md` + * breakpoint so JS-driven and class-driven responsiveness agree. */ +export const NARROW_QUERY = '(max-width: 767px)'; + +/** The Header carries a wider control set than the Toolbar (view mode, + * background, grid, undo/redo, history, exports) plus whatever chrome the + * route injects, and measurably stops fitting below ~1024px — so it collapses + * a breakpoint earlier, at Tailwind's `lg`. */ +export const COMPACT_HEADER_QUERY = '(max-width: 1023px)'; + +function mediaQueryList(query: string): MediaQueryList | null { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null; + return window.matchMedia(query); +} + +/** + * True while the viewport is narrow enough that the chrome bars must collapse + * their secondary controls into an overflow menu. + * + * Implemented with `useSyncExternalStore` rather than `useState` + effect so + * there is no set-state-in-effect on mount and no first-paint flash of the + * desktop layout. Falls back to `false` (desktop) when `matchMedia` is + * unavailable — SSR and the happy-dom test environment both take that path. + */ +export function useIsNarrow(query: string = NARROW_QUERY): boolean { + const subscribe = useCallback((onChange: () => void) => { + const mql = mediaQueryList(query); + if (!mql || typeof mql.addEventListener !== 'function') return () => {}; + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, [query]); + + const getSnapshot = useCallback(() => mediaQueryList(query)?.matches ?? false, [query]); + + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/src/studio/routes/p.$slug.tsx b/src/studio/routes/p.$slug.tsx index 9fc1fde81..b1651ce69 100644 --- a/src/studio/routes/p.$slug.tsx +++ b/src/studio/routes/p.$slug.tsx @@ -2,6 +2,7 @@ // Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors import { createFileRoute } from '@tanstack/react-router'; import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { Globe, Lock } from 'lucide-react'; import App from '../App'; import { SignInButton } from '../../funnel/components/SignInButton'; import { ProjectViewerActions } from './-ProjectViewerActions'; @@ -195,7 +196,12 @@ function ProjectPage() { const headerLeft = (
- + {/* The header row is shared with the privacy/share buttons, the overflow + menu and the account slot, so on a phone the title truncates down to + nothing (`min-w-0`, not a pixel floor) and drops out entirely under + 400px — otherwise it pushes the live badge past the header's clip and + the badge renders as a sliver of green border. */} + {project.title} @@ -203,9 +209,10 @@ function ProjectPage() { - ● live + ● live
); @@ -241,8 +248,20 @@ function ProjectPage() { Upgrade to keep private ) : ( - ); }