1/**
2 * Browser defaults - resolves the configured starting state for preview tabs.
3 *
4 * Settings → Integrations → Browser lets the user pick the viewport, zoom, and
5 * appearance a preview tab should open at. Those preferences apply to every
6 * entry point that opens a tab without stating its own: the user opening a
7 * browser panel, and agents calling `preview_open` with no size.
8 *
9 * The values live in client settings because the Chromium guest they configure
10 * is desktop-local, so this module reads them through the same external store
11 * the settings UI writes to, and exposes a non-hook accessor for the imperative
12 * open paths that run outside React.
13 *
14 * @module browserDefaults
15 */
16import {
17 DEFAULT_BROWSER_PROFILE_ID,
18 resolveBrowserProfiles,
19 type BrowserProfile,
20 type DesktopPreviewTabDefaults,
21 type PreviewAppearancePreference,
22 type PreviewViewportSetting,
23} from "@t3tools/contracts";
24
25import {
26 ensureClientSettingsHydrated,
27 getClientSettings,
28 useClientSettings,
29} from "~/hooks/useSettings";
30
31import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout";
32
33export interface BrowserDefaults {
34 readonly viewport: PreviewViewportSetting;
35 readonly zoomFactor: number;
36 readonly appearance: PreviewAppearancePreference;
37 readonly autoShowFloatingPreview: boolean;
38 readonly profiles: ReadonlyArray<BrowserProfile>;
39 readonly profileId: string;
40}
41
42const toBrowserDefaults = (settings: {
43 readonly browserDefaultViewport: PreviewViewportSetting;
44 readonly browserDefaultZoomFactor: number;
45 readonly browserDefaultAppearance: PreviewAppearancePreference;
46 readonly browserAutoShowFloatingPreview: boolean;
47 readonly browserProfiles: ReadonlyArray<BrowserProfile>;
48 readonly browserDefaultProfileId: string;
49}): BrowserDefaults => {
50 const profiles = resolveBrowserProfiles(settings.browserProfiles);
51 return {
52 viewport: settings.browserDefaultViewport,
53 zoomFactor: settings.browserDefaultZoomFactor,
54 appearance: settings.browserDefaultAppearance,
55 autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
56 profiles,
57 // A default pointing at a deleted profile falls back rather than opening
58 // tabs into a partition with no profile behind it.
59 // Incognito is a per-tab choice, not a default: a profile that discards
60 // everything on close would leave every new tab signed out. Excluding it
61 // here keeps the resolved default equal to what the settings list offers,
62 // so the row badged "Default" is the one tabs actually open under.
63 profileId:
64 profiles.find(
65 (profile) =>
66 profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito",
67 )?.id ?? DEFAULT_BROWSER_PROFILE_ID,
68 };
69};
70
71/** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
72export function getBrowserDefaults(): BrowserDefaults {
73 return toBrowserDefaults(getClientSettings());
74}
75
76/**
77 * The defaults, once client settings have actually loaded.
78 *
79 * Opening a preview is asynchronous anyway, and before hydration the snapshot
80 * is the schema defaults rather than the user's — a tab opened in that window
81 * would be born at the wrong viewport, zoom and appearance and never corrected.
82 * Read failures reject so a new tab cannot use the wrong profile or viewport.
83 */
84export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {
85 await ensureClientSettingsHydrated();
86 return getBrowserDefaults();
87}
88
89export function useBrowserDefaults(): BrowserDefaults {
90 return useClientSettings(toBrowserDefaults);
91}
92
93/**
94 * The zoom/appearance half of the defaults, in the shape `createTab` takes.
95 * Passing these at creation rather than after registration keeps the guest from
96 * painting a frame at 100%/system first.
97 */
98export function browserDefaultTabState(
99 defaults: BrowserDefaults = getBrowserDefaults(),
100): DesktopPreviewTabDefaults {
101 return { zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance };
102}
103
104/**
105 * The viewport a *newly opened* tab should start at. Sent with `preview.open`
106 * so the session is born at the configured size instead of being resized a
107 * frame later, which the user would see as a visible reflow.
108 */
109export function browserDefaultOpenViewport(
110 defaults: BrowserDefaults = getBrowserDefaults(),
111): PreviewViewportSetting {
112 return defaults.viewport;
113}
114
115/** Profile a tab opens under when the caller doesn't name one. */
116export function browserDefaultOpenProfileId(
117 defaults: BrowserDefaults = getBrowserDefaults(),
118): string {
119 return defaults.profileId;
120}
121
122/**
123 * The viewport to switch to when the user turns the device toolbar on for a tab
124 * currently in fill mode.
125 *
126 * A configured non-fill default is what the user said they want to look at, so
127 * it wins. When the default is fill there is no stated preference to honour, so
128 * fall back to fitting the panel — and only when the panel hasn't been measured
129 * yet does a fixed size apply.
130 */
131export const FALLBACK_RESPONSIVE_VIEWPORT_SIZE = { width: 1024, height: 768 } as const;
132
133export function browserResponsiveViewportForToggle(input: {
134 readonly defaults?: BrowserDefaults;
135 readonly panelRect: { readonly width: number; readonly height: number } | null;
136 readonly zoomFactor: number | undefined;
137}): PreviewViewportSetting {
138 const defaults = input.defaults ?? getBrowserDefaults();
139 if (defaults.viewport._tag !== "fill") return defaults.viewport;
140 const size = input.panelRect
141 ? resolveResponsiveBrowserViewportSize(input.panelRect, input.zoomFactor)
142 : FALLBACK_RESPONSIVE_VIEWPORT_SIZE;
143 return { _tag: "freeform", ...size };
144}