apps/mobile/src/lib/layout.ts
import { scaledTypographyLineHeight } from "./appearancePreferences";
import { MOBILE_TYPOGRAPHY } from "./typography";
import type { NativeLayoutMetrics } from "./reserved-regions";
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
const MEDIUM_LAYOUT_MIN_WIDTH = 600;
const EXPANDED_LAYOUT_MIN_WIDTH = 720;
const SPLIT_LAYOUT_MIN_HEIGHT = 600;
const SPLIT_SIDEBAR_MIN_WIDTH = 280;
const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380;
export const CHAT_CONTENT_MAX_WIDTH = 960;
// Limit Android reading width for wide foldables and keyboard-open split views.
export const ANDROID_READING_MAX_WIDTH = 640;
// min-h-8 uses the 14px rem configured in metro.config.js.
export const THREAD_WORK_ROW_MIN_HEIGHT = 28;
export function deriveThreadArrangementSizing(input: {
readonly baseFontSize: number;
readonly fontScale: number;
}) {
const bodyLineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.body, input.baseFontSize);
const headerLineHeight = scaledTypographyLineHeight(
MOBILE_TYPOGRAPHY.footnote,
input.baseFontSize,
);
return {
rowHeight: Math.max(56, Math.ceil(2 * bodyLineHeight * input.fontScale) + 10),
headerHeight: Math.max(48, Math.ceil(headerLineHeight * input.fontScale) + 24),
};
}
export function deriveThreadWorkLogSizing(input: {
readonly baseFontSize: number;
readonly fontScale: number;
}) {
const lineHeight = scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.footnote, input.baseFontSize);
return {
textSizeKey: `${input.baseFontSize}:${input.fontScale}`,
estimatedRowHeight: Math.max(
THREAD_WORK_ROW_MIN_HEIGHT,
Math.ceil(lineHeight * input.fontScale),
),
// Accessibility scaling can exceed authored line heights; leave those rows measured.
fixedRowHeight:
input.fontScale <= 1 && lineHeight <= THREAD_WORK_ROW_MIN_HEIGHT
? THREAD_WORK_ROW_MIN_HEIGHT
: undefined,
};
}
const AUXILIARY_PANE_MIN_WIDTH = 260;
const AUXILIARY_PANE_MAX_WIDTH = 480;
const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320;
const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820;
const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560;
// UIKit's inspector column keeps its own width, so the sidebar steps aside only when the page
// would get narrower than this: landscape 11" and 13" iPads keep three columns, portrait ones don't.
export const NATIVE_INSPECTOR_MIN_MAIN_WIDTH = 320;
/**
* The window's width class. Compact: the sidebar is the root and pages push over it. Medium: the
* sidebar floats over the content. Expanded: the sidebar sits beside the content.
*/
export type LayoutVariant = "compact" | "medium" | "expanded";
export interface Layout {
readonly variant: LayoutVariant;
/** Medium or expanded: pages replace the content beside the sidebar instead of pushing. */
readonly usesSplitView: boolean;
readonly listPaneWidth: number | null;
readonly listPaneGap?: number;
readonly shellPadding: number;
}
export interface WorkspacePaneLayout {
readonly primarySidebarVisible: boolean;
/** Whether the sidebar shows once a collapsed split expands; independent of the current width. */
readonly primarySidebarWhenExpanded: boolean;
readonly primarySidebarSuppressedByAuxiliary: boolean;
readonly contentPaneWidth: number;
readonly supportsAuxiliaryPane: boolean;
readonly auxiliaryPaneVisible: boolean;
readonly auxiliaryPaneWidth: number | null;
}
export interface FileInspectorPaneLayout {
readonly supported: boolean;
readonly width: number | null;
}
export function deriveThreadFeedInitialContentInset(input: {
readonly platform: string;
readonly usesNativeAutomaticInsets: boolean;
readonly bottomContentInset: number;
}): { readonly bottom: number } | undefined {
if (input.platform !== "android" || input.usesNativeAutomaticInsets) {
return undefined;
}
return { bottom: Math.max(0, input.bottomContentInset) };
}
export function deriveLayout(input: {
readonly width: number;
readonly height: number;
readonly nativeMetrics?: NativeLayoutMetrics | null;
readonly nativeWorkspace?: boolean;
}): Layout {
const { width, height } = input;
const metrics = input.nativeMetrics;
const usableWidth = width - (metrics?.safeArea.left ?? 0) - (metrics?.safeArea.right ?? 0);
// UIKit retains split columns below the generic tablet breakpoint. Native
// columns follow its size class while reserving room for both column minimums.
// Unfolded in portrait, the inner display reports neither a side bar nor a
// fold, only its hinge.
const hasDuoGeometry =
metrics &&
(metrics.hasHinge === true ||
metrics.verticalBarEdge !== "none" ||
metrics.reservedRegions.some((region) => region.kind === "division"));
const variant: LayoutVariant =
metrics && (input.nativeWorkspace || hasDuoGeometry)
? metrics.horizontalSizeClass === "regular" &&
usableWidth >= SPLIT_SIDEBAR_MIN_WIDTH + 320 &&
(input.nativeWorkspace || height >= 400)
? // The native split floats its sidebar over an unfolded portrait Duo.
input.nativeWorkspace === true && metrics.hasHinge === true && width < height
? "medium"
: "expanded"
: "compact"
: height < SPLIT_LAYOUT_MIN_HEIGHT || width < MEDIUM_LAYOUT_MIN_WIDTH
? "compact"
: width >= EXPANDED_LAYOUT_MIN_WIDTH
? "expanded"
: // Only the Android shell floats a sidebar over a page; iOS keeps its phone stack.
metrics
? "compact"
: "medium";
if (variant === "compact") {
return {
variant,
usesSplitView: false,
listPaneWidth: null,
shellPadding: 0,
};
}
const division = metrics?.reservedRegions.find(
(region) =>
region.kind === "division" &&
region.height >= height / 2 &&
region.x - metrics.safeArea.left >= SPLIT_SIDEBAR_MIN_WIDTH &&
width - region.x - region.width - metrics.safeArea.right >= 320,
);
return {
variant,
usesSplitView: true,
listPaneWidth:
division?.x ??
clamp(Math.round(width * 0.32), SPLIT_SIDEBAR_MIN_WIDTH, SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH),
...(division ? { listPaneGap: division.width } : {}),
shellPadding: 0,
};
}
export interface VerticalHinge {
readonly x: number;
readonly width: number;
}
/**
* Moves the list/detail split onto a book-posture hinge so neither pane straddles the crease.
* `leadingWidth` is chrome left of the list pane, such as the Android navigation rail.
*/
export function alignListPaneToHinge(input: {
readonly layout: Layout;
readonly hinge: VerticalHinge | null;
readonly viewportWidth: number;
readonly leadingWidth: number;
}): Layout {
const { layout, hinge } = input;
if (!hinge || !layout.usesSplitView) return layout;
const listPaneWidth = hinge.x - input.leadingWidth;
if (
listPaneWidth < SPLIT_SIDEBAR_MIN_WIDTH ||
input.viewportWidth - hinge.x - hinge.width < 320
) {
return layout;
}
return { ...layout, listPaneWidth, listPaneGap: hinge.width };
}
/**
* Whether the workspace root must keep the primary sidebar open. Native split
* columns keep a sidebar toggle in the empty detail header, so only they may
* collapse it there. Compact layouts stay pinned so a reveal request (such as
* focusing search) cannot flip the saved preference for a later split view.
*/
/** Home's list, or the Settings list in its place, with no detail selected. */
export function isWorkspaceListPath(pathname: string): boolean {
return pathname === "/" || pathname === "/settings";
}
export function isPrimarySidebarPinned(input: {
readonly pathname: string;
readonly nativeWorkspace: boolean;
readonly usesSplitView: boolean;
}): boolean {
return isWorkspaceListPath(input.pathname) && !(input.nativeWorkspace && input.usesSplitView);
}
export function deriveWorkspacePaneLayout(input: {
readonly layout: Layout;
readonly viewportWidth: number;
readonly nativeWorkspace?: boolean;
/** Whether the sidebar floats over the content rather than taking its room; medium by default. */
readonly primarySidebarFloats?: boolean;
readonly primarySidebarPreferredVisible: boolean;
readonly auxiliaryPanePreferredVisible: boolean;
readonly auxiliaryPanePreferredWidth?: number;
/** The native inspector column's width; UIKit lays it out, not this layout. */
readonly nativeInspectorWidth?: number;
}): WorkspacePaneLayout {
const viewportWidth = Math.max(0, input.viewportWidth);
const floats = input.primarySidebarFloats ?? input.layout.variant === "medium";
const preferredPrimarySidebarVisible =
input.layout.usesSplitView && input.primarySidebarPreferredVisible;
const preferredPrimarySidebarWidth =
preferredPrimarySidebarVisible && !floats
? (input.layout.listPaneWidth ?? 0) + (input.layout.listPaneGap ?? 0)
: 0;
const fileInspector = deriveFileInspectorPaneLayout({
layout: input.layout,
viewportWidth,
preferredWidth: input.auxiliaryPanePreferredWidth,
reservedLeadingWidth: preferredPrimarySidebarWidth,
});
const auxiliaryPaneVisible = fileInspector.supported && input.auxiliaryPanePreferredVisible;
const inspectorWidth = input.nativeWorkspace
? (input.nativeInspectorWidth ?? null)
: fileInspector.width;
const primarySidebarSuppressedByAuxiliary =
preferredPrimarySidebarVisible &&
!floats &&
auxiliaryPaneVisible &&
inspectorWidth !== null &&
input.layout.listPaneWidth !== null &&
viewportWidth - input.layout.listPaneWidth - (input.layout.listPaneGap ?? 0) - inspectorWidth <
(input.nativeWorkspace ? NATIVE_INSPECTOR_MIN_MAIN_WIDTH : FILE_INSPECTOR_MIN_MAIN_WIDTH);
const primarySidebarVisible =
preferredPrimarySidebarVisible && !primarySidebarSuppressedByAuxiliary;
const primarySidebarWidth = primarySidebarVisible ? preferredPrimarySidebarWidth : 0;
return {
primarySidebarVisible,
primarySidebarWhenExpanded: input.primarySidebarPreferredVisible,
primarySidebarSuppressedByAuxiliary,
contentPaneWidth: Math.max(0, viewportWidth - primarySidebarWidth),
supportsAuxiliaryPane: fileInspector.supported,
auxiliaryPaneVisible,
auxiliaryPaneWidth: fileInspector.width,
};
}
/**
* UIKit drops the inspector column, and refuses to show it again, once the page beside it would
* fall under its minimum inside the safe area (the Duo's system bar takes width from the window).
*/
export function nativeInspectorColumnWidth(input: {
readonly viewportWidth: number;
readonly horizontalSafeArea: number;
readonly fraction: number;
}) {
const { viewportWidth } = input;
const maxWidth = Math.min(
viewportWidth * 0.6,
viewportWidth - input.horizontalSafeArea - NATIVE_INSPECTOR_MIN_MAIN_WIDTH,
);
return Math.max(260, Math.min(maxWidth, viewportWidth * input.fraction));
}
export function deriveFileInspectorPaneLayout(input: {
readonly layout: Layout;
readonly viewportWidth: number;
readonly preferredWidth?: number;
readonly reservedLeadingWidth?: number;
}): FileInspectorPaneLayout {
const viewportWidth = Math.max(0, input.viewportWidth);
const reservedLeadingWidth = Number.isFinite(input.reservedLeadingWidth)
? Math.max(0, input.reservedLeadingWidth ?? 0)
: 0;
const availableContentWidth = Math.max(0, viewportWidth - reservedLeadingWidth);
const supported =
input.layout.usesSplitView && viewportWidth >= FILE_INSPECTOR_MIN_VIEWPORT_WIDTH;
return {
supported,
width: supported
? constrainAuxiliaryPaneWidth({
preferredWidth:
input.preferredWidth ??
clamp(
Math.round(availableContentWidth * 0.28),
AUXILIARY_PANE_MIN_WIDTH,
AUXILIARY_PANE_DEFAULT_MAX_WIDTH,
),
availableWidth: availableContentWidth,
})
: null,
};
}
export function constrainAuxiliaryPaneWidth(input: {
readonly preferredWidth: number;
readonly availableWidth: number;
}): number {
const safePreferredWidth = Number.isFinite(input.preferredWidth)
? input.preferredWidth
: AUXILIARY_PANE_MIN_WIDTH;
const availableWidth = Number.isFinite(input.availableWidth)
? Math.max(0, input.availableWidth)
: 0;
const maxWidth = Math.max(
AUXILIARY_PANE_MIN_WIDTH,
Math.min(AUXILIARY_PANE_MAX_WIDTH, availableWidth - FILE_INSPECTOR_MIN_MAIN_WIDTH),
);
return clamp(Math.round(safePreferredWidth), AUXILIARY_PANE_MIN_WIDTH, maxWidth);
}
export function deriveCenteredContentHorizontalPadding(input: {
readonly viewportWidth: number;
readonly maxContentWidth: number | null;
readonly minimumPadding: number;
}): number {
const viewportWidth = Number.isFinite(input.viewportWidth) ? Math.max(0, input.viewportWidth) : 0;
const minimumPadding = Number.isFinite(input.minimumPadding)
? Math.max(0, input.minimumPadding)
: 0;
if (
input.maxContentWidth === null ||
!Number.isFinite(input.maxContentWidth) ||
input.maxContentWidth <= 0
) {
return minimumPadding;
}
return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2);
}