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";