Settings: a section per plugin, and call the folder Spam

Plugin settings pages were stacked at the bottom of Settings -> Accounts, which
put antispam's thresholds and lists under a heading about mail accounts. Each
`settings.page` now gets its own entry in the Settings nav: "Antispam filtering"
and "Mail filters".

Slot gains an optional `title` for this — the host needs a label when it gives a
slot an area of its own, and falls back to the plugin name. PluginSlot gains an
optional `plugin` filter so a host can render one plugin's bundle rather than
every binding for a slot name.

Wording: the app calls that folder Spam (i18n `nav.spam`), so the plugin's copy
now does too — "filed into Spam", "Move to Spam", "marked as spam". The IMAP
special-use stays `junk`, which is what the protocol calls it; only what the
user reads changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 08:19:11 +02:00
co-authored by Claude Opus 5
parent fe471dbf01
commit 6414504f6e
11 changed files with 71 additions and 21 deletions
+13 -2
View File
@@ -44,8 +44,19 @@ function SlotMount({
return <div ref={ref} data-plugin-slot={slotName} data-plugin={pluginName} />;
}
export function PluginSlot({ name, props }: { name: string; props?: Record<string, unknown> }) {
const bindings = usePluginSlots(name);
export function PluginSlot({
name,
props,
plugin,
}: {
name: string;
props?: Record<string, unknown>;
/** Render only this plugin's bundle. Used where the host gives each plugin its
* own area (Settings sections) instead of stacking them together. */
plugin?: string;
}) {
const all = usePluginSlots(name);
const bindings = plugin ? all.filter((b) => b.plugin === plugin) : all;
if (bindings.length === 0) return null;
return (
<>
+5
View File
@@ -37,3 +37,8 @@ export function usePluginPages(): PageBinding[] {
.flatMap((p) => p.pages.map((pg) => ({ ...pg, plugin: p.name })))
.sort((a, b) => a.nav.order - b.nav.order);
}
/** Plugins contributing a `settings.page`, each shown as its own Settings section. */
export function usePluginSettingsSections(): SlotBinding[] {
return usePluginSlots("settings.page");
}
+1
View File
@@ -3,6 +3,7 @@
export interface SlotDecl {
name: string; // one of the SPEC §11.3 slot names
bundle: string; // URL of the ESM bundle to mount (served by the plugin router)
title?: string; // label when the host gives the slot an area of its own (settings.page)
}
export interface PageNavDecl {
+24 -6
View File
@@ -11,6 +11,7 @@ import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot";
import { usePluginSettingsSections } from "../../plugins/manifest";
import { AccountWizard } from "../wizard/AccountWizard";
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
@@ -29,10 +30,14 @@ export function Settings() {
// The add-account wizard renders inside this layout (route /settings/accounts/new),
// with the Accounts nav item highlighted.
const addingAccount = useLocation().pathname.endsWith("/accounts/new");
// Plugins contributing `settings.page` each get their own nav entry, keyed by
// plugin name, rather than being stacked at the bottom of Accounts.
const pluginSections = usePluginSettingsSections();
const isPluginSection = pluginSections.some((b) => b.plugin === params.section);
const section = (
addingAccount
? "accounts"
: SECTIONS.includes(params.section as Section)
: SECTIONS.includes(params.section as Section) || isPluginSection
? params.section
: "profile"
) as Section;
@@ -58,6 +63,19 @@ export function Settings() {
{t(`settings.${s}` as const)}
</Link>
))}
{pluginSections.map((b) => (
<Link
key={b.plugin}
to={`/settings/${b.plugin}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${
section === (b.plugin as Section)
? "bg-app-active font-medium text-app-fg"
: "hover:bg-app-hover"
}`}
>
{b.title ?? b.plugin}
</Link>
))}
</nav>
<main className="flex-1 overflow-y-auto p-6">
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
@@ -67,11 +85,11 @@ export function Settings() {
{section === "calendars" && <CalendarsSection />}
{section === "appearance" && <AppearanceSection />}
{section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
Matches the accounts section width (max-w-5xl) with a gap above it. */}
{!addingAccount && section === "accounts" && (
<div className="mt-8 max-w-5xl">
<PluginSlot name="settings.page" />
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
per plugin, selected from the nav like any built-in section. */}
{isPluginSection && (
<div className="max-w-5xl">
<PluginSlot name="settings.page" plugin={section} />
</div>
)}
</main>