From 6dc9a565b0b73ade82196cb1ed5fe1fb753e0917 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 10:39:41 +0200 Subject: [PATCH] feat: add assistant board styling tools --- apps/docs/docs/management/assistant.mdx | 12 + apps/docs/docs/management/boards/index.mdx | 4 + apps/docs/docs/management/mcp.mdx | 119 ++++-- .../chat/assistant-tool-policy.spec.ts | 30 ++ .../assistant/chat/assistant-tool-policy.ts | 20 +- .../src/app/api/assistant/chat/route.ts | 1 + .../assistant-board-settings-tool.tsx | 401 ++++++++++++++++++ .../assistant-board-settings.spec.ts | 29 ++ .../assistant/assistant-board-settings.ts | 33 ++ .../assistant/assistant-panel.module.css | 50 ++- .../components/assistant/assistant-panel.tsx | 20 +- .../assistant-pending-action.spec.ts | 14 + .../assistant/assistant-pending-action.ts | 6 +- .../assistant/assistant-provider.tsx | 7 + .../assistant-tool-contracts.spec.ts | 30 ++ .../assistant/assistant-tool-contracts.ts | 49 +++ packages/api/src/router/board.ts | 305 ++++++++----- packages/api/src/router/test/board.spec.ts | 22 + packages/translation/src/lang/en.json | 39 +- packages/validation/src/board.ts | 11 +- 20 files changed, 1045 insertions(+), 157 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-board-settings.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-board-settings.ts diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index f82402ae7..8d133f5fa 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -133,6 +133,18 @@ App creation uses Homarr's native app form inside the conversation. The form reu including automatic matching and searches through the `icon.findIcons` procedure, before the final `app.create` approval is requested. The model is instructed not to invent external icon CDN URLs. +Board appearance changes use a similar review flow. The assistant first reads the board's current settings, +including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance, +Background, and General tabs. The form highlights how many fields were proposed, preserves settings the +assistant did not change, and returns only values that differ from the current board. Custom CSS can be +copied or restored before continuing, and Homarr warns when it contains an imported stylesheet or remote +resource URL. Continuing from the form opens the normal mutation approval before anything is saved. + +Ask for changes such as “make the cards denser on my Home board,” “add a mobile CSS rule without removing +my existing styles,” or “change the board colors and disable app status checks.” The current user must have +modify permission on the selected board. The empty conversation includes an **Improve a dashboard** starter, +and `/style` prepares the same guided workflow from the composer. + Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to safe internal routes and open Homarr interfaces such as the command menu and media request search. They never receive provider credentials and cannot navigate to arbitrary external URLs. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index ef408e221..aaf0f36cd 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -247,6 +247,10 @@ Change the radius of your items and Containers. This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. +When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom +CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an +editable review form before requesting approval to save the change. + [See this guide on how to work with custom CSS](/docs/advanced/styling/). ### Behaviour diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index b4c52f040..9cbbf5848 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -8,7 +8,12 @@ tags: # Model Context Protocol (MCP) -Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language. +Homarr exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`, enabling AI assistants like Claude, Cursor, VS Code Copilot, and Claude Code to interact with your Homarr instance using natural language. + +The endpoint implements the stateless MCP 2026-07-28 protocol and accepts 2025 Streamable HTTP clients through a +stateless compatibility path. It does not create protocol sessions or require sticky sessions. Homarr creates the +server and extracts its tools only after an authenticated request reaches the route. The previous `/api/mcp/mcp` URL +remains available as a compatibility alias, but new clients should always use `/api/mcp`. With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools. @@ -46,7 +51,7 @@ Add the following to your client's MCP configuration file: { "mcpServers": { "homarr": { - "url": "https://your-homarr-instance.com/api/mcp/mcp", + "url": "https://your-homarr-instance.com/api/mcp", "headers": { "ApiKey": "" } @@ -77,7 +82,7 @@ Replace `` with your actual API key. Never commit API keys to vers "mcpServers": { "homarr": { "command": "npx", - "args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:"] + "args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp", "--header", "ApiKey:"] } } } @@ -100,6 +105,10 @@ Custom Widget and Workshop authoring are different: their tools, prompts, and re key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an API key never elevates the owner. +OAuth authorization and token requests bind issued credentials to the canonical `/api/mcp` resource. Dynamic client +registration remains available for compatibility with current MCP clients while the ecosystem transitions toward +pre-registered clients and Client ID Metadata Documents. + ## Testing the Connection Verify your MCP endpoint is working: @@ -107,13 +116,17 @@ Verify your MCP endpoint is working: ```bash curl -s -X POST \ -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ + -H "Accept: application/json" \ -H "ApiKey: " \ - -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \ - https://your-homarr-instance.com/api/mcp/mcp + -H "Mcp-Method: server/discover" \ + -H "Mcp-Name: homarr" \ + -d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' \ + https://your-homarr-instance.com/api/mcp ``` -A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools: +A successful response includes the supported protocol versions and server capabilities. Use `tools/list` to see all +available tools. MCP v2 clients add the per-request metadata and `Mcp-Method` / `Mcp-Name` headers automatically; the +abbreviated request below uses the stateless 2025 compatibility path for readability: ```bash curl -s -X POST \ @@ -121,33 +134,36 @@ curl -s -X POST \ -H "Accept: application/json, text/event-stream" \ -H "ApiKey: " \ -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ - https://your-homarr-instance.com/api/mcp/mcp + https://your-homarr-instance.com/api/mcp ``` +Tool catalogs are returned in deterministic order and may be cached privately by the client for five minutes. The +removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`; use the single Streamable HTTP endpoint instead. + ## Available Tools Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action). -| Namespace | Example Tools | Type | -| ------------------ | --------------------------------------------------------- | ---------- | -| `app` | List all apps, get app by ID, create/update/delete app | GET + POST | -| `apiKeys` | List API keys | GET | -| `beszel` | Systems status, alerts, historical system metrics | GET | -| `board` | List boards, get board by ID, create/update/delete board | GET + POST | -| `docker` | List containers, inspect container, start/stop/restart | GET + POST | -| `icon` | Search icons | GET | -| `info` | Server health, version info | GET | -| `integration` | List integrations, test connection, delete integration | GET + POST | -| `invite` | List invitations, delete invite | GET + POST | -| `calendar` | Calendar events from media servers | GET | -| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST | -| `dnsHole` | DNS hole summary, toggle blocking | GET + POST | -| `downloads` | Active downloads, pause/resume/delete | GET + POST | -| `healthMonitoring` | Resource usage stats | GET | -| `patchmon` | Host patch and security update statistics | GET | -| `mediaRequests` | Approve/decline media requests | GET + POST | -| `mediaServer` | Media server sessions and stats | GET | -| `smartHome` | Execute automations, device states | GET + POST | +| Namespace | Example Tools | Type | +| ------------------ | ---------------------------------------------------------- | ---------- | +| `app` | List all apps, get app by ID, create/update/delete app | GET + POST | +| `apiKeys` | List API keys | GET | +| `beszel` | Systems status, alerts, historical system metrics | GET | +| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST | +| `docker` | List containers, inspect container, start/stop/restart | GET + POST | +| `icon` | Search icons | GET | +| `info` | Server health, version info | GET | +| `integration` | List integrations, test connection, delete integration | GET + POST | +| `invite` | List invitations, delete invite | GET + POST | +| `calendar` | Calendar events from media servers | GET | +| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST | +| `dnsHole` | DNS hole summary, toggle blocking | GET + POST | +| `downloads` | Active downloads, pause/resume/delete | GET + POST | +| `healthMonitoring` | Resource usage stats | GET | +| `patchmon` | Host patch and security update statistics | GET | +| `mediaRequests` | Approve/decline media requests | GET + POST | +| `mediaServer` | Media server sessions and stats | GET | +| `smartHome` | Execute automations, device states | GET + POST | The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance. @@ -179,21 +195,36 @@ An agent creates one `homarr-custom-widget-v2` definition at a time. Start with The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview, secrets, publication, Workshop installation, and the HTTP authoring resources. -Then, read the live schema and component resources. These resources describe the installed Mantine components. +Load the installed skill before authoring. `customWidget_getSkill` returns the complete `SKILL.md` entrypoint and every +bundled reference in one response, so an MCP client does not need filesystem access or a second documentation fetch. +Then read the live schema and only the component resources required by the design. These resources describe the +installed Mantine components. -1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`. -2. Build one Custom Widget definition. -3. Call `customWidget_validate` without saving the definition. -4. Call `customWidget_previewCreate`. -5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`. -6. Open the returned preview URL. -7. Examine `customWidget_previewJournal`. -8. Revise the local candidate and create another preview session until it is ready. -9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided. -10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion. -11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again. -12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections. -13. Repeat this procedure for the next Custom Widget. +1. Call `customWidget_getSkill`, `customWidget_getAuthoringPrompt`, `customWidget_schema`, and + `customWidget_getComponentCatalog`. +2. Fetch only the relevant installed component documentation with `customWidget_getComponent` and, when useful, + one interaction example with `customWidget_getExample`. Component results contain component-specific props; collect + any needed shared prop names from the compact catalog and fetch them together once with `customWidget_getSharedProps`. +3. Build one Custom Widget definition. Use `templateLines` for generated multiline JSX to avoid a fragile JSON-escaped + template string. +4. Call `customWidget_validate` without saving the definition and repair every returned issue path. +5. Call `customWidget_previewCreate`. +6. Call `customWidget_previewQuery` for every entry in the returned `queries` list. Supply the listed invocation + parameters for manual queries and inspect the real HTTP status and response shape before binding template fields. +7. Test relevant actions with `customWidget_previewAction` in simulation mode. +8. Open the returned `previewPath` or `previewUrl`. +9. Examine `customWidget_previewJournal`. +10. Revise the local candidate and create another preview session until validation, query data, actions, and rendering + are ready. +11. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided. +12. Open the returned `managementPath` to review the exact saved definition. +13. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion. +14. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again. +15. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections. +16. Repeat this procedure for the next Custom Widget. + +Validation and preview creation return compact lifecycle metadata. They do not echo the submitted widget or duplicate +its template as both a string and line array; the agent should retain and reuse its local candidate. The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist. @@ -229,6 +260,10 @@ The Custom Widget tools are: - `customWidget_workshopGet` - `customWidget_workshopInstall` - `customWidget_getAuthoringPrompt` +- `customWidget_getComponentCatalog` +- `customWidget_getComponent` +- `customWidget_getSharedProps` +- `customWidget_getExample` - `customWidget_getSkill` Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts index 0fb365986..c00845b72 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.spec.ts @@ -57,6 +57,36 @@ describe("getForcedAssistantToolName", () => { ).toBeUndefined(); }); + test("continues directly from reviewed board settings to the settings mutation", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_board_settings", + toolCallId: "settings-1", + input: { boardId: "board-1", boardName: "Home", changes: { customCss: ".item {}" } }, + state: "output-available", + output: { id: "board-1", customCss: ".item {}" }, + }), + ]), + ).toBe("board_savePartialBoardSettings"); + }); + + test("does not request a mutation when board settings were left unchanged", () => { + expect( + getForcedAssistantToolName([ + assistantMessage({ + type: "dynamic-tool", + toolName: "configure_board_settings", + toolCallId: "settings-1", + input: { boardId: "board-1", boardName: "Home", changes: {} }, + state: "output-available", + output: { id: "board-1", cancelled: true }, + }), + ]), + ).toBeUndefined(); + }); + test("does not repeat app creation after the mutation tool has been called", () => { expect( getForcedAssistantToolName([ diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts index 56975c90b..476869cce 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-policy.ts @@ -14,13 +14,23 @@ export const getForcedAssistantToolName = (messages: UIMessage[]) => { if (latestMessage?.role !== "assistant") return undefined; const latestToolPart = latestMessage.parts.toReversed().find((part) => isToolUIPart(part)); + if (latestToolPart === undefined || latestToolPart.state !== "output-available") return undefined; + + const nextToolByHumanTool = { + configure_app: "app_create", + configure_board_settings: "board_savePartialBoardSettings", + } as const; + const toolName = getToolName(latestToolPart); if ( - latestToolPart === undefined || - getToolName(latestToolPart) !== "configure_app" || - latestToolPart.state !== "output-available" + toolName === "configure_board_settings" && + typeof latestToolPart.output === "object" && + latestToolPart.output !== null && + "cancelled" in latestToolPart.output && + latestToolPart.output.cancelled === true ) { return undefined; } - - return "app_create" as const; + return toolName in nextToolByHumanTool + ? nextToolByHumanTool[toolName as keyof typeof nextToolByHumanTool] + : undefined; }; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index e7dd274e0..93a1da594 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -94,6 +94,7 @@ Action rules: - A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call. - Do not retry a denied action. - Before creating an app, call configure_app with the best defaults so the user can review Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. +- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement. - When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL. - Browser tools can navigate within Homarr or open existing Homarr UI. Never navigate to an arbitrary external URL. - Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON. diff --git a/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx new file mode 100644 index 000000000..ca3c25012 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-board-settings-tool.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { + Alert, + Badge, + Box, + Button, + ColorInput, + CopyButton, + Group, + InputWrapper, + Select, + SimpleGrid, + Skeleton, + Slider, + Stack, + Switch, + Tabs, + Text, + Textarea, + TextInput, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconCheck, + IconCopy, + IconFileTypeCss, + IconPalette, + IconPhoto, + IconRefresh, + IconSettings, +} from "@tabler/icons-react"; +import type { z } from "zod/v4"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions"; +import { useZodForm } from "@homarr/form"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { boardSavePartialSettingsSchema } from "@homarr/validation/board"; + +import { getChangedBoardSettings, getCustomCssWarnings } from "./assistant-board-settings"; +import classes from "./assistant-panel.module.css"; +import type { + AssistantBoardSettingsChanges, + ConfigureBoardSettingsArgs, + ConfigureBoardSettingsResult, +} from "./assistant-tool-contracts"; + +type BoardSettings = RouterOutputs["board"]["getBoardSettings"]; +const completeSettingsSchema = boardSavePartialSettingsSchema.required(); +type BoardSettingsFormValues = z.infer; + +const normalizeFormValues = ( + current: BoardSettings, + proposed: AssistantBoardSettingsChanges, +): BoardSettingsFormValues => { + const values = { ...current, ...proposed }; + return { + pageTitle: values.pageTitle ?? "", + metaTitle: values.metaTitle ?? "", + logoImageUrl: values.logoImageUrl ?? "", + faviconImageUrl: values.faviconImageUrl ?? "", + backgroundImageUrl: values.backgroundImageUrl ?? "", + backgroundImageAttachment: values.backgroundImageAttachment, + backgroundImageRepeat: values.backgroundImageRepeat, + backgroundImageSize: values.backgroundImageSize, + primaryColor: values.primaryColor, + secondaryColor: values.secondaryColor, + opacity: values.opacity, + customCss: values.customCss, + iconColor: values.iconColor ?? "", + itemRadius: values.itemRadius, + disableStatus: values.disableStatus, + }; +}; + +const toCurrentValues = (current: BoardSettings): BoardSettingsFormValues => normalizeFormValues(current, {}); + +export const AssistantConfigureBoardSettingsTool = ({ + args, + result, + addResult, +}: ToolCallMessagePartProps) => { + const t = useScopedI18n("common.assistant.configureBoardSettings"); + const boardId = args?.boardId ?? ""; + const settings = clientApi.board.getBoardSettings.useQuery( + { id: boardId }, + { enabled: result === undefined && boardId.length > 0, retry: false }, + ); + + if (result) { + const cancelled = "cancelled" in result && result.cancelled; + const changedCount = cancelled ? 0 : Math.max(0, Object.keys(result).length - 1); + return ( + + + + + + + {cancelled ? t("cancelled") : t("ready")} + + + {cancelled ? args?.boardName : t("changeCount", { count: changedCount })} + + + + ); + } + + if (!args?.boardId || !args.boardName) { + return ; + } + + if (settings.isError) { + return ( + }> + + {t("loadErrorDescription")} + + + + ); + } + + if (!settings.data) return ; + + return ( + addResult({ id: settings.data.id, ...changes })} + onCancel={() => addResult({ id: settings.data.id, cancelled: true })} + /> + ); +}; + +const BoardSettingsSkeleton = ({ label }: { label: string }) => ( + + + + + + + + +); + +const BoardSettingsForm = ({ + current, + args, + onSubmit, + onCancel, +}: { + current: BoardSettings; + args: ConfigureBoardSettingsArgs; + onSubmit: (changes: AssistantBoardSettingsChanges) => void; + onCancel: () => void; +}) => { + const t = useScopedI18n("common.assistant.configureBoardSettings"); + const boardT = useI18n(); + const [submitting, setSubmitting] = useState(false); + const currentValues = useMemo(() => toCurrentValues(current), [current]); + const form = useZodForm(completeSettingsSchema, { + initialValues: normalizeFormValues(current, args.changes), + }); + const changes = getChangedBoardSettings(currentValues, form.values); + const changedCount = Object.keys(changes).length; + const cssWarnings = getCustomCssWarnings(form.values.customCss); + const proposedKeys = Object.keys(args.changes); + const defaultTab = + args.changes.customCss !== undefined ? "css" : proposedKeys.some(isAppearanceKey) ? "appearance" : "general"; + const optionData = ( + values: readonly TValue[], + key: "backgroundImageAttachment" | "backgroundImageRepeat" | "backgroundImageSize", + ) => + values.map((value) => ({ + value, + label: boardT(`board.field.${key}.option.${value}.label` as never), + })); + + return ( + + + + + + + + {t("title")} + + {args.boardName} + + + + {args.summary} + + {proposedKeys.length > 0 && ( + + {t("proposedCount", { count: proposedKeys.length })} + + )} + + + +
{ + if (submitting) return; + const nextChanges = getChangedBoardSettings(currentValues, values); + setSubmitting(true); + if (Object.keys(nextChanges).length === 0) { + onCancel(); + return; + } + onSubmit(nextChanges); + })} + > + + + }> + {t("tabs.css")} + + }> + {t("tabs.appearance")} + + }> + {t("tabs.background")} + + }> + {t("tabs.general")} + + + + + +