From 17b1623e83ed308f31b92d084d8c8a77d71eb2d9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 16:02:11 -0700 Subject: [PATCH] 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 --- .../components/Settings/SettingsAgents.tsx | 22 ++-- .../Settings/SettingsCertificates.tsx | 37 +++--- .../components/Settings/SettingsHotkeys.tsx | 105 +++++++++--------- .../components/Settings/SettingsProxy.tsx | 18 ++- apps/yaak-proxy/components/SettingsProxy.tsx | 11 +- .../components/settings/SettingsGeneral.tsx | 16 ++- .../components/settings/SettingsInterface.tsx | 11 +- .../src/components/settings/SettingsPage.tsx | 37 ++++++ .../src/components/settings/SettingsTheme.tsx | 17 +-- .../src/components/settings/settings.test.tsx | 31 ++++++ packages/ui/src/index.ts | 2 + 11 files changed, 174 insertions(+), 133 deletions(-) create mode 100644 packages/ui/src/components/settings/SettingsPage.tsx diff --git a/apps/yaak-client/components/Settings/SettingsAgents.tsx b/apps/yaak-client/components/Settings/SettingsAgents.tsx index bdc0f629f..2cbfbaa82 100644 --- a/apps/yaak-client/components/Settings/SettingsAgents.tsx +++ b/apps/yaak-client/components/Settings/SettingsAgents.tsx @@ -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 ( - -
- Agents -

- Connect AI coding agents like Claude Code and Cursor to Yaak. -

-
+
@@ -134,6 +126,6 @@ export function SettingsAgents() { /> - + ); } diff --git a/apps/yaak-client/components/Settings/SettingsCertificates.tsx b/apps/yaak-client/components/Settings/SettingsCertificates.tsx index b076918c3..6ffcc5b2a 100644 --- a/apps/yaak-client/components/Settings/SettingsCertificates.tsx +++ b/apps/yaak-client/components/Settings/SettingsCertificates.tsx @@ -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 ( - -
- -
- Client Certificates -

- Add and manage TLS certificates on a per domain basis -

-
- -
-
- - - - {certificates.length > 0 && ( + + Add Certificate + + } + banner={ + + } + > + {certificates.length > 0 ? ( {certificates.map((cert, index) => ( ))} - )} -
+ ) : null} + ); } diff --git a/apps/yaak-client/components/Settings/SettingsHotkeys.tsx b/apps/yaak-client/components/Settings/SettingsHotkeys.tsx index 14709ec2e..2da79d904 100644 --- a/apps/yaak-client/components/Settings/SettingsHotkeys.tsx +++ b/apps/yaak-client/components/Settings/SettingsHotkeys.tsx @@ -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 ( - -
- Keyboard Shortcuts -

- Click the menu button to add, remove, or reset keyboard shortcuts. -

-
- - - - - Scope - Action - Shortcut - - - - {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */} - - {filteredActions.map((action) => ( - { - const newHotkeys = { ...settings.hotkeys }; - if (arraysEqual(keys, defaultHotkeys[action])) { - // Remove from settings if it matches default (use default) + + + +
+ + + Scope + Action + Shortcut + + + + {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */} + + {filteredActions.map((action) => ( + { + 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 }); - }} - /> - ))} - -
-
+ await patchModel(settings, { hotkeys: newHotkeys }); + }} + /> + ))} + + + + ); } diff --git a/apps/yaak-client/components/Settings/SettingsProxy.tsx b/apps/yaak-client/components/Settings/SettingsProxy.tsx index 98127b711..4affdead7 100644 --- a/apps/yaak-client/components/Settings/SettingsProxy.tsx +++ b/apps/yaak-client/components/Settings/SettingsProxy.tsx @@ -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 ( - -
- Proxy -

- Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging - traffic, or routing through specific infrastructure. -

-
- + } + > )} -
+ ); } diff --git a/apps/yaak-proxy/components/SettingsProxy.tsx b/apps/yaak-proxy/components/SettingsProxy.tsx index 94359b1eb..41ee662c4 100644 --- a/apps/yaak-proxy/components/SettingsProxy.tsx +++ b/apps/yaak-proxy/components/SettingsProxy.tsx @@ -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 ( - -
- Proxy -

Listening, decryption, traffic and terminals

-
+ @@ -108,7 +103,7 @@ export function SettingsProxy() { -
+ ); } diff --git a/packages/ui/src/components/settings/SettingsGeneral.tsx b/packages/ui/src/components/settings/SettingsGeneral.tsx index f8d830815..ed69ddf31 100644 --- a/packages/ui/src/components/settings/SettingsGeneral.tsx +++ b/packages/ui/src/components/settings/SettingsGeneral.tsx @@ -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 ( - -
- General -

Configure general settings for update behavior and more.

-
- {banner != null &&
{banner}
} + {view.updates != null && } @@ -85,7 +83,7 @@ export function SettingsGeneral({ view, callbacks, banner, extraSections }: Sett -
+ ); } diff --git a/packages/ui/src/components/settings/SettingsInterface.tsx b/packages/ui/src/components/settings/SettingsInterface.tsx index 0048f1a55..db135a45b 100644 --- a/packages/ui/src/components/settings/SettingsInterface.tsx +++ b/packages/ui/src/components/settings/SettingsInterface.tsx @@ -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 ( - -
- Interface -

Tweak settings related to the user interface.

-
+ {leadingSections} @@ -183,7 +178,7 @@ export function SettingsInterface({ {trailingSections} -
+ ); } diff --git a/packages/ui/src/components/settings/SettingsPage.tsx b/packages/ui/src/components/settings/SettingsPage.tsx new file mode 100644 index 000000000..624d9332c --- /dev/null +++ b/packages/ui/src/components/settings/SettingsPage.tsx @@ -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 ( +
+
+
+ {title} + {description != null &&

{description}

} +
+ {actions} +
+ {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) +
{banner}
+ )} + {children != null &&
{children}
} +
+ ); +} diff --git a/packages/ui/src/components/settings/SettingsTheme.tsx b/packages/ui/src/components/settings/SettingsTheme.tsx index 6abb22d1c..44e3f7bf9 100644 --- a/packages/ui/src/components/settings/SettingsTheme.tsx +++ b/packages/ui/src/components/settings/SettingsTheme.tsx @@ -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 ( - -
- Theme -

+ {/* One text node, so the name kerns with the words around it */} {`Make ${app.name} your own by selecting a theme, or`}{" "} Create Your Own -

-
+ + } + > -
+ ); } diff --git a/packages/ui/src/components/settings/settings.test.tsx b/packages/ui/src/components/settings/settings.test.tsx index 497baa6da..44b55088f 100644 --- a/packages/ui/src/components/settings/settings.test.tsx +++ b/packages/ui/src/components/settings/settings.test.tsx @@ -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( + Banner

}> +

Content

+
, + ); + expect(html).toMatch(/

Banner<\/p><\/div>/); + expect(html).toContain('

Content

'); + 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 [,
]) { + const html = render( + +

Content

+
, + ); + // The slot hides itself when nothing inside it has content + expect(html).toMatch(/
/); + expect(text(html)).toBe("General Content"); + } + }); +}); + describe("SettingsTheme", () => { test("renders nothing until the themes load", () => { expect(render()).toBe( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 466d5f7d9..5eff1cfab 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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";