- {/* ═══ PORTRAIT LAYOUT: tabbed panel → Viewer at bottom ═══ */}
- {/* Visualizer / tabs panel */}
{expanded !== 'visualizer' && (
)}
{expanded === 'visualizer' && (
- {TABS.map(({ id, label, Icon }) => (
+ {landscapeTabs.map(({ id, label, Icon }) => (
)}
@@ -190,7 +191,6 @@ export function TabletAccordion({ tabletTab, setTabletTab, onLaunchPanel }: Tabl
)}
- {/* Controls (Spindle & Overrides) panel */}
{expanded !== 'controls' && (
- {hasSpindle && <>
-
-
- >}
+ {hasSpindle && (
+ <>
+
+
+ >
+ )}
diff --git a/src/components/TabletCompactLandscapeLayout.tsx b/src/components/TabletCompactLandscapeLayout.tsx
new file mode 100644
index 0000000..74c3c76
--- /dev/null
+++ b/src/components/TabletCompactLandscapeLayout.tsx
@@ -0,0 +1,85 @@
+import { DRO } from './DRO'
+import { TabletJogPad } from './JogPad'
+import { TabletAccordion } from './TabletAccordion'
+import { PluginFrame } from './PluginFrame'
+import type { Plugin } from '../types'
+import type { TabletTabId } from '../lib/tabletTabs'
+import {
+ COMPACT_LANDSCAPE_PLUGIN_MIN_HEIGHT,
+ useCompactLandscapeTopRowHeight,
+} from '../lib/compactLandscapeLayout'
+
+interface TabletCompactLandscapeLayoutProps {
+ tabletTab: TabletTabId
+ setTabletTab: (tab: TabletTabId) => void
+ onLaunchPanel?: (plugin: Plugin) => void
+ jogPlugin: Plugin | null
+ onCloseJogPlugin: () => void
+ workspacePlugin?: Plugin | null
+ onCloseWorkspacePlugin?: () => void
+ controlsPlugin?: Plugin | null
+ onCloseControlsPlugin?: () => void
+}
+
+const TOP_ROW_COLUMN_CLASS =
+ 'flex flex-col flex-1 min-w-0 basis-1/2 h-full min-h-0 overflow-hidden'
+
+/**
+ * Short-viewport landscape tablets (layout height < 640px):
+ * Row 1 — POSITION | JOG (side by side, equal height)
+ * Row 2 — tabbed workspace (natural height; page scrolls)
+ */
+export function TabletCompactLandscapeLayout({
+ tabletTab,
+ setTabletTab,
+ onLaunchPanel,
+ jogPlugin,
+ onCloseJogPlugin,
+ workspacePlugin,
+ onCloseWorkspacePlugin,
+ controlsPlugin,
+ onCloseControlsPlugin,
+}: TabletCompactLandscapeLayoutProps) {
+ const topRowHeight = useCompactLandscapeTopRowHeight()
+
+ return (
+
+
+
+
+
+
+ {jogPlugin ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {workspacePlugin && onCloseWorkspacePlugin ? (
+
+ ) : controlsPlugin && onCloseControlsPlugin ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/TabletMainShell.tsx b/src/components/TabletMainShell.tsx
new file mode 100644
index 0000000..97cd4cb
--- /dev/null
+++ b/src/components/TabletMainShell.tsx
@@ -0,0 +1,112 @@
+import { DRO } from './DRO'
+import { TabletJogPad } from './JogPad'
+import { TabletAccordion } from './TabletAccordion'
+import { TabletCompactLandscapeLayout } from './TabletCompactLandscapeLayout'
+import { PluginFrame } from './PluginFrame'
+import type { Plugin } from '../types'
+import type { TabletTabId } from '../lib/tabletTabs'
+import { useIsCompactLandscape } from '../lib/viewport'
+
+interface TabletMainShellProps {
+ tabletTab: TabletTabId
+ setTabletTab: (tab: TabletTabId) => void
+ onLaunchPanel?: (plugin: Plugin) => void
+ jogPlugin: Plugin | null
+ onCloseJogPlugin: () => void
+ workspacePlugin: Plugin | null
+ onCloseWorkspacePlugin: () => void
+ controlsPlugin: Plugin | null
+ onCloseControlsPlugin: () => void
+}
+
+const TABLET_LEFT_COLUMN_CLASS =
+ 'flex flex-col gap-1 portrait:shrink-0 landscape:flex-1 landscape:basis-1/2 landscape:min-h-0 landscape:overflow-hidden'
+
+function TabletLeftColumn({
+ jogPlugin,
+ onCloseJogPlugin,
+}: {
+ jogPlugin: Plugin | null
+ onCloseJogPlugin: () => void
+}) {
+ return (
+
+
+
+
+ {jogPlugin ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function TabletMainShell({
+ tabletTab,
+ setTabletTab,
+ onLaunchPanel,
+ jogPlugin,
+ onCloseJogPlugin,
+ workspacePlugin,
+ onCloseWorkspacePlugin,
+ controlsPlugin,
+ onCloseControlsPlugin,
+}: TabletMainShellProps) {
+ const isCompactLandscape = useIsCompactLandscape()
+
+ if (isCompactLandscape) {
+ return (
+
+ )
+ }
+
+ const shellClass =
+ 'flex-1 min-h-[0px] flex portrait:flex-col landscape:flex landscape:flex-row gap-3 p-3 overflow-y-auto landscape:overflow-hidden'
+
+ if (workspacePlugin) {
+ return (
+
+ )
+ }
+
+ if (controlsPlugin) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/TabletTabbedPanel.tsx b/src/components/TabletTabbedPanel.tsx
new file mode 100644
index 0000000..97a3e8a
--- /dev/null
+++ b/src/components/TabletTabbedPanel.tsx
@@ -0,0 +1,95 @@
+import { GCodeViewer } from './GCodeViewer'
+import { FileManager } from './FileManager'
+import { Macros } from './Macros'
+import { ProbePanel } from './ProbePanel'
+import { ManualATCPanel } from './ManualATCPanel'
+import { Terminal } from './Terminal'
+import { OverridesPanel, SpindlePanel } from './JogPad'
+import { PluginLauncher } from './PluginLauncher'
+import type { Plugin } from '../types'
+import type { TabletTabDef, TabletTabId } from '../lib/tabletTabs'
+
+interface TabletTabbedPanelProps {
+ tabs: TabletTabDef[]
+ activeTab: TabletTabId
+ onTabChange: (tab: TabletTabId) => void
+ onLaunchPanel?: (plugin: Plugin) => void
+ hasProbingInput: boolean
+ hasSpindle: boolean
+ hasManualATC?: boolean
+ portraitMinHeight?: boolean
+ tabLabelFontSize?: string
+ viewerClassName?: string
+ fitToViewSignal?: boolean
+}
+
+export function TabletTabbedPanel({
+ tabs,
+ activeTab,
+ onTabChange,
+ onLaunchPanel,
+ hasProbingInput,
+ hasSpindle,
+ hasManualATC = false,
+ portraitMinHeight = false,
+ tabLabelFontSize = 'clamp(10px, 2.2vw, 20px)',
+ viewerClassName,
+ fitToViewSignal,
+}: TabletTabbedPanelProps) {
+ const viewerClass = viewerClassName ?? (portraitMinHeight ? 'min-h-[55vh]' : 'flex-1 min-h-[300px]')
+
+ return (
+
+
+ {tabs.map(({ id, label, Icon }) => (
+
+ ))}
+
+
+ {activeTab === 'viewer' && (
+
+
+
+ )}
+ {activeTab === 'files' &&
}
+ {activeTab === 'macros' &&
}
+ {activeTab === 'tooling' && hasManualATC && (
+
+
+
+ )}
+ {activeTab === 'probing' && hasProbingInput && (
+
+ )}
+ {activeTab === 'terminal' &&
}
+ {activeTab === 'spindle' && hasSpindle && (
+
+
+
+ )}
+ {activeTab === 'overrides' && (
+
+
+
+ )}
+ {activeTab === 'plugins' && (
+
+ )}
+
+
+ )
+}
diff --git a/src/lib/compactLandscapeLayout.ts b/src/lib/compactLandscapeLayout.ts
new file mode 100644
index 0000000..2f91b13
--- /dev/null
+++ b/src/lib/compactLandscapeLayout.ts
@@ -0,0 +1,32 @@
+import { useViewportMetrics, type ViewportMetrics } from './viewport'
+
+/** Share of layout viewport height for the Position + Jog band. */
+export const COMPACT_LANDSCAPE_TOP_ROW_RATIO = 0.55
+
+export const COMPACT_LANDSCAPE_TOP_ROW_MIN_PX = 228
+
+export const COMPACT_LANDSCAPE_TOP_ROW_MAX_PX = 360
+
+/** Stacked G-code viewer; dvh accounts for browser chrome. */
+export const COMPACT_LANDSCAPE_VIEWER_CLASS = 'h-[min(62dvh,28rem)]'
+
+/** Min height for inline plugin panels below the top row. */
+export const COMPACT_LANDSCAPE_PLUGIN_MIN_HEIGHT = 'min(50dvh, 24rem)'
+
+export function layoutViewportHeight(metrics: ViewportMetrics): number {
+ return metrics.visualViewportHeight ?? metrics.innerHeight
+}
+
+export function compactLandscapeTopRowHeightPx(metrics: ViewportMetrics): number {
+ const h = layoutViewportHeight(metrics)
+ return Math.min(
+ Math.max(Math.round(h * COMPACT_LANDSCAPE_TOP_ROW_RATIO), COMPACT_LANDSCAPE_TOP_ROW_MIN_PX),
+ COMPACT_LANDSCAPE_TOP_ROW_MAX_PX,
+ )
+}
+
+export function useCompactLandscapeTopRowHeight(): number | undefined {
+ const metrics = useViewportMetrics()
+ if (!metrics.isCompactLandscape) return undefined
+ return compactLandscapeTopRowHeightPx(metrics)
+}
diff --git a/src/lib/tabletTabs.ts b/src/lib/tabletTabs.ts
new file mode 100644
index 0000000..dc207e3
--- /dev/null
+++ b/src/lib/tabletTabs.ts
@@ -0,0 +1,52 @@
+import { Eye, FolderOpen, Puzzle, Sliders, Target, TerminalSquare, Wrench, Zap } from '../icons'
+import { Power } from '../icons'
+
+export type TabletTabId =
+ | 'viewer'
+ | 'files'
+ | 'macros'
+ | 'tooling'
+ | 'probing'
+ | 'terminal'
+ | 'spindle'
+ | 'overrides'
+ | 'plugins'
+
+export interface TabletTabDef {
+ id: TabletTabId
+ label: string
+ Icon: typeof Eye
+}
+
+export function buildLandscapeAccordionTabs(
+ hasProbingInput: boolean,
+ hasManualATC = false,
+): TabletTabDef[] {
+ return [
+ { id: 'viewer', label: 'Viewer', Icon: Eye },
+ { id: 'files', label: 'Files', Icon: FolderOpen },
+ { id: 'macros', label: 'Macros', Icon: Zap },
+ ...(hasManualATC ? [{ id: 'tooling' as const, label: 'Tooling', Icon: Wrench }] : []),
+ ...(hasProbingInput ? [{ id: 'probing' as const, label: 'Probing', Icon: Target }] : []),
+ { id: 'terminal', label: 'Terminal', Icon: TerminalSquare },
+ { id: 'plugins', label: 'Plugins', Icon: Puzzle },
+ ]
+}
+
+export function buildFullTabletTabs(
+ hasProbingInput: boolean,
+ hasSpindle: boolean,
+ hasManualATC = false,
+): TabletTabDef[] {
+ return [
+ { id: 'viewer', label: 'Viewer', Icon: Eye },
+ { id: 'files', label: 'Files', Icon: FolderOpen },
+ { id: 'macros', label: 'Macros', Icon: Zap },
+ ...(hasManualATC ? [{ id: 'tooling' as const, label: 'Tooling', Icon: Wrench }] : []),
+ ...(hasProbingInput ? [{ id: 'probing' as const, label: 'Probing', Icon: Target }] : []),
+ { id: 'terminal', label: 'Terminal', Icon: TerminalSquare },
+ ...(hasSpindle ? [{ id: 'spindle' as const, label: 'Spindle', Icon: Power }] : []),
+ { id: 'overrides', label: 'Overrides', Icon: Sliders },
+ { id: 'plugins', label: 'Plugins', Icon: Puzzle },
+ ]
+}
diff --git a/src/lib/viewport.tsx b/src/lib/viewport.tsx
new file mode 100644
index 0000000..3352808
--- /dev/null
+++ b/src/lib/viewport.tsx
@@ -0,0 +1,100 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+
+/** Short landscape viewports (7–8" tablets, browser chrome, split-screen). */
+export const COMPACT_LANDSCAPE_MAX_HEIGHT = 640
+
+export interface ViewportMetrics {
+ innerWidth: number
+ innerHeight: number
+ visualViewportWidth: number | null
+ visualViewportHeight: number | null
+ devicePixelRatio: number
+ isPortrait: boolean
+ isLandscape: boolean
+ isCompactLandscape: boolean
+}
+
+function readViewportMetrics(): ViewportMetrics {
+ const innerWidth = typeof window === 'undefined' ? 1270 : window.innerWidth
+ const innerHeight = typeof window === 'undefined' ? 800 : window.innerHeight
+ const vv = typeof window === 'undefined' ? null : window.visualViewport
+ const layoutHeight = vv?.height ?? innerHeight
+ const isPortrait = typeof window === 'undefined'
+ ? false
+ : window.matchMedia('(orientation: portrait)').matches
+ const isLandscape = !isPortrait
+
+ return {
+ innerWidth,
+ innerHeight,
+ visualViewportWidth: vv?.width ?? null,
+ visualViewportHeight: vv?.height ?? null,
+ devicePixelRatio: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
+ isPortrait,
+ isLandscape,
+ isCompactLandscape: isLandscape && layoutHeight < COMPACT_LANDSCAPE_MAX_HEIGHT,
+ }
+}
+
+let pending = false
+const listeners = new Set<() => void>()
+
+function scheduleViewportUpdate() {
+ if (pending || typeof window === 'undefined') return
+ pending = true
+ requestAnimationFrame(() => {
+ pending = false
+ listeners.forEach(listener => listener())
+ })
+}
+
+function subscribeViewport(listener: () => void) {
+ listeners.add(listener)
+ if (listeners.size === 1 && typeof window !== 'undefined') {
+ window.addEventListener('resize', scheduleViewportUpdate)
+ window.addEventListener('orientationchange', scheduleViewportUpdate)
+ window.visualViewport?.addEventListener('resize', scheduleViewportUpdate)
+ }
+ return () => {
+ listeners.delete(listener)
+ if (listeners.size === 0 && typeof window !== 'undefined') {
+ window.removeEventListener('resize', scheduleViewportUpdate)
+ window.removeEventListener('orientationchange', scheduleViewportUpdate)
+ window.visualViewport?.removeEventListener('resize', scheduleViewportUpdate)
+ }
+ }
+}
+
+const ViewportContext = createContext
(null)
+
+export function ViewportProvider({ children }: { children: ReactNode }) {
+ const [metrics, setMetrics] = useState(readViewportMetrics)
+
+ useEffect(() => {
+ const update = () => setMetrics(readViewportMetrics())
+ update()
+ return subscribeViewport(update)
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useViewportMetrics(): ViewportMetrics {
+ const ctx = useContext(ViewportContext)
+ if (!ctx) {
+ throw new Error('useViewportMetrics must be used within ViewportProvider')
+ }
+ return ctx
+}
+
+export function useIsPortrait(): boolean {
+ return useViewportMetrics().isPortrait
+}
+
+export function useIsCompactLandscape(): boolean {
+ return useViewportMetrics().isCompactLandscape
+}