diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
index 58f4d10b3..18fbf41c3 100644
--- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
+++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx
@@ -9,6 +9,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { useNavigate } from "@tanstack/react-router";
import { mergeEnvironmentSettings } from "../../hooks/useSettings";
+import { isProfileSettingVisible } from "../../otterware/settingsRegistry";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
applyProviderInstanceSettings,
@@ -213,6 +214,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
settingKeys={["defaultThreadEnvMode"]}
mixed={mixedWorkspace}
id={searchableSetting("new-threads").id}
+ hidden={!isProfileSettingVisible("new-threads")}
title="Workspace"
description={
isProjectScope
@@ -347,6 +349,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
settingKeys={["worktreeSubmodules"]}
mixed={mixedSubmodules}
{...searchableSetting("worktree-submodules")}
+ hidden={!isProfileSettingVisible("worktree-submodules")}
description={
isProjectScope
? "How new worktrees in this project populate git submodules."
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 89d372ca9..78928b2b6 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,4 +1,5 @@
import { ProfileWordmark } from "../../otterware/ProfileLogo";
+import { isProfileSettingVisible } from "../../otterware/settingsRegistry";
import { SettingsGroup } from "./SettingsGroup";
import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
import { Spinner } from "~/components/ui/spinner";
@@ -2124,7 +2125,13 @@ function LegacyFeaturesSection() {
}, [searchTargetId]);
return (
-
+
@@ -2277,7 +2284,11 @@ export function GeneralSettingsPanel() {
return (
-
+
-
+
-
+
// Otterware seam: module pages, their icons, grouping and profile visibility
// follow the registry (docs/internals/modules.md).
import {
+ OTTERWARE_SETTINGS_NAV_LABELS,
OTTERWARE_SETTINGS_SECTION_ICONS,
isProfileSettingsPathVisible,
settingsNavLayout,
@@ -123,7 +124,7 @@ const UPSTREAM_SETTINGS_ORDER = (Object.keys(SETTINGS_SECTION_LABELS) as Setting
const navItem = (to: SettingsPath): SettingsNavItem => ({
to,
- label: SETTINGS_SECTION_LABELS[to],
+ label: OTTERWARE_SETTINGS_NAV_LABELS[to] ?? SETTINGS_SECTION_LABELS[to],
icon: SETTINGS_SECTION_ICONS[to],
});
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index f8debd914..4600fc400 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -41,6 +41,7 @@ import {
type SettingOverridingProject,
} from "./SettingInheritance";
import { ModuleSettingsMerges } from "../../otterware/ModuleSettingsMerges";
+import { showsSettingsScopeSentence } from "../../otterware/settingsRegistry";
const EMPTY_SETTING_KEYS: readonly (keyof ServerSettings)[] = [];
@@ -551,6 +552,8 @@ export function SettingsPageContainer({
}) {
const navigate = useNavigate();
const hash = useLocation({ select: (location) => location.hash });
+ const pathname = useLocation({ select: (location) => location.pathname });
+ const scopeSearch = useOptionalSettingsScope()?.search;
const highlightTarget = useLocation({
select: (location) => location.state.settingsTargetHighlight !== false,
});
@@ -576,7 +579,10 @@ export function SettingsPageContainer({
data-settings-page-scroll
>
-
+ {/* Otterware seam: module pages name their own scope. */}
+ {showsSettingsScopeSentence(pathname, scopeSearch ?? {}) ? (
+
+ ) : null}
{children}
{/* Otterware seam: module settings pages merged into this host page. */}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index ddce3294f..39634a5a5 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -19,6 +19,7 @@ import {
OTTERWARE_SETTINGS_LABEL_OVERRIDES,
OTTERWARE_SETTINGS_CATEGORY_SCOPES,
OTTERWARE_SETTINGS_SEARCH_ITEMS,
+ isProfileSettingVisible,
isProfileSettingsPathVisible,
type OtterwareSettingsPath,
} from "../../otterware/settingsRegistry";
@@ -1218,6 +1219,7 @@ export function filterAvailableSettingsSearchItems(
return items.filter(
(item) =>
isProfileSettingsPathVisible(item.to) &&
+ isProfileSettingVisible(item.targetId ?? item.id) &&
(!item.cloudOnly || availability.hasCloudPublicConfig) &&
(!item.environmentOnly || availability.hasEnvironment) &&
(!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
diff --git a/apps/web/src/otterware/ModuleSettingsMerges.tsx b/apps/web/src/otterware/ModuleSettingsMerges.tsx
index ad71c3ec3..2fb932c90 100644
--- a/apps/web/src/otterware/ModuleSettingsMerges.tsx
+++ b/apps/web/src/otterware/ModuleSettingsMerges.tsx
@@ -1,14 +1,14 @@
import { ProviderConnections } from "./account/ProviderConnections";
/**
* Module settings pages whose placement merges them into the host page being
- * shown (ADR 0011): each renders as one section, titled with the page's label,
- * below the host page's own rows. `SettingsPageContainer` renders this once
- * per page through its seam; module pages themselves add nothing here.
+ * shown (ADR 0011), below the host page's own sections. A module page draws
+ * its own titled sections, as on its own page, so nothing wraps it here.
+ * `SettingsPageContainer` renders this once per page through its seam.
*/
import { useLocation } from "@tanstack/react-router";
import { Suspense } from "react";
-import { SettingsSection } from "../components/settings/settingsLayout";
+import { SettingsSearchTarget, SettingsSection } from "../components/settings/settingsLayout";
import { mergedModuleSettingsPages } from "./settingsRegistry";
export function ModuleSettingsMerges() {
@@ -23,11 +23,15 @@ export function ModuleSettingsMerges() {
{mergedModuleSettingsPages(pathname).map((entry) => {
const Page = entry.page.component;
return (
-
+
-
+
);
})}
>
diff --git a/apps/web/src/otterware/calendar/CalendarWebModule.tsx b/apps/web/src/otterware/calendar/CalendarWebModule.tsx
index 514229ebd..95632715f 100644
--- a/apps/web/src/otterware/calendar/CalendarWebModule.tsx
+++ b/apps/web/src/otterware/calendar/CalendarWebModule.tsx
@@ -25,18 +25,18 @@ import { toastManager } from "../../components/ui/toast";
const settingsPages = [
{
- id: "calendar",
- label: "Calendar",
+ id: "general",
+ label: "General",
placement: "page",
rows: [
- "Week start",
- "Time zone",
+ "Week starts on",
+ "Show weekends",
+ "Custom view",
"Working hours",
"Default length",
- "Custom-view days",
- "Weekends",
- "Declined",
"New events go to",
+ "Show declined events",
+ "Time zone",
"Reminders",
],
},
@@ -52,7 +52,7 @@ const settingsPages = [
placement: "connections",
rows: ["Google sign-in", "OAuth client", "Demo accounts"],
},
- { id: "general", label: "Time", placement: "general", rows: ["Time format"] },
+ { id: "time", label: "Time", placement: "general", rows: ["Time format"] },
] as const;
const commands: WebModuleCommand[] = [
@@ -246,8 +246,12 @@ export const CalendarWebModule = createCalendarWebModule({
id:
(
{
- "Custom-view days": "calendar-custom-days",
+ "Week starts on": "calendar-week-start",
+ "Show weekends": "calendar-weekends",
+ "Custom view": "calendar-custom-days",
+ "Default length": "calendar-default-length",
"New events go to": "calendar-default-calendar",
+ "Show declined events": "calendar-declined",
"Time format": "calendar-hour-format",
} as Record
)[title] ?? `calendar-${title.toLowerCase().replaceAll(" ", "-")}`,
diff --git a/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx b/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx
index d86d12295..fa8a2463a 100644
--- a/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx
+++ b/apps/web/src/otterware/calendar/ui/CalendarSettings.tsx
@@ -1,11 +1,13 @@
-import type { ComponentProps } from "react";
+import { Fragment, type ComponentProps } from "react";
+import { PlusIcon, RefreshCwIcon, XIcon } from "lucide-react";
import {
SettingsRow,
SettingsSection as HostSettingsSection,
} from "~/components/settings/settingsLayout";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
-import { Checkbox } from "~/components/ui/checkbox";
+import { Switch } from "~/components/ui/switch";
+import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
import { CalendarSelect } from "./CalendarSelect";
import { useEffect, useRef, useState } from "react";
import { type CalendarId, type GoogleConnectState } from "@otterware/calendar-contracts";
@@ -17,10 +19,39 @@ import {
const OAUTH_CLIENT_FORM = "calendar-oauth-client";
+const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+const EVENT_LENGTH_CHOICES = [15, 30, 45, 60, 90, 120];
+const CUSTOM_DAY_CHOICES = [2, 3, 4, 5, 6, 7, 10, 14];
+const DEVICE_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+/** The choices, plus a saved value that isn't one of them. */
+const withCurrent = (choices: ReadonlyArray, current: number) =>
+ choices.includes(current) ? choices : [...choices, current].toSorted((a, b) => a - b);
+
+/** The week's days in the order the week starts on. */
+const workingDayOrder = (weekStartsOn: number) =>
+ Array.from({ length: 7 }, (_, index) => (weekStartsOn + index) % 7);
+
+const minutesToTime = (minutes: number) =>
+ `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
+
+function formatMinutes(minutes: number): string {
+ if (minutes < 60 || minutes % 60 !== 0) return `${minutes} min`;
+ const hours = minutes / 60;
+ return hours === 1 ? "1 hour" : `${hours} hours`;
+}
+
+let timeZones: ReadonlyArray | null = null;
+/** Every IANA zone this runtime knows, plus a saved one it doesn't. */
+function timeZoneChoices(saved: string | null): ReadonlyArray {
+ timeZones ??= Intl.supportedValuesOf("timeZone");
+ return saved && !timeZones.includes(saved) ? [saved, ...timeZones] : timeZones;
+}
+
export default function CalendarSettings({
- page = "calendar",
+ page = "general",
}: {
- page?: "calendar" | "calendars" | "connections" | "general";
+ page?: "general" | "calendars" | "connections" | "time";
}) {
const client = useCalendarClient();
const { directory, error: directoryError, canWrite } = useCalendarDirectory();
@@ -103,10 +134,9 @@ export default function CalendarSettings({
{error ?? directoryError}
);
- // Connections merges into the host page's card, so its rows are the card's own children.
if (page === "connections")
return (
- <>
+
{alert}
{flow?._tag === "waiting" && (
}
/>
- >
+
);
return (
-
+ <>
{alert}
- {page === "general" && preferences && (
-
+ {page === "time" && preferences && (
+
}
/>
-
+
)}
- {page === "calendar" && preferences && (
+ {page === "general" && preferences && (
<>
-
+
save({ timeZone: event.target.value.trim() || null })}
+ checked={preferences.showWeekends}
+ onCheckedChange={(checked) => save({ showWeekends: checked })}
/>
}
/>
- {(["start", "end"] as const).map((part) => (
- {
- const [h, m] = event.target.value.split(":").map(Number);
- if (h !== undefined && m !== undefined)
- save({
- workingHours: { ...preferences.workingHours, [part]: h * 60 + m },
- });
- }}
- />
- ))}
-
- }
- >
-
- {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day, index) => (
-
-
- save({
- workingHours: {
- ...preferences.workingHours,
- days: checked
- ? [...preferences.workingHours.days, index]
- : preferences.workingHours.days.filter((value) => value !== index),
- },
- })
- }
- />
- {day}
-
- ))}
-
-
- save({ defaultEventMinutes: Number(event.target.value) })}
- />
+ onChange={(event) => save({ customDays: Number(event.target.value) })}
+ >
+ {withCurrent(CUSTOM_DAY_CHOICES, preferences.customDays).map((days) => (
+
+ {`${days} days`}
+
+ ))}
+
}
/>
+
+
save({ customDays: Number(event.target.value) })}
- />
+ value={preferences.workingHours.days.map(String)}
+ onValueChange={(next) =>
+ save({
+ workingHours: {
+ ...preferences.workingHours,
+ days: next.map(Number).toSorted((a, b) => a - b),
+ },
+ })
+ }
+ >
+ {workingDayOrder(preferences.weekStartsOn).map((day) => (
+
+ {WEEKDAYS[day]?.slice(0, 2)}
+
+ ))}
+
}
/>
save({ showWeekends: checked })}
- />
+
+ {(["start", "end"] as const).map((part, index) => (
+
+ {index === 1 ? – : null}
+ {
+ const [h, m] = event.target.value.split(":").map(Number);
+ if (h !== undefined && m !== undefined)
+ save({
+ workingHours: { ...preferences.workingHours, [part]: h * 60 + m },
+ });
+ }}
+ />
+
+ ))}
+
}
/>
+
+
save({ showDeclined: checked })}
- />
+ onChange={(event) => save({ defaultEventMinutes: Number(event.target.value) })}
+ >
+ {withCurrent(EVENT_LENGTH_CHOICES, preferences.defaultEventMinutes).map(
+ (minutes) => (
+
+ {formatMinutes(minutes)}
+
+ ),
+ )}
+
}
/>
}
/>
-
-
- Event reminders · Account
-
- Default popup reminders for new events and local reminders for upcoming events.
- Per-event overrides take priority.
-
+ save({ showDeclined: checked })}
+ />
+ }
+ />
+ save({ timeZone: event.target.value || null })}
+ >
+ {`This device (${DEVICE_TIME_ZONE})`}
+ {timeZoneChoices(preferences.timeZone).map((zone) => (
+
+ {zone.replaceAll("_", " ")}
+
+ ))}
+
+ }
+ />
+
+
{directory.preferences.notificationRules.map((rule, index) => (
-
-
-
- void run(() =>
- client.preferences({
- notificationRules: directory.preferences.notificationRules.map(
- (item, i) => (i === index ? { ...item, enabled: checked } : item),
- ),
- }),
- )
- }
- />
- Enabled
-
-
- Minutes before
- {
- const minutesBefore = Number(event.target.value);
- if (minutesBefore !== rule.minutesBefore)
+
+ {
+ const minutesBefore = Number(event.target.value);
+ if (minutesBefore !== rule.minutesBefore)
+ void run(() =>
+ client.preferences({
+ notificationRules: directory.preferences.notificationRules.map(
+ (item, i) => (i === index ? { ...item, minutesBefore } : item),
+ ),
+ }),
+ );
+ }}
+ />
+ min
+
void run(() =>
client.preferences({
notificationRules: directory.preferences.notificationRules.map(
- (item, i) => (i === index ? { ...item, minutesBefore } : item),
+ (item, i) => (i === index ? { ...item, enabled: checked } : item),
),
}),
- );
- }}
- />
-
+ )
+ }
+ />
+
+ void run(() =>
+ client.preferences({
+ notificationRules: directory.preferences.notificationRules.filter(
+ (_, i) => i !== index,
+ ),
+ }),
+ )
+ }
+ >
+
+
+
+ }
+ />
+ ))}
+ = 5}
onClick={() =>
void run(() =>
client.preferences({
- notificationRules: directory.preferences.notificationRules.filter(
- (_, i) => i !== index,
- ),
+ notificationRules: [
+ ...directory.preferences.notificationRules,
+ { minutesBefore: 10, enabled: true },
+ ],
}),
)
}
>
- Remove
+
+ Add reminder
-
- ))}
- = 5}
- onClick={() =>
- void run(() =>
- client.preferences({
- notificationRules: [
- ...directory.preferences.notificationRules,
- { minutesBefore: 10, enabled: true },
- ],
- }),
- )
}
- >
- Add reminder
-
-
- {
- const enabled = checked;
- void run(async () => {
- if (enabled && !calendarDesktopBridge()) {
- if (typeof Notification === "undefined")
- throw new Error("This browser does not support notifications.");
- if ((await Notification.requestPermission()) !== "granted")
- throw new Error(
- "Allow notifications in this browser's settings to receive reminders.",
- );
- }
- localStorage.setItem(CALENDAR_NOTIFICATION_DEVICE_KEY, String(enabled));
- setDeviceNotifications(enabled);
- });
- }}
- />
- System notifications on this device
-
-
+ />
+ {
+ const enabled = checked;
+ void run(async () => {
+ if (enabled && !calendarDesktopBridge()) {
+ if (typeof Notification === "undefined")
+ throw new Error("This browser does not support notifications.");
+ if ((await Notification.requestPermission()) !== "granted")
+ throw new Error(
+ "Allow notifications in this browser's settings to receive reminders.",
+ );
+ }
+ localStorage.setItem(CALENDAR_NOTIFICATION_DEVICE_KEY, String(enabled));
+ setDeviceNotifications(enabled);
+ });
+ }}
+ />
+ }
+ />
+
>
)}
{page === "calendars" && directory && (
@@ -553,7 +625,12 @@ export default function CalendarSettings({
{directory.calendars
.filter((calendar) => calendar.accountId === account.accountId)
@@ -564,16 +641,17 @@ export default function CalendarSettings({
description={
calendar.watchOnly
? "Watch only · Excluded from attention and free-time search"
- : "Account"
+ : undefined
}
control={
-
-
+
void run(() =>
client.calendar({
@@ -583,36 +661,39 @@ export default function CalendarSettings({
)
}
/>
-
-
+ Watch only
+
+ checked={calendar.watchOnly === true}
+ onCheckedChange={(watchOnly) =>
void run(() =>
- client.calendar({ calendarId: calendar.calendarId, visible }),
+ client.calendar({ calendarId: calendar.calendarId, watchOnly }),
)
}
/>
- Visible
-
-
+ Show
+
+ checked={calendar.visible}
+ onCheckedChange={(visible) =>
void run(() =>
- client.calendar({ calendarId: calendar.calendarId, watchOnly }),
+ client.calendar({ calendarId: calendar.calendarId, visible }),
)
}
/>
- Watch only
}
/>
))}
))}
-
+
{(["pastWeeks", "futureWeeks"] as const).map((part) => (
- void run(() =>
- client.environmentPreferences({
- syncWindow: {
- ...directory.environmentPreferences.syncWindow,
- [part]: Number(event.target.value),
- },
- }),
- )
- }
- />
+
+
+ void run(() =>
+ client.environmentPreferences({
+ syncWindow: {
+ ...directory.environmentPreferences.syncWindow,
+ [part]: Number(event.target.value),
+ },
+ }),
+ )
+ }
+ />
+ weeks
+
}
/>
))}
void run(client.sync)}>
- Sync
+ void run(client.sync)}
+ >
+
+ Sync now
}
/>
>
)}
-
+ >
);
}
function SettingsSection({
description,
...props
-}: ComponentProps & { description?: string }) {
+}: ComponentProps & { description?: string | undefined }) {
return (
- Run otterdrive auth login and approve this device in your browser with your
- Otter Account. API keys are optional. Browser approval is currently unavailable in this
- app.
- >
- }
- />
+
+
+ Run otterdrive auth login and approve this device in your browser with
+ your Otter Account. API keys are optional. Browser approval is currently unavailable
+ in this app.
+ >
+ }
+ />
+
+ );
+ if (page === "appearance")
+ return (
+
+
+
);
- if (page === "appearance") return ;
return (
-
- ({ value: folder.id, label: folderLabel(folder) }))}
- disabled={undefined}
- onValueChange={(value) => {
- if (typeof value === "string") void selectFolder(value);
- }}
- >
-
-
-
-
- {folders
- .map((folder) => ({ value: folder.id, label: folderLabel(folder) }))
- .map((option) => (
-
- {option.label}
-
- ))}
-
-
- }
- />
+ <>
{page === "access" ? : }
-
+ >
);
}
export function DriveSettings({ host, page = "drives" }: { host: DriveHost; page?: string }) {
diff --git a/apps/web/src/otterware/drive/ui/main/settings/folder-pane.tsx b/apps/web/src/otterware/drive/ui/main/settings/folder-pane.tsx
index d5660eaac..b97af7e8f 100644
--- a/apps/web/src/otterware/drive/ui/main/settings/folder-pane.tsx
+++ b/apps/web/src/otterware/drive/ui/main/settings/folder-pane.tsx
@@ -14,6 +14,7 @@ import { api } from "../../lib/api";
import { NewFolderDialog } from "../drive/new-folder-dialog";
import { announceFoldersChanged, driveForFolder, folderLabel, useFolders } from "../folders";
+import { FolderPickerSection } from "./folder-picker";
import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settings-ui";
export function FolderPane() {
const { activeFolder: folder, folders, selectFolder } = useFolders(),
@@ -34,10 +35,8 @@ export function FolderPane() {
}
}
return (
-
+
+
({ value: folder.id, label: folderLabel(folder) }));
+ return (
+
+ {
+ if (typeof value === "string") void selectFolder(value);
+ }}
+ >
+
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ }
+ />
+
+ );
+}
diff --git a/apps/web/src/otterware/drive/ui/main/settings/members-pane.tsx b/apps/web/src/otterware/drive/ui/main/settings/members-pane.tsx
index 5d3365b91..6259f0b46 100644
--- a/apps/web/src/otterware/drive/ui/main/settings/members-pane.tsx
+++ b/apps/web/src/otterware/drive/ui/main/settings/members-pane.tsx
@@ -14,6 +14,7 @@ import { api } from "../../lib/api";
import { announceFoldersChanged, driveForFolder, useFolders } from "../folders";
import { folderUrl } from "../../lib/links";
+import { FolderPickerSection } from "./folder-picker";
import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settings-ui";
interface Member {
id: string;
@@ -66,13 +67,13 @@ export function MembersPane() {
}
return (
+
{enabled ? (
<>
diff --git a/apps/web/src/otterware/drive/ui/main/settings/settings-ui.tsx b/apps/web/src/otterware/drive/ui/main/settings/settings-ui.tsx
index e3e523b95..6de267936 100644
--- a/apps/web/src/otterware/drive/ui/main/settings/settings-ui.tsx
+++ b/apps/web/src/otterware/drive/ui/main/settings/settings-ui.tsx
@@ -1,21 +1,20 @@
import type { ReactNode } from "react";
export { SettingsRow, SettingsSection } from "~/components/settings/settingsLayout";
+
+/** A Drive settings page: a line about what it covers, then its sections. The breadcrumb names it. */
export function SettingsPageContainer({
- title,
description,
children,
}: {
- title: ReactNode;
description?: ReactNode;
children: ReactNode;
}) {
return (
-
-
-
{title}
-
{description}
-
+ <>
+ {description ? (
+ {description}
+ ) : null}
{children}
-
+ >
);
}
diff --git a/apps/web/src/otterware/mail/MailSettings.tsx b/apps/web/src/otterware/mail/MailSettings.tsx
index 3ea738817..676706fcd 100644
--- a/apps/web/src/otterware/mail/MailSettings.tsx
+++ b/apps/web/src/otterware/mail/MailSettings.tsx
@@ -3,9 +3,8 @@ import type { ReactNode } from "react";
import { useMailStore } from "./mailStore";
import { MailContentProvider } from "./content/host/surface";
import { GeneralRows } from "./content/main/settings/general-rows";
-import { ReadingRows } from "./content/main/settings/reading-rows";
+import { AppearanceRows } from "./content/main/settings/appearance-rows";
import { AccountsPane } from "./content/main/settings/accounts-pane";
-import { TranslationSection } from "./content/main/settings/translation-section";
import { TodoistSettingsPane } from "./content/main/integrations/todoist";
import { MailSettingScope } from "./content/main/settings/settings-ui";
@@ -37,7 +36,10 @@ function MailSettingsRows({
return (
-
+
{children}
@@ -48,21 +50,13 @@ export function MailboxesSettings() {
return (
-
);
}
-export function ReadingSettings() {
+export function AppearanceSettings() {
return (
-
-
- );
-}
-export function TranslationSettings() {
- return (
-
-
+
);
}
diff --git a/apps/web/src/otterware/mail/MailWebModule.ts b/apps/web/src/otterware/mail/MailWebModule.ts
index 47c46f060..d563dfb2e 100644
--- a/apps/web/src/otterware/mail/MailWebModule.ts
+++ b/apps/web/src/otterware/mail/MailWebModule.ts
@@ -1,6 +1,6 @@
import { createMailWebModule } from "@otterware/mail-web/module";
import { lazy } from "react";
-import { MailIcon, BookOpenIcon, LanguagesIcon, SettingsIcon, PlugIcon } from "lucide-react";
+import { MailIcon, PaletteIcon, SettingsIcon, PlugIcon } from "lucide-react";
import {
mailPaletteCommands,
useMailSpaces,
@@ -21,6 +21,26 @@ export const MailWebModule = createMailWebModule({
runCommand: runDynamicMailCommand,
keybindingAliases: mailKeybindingAliases,
settings: [
+ {
+ id: "general",
+ label: "General",
+ icon: SettingsIcon,
+ component: lazy(() => import("./MailSettings").then((m) => ({ default: m.GeneralSettings }))),
+ placement: { grouped: "page", standalone: "general" },
+ searchTerms: ["notifications", "sync", "translate", "language", "tour", "setup", "feedback"],
+ rows: MAIL_SETTING_ROWS.general,
+ },
+ {
+ id: "appearance",
+ label: "Appearance",
+ icon: PaletteIcon,
+ component: lazy(() =>
+ import("./MailSettings").then((m) => ({ default: m.AppearanceSettings })),
+ ),
+ placement: { grouped: "page", standalone: "appearance" },
+ searchTerms: ["layout", "reading", "width", "messages", "read"],
+ rows: MAIL_SETTING_ROWS.appearance,
+ },
{
id: "mailboxes",
label: "Mailboxes",
@@ -29,38 +49,9 @@ export const MailWebModule = createMailWebModule({
import("./MailSettings").then((m) => ({ default: m.MailboxesSettings })),
),
placement: { grouped: "page", standalone: "page" },
- searchTerms: ["signature", "sync", "mailbox", "behavior"],
+ searchTerms: ["signature", "mailbox", "account"],
rows: MAIL_SETTING_ROWS.mailboxes,
},
- {
- id: "reading",
- label: "Reading",
- icon: BookOpenIcon,
- component: lazy(() => import("./MailSettings").then((m) => ({ default: m.ReadingSettings }))),
- placement: { grouped: "page", standalone: "appearance" },
- searchTerms: ["layout", "width", "messages", "read"],
- rows: MAIL_SETTING_ROWS.reading,
- },
- {
- id: "translation",
- label: "Translation",
- icon: LanguagesIcon,
- component: lazy(() =>
- import("./MailSettings").then((m) => ({ default: m.TranslationSettings })),
- ),
- placement: { grouped: "page", standalone: "general" },
- searchTerms: ["language", "translate", "OpenRouter"],
- rows: MAIL_SETTING_ROWS.translation,
- },
- {
- id: "general",
- label: "Mail",
- icon: SettingsIcon,
- component: lazy(() => import("./MailSettings").then((m) => ({ default: m.GeneralSettings }))),
- placement: { grouped: "general", standalone: "general" },
- searchTerms: ["notifications", "tour", "setup", "feedback"],
- rows: MAIL_SETTING_ROWS.general,
- },
{
id: "integrations",
label: "Todoist",
diff --git a/apps/web/src/otterware/mail/content/main/home-view.tsx b/apps/web/src/otterware/mail/content/main/home-view.tsx
index c2067c16f..89789a5a7 100644
--- a/apps/web/src/otterware/mail/content/main/home-view.tsx
+++ b/apps/web/src/otterware/mail/content/main/home-view.tsx
@@ -104,6 +104,7 @@ import { ProjectView } from "./gmail/project-view";
import { NewProjectDialog } from "./gmail/project-menus";
import { Tour } from "./onboarding/tour";
+import { mailSettingsPath } from "../../settingsPaths";
import {
endTour,
getSetupStage,
@@ -374,7 +375,7 @@ function MailHome() {
const openSettings = useCallback((route: SettingsRoute) => {
if (route.pane === "keybindings") setKeybindingsChip("mail");
mailHost().navigate(
- `${route.pane === "accounts" ? "/settings/mail/mailboxes" : route.pane === "agents" ? "/settings/providers" : route.pane === "browser" ? "/settings/extensions" : route.pane === "otter" ? "/settings/account" : `/settings/${route.pane}`}${route.target ? `?target=${encodeURIComponent(route.target)}` : ""}`,
+ `${mailSettingsPath(route.pane)}${route.target ? `?target=${encodeURIComponent(route.target)}` : ""}`,
);
}, []);
const openSettingsRef = useLatest(openSettings);
diff --git a/apps/web/src/otterware/mail/content/main/reading-preferences.ts b/apps/web/src/otterware/mail/content/main/reading-preferences.ts
index 919a7d5ce..f57bc0cc5 100644
--- a/apps/web/src/otterware/mail/content/main/reading-preferences.ts
+++ b/apps/web/src/otterware/mail/content/main/reading-preferences.ts
@@ -60,7 +60,7 @@ export const READING_WIDTHS = {
full: { label: "Full width", maxWidth: "none" },
} as const;
export type ReadingWidth = keyof typeof READING_WIDTHS;
-const DEFAULT_READING_WIDTH: ReadingWidth = "full";
+export const DEFAULT_READING_WIDTH: ReadingWidth = "full";
const READING_WIDTH_KEY = "otter:reading-width";
export const MESSAGE_LIST_STYLES = {
@@ -68,7 +68,7 @@ export const MESSAGE_LIST_STYLES = {
dividers: { label: "With dividers", description: "A compact list with full-width separators." },
} as const;
export type MessageListStyle = keyof typeof MESSAGE_LIST_STYLES;
-const DEFAULT_MESSAGE_LIST_STYLE: MessageListStyle = "classic";
+export const DEFAULT_MESSAGE_LIST_STYLE: MessageListStyle = "classic";
const MESSAGE_LIST_STYLE_KEY = "otter:message-list-style";
export const MAIL_LAYOUTS = {
@@ -80,7 +80,7 @@ export const MAIL_LAYOUTS = {
},
} as const;
export type MailLayout = keyof typeof MAIL_LAYOUTS;
-const DEFAULT_MAIL_LAYOUT: MailLayout = "split";
+export const DEFAULT_MAIL_LAYOUT: MailLayout = "split";
const MAIL_LAYOUT_KEY = "otter:mail-layout";
function getMailLayout(): MailLayout {
diff --git a/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx b/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx
index ed7bd961b..f5917bfc5 100644
--- a/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx
+++ b/apps/web/src/otterware/mail/content/main/settings/accounts-pane.tsx
@@ -32,8 +32,6 @@ import { Btn, HintTooltip, cn, restoreFocusForKeyboardOnly } from "../gmail/ui";
import {
DraftInput,
SettingResetButton,
- SettingsGroup,
- SettingsPageContainer,
SettingsRow,
SettingsSearchTarget,
SettingsSection,
@@ -613,15 +611,13 @@ export function AccountsPane() {
useEffect(() => void gmailApi.refreshSignatures().catch(() => {}), []);
return (
- }
- >
- {current ? (
- <>
-
+ <>
+ }
+ >
+ {current ? (
+ <>
{accounts.length > 1 ? (
);
})}
-
-
- >
- ) : (
-
+ >
+ ) : (
-
- )}
-
+ )}
+
+ {current ? : null}
+ >
);
}
diff --git a/apps/web/src/otterware/mail/content/main/settings/appearance-rows.tsx b/apps/web/src/otterware/mail/content/main/settings/appearance-rows.tsx
new file mode 100644
index 000000000..32f74f68f
--- /dev/null
+++ b/apps/web/src/otterware/mail/content/main/settings/appearance-rows.tsx
@@ -0,0 +1,280 @@
+import { Switch } from "~/components/ui/switch";
+import { cn } from "~/lib/utils";
+import { SettingsSlider } from "~/otterware/settings/SettingsSlider";
+import { SettingsTilePicker } from "~/otterware/settings/SettingsTilePicker";
+import { RowSelect, SettingResetButton, SettingsRow, SettingsSection } from "./settings-ui";
+import { searchableSetting } from "./settings-search";
+import {
+ DEFAULT_MAIL_LAYOUT,
+ DEFAULT_MESSAGE_LIST_STYLE,
+ DEFAULT_READING_WIDTH,
+ MAIL_LAYOUTS,
+ MESSAGE_LIST_STYLES,
+ READING_WIDTHS,
+ GROUP_MESSAGES_BY_DAY,
+ DIM_READ_MESSAGES,
+ OPEN_MESSAGES_WITH_ARROWS,
+ MARK_READ_DELAY,
+ useMailLayout,
+ useMessageListStyle,
+ useReadingWidth,
+ useInterfaceToggle,
+ useInterfaceSetting,
+ setMailLayout,
+ setMessageListStyle,
+ setReadingWidth,
+ setInterfaceToggle,
+ setInterfaceSetting,
+ type InterfaceToggle,
+ type MailLayout,
+ type MessageListStyle,
+ type ReadingWidth,
+} from "../reading-preferences";
+
+const bar = "block h-1 rounded-full";
+
+/** A miniature of the mail window in one layout: rail, list, and where a message opens. */
+function MailLayoutPreview({ layout }: { layout: MailLayout }) {
+ const split = layout === "split";
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2, 3].map((row) => (
+
+
+
+
+ ))}
+
+ {split ? (
+
+
+
+
+
+
+ ) : null}
+ {layout === "floating" ? (
+
+
+
+
+ ×
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+const SAMPLE_MESSAGES = [
+ { sender: "Alex Morgan", subject: "Weekend plans", snippet: "How about Saturday?" },
+ { sender: "Jamie Lee", subject: "A little update", snippet: "Here's what I've been working on." },
+];
+
+/** Two sample rows, separated the way the style separates the message list. */
+function MessageListStylePreview({ style }: { style: MessageListStyle }) {
+ return (
+
+ {SAMPLE_MESSAGES.map(({ sender, subject, snippet }) => (
+
+ {sender}
+ {subject}
+ {snippet}
+
+ ))}
+
+ );
+}
+
+function ToggleRow({
+ id,
+ toggle,
+ resetLabel,
+ description,
+}: {
+ id: string;
+ toggle: InterfaceToggle;
+ resetLabel: string;
+ description: string;
+}) {
+ const value = useInterfaceToggle(toggle);
+ const setting = searchableSetting(id);
+ return (
+ setInterfaceToggle(toggle, toggle.defaultValue)}
+ />
+ ) : null
+ }
+ control={
+ setInterfaceToggle(toggle, next)}
+ />
+ }
+ />
+ );
+}
+
+/** Mail › Appearance: how mail is laid out and read (Otter Mail's Appearance). */
+export function AppearanceRows() {
+ const layout = useMailLayout();
+ const style = useMessageListStyle();
+ const width = useReadingWidth();
+ const delay = useInterfaceSetting(MARK_READ_DELAY);
+ return (
+ <>
+ setMailLayout(DEFAULT_MAIL_LAYOUT)}
+ />
+ ) : null
+ }
+ >
+ ({
+ value,
+ label: MAIL_LAYOUTS[value].label,
+ description: MAIL_LAYOUTS[value].description,
+ preview: ,
+ }))}
+ />
+
+ setMessageListStyle(DEFAULT_MESSAGE_LIST_STYLE)}
+ />
+ ) : null
+ }
+ >
+ ({
+ value,
+ label: MESSAGE_LIST_STYLES[value].label,
+ description: MESSAGE_LIST_STYLES[value].description,
+ preview: ,
+ }))}
+ />
+
+
+
+
+
+
+
+ setInterfaceSetting(MARK_READ_DELAY, MARK_READ_DELAY.defaultValue)}
+ />
+ ) : null
+ }
+ control={
+ setInterfaceSetting(MARK_READ_DELAY, value)}
+ />
+ }
+ />
+ setReadingWidth(DEFAULT_READING_WIDTH)}
+ />
+ ) : null
+ }
+ control={
+ ({
+ value,
+ label: READING_WIDTHS[value].label,
+ }))}
+ onValueChange={(value) => setReadingWidth(value as ReadingWidth)}
+ />
+ }
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/otterware/mail/content/main/settings/general-rows.tsx b/apps/web/src/otterware/mail/content/main/settings/general-rows.tsx
index 468f9acfc..8cbdfaa52 100644
--- a/apps/web/src/otterware/mail/content/main/settings/general-rows.tsx
+++ b/apps/web/src/otterware/mail/content/main/settings/general-rows.tsx
@@ -20,6 +20,7 @@ import { Btn } from "../gmail/ui";
import { requestTour, startSetup } from "../onboarding/onboarding";
import { requestProblemReport } from "../support/report-problem";
import { useRailUnreadDots } from "../gmail/rail-preferences";
+import { TranslationSection } from "./translation-section";
/** Where the settings page is. */
export type { SettingsRoute } from "../../host/public";
@@ -50,7 +51,8 @@ const ADVANCE_DIRECTION_OPTIONS: { value: AdvanceDirection; label: string }[] =
// General
// ---------------------------------------------------------------------------
-export function GeneralRows({ behavior = false }: { behavior?: boolean | undefined }) {
+/** Mail › General: Otter Mail's General sections, in its order. */
+export function GeneralRows() {
const [syncInterval, setSyncInterval] = useState(null);
const [notificationsMode, setNotificationsMode] = useState(null);
const [advanceDirection, setAdvanceDirectionState] = useState(() =>
@@ -170,26 +172,12 @@ export function GeneralRows({ behavior = false }: { behavior?: boolean | undefin
void loadMailApps();
};
- // The rail's unread dots, next to the Dock badge in the Mac app; in Mail elsewhere.
- const railDotsRow = (
- setRailDots(false)} />
- ) : null
- }
- description="A dot on each mailbox in the rail whose Inbox has unread mail, and each view with unread mail."
- control={ }
- />
- );
-
return (
-
+ <>
{/* What only the desktop app has isn't shown elsewhere. */}
{features.launchAtLogin || features.dockBadge ? (
- <>
- {!behavior && features.launchAtLogin ? (
+
+ {features.launchAtLogin ? (
) : null}
- {behavior && features.dockBadge ? (
+ {features.dockBadge ? (
) : null}
- {behavior && features.dockBadge ? railDotsRow : null}
- >
+
) : null}
- <>
- {behavior && !features.dockBadge ? railDotsRow : null}
- {behavior ? (
-
- void handleSyncIntervalChange(String(DEFAULT_SETTINGS.syncIntervalSeconds))
- }
- />
- ) : null
- }
- description="Sync runs in the background at this cadence."
- control={
- void handleSyncIntervalChange(v)}
- options={SYNC_INTERVAL_OPTIONS.map((o) => ({
- value: String(o.value),
- label: o.label,
- }))}
- ariaLabel="Check for new mail"
+
+
+ void handleSyncIntervalChange(String(DEFAULT_SETTINGS.syncIntervalSeconds))
+ }
/>
- }
- />
- ) : null}
- {!behavior ? (
-
- void handleNotificationsModeChange(DEFAULT_SETTINGS.notificationsMode)
- }
- />
- ) : null
- }
- description="Notify about new mail found by background sync."
- control={
- void handleNotificationsModeChange(v)}
- options={NOTIFICATIONS_OPTIONS}
- ariaLabel="Notifications"
+ ) : null
+ }
+ description="Sync runs in the background at this cadence."
+ control={
+ void handleSyncIntervalChange(v)}
+ options={SYNC_INTERVAL_OPTIONS.map((o) => ({
+ value: String(o.value),
+ label: o.label,
+ }))}
+ ariaLabel="Check for new mail"
+ />
+ }
+ />
+
+ void handleNotificationsModeChange(DEFAULT_SETTINGS.notificationsMode)
+ }
/>
- }
- />
- ) : null}
- {behavior ? (
- handleAdvanceDirectionChange("next")}
- />
- ) : null
- }
- description="Which message to select next in the list."
- control={
- void handleNotificationsModeChange(v)}
+ options={NOTIFICATIONS_OPTIONS}
+ ariaLabel="Notifications"
+ />
+ }
+ />
+ handleAdvanceDirectionChange("next")}
/>
- }
- />
- ) : null}
- >
+ ) : null
+ }
+ description="Which message to select next in the list."
+ control={
+
+ }
+ />
+ setRailDots(false)} />
+ ) : null
+ }
+ description="A dot on each mailbox in the rail whose Inbox has unread mail, and each view with unread mail."
+ control={ }
+ />
+
+
+
- {behavior && features.defaultMailApp ? (
- <>
+ {features.defaultMailApp ? (
+
}
/>
- >
+
) : null}
- {!behavior ? (
- <>
- <>
-
- Take the tour
-
- }
- />
- startSetup()}>
- Run setup again
-
- }
- />
- >
+
+
+ Take the tour
+
+ }
+ />
+ startSetup()}>
+ Run setup again
+
+ }
+ />
+
- <>
-
- Send feedback
-
- }
- />
- >
- >
- ) : null}
-
+
+
+ Send feedback
+
+ }
+ />
+
+ >
);
}
diff --git a/apps/web/src/otterware/mail/content/main/settings/reading-rows.tsx b/apps/web/src/otterware/mail/content/main/settings/reading-rows.tsx
deleted file mode 100644
index 6c256ee97..000000000
--- a/apps/web/src/otterware/mail/content/main/settings/reading-rows.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { Switch } from "~/components/ui/switch";
-import { Input } from "~/components/ui/input";
-import { RowSelect, SettingsRow, SettingsSection } from "./settings-ui";
-import { searchableSetting } from "./settings-search";
-import {
- MAIL_LAYOUTS,
- MESSAGE_LIST_STYLES,
- READING_WIDTHS,
- GROUP_MESSAGES_BY_DAY,
- DIM_READ_MESSAGES,
- OPEN_MESSAGES_WITH_ARROWS,
- MARK_READ_DELAY,
- useMailLayout,
- useMessageListStyle,
- useReadingWidth,
- useInterfaceToggle,
- useInterfaceSetting,
- setMailLayout,
- setMessageListStyle,
- setReadingWidth,
- setInterfaceToggle,
- setInterfaceSetting,
- type MailLayout,
- type MessageListStyle,
- type ReadingWidth,
-} from "../reading-preferences";
-export function ReadingRows() {
- const layout = useMailLayout();
- const style = useMessageListStyle();
- const width = useReadingWidth();
- const group = useInterfaceToggle(GROUP_MESSAGES_BY_DAY);
- const dim = useInterfaceToggle(DIM_READ_MESSAGES);
- const arrows = useInterfaceToggle(OPEN_MESSAGES_WITH_ARROWS);
- const delay = useInterfaceSetting(MARK_READ_DELAY);
- return (
-
- ({
- value,
- label: item.label,
- }))}
- onValueChange={(value) => setMailLayout(value as MailLayout)}
- />
- }
- />
- ({
- value,
- label: item.label,
- }))}
- onValueChange={(value) => setMessageListStyle(value as MessageListStyle)}
- />
- }
- />
- ({
- value,
- label: item.label,
- }))}
- onValueChange={(value) => setReadingWidth(value as ReadingWidth)}
- />
- }
- />
- setInterfaceToggle(GROUP_MESSAGES_BY_DAY, value)}
- />
- }
- />
- setInterfaceToggle(DIM_READ_MESSAGES, value)}
- />
- }
- />
- setInterfaceToggle(OPEN_MESSAGES_WITH_ARROWS, value)}
- />
- }
- />
- setInterfaceSetting(MARK_READ_DELAY, Number(event.target.value))}
- />
- }
- />
-
- );
-}
diff --git a/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx b/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx
index b888341b0..7c72f9345 100644
--- a/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx
+++ b/apps/web/src/otterware/mail/content/main/settings/settings-ui.tsx
@@ -23,16 +23,21 @@ export function SettingsRow(props: ComponentProps) {
: props.id === "mailbox-status"
? "environment"
: inheritedScope;
+ // Account is Mail's default scope (ADR 0011), so only the exceptions are named.
return (
- {props.status}
-
- {scope === "client" ? "This device" : scope === "account" ? "Account" : "Environment"}
-
- >
+ scope === "account" ? (
+ props.status
+ ) : (
+ <>
+ {props.status}
+
+ {scope === "client" ? "This device" : "Environment"}
+
+ >
+ )
}
/>
);
@@ -87,6 +92,8 @@ export function RowSelect({
return (
{
if (next !== null) onValueChange(next);
}}
diff --git a/apps/web/src/otterware/mail/content/main/settings/translation-section.tsx b/apps/web/src/otterware/mail/content/main/settings/translation-section.tsx
index e7446c316..a2787a61b 100644
--- a/apps/web/src/otterware/mail/content/main/settings/translation-section.tsx
+++ b/apps/web/src/otterware/mail/content/main/settings/translation-section.tsx
@@ -1,4 +1,3 @@
-import { mailHost } from "../../host/bridge";
import { DEFAULT_SETTINGS } from "@otterware/mail-contracts";
import { Switch } from "../../components/ui/switch";
import { toast } from "../gmail/toast";
@@ -140,14 +139,6 @@ export function TranslationSection() {
/>
}
/>
- mailHost().navigate("/settings/providers")}>Configure OpenRouter
- }
- />
);
}
diff --git a/apps/web/src/otterware/mail/rpc/host.ts b/apps/web/src/otterware/mail/rpc/host.ts
index da4facb1a..b1b81b7a7 100644
--- a/apps/web/src/otterware/mail/rpc/host.ts
+++ b/apps/web/src/otterware/mail/rpc/host.ts
@@ -17,6 +17,7 @@ import { ensureLocalApi } from "../../../localApi";
import { appAtomRegistry } from "../../../rpc/atomRegistry";
import { mailOperations, mailWriteCommand } from "./operations";
import { subscribeMailEvents } from "./events";
+import { mailSettingsPath } from "../settingsPaths";
const unavailable = async (): Promise => {
throw new Error("This capability is provided by Otterware Settings.");
@@ -173,16 +174,7 @@ export function createMailHost(
case "app:takePendingMailto":
return null;
case "window:openSettings":
- callbacks.navigate(
- (
- {
- accounts: "/settings/mail/mailboxes",
- agents: "/settings/providers",
- browser: "/settings/extensions",
- otter: "/settings/account",
- } as Record
- )[text(params.pane)] ?? `/settings/${encodeURIComponent(text(params.pane) || "general")}`,
- );
+ callbacks.navigate(mailSettingsPath(text(params.pane)));
return;
case "gmail:pickAttachments":
return pickAttachments(typeof params.existingBytes === "number" ? params.existingBytes : 0);
diff --git a/apps/web/src/otterware/mail/settingsPaths.ts b/apps/web/src/otterware/mail/settingsPaths.ts
new file mode 100644
index 000000000..05e9e04e3
--- /dev/null
+++ b/apps/web/src/otterware/mail/settingsPaths.ts
@@ -0,0 +1,22 @@
+import { moduleSettingsHref } from "../settingsRegistry";
+
+/** Otter Mail's settings panes that became Mail's own pages, by their page id. */
+const MAIL_PAGES: Readonly> = {
+ general: "general",
+ appearance: "appearance",
+ accounts: "mailboxes",
+};
+
+/** Panes that became shared App pages. */
+const APP_PAGES: Readonly> = {
+ agents: "/settings/providers",
+ browser: "/settings/extensions",
+ otter: "/settings/account",
+};
+
+/** Where one of Otter Mail's settings panes lives in the shell's Settings. */
+export function mailSettingsPath(pane: string): string {
+ const page = MAIL_PAGES[pane];
+ const own = page === undefined ? null : moduleSettingsHref("mail", page);
+ return own ?? APP_PAGES[pane] ?? `/settings/${encodeURIComponent(pane || "general")}`;
+}
diff --git a/apps/web/src/otterware/mail/settingsRows.ts b/apps/web/src/otterware/mail/settingsRows.ts
index 0a3fc84f7..96d336c92 100644
--- a/apps/web/src/otterware/mail/settingsRows.ts
+++ b/apps/web/src/otterware/mail/settingsRows.ts
@@ -2,36 +2,33 @@ const rows = (entries: Record) =>
Object.entries(entries).map(([id, title]) => ({ id, title }));
export const MAIL_SETTING_ROWS = {
mailboxes: rows({
+ mailboxes: "Mailboxes",
+ "all-mailboxes": "All mailboxes",
"mailbox-status": "Mailbox status",
"mailbox-display-name": "Display name",
"mailbox-color": "Mailbox color",
signature: "Signature",
"remove-mailbox": "Remove mailbox",
- "all-mailboxes": "All mailboxes",
- mailboxes: "Mailboxes",
- "sync-interval": "Check for new mail",
- "advance-direction": "After archive, delete, or move",
- "rail-unread-dots": "Unread dots in rail",
- "dock-badge": "Dock unread count",
- "default-mail-app": "Default email app",
}),
- reading: rows({
+ appearance: rows({
"mail-layout": "Mail layout",
"message-list-style": "Message list style",
- "reading-width": "Reading width",
- "group-messages-by-day": "Group by day",
+ "group-messages-by-day": "Group messages by day",
"dim-read-messages": "Dim read messages",
- "open-messages-with-arrows": "Arrow keys open messages",
+ "open-messages-with-arrows": "Open messages with arrow keys",
"mark-read-delay": "Mark as read delay",
- }),
- translation: rows({
- "read-languages": "Languages I read",
- "auto-translate": "Translate automatically",
- openrouter: "OpenRouter",
+ "reading-width": "Reading width",
}),
general: rows({
"launch-at-login": "Launch at login",
- notifications: "Mail notifications",
+ "dock-badge": "Dock unread count",
+ "sync-interval": "Check for new mail",
+ notifications: "Notifications",
+ "advance-direction": "After archive, delete, or move",
+ "rail-unread-dots": "Unread dots in rail",
+ "read-languages": "Languages I read",
+ "auto-translate": "Translate automatically",
+ "default-mail-app": "Default email app",
tour: "Tour",
setup: "Setup",
"send-feedback": "Send feedback",
diff --git a/apps/web/src/otterware/settings/SettingsSlider.tsx b/apps/web/src/otterware/settings/SettingsSlider.tsx
new file mode 100644
index 000000000..6da41d959
--- /dev/null
+++ b/apps/web/src/otterware/settings/SettingsSlider.tsx
@@ -0,0 +1,59 @@
+import type { CSSProperties } from "react";
+
+/**
+ * A range with its value in a pill beside it, as Appearance's Motion slider
+ * draws it (the `settings-slider` styles). Give it a row's `control`.
+ */
+export function SettingsSlider({
+ id,
+ ariaLabel,
+ value,
+ valueLabel,
+ min,
+ max,
+ step,
+ onValueChange,
+ disabled,
+}: {
+ readonly id: string;
+ readonly ariaLabel: string;
+ readonly value: number;
+ readonly valueLabel: string;
+ readonly min: number;
+ readonly max: number;
+ readonly step: number;
+ readonly onValueChange: (value: number) => void;
+ readonly disabled?: boolean | undefined;
+}) {
+ const ratio = max === min ? 0 : (value - min) / (max - min);
+ const style = {
+ "--settings-slider-progress": `${ratio * 100}%`,
+ "--settings-slider-fill-offset": `${0.5 - ratio}rem`,
+ } as CSSProperties;
+ return (
+
+
+ {valueLabel}
+
+ {
+ const next = Number(event.currentTarget.value);
+ if (Number.isFinite(next)) onValueChange(next);
+ }}
+ />
+
+ );
+}
diff --git a/apps/web/src/otterware/settings/SettingsTilePicker.tsx b/apps/web/src/otterware/settings/SettingsTilePicker.tsx
new file mode 100644
index 000000000..8c0ab1064
--- /dev/null
+++ b/apps/web/src/otterware/settings/SettingsTilePicker.tsx
@@ -0,0 +1,91 @@
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export interface SettingsTileOption {
+ readonly value: Value;
+ readonly label: string;
+ readonly description?: string;
+ /** A miniature of the choice, drawn above its label. */
+ readonly preview: ReactNode;
+}
+
+/**
+ * Pick one of a few choices by its picture, with the look of Appearance's
+ * light/dark/system tiles. Place it in a plain `SettingsSection` whose title
+ * names the setting.
+ */
+export function SettingsTilePicker({
+ ariaLabel,
+ options,
+ value,
+ onValueChange,
+ disabled,
+}: {
+ readonly ariaLabel: string;
+ readonly options: ReadonlyArray>;
+ readonly value: Value;
+ readonly onValueChange: (value: Value) => void;
+ readonly disabled?: boolean | undefined;
+}) {
+ return (
+
+
+ {options.map((option) => {
+ const isActive = option.value === value;
+ const descriptionId = `${ariaLabel}-${option.value}-description`
+ .toLowerCase()
+ .replaceAll(" ", "-");
+ return (
+ onValueChange(option.value)}
+ className={cn(
+ "flex cursor-pointer flex-col items-stretch gap-1 rounded-xl border p-2 text-left outline-none transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring disabled:cursor-default disabled:opacity-64",
+ isActive
+ ? "border-transparent bg-accent/30"
+ : "border-border/70 bg-card/60 hover:bg-accent/10",
+ )}
+ style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
+ >
+
+ {option.preview}
+
+
+ {option.label}
+
+ {option.description ? (
+
+ {option.description}
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/src/otterware/settingsRegistry.ts b/apps/web/src/otterware/settingsRegistry.ts
index 01ca32557..d560c5f6d 100644
--- a/apps/web/src/otterware/settingsRegistry.ts
+++ b/apps/web/src/otterware/settingsRegistry.ts
@@ -32,14 +32,11 @@ const ACCOUNT_SETTINGS_PATH = "/settings/account";
* host's tables are keyed by. A new module page adds its id here.
*/
export type ModuleSettingsPageId =
- | "mail"
| "general"
+ | "appearance"
| "integrations"
- | "calendar"
- | "drive"
- | "reading"
+ | "time"
| "mailboxes"
- | "translation"
| "calendars"
| "drives"
| "access";
@@ -111,10 +108,40 @@ export function moduleSettingsPage(
return ownPages.find((entry) => entry.module.id === moduleId && entry.page.id === pageId) ?? null;
}
-/** Breadcrumb and search subtitles: `Mail › Reading` while several modules group, else the page. */
+/** Where a module page is shown: its own page, or the host page it merges into. */
+export function moduleSettingsHref(moduleId: string, pageId: string): string | null {
+ const entry = MODULE_SETTINGS_PAGES.find(
+ (candidate) => candidate.module.id === moduleId && candidate.page.id === pageId,
+ );
+ if (entry === undefined) return null;
+ return entry.placement === "page" ? entry.path : HOST_SETTINGS_PAGE_PATHS[entry.placement];
+}
+
+/** Breadcrumb and search subtitles: `Mail › Appearance` while several modules group, else the page. */
const ownPageLabel = (entry: PlacedModuleSettingsPage) =>
shellKind === "combined" ? `${entry.module.label} › ${entry.page.label}` : entry.page.label;
+/** Sidebar labels: the module's group header already names it, so only the page. */
+export const OTTERWARE_SETTINGS_NAV_LABELS: Readonly>> =
+ Object.fromEntries(ownPages.map((entry) => [entry.path, entry.page.label]));
+
+/**
+ * The scope sentence ("Applying settings for … across …") picks where Code
+ * settings are written; a module page's rows name their own scope, so it hides
+ * there unless a project or environment is already picked and must stay changeable.
+ */
+export function showsSettingsScopeSentence(
+ pathname: string,
+ search: {
+ readonly project?: string | undefined;
+ readonly machine?: string | undefined;
+ readonly checkout?: string | undefined;
+ },
+): boolean {
+ if (!ownPages.some((entry) => entry.path === pathname)) return true;
+ return Boolean(search.project || search.machine || search.checkout);
+}
+
export const OTTERWARE_SETTINGS_SECTION_LABELS = {
[MODULES_SETTINGS_PATH]: "Modules",
[ACCOUNT_SETTINGS_PATH]: "Account",
@@ -199,6 +226,44 @@ export const OTTERWARE_SETTINGS_SEARCH_ITEMS: ReadonlyArray = new Set([
+ "new-threads",
+ "worktree-submodules",
+ "proactive-panels",
+ "organization",
+ "project-grouping",
+ "project-order",
+ "snooze-limited-threads",
+ "auto-resume-limited-threads",
+ "working-shelf",
+ "auto-settle-inactive-threads",
+ "auto-settle-merged-threads",
+ "days-before-auto-settle",
+ "hide-whitespace-changes",
+ "default-diff-file-state",
+ "diff-layout",
+ "projects-and-threads",
+ "start-from-origin",
+ "add-project-starts-in",
+ "text-generation",
+ "text-generation-model",
+ "legacy-features",
+ "legacy-plan-mode",
+ "legacy-context-window-indicator",
+ "legacy-sidebar",
+]);
+
+/** Whether a host settings row or section shows in this profile. */
+export function isProfileSettingVisible(id: string): boolean {
+ return activeProfile.codeVisible || !CODE_ONLY_GENERAL_SETTINGS.has(id);
+}
+
const CODE_SETTINGS_PATHS = [
"/settings/projects",
"/settings/project-groups",
diff --git a/apps/web/src/routes/settings.$moduleId.$pageId.tsx b/apps/web/src/routes/settings.$moduleId.$pageId.tsx
index 74416e8cb..2c83a7c7a 100644
--- a/apps/web/src/routes/settings.$moduleId.$pageId.tsx
+++ b/apps/web/src/routes/settings.$moduleId.$pageId.tsx
@@ -1,10 +1,14 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { Suspense } from "react";
-import { SettingsPageContainer, SettingsSection } from "../components/settings/settingsLayout";
+import { SettingsPageContainer, SettingsSearchTarget } from "../components/settings/settingsLayout";
import { moduleSettingsPage } from "../otterware/settingsRegistry";
-/** A module's own settings page (`WebModuleSettingsPage` placed as a page), at `/settings//`. */
+/**
+ * A module's own settings page (`WebModuleSettingsPage` placed as a page), at
+ * `/settings//`. The page renders its own sections; the header's
+ * breadcrumb names it, so no title is drawn here.
+ */
function ModuleSettingsRoutePage() {
const { moduleId, pageId } = Route.useParams();
const entry = moduleSettingsPage(moduleId, pageId);
@@ -12,11 +16,11 @@ function ModuleSettingsRoutePage() {
const Page = entry.page.component;
return (
-
+
-
+
);
}
diff --git a/scripts/otterware/upstream-seams.txt b/scripts/otterware/upstream-seams.txt
index bd707ed92..85613aa42 100644
--- a/scripts/otterware/upstream-seams.txt
+++ b/scripts/otterware/upstream-seams.txt
@@ -25,6 +25,9 @@ apps/web/src/components/CommandPalette.tsx
apps/web/src/components/settings/settingsSearch.ts
apps/web/src/components/settings/settingsLayout.tsx
apps/web/src/components/settings/SettingsSidebarNav.tsx
+# General page: `hidden` on the Code-only sections and rows a standalone app has no use for
+apps/web/src/components/settings/SettingsPanels.tsx
+apps/web/src/components/settings/ProjectDefaultsSettings.tsx
apps/web/src/components/settings/KeybindingsSettings.tsx
apps/web/src/components/settings/KeybindingsSettings.logic.ts
apps/web/src/branding*.ts