Skip to content

Commit 0009cb1

Browse files
fix(web): keep the profile chrome from crowding its neighbours
The leading-icon rules added for the Browser sub-trigger were scoped with `:first-of-type`, which on a sub-trigger with no leading icon matches the trailing chevron instead — the compound selector outranks its `ms-auto` and took away the right alignment on the existing Appearance and Turn triggers. Scoping away from the last child leaves the chevron alone. The profile badge in the chrome row was unbounded while profile names run to 48 characters, so it took width from the URL input, the only flexible element there. It is capped and truncated. Removing a profile now confirms first, like every other destructive action in Settings, and Incognito is no longer offered as — or resolved to — the default profile: as a default it would open every new tab into storage discarded on close, and the settings list and the resolved default now agree on that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 61d5a11 commit 0009cb1

5 files changed

Lines changed: 105 additions & 9 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it, vi } from "vite-plus/test";
2+
import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
3+
4+
const settings = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
5+
6+
vi.mock("~/hooks/useSettings", () => ({
7+
getClientSettings: () => settings.current,
8+
useClientSettings: () => undefined,
9+
ensureClientSettingsHydrated: () => Promise.resolve(),
10+
}));
11+
12+
const { getBrowserDefaults } = await import("./browserDefaults");
13+
14+
const withDefaultProfile = (browserDefaultProfileId: string) => {
15+
settings.current = {
16+
browserDefaultViewport: { _tag: "fill" },
17+
browserDefaultZoomFactor: 1,
18+
browserDefaultAppearance: "system",
19+
browserAutoShowFloatingPreview: true,
20+
browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
21+
browserDefaultProfileId,
22+
};
23+
return getBrowserDefaults();
24+
};
25+
26+
describe("getBrowserDefaults profile resolution", () => {
27+
it("keeps a configured persistent profile", () => {
28+
expect(withDefaultProfile("work").profileId).toBe("work");
29+
});
30+
31+
it("falls back for an unknown profile", () => {
32+
expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID);
33+
});
34+
35+
it("refuses incognito as the default", () => {
36+
// A stored incognito default would open every new tab into storage that is
37+
// discarded on close, and the settings list no longer offers it — so the
38+
// row badged "Default" must be the one tabs actually open under.
39+
expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe(
40+
DEFAULT_BROWSER_PROFILE_ID,
41+
);
42+
});
43+
});

apps/web/src/browser/browserDefaults.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
*/
1616
import {
1717
DEFAULT_BROWSER_PROFILE_ID,
18-
findBrowserProfile,
1918
resolveBrowserProfiles,
2019
type BrowserProfile,
2120
type DesktopPreviewTabDefaults,
@@ -57,9 +56,15 @@ const toBrowserDefaults = (settings: {
5756
profiles,
5857
// A default pointing at a deleted profile falls back rather than opening
5958
// 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.
6063
profileId:
61-
findBrowserProfile(profiles, settings.browserDefaultProfileId)?.id ??
62-
DEFAULT_BROWSER_PROFILE_ID,
64+
profiles.find(
65+
(profile) =>
66+
profile.id === settings.browserDefaultProfileId && profile.kind !== "incognito",
67+
)?.id ?? DEFAULT_BROWSER_PROFILE_ID,
6368
};
6469
};
6570

apps/web/src/components/preview/PreviewView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,10 @@ export function PreviewView({
697697
// "Default" would be noise on the common case, while a tab in
698698
// another profile is exactly what needs calling out.
699699
activeProfile && activeProfile.id !== browserDefaults.profileId ? (
700-
<Badge variant="outline" className="shrink-0">
700+
// Capped and truncated: profile names run to 48 characters, and an
701+
// unbounded badge in this row takes its width from the URL input,
702+
// the only flexible element in the compact chrome.
703+
<Badge variant="outline" className="max-w-28 shrink-0 truncate">
701704
{activeProfile.name}
702705
</Badge>
703706
) : null

apps/web/src/components/settings/IntegrationsSettings.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99
import {
1010
BROWSER_PROFILE_MAX_COUNT,
11+
type BrowserProfile,
1112
BROWSER_PROFILE_NAME_MAX_LENGTH,
1213
DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
1314
DEFAULT_BROWSER_PROFILE_ID,
@@ -28,6 +29,7 @@ import {
2829
} from "@t3tools/contracts";
2930
import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
3031
import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react";
32+
import { useState } from "react";
3133
import type { ReactNode } from "react";
3234

3335
import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
@@ -37,6 +39,15 @@ import { usePrimaryEnvironment } from "~/state/environments";
3739
import { isElectron } from "../../env";
3840

3941
import { Badge } from "../ui/badge";
42+
import {
43+
AlertDialog,
44+
AlertDialogClose,
45+
AlertDialogDescription,
46+
AlertDialogFooter,
47+
AlertDialogHeader,
48+
AlertDialogPopup,
49+
AlertDialogTitle,
50+
} from "../ui/alert-dialog";
4051
import { Button } from "../ui/button";
4152
import { DraftInput } from "../ui/draft-input";
4253
import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
@@ -477,6 +488,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
477488
const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
478489
const updateSettings = useUpdatePrimarySettings();
479490
const environmentId = usePrimaryEnvironment()?.environmentId;
491+
const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
480492

481493
const addProfile = () => {
482494
if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;
@@ -502,6 +514,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
502514
};
503515

504516
const removeProfile = (id: string) => {
517+
setProfilePendingRemoval(null);
505518
// Drop the partition's data too, otherwise a removed profile's cookies
506519
// stay on disk with nothing in the UI pointing at them.
507520
if (environmentId) {
@@ -564,7 +577,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
564577
variant="ghost-muted"
565578
disabled={disabled}
566579
aria-label={`Remove ${profile.name}`}
567-
onClick={() => removeProfile(profile.id)}
580+
onClick={() => setProfilePendingRemoval(profile)}
568581
>
569582
<Trash2Icon />
570583
</Button>
@@ -577,6 +590,33 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
577590
);
578591
})}
579592
</div>
593+
<AlertDialog
594+
open={profilePendingRemoval !== null}
595+
onOpenChange={(open) => {
596+
if (!open) setProfilePendingRemoval(null);
597+
}}
598+
>
599+
<AlertDialogPopup>
600+
<AlertDialogHeader>
601+
<AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
602+
<AlertDialogDescription>
603+
Its cookies, logins, and cache are deleted with it. Tabs open in this profile move to
604+
the default one.
605+
</AlertDialogDescription>
606+
</AlertDialogHeader>
607+
<AlertDialogFooter>
608+
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
609+
<Button
610+
variant="destructive"
611+
onClick={() => {
612+
if (profilePendingRemoval) removeProfile(profilePendingRemoval.id);
613+
}}
614+
>
615+
Remove profile
616+
</Button>
617+
</AlertDialogFooter>
618+
</AlertDialogPopup>
619+
</AlertDialog>
580620
</SettingsRow>
581621
);
582622
}
@@ -585,7 +625,11 @@ function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean
585625
const userProfiles = useClientSettings((settings) => settings.browserProfiles);
586626
const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
587627
const updateSettings = useUpdatePrimarySettings();
588-
const profiles = resolveBrowserProfiles(userProfiles);
628+
// Incognito is deliberately absent: as a default it would open every tab
629+
// into storage that is discarded on close.
630+
const profiles = resolveBrowserProfiles(userProfiles).filter(
631+
(profile) => profile.kind !== "incognito",
632+
);
589633
const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
590634

591635
return (

apps/web/src/components/ui/menu.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,10 @@ function MenuSubTrigger({
237237
className={cn(
238238
// Leading-icon treatment matches `MenuItem`: a sub-trigger sits in the
239239
// same column as the items around it, so its icon has to align and dim
240-
// with theirs. Scoped to the first svg because the chevron below is
241-
// also a direct child, and `-mx-0.5` would override its `-me-0.5`.
242-
"[&>svg:first-of-type]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:first-of-type:not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",
240+
// with theirs. Scoped away from the last child because the chevron is
241+
// also a direct svg — on a sub-trigger with no leading icon it is the
242+
// only one, and these rules would take away its `ms-auto` alignment.
243+
"[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",
243244
className,
244245
)}
245246
data-inset={inset}

0 commit comments

Comments
 (0)