Settings: one shared page layout with a fixed gap under the header

Every settings tab in Yaak and Yaak Proxy now renders its heading, description,
optional banner and content through SettingsPage, which puts the same 1.5rem gap
between each. The banner slot hides itself when nothing inside it has content,
so Yaak's General tab no longer gets an accidental gap from the empty
commercial-use banner, and the proxy's General tab no longer sits tight under
its description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwba7befxwzptzju committed 2026-11-10 16:06:36 -07:00
1 parent dc09688bc9
commit 17b1623e83
11 files changed
+174 -133

No files matched your search

@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,12 +14,7 @@ import {
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import {
SettingCommand,
SettingSnippet,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
@@ -56,13 +51,10 @@ export function SettingsAgents() {
const cli = cliSetup(client);
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak.
</p>
</div>
<SettingsPage
title="Agents"
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
@@ -134,6 +126,6 @@ export function SettingsAgents() {
/>
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<SettingsPage
title="Client Certificates"
description="Add and manage TLS certificates on a per domain basis"
actions={
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
}
banner={
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
}
>
{certificates.length > 0 ? (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
/>
))}
</VStack>
)}
</VStack>
) : null}
</SettingsPage>
);
}
@@ -1,8 +1,8 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
SettingsPage,
Table,
TableBody,
TableCell,
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
<SettingsPage
title="Keyboard Shortcuts"
description="Click the menu button to add, remove, or reset keyboard shortcuts."
>
<VStack space={3}>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
@@ -26,15 +26,11 @@ export function SettingsProxy() {
};
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsPage
title="Proxy"
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
@@ -160,7 +156,7 @@ export function SettingsProxy() {
</>
)}
</SettingsList>
</VStack>
</SettingsPage>
);
}
+3 -8
View File
@@ -5,7 +5,6 @@ import {
Button,
Dialog,
DialogFooter,
Heading,
Icon,
RadioDropdown,
SettingRow,
@@ -15,8 +14,8 @@ import {
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsPage,
SettingsSection,
VStack,
} from "@yaakapp-internal/ui";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useState } from "react";
@@ -59,11 +58,7 @@ export function SettingsProxy() {
const elsewhere = proxyState?.otherOwner != null;
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">Listening, decryption, traffic and terminals</p>
</div>
<SettingsPage title="Proxy" description="Listening, decryption, traffic and terminals">
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<PortRow disabled={elsewhere} />
@@ -108,7 +103,7 @@ export function SettingsProxy() {
<TerminalRow />
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -1,7 +1,5 @@
import type { ReactNode } from "react";
import { Heading } from "../Heading";
import { IconButton } from "../IconButton";
import { VStack } from "../Stacks";
import {
SettingRow,
SettingRowBoolean,
@@ -11,6 +9,7 @@ import {
SettingsSection,
SettingValue,
} from "./SettingRow";
import { SettingsPage } from "./SettingsPage";
import type { SettingsCallbacks, SettingsView } from "./types";
export interface SettingsGeneralProps {
@@ -25,12 +24,11 @@ export interface SettingsGeneralProps {
export function SettingsGeneral({ view, callbacks, banner, extraSections }: SettingsGeneralProps) {
const { app, values } = view;
return (
<VStack space={1.5} className="mb-4">
<div>
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
{banner != null && <div className="mt-3 mb-5">{banner}</div>}
<SettingsPage
title="General"
description="Configure general settings for update behavior and more."
banner={banner}
>
<SettingsList className="space-y-8">
{view.updates != null && <SettingsUpdatesSection view={view} callbacks={callbacks} />}
@@ -85,7 +83,7 @@ export function SettingsGeneral({ view, callbacks, banner, extraSections }: Sett
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -3,8 +3,6 @@ import { useState } from "react";
import { clamp } from "../../lib/clamp";
import { Button } from "../Button";
import { Checkbox } from "../Checkbox";
import { Heading } from "../Heading";
import { VStack } from "../Stacks";
import {
SettingRow,
SettingRowBoolean,
@@ -13,6 +11,7 @@ import {
SettingsList,
SettingsSection,
} from "./SettingRow";
import { SettingsPage } from "./SettingsPage";
import type {
SettingsCallbacks,
SettingsEditorKeymap,
@@ -52,11 +51,7 @@ export function SettingsInterface({
const shown = (row: SettingsInterfaceRow) => !view.hiddenInterfaceRows?.includes(row);
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsPage title="Interface" description="Tweak settings related to the user interface.">
<SettingsList className="space-y-8">
{leadingSections}
@@ -183,7 +178,7 @@ export function SettingsInterface({
{trailingSections}
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import { Heading } from "../Heading";
export interface SettingsPageProps {
title: ReactNode;
description?: ReactNode;
/** Beside the heading, like the client's Add Certificate button */
actions?: ReactNode;
/** Between the header and the content, like the client's commercial-use banner */
banner?: ReactNode;
children?: ReactNode;
}
/**
* A settings tab: its heading and description, then an optional banner, then its content, each
* the same fixed distance below the last. A banner that renders nothing takes no space, so the
* content sits where it would without one.
*/
export function SettingsPage({ title, description, actions, banner, children }: SettingsPageProps) {
return (
<div className="mb-4">
<div className="flex items-start justify-between gap-3">
<div>
<Heading>{title}</Heading>
{description != null && <p className="text-text-subtle">{description}</p>}
</div>
{actions}
</div>
{banner != null && (
// Hidden when nothing inside has content: a banner that renders null, or only an empty
// wrapper (the client's commercial-use banner once dismissed)
<div className="mt-6 [&:not(:has(:not(:empty)))]:hidden">{banner}</div>
)}
{children != null && <div className="mt-6">{children}</div>}
</div>
);
}
@@ -2,12 +2,12 @@ import { Suspense } from "react";
import type { ButtonProps } from "../Button";
import { useEditorHost } from "../EditorHost";
import { ExternalLink } from "../ExternalLink";
import { Heading } from "../Heading";
import { Icon, type IconProps } from "../Icon";
import { IconButton } from "../IconButton";
import type { SelectProps } from "../Select";
import { HStack, VStack } from "../Stacks";
import { SettingRowSelect, SettingsList, SettingsSection } from "./SettingRow";
import { SettingsPage } from "./SettingsPage";
import type { SettingsCallbacks, SettingsView } from "./types";
const buttonColors: ButtonProps["color"][] = [
@@ -67,10 +67,10 @@ export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
<SettingsPage
title="Theme"
description={
<>
{/* One text node, so the name kerns with the words around it */}
{`Make ${app.name} your own by selecting a theme, or`}{" "}
<ExternalLink
@@ -79,8 +79,9 @@ export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
>
Create Your Own
</ExternalLink>
</p>
</div>
</>
}
>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<SettingRowSelect
@@ -168,6 +169,6 @@ export function SettingsTheme({ view, callbacks }: SettingsThemeProps) {
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -7,6 +7,7 @@ import { SettingsDialog } from "./SettingsDialog";
import { SettingsGeneral } from "./SettingsGeneral";
import { SettingsInterface } from "./SettingsInterface";
import { SettingsLicense } from "./SettingsLicense";
import { SettingsPage } from "./SettingsPage";
import { SettingsTheme } from "./SettingsTheme";
import type { SettingsCallbacks, SettingsLicenseStatus, SettingsView } from "./types";
@@ -145,6 +146,36 @@ describe("SettingsGeneral", () => {
});
});
describe("SettingsPage", () => {
function NoBanner() {
return null;
}
test("puts a banner between the header and the content, each the same gap apart", () => {
const html = render(
<SettingsPage title="General" description="About it" banner={<p>Banner</p>}>
<p>Content</p>
</SettingsPage>,
);
expect(html).toMatch(/<div class="mt-6 [^"]*:hidden"><p>Banner<\/p><\/div>/);
expect(html).toContain('<div class="mt-6"><p>Content</p></div>');
expect(text(html)).toBe("General About it Banner Content");
});
test("hides the banner slot when the banner renders nothing or only an empty wrapper", () => {
for (const banner of [<NoBanner key="null" />, <div key="wrapper" className="w-full" />]) {
const html = render(
<SettingsPage title="General" banner={banner}>
<p>Content</p>
</SettingsPage>,
);
// The slot hides itself when nothing inside it has content
expect(html).toMatch(/<div class="mt-6 \[&amp;:not\(:has\(:not\(:empty\)\)\)\]:hidden">/);
expect(text(html)).toBe("General Content");
}
});
});
describe("SettingsTheme", () => {
test("renders nothing until the themes load", () => {
expect(render(<SettingsTheme view={stubView({ themes: null })} callbacks={callbacks} />)).toBe(
+2
View File
@@ -195,6 +195,8 @@ export type { SettingsInterfaceProps } from "./components/settings/SettingsInter
export { SettingsInterface } from "./components/settings/SettingsInterface";
export type { SettingsLicenseProps } from "./components/settings/SettingsLicense";
export { SettingsLicense } from "./components/settings/SettingsLicense";
export type { SettingsPageProps } from "./components/settings/SettingsPage";
export { SettingsPage } from "./components/settings/SettingsPage";
export type { SettingsThemeProps } from "./components/settings/SettingsTheme";
export { SettingsTheme } from "./components/settings/SettingsTheme";
export * from "./components/settings/types";