From e463366cfbcb77b6ac956a4b329a796f53d07f5e Mon Sep 17 00:00:00 2001
From: Benjamin Franzke <ben@bnf.dev>
Date: Fri, 5 Jul 2024 11:41:01 +0200
Subject: [PATCH] [FEATURE] Introduce Site Settings Editor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A new Site Settings editor has been introduced that allows to configure
per-site settings in `config/sites/*/settings.yaml`.

The new backend module `Site Management > Settings` provides an overview
of sites which offer configurable settings and makes them editable based
on Site Set provided Settings Definitions.

The editor shows a list of settings categories and respective settings.
It will persist all settings into `config/sites/*/settings.yaml`.
The module will only persist settings that deviate from the site-scoped
default value. That means it will only change the minimal difference to
the settings set defined by the active sets for the respective site.

The backend module is currently available for administrators only, but
will likely be extended to be made available for editors in future.

Anonymous (undefined) site settings – as supported since TYPO3 v10 –
will not be made editable, but will be preserved as-is when persisting
changes through the settings editor.

Resolves: #104794
Releases: main
Change-Id: I7eea8445b86b50efa498238daa3436f0940f4b78
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/84342
Tested-by: Benjamin Kott <benjamin.kott@outlook.com>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Benjamin Franzke <ben@bnf.dev>
Reviewed-by: Benjamin Franzke <ben@bnf.dev>
Reviewed-by: Benjamin Kott <benjamin.kott@outlook.com>
---
 Build/Scripts/checkIntegritySetLabels.php     |   6 +-
 Build/Sources/Sass/backend.scss               |   1 +
 Build/Sources/Sass/component/_settings.scss   | 264 ++++++++++++++
 .../TypeScript/backend/copy-to-clipboard.ts   |  57 +--
 .../TypeScript/backend/settings/editor.ts     | 210 +++++++++++
 .../settings/editor/editable-setting.ts       | 197 +++++++++++
 .../TypeScript/backend/settings/type/base.ts  | 194 +++++++++++
 .../TypeScript/backend/settings/type/bool.ts  |  55 +++
 .../TypeScript/backend/settings/type/color.ts |  63 ++++
 .../TypeScript/backend/settings/type/int.ts   |  42 +++
 .../backend/settings/type/number.ts           |  43 +++
 .../backend/settings/type/string.ts           |  42 +++
 .../backend/settings/type/stringlist.ts       |  86 +++++
 .../playwright/accessibility/modules.spec.ts  |   4 +
 .../SiteConfigurationController.php           |  32 +-
 .../Controller/SiteSettingsController.php     | 326 ++++++++++++++++++
 .../Classes/Dto/Settings/EditableSetting.php  |  38 ++
 .../backend/Configuration/Backend/Modules.php |  26 ++
 .../Language/locallang_siteconfiguration.xlf  |   9 +
 .../Language/locallang_sitesettings.xlf       |  53 +++
 .../locallang_sitesettings_module.xlf         |  17 +
 .../Templates/SiteConfiguration/Overview.html |  25 ++
 .../Private/Templates/SiteSettings/Edit.html  |  35 ++
 .../Templates/SiteSettings/Overview.html      |  54 +++
 .../backend/Resources/Public/Css/backend.css  |  58 ++++
 .../Public/JavaScript/copy-to-clipboard.js    |   2 +-
 .../Public/JavaScript/settings/editor.js      |  62 ++++
 .../settings/editor/editable-setting.js       |  69 ++++
 .../Public/JavaScript/settings/type/base.js   |  13 +
 .../Public/JavaScript/settings/type/bool.js   |  24 ++
 .../Public/JavaScript/settings/type/color.js  |  21 ++
 .../Public/JavaScript/settings/type/int.js    |  21 ++
 .../Public/JavaScript/settings/type/number.js |  22 ++
 .../Public/JavaScript/settings/type/string.js |  21 ++
 .../JavaScript/settings/type/stringlist.js    |  45 +++
 .../core/Classes/Configuration/SiteWriter.php |  10 +-
 .../sysext/core/Classes/Settings/Category.php |  43 +++
 .../Classes/Settings/CategoryAccumulator.php  |  93 +++++
 .../Classes/Settings/CategoryDefinition.php   |  42 +++
 .../Classes/Settings/SettingDefinition.php    |   2 +-
 .../Settings/SettingsTypeInterface.php        |   2 +
 .../core/Classes/Settings/Type/BoolType.php   |   5 +
 .../core/Classes/Settings/Type/ColorType.php  |   5 +
 .../core/Classes/Settings/Type/IntType.php    |   5 +
 .../core/Classes/Settings/Type/NumberType.php |   5 +
 .../Classes/Settings/Type/StringListType.php  |  14 +
 .../core/Classes/Settings/Type/StringType.php |   5 +
 .../sysext/core/Classes/Site/Entity/Site.php  |  12 +
 .../Classes/Site/Set/CategoryRegistry.php     |  59 ++++
 .../core/Classes/Site/Set/SetDefinition.php   |   3 +
 .../Site/Set/YamlSetDefinitionProvider.php    |  20 +-
 .../core/Classes/Site/SiteSettingsFactory.php |  25 ++
 .../core/Classes/Site/SiteSettingsService.php | 180 ++++++++++
 ...ure-104794-IntroduceSiteSettingsEditor.rst |  81 +++++
 .../Configuration/Sets/Felogin/labels.xlf     |   5 +
 .../Sets/Felogin/settings.definitions.yaml    |  28 ++
 .../Sets/FluidStyledContent/labels.xlf        |  11 +
 .../settings.definitions.yaml                 |  31 ++
 .../Sets/IndexedSearch/labels.xlf             |   7 +
 .../IndexedSearch/settings.definitions.yaml   |  10 +
 .../Configuration/Backend/Modules.php         |   2 +-
 .../Configuration/Sets/redirects/config.yaml  |   1 +
 .../Configuration/Sets/redirects/labels.xlf   |  39 +++
 .../Sets/redirects/settings.definitions.yaml  |  20 ++
 .../seo/Configuration/Sets/Sitemap/labels.xlf |   7 +
 .../Sets/Sitemap/settings.definitions.yaml    |  11 +
 66 files changed, 2984 insertions(+), 36 deletions(-)
 create mode 100644 Build/Sources/Sass/component/_settings.scss
 create mode 100644 Build/Sources/TypeScript/backend/settings/editor.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/base.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/bool.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/color.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/int.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/number.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/string.ts
 create mode 100644 Build/Sources/TypeScript/backend/settings/type/stringlist.ts
 create mode 100644 typo3/sysext/backend/Classes/Controller/SiteSettingsController.php
 create mode 100644 typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php
 create mode 100644 typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf
 create mode 100644 typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf
 create mode 100644 typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html
 create mode 100644 typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js
 create mode 100644 typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js
 create mode 100644 typo3/sysext/core/Classes/Settings/Category.php
 create mode 100644 typo3/sysext/core/Classes/Settings/CategoryAccumulator.php
 create mode 100644 typo3/sysext/core/Classes/Settings/CategoryDefinition.php
 create mode 100644 typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php
 create mode 100644 typo3/sysext/core/Classes/Site/SiteSettingsService.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst
 create mode 100644 typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml
 create mode 100644 typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf
 create mode 100644 typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml

diff --git a/Build/Scripts/checkIntegritySetLabels.php b/Build/Scripts/checkIntegritySetLabels.php
index b001664560f7..2c7d23a8a2d6 100755
--- a/Build/Scripts/checkIntegritySetLabels.php
+++ b/Build/Scripts/checkIntegritySetLabels.php
@@ -93,10 +93,14 @@ final readonly class CheckIntegritySetLabels
         ];
 
         $settingsDefinitions = Yaml::parseFile(dirname($labelFile) . '/settings.definitions.yaml');
-        foreach ($settingsDefinitions['settings'] as $key => $settingsDefinition) {
+        foreach (($settingsDefinitions['settings'] ?? []) as $key => $_) {
             $requiredLabels[] = 'settings.' . $key;
             $optionalLabels[] = 'settings.description.' . $key;
         }
+        foreach (($settingsDefinitions['categories'] ?? []) as $key => $_) {
+            $requiredLabels[] = 'categories.' . $key;
+            $optionalLabels[] = 'categories.description.' . $key;
+        }
 
         $setName = Yaml::parseFile(dirname($labelFile) . '/config.yaml')['name'];
 
diff --git a/Build/Sources/Sass/backend.scss b/Build/Sources/Sass/backend.scss
index 1fc8b62a7e1d..5d3bf18ad7d4 100644
--- a/Build/Sources/Sass/backend.scss
+++ b/Build/Sources/Sass/backend.scss
@@ -48,6 +48,7 @@
 @import "component/treelist";
 @import "component/indent";
 @import "component/pagination";
+@import "component/settings";
 @import "component/example";
 
 //
diff --git a/Build/Sources/Sass/component/_settings.scss b/Build/Sources/Sass/component/_settings.scss
new file mode 100644
index 000000000000..21b40957f8f3
--- /dev/null
+++ b/Build/Sources/Sass/component/_settings.scss
@@ -0,0 +1,264 @@
+:root {
+    --settings-color: var(--typo3-component-color);
+    --settings-padding: calc(var(--typo3-spacing) * 2);
+    --settings-bg: var(--typo3-component-bg);
+    --settings-border-width: var(--typo3-component-border-width);
+    --settings-border-color: var(--typo3-component-border-color);
+    --settings-border-radius: var(--typo3-component-border-radius);
+    --settings-box-shadow: var(--typo3-component-box-shadow);
+    --settings-highlight: var(--typo3-component-primary-color);
+    --settings-indicator-bg: transparent;
+    --settings-item-color: var(--settings-color);
+    --settings-item-bg: var(--settings-bg);
+}
+
+.module[data-module-name="site_settings"] {
+    .module-body {
+        width: 100%;
+        max-width: 1320px;
+        margin: 0 auto;
+    }
+}
+
+.settings-container {
+    container-type: inline-size;
+}
+
+.settings {
+    display: flex;
+    align-items: flex-start;
+    flex-wrap: wrap;
+    color: var(--settings-color);
+    box-shadow: var(--settings-box-shadow);
+
+    &-navigation,
+    &-body {
+        padding: var(--settings-padding);
+    }
+
+    &-body {
+        background: var(--settings-bg);
+        border: var(--settings-border-width) solid var(--settings-border-color);
+        border-radius: var(--settings-border-radius);
+    }
+
+    &-navigation {
+        border-inline-start: var(--settings-border-width) solid var(--settings-border-color);
+        border-inline-end: var(--settings-border-width) solid var(--settings-border-color);
+        border-top: var(--settings-border-width) solid var(--settings-border-color);
+        border-bottom: 0;
+        flex: 1 1 auto;
+    }
+}
+
+/* clean-css ignore:start */
+@container (min-width: 780px) {
+    .settings {
+        flex-wrap: nowrap;
+        box-shadow: none;
+
+        &-body {
+            flex: 1 1 auto;
+            border-inline-start: var(--settings-border-width) solid var(--settings-border-color);
+            border-start-start-radius: 0;
+            box-shadow: var(--settings-box-shadow);
+        }
+
+        &-navigation {
+            box-shadow: var(--settings-box-shadow);
+            flex: 0 0 300px;
+            position: sticky;
+            top: calc(var(--module-body-padding-y) + var(--module-docheader-height));
+            border-inline-end: 0;
+            border-inline-start: var(--settings-border-width) solid var(--settings-border-color);
+            border-top: var(--settings-border-width) solid var(--settings-border-color);
+            border-bottom: var(--settings-border-width) solid var(--settings-border-color);
+        }
+    }
+}
+
+/* clean-css ignore:end */
+
+.settings-navigation {
+    ul {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+
+        ul {
+            padding-inline-start: 1rem;
+        }
+    }
+}
+
+.settings-navigation-item {
+    color: var(--typo3-component-color);
+    position: relative;
+    display: flex;
+    border-radius: calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));
+    gap: .5em;
+    padding: var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);
+    cursor: pointer;
+    text-decoration: none;
+
+    &.active,
+    &:hover,
+    &:focus {
+        z-index: 1;
+        outline-offset: -1px;
+    }
+
+    &:hover {
+        color: var(--typo3-component-hover-color);
+        background-color: var(--typo3-component-hover-bg);
+        outline: 1px solid var(--typo3-component-hover-border-color);
+    }
+
+    &.active,
+    &:focus {
+        color: var(--typo3-component-focus-color);
+        background-color: var(--typo3-component-focus-bg);
+        outline: 1px solid var(--typo3-component-focus-border-color);
+    }
+
+    &-icon {
+        user-select: none;
+        flex-shrink: 0;
+        flex-grow: 0;
+        width: var(--icon-size-small);
+    }
+
+    &-label {
+        user-select: none;
+        flex-grow: 1;
+    }
+}
+
+.settings-category {
+    max-width: 600px;
+    text-wrap: balance;
+
+    &-list + &-list {
+        margin-top: calc(var(--typo3-spacing) * 2);
+    }
+}
+
+.settings-item {
+    position: relative;
+    color: var(--settings-item-color);
+    background: var(--settings-item-bg);
+    border-radius: calc(var(--settings-border-radius) / 2);
+    padding-block: var(--typo3-component-padding-y);
+    padding-inline-start: calc(var(--typo3-component-padding-x) + 4px);
+    padding-inline-end: calc(var(--typo3-component-padding-x) + 3rem);
+    margin-inline-start: calc(-1 * var(--typo3-component-padding-x));
+    margin-inline-end: calc(-1 * var(--typo3-component-padding-x));
+
+    &:focus-within,
+    &:focus-within * {
+        --settings-item-bg: var(--typo3-component-focus-bg);
+        --settings-item-color: var(--typo3-component-focus-color);
+    }
+
+    &:focus-within {
+        outline-offset: -1px;
+        outline: 1px solid var(--typo3-component-focus-border-color);
+    }
+
+    &:hover,
+    &:focus,
+    &:focus-within {
+        .settings-item-actions {
+            opacity: 1;
+        }
+    }
+
+    &-indicator {
+        position: absolute;
+        background: var(--settings-indicator-bg);
+        inset-inline-start: 1px;
+        inset-block-start: 1px;
+        inset-block-end: 1px;
+        width: 4px;
+        border-radius: 0;
+    }
+
+    &[data-status="modified"],
+    &[data-status="modified"] * {
+        --settings-indicator-bg: #{$info};
+    }
+
+    &[data-status="error"],
+    &[data-status="error"] * {
+        --settings-indicator-bg: #{$danger};
+    }
+
+    &-actions {
+        opacity: 0;
+        position: absolute;
+        display: flex;
+        justify-content: center;
+        inset-inline-end: 0;
+        inset-block-start: 0;
+        inset-block-end: 0;
+        padding-block: var(--typo3-component-padding-y);
+        width: 3rem;
+        transition: opacity .3s ease-in-out;
+
+        & > .dropdown > button {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            background-color: transparent;
+            border: none;
+            color: inherit;
+            outline: none;
+            width: 32px;
+            height: 32px;
+            padding: 0;
+            margin-top: -4px;
+            border-radius: 50%;
+
+            &:hover {
+                background: color-mix(in srgb, var(--settings-item-bg), var(--settings-item-color) 10%);
+            }
+
+            &:focus {
+                background: color-mix(in srgb, var(--typo3-component-focus-bg), var(--typo3-component-focus-border-color) 20%);
+                color: var(--typo3-component-focus-color);
+            }
+
+            &:after {
+                display: none;
+            }
+        }
+    }
+
+    &-title {
+        margin-bottom: calc(var(--typo3-spacing) / 2);
+    }
+
+    &-label {
+        font-weight: bold;
+    }
+
+    &-description {
+        color: color-mix(in srgb, var(--settings-color), var(--settings-bg) 25%);
+    }
+
+    &-key {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        font-family: var(--typo3-font-family-code);
+        color: var(--settings-highlight);
+    }
+
+    &-message {
+        margin-top: calc(var(--typo3-spacing) / 2);
+
+        &:empty {
+            display: none;
+        }
+    }
+}
diff --git a/Build/Sources/TypeScript/backend/copy-to-clipboard.ts b/Build/Sources/TypeScript/backend/copy-to-clipboard.ts
index 4e74d4e13bfa..26a7d0eec92d 100644
--- a/Build/Sources/TypeScript/backend/copy-to-clipboard.ts
+++ b/Build/Sources/TypeScript/backend/copy-to-clipboard.ts
@@ -16,6 +16,37 @@ import { customElement, property } from 'lit/decorators';
 import Notification from '@typo3/backend/notification';
 import { lll } from '@typo3/core/lit-helper';
 
+export function copyToClipboard(text: string): void {
+  if (!text.length) {
+    console.warn('No text for copy to clipboard given.');
+    Notification.error(lll('copyToClipboard.error'));
+    return;
+  }
+  if (navigator.clipboard) {
+    navigator.clipboard.writeText(text).then((): void => {
+      Notification.success(lll('copyToClipboard.success'), '', 1);
+    }).catch((): void => {
+      Notification.error(lll('copyToClipboard.error'));
+    });
+  } else {
+    const textarea = document.createElement('textarea');
+    textarea.value = text;
+    document.body.appendChild(textarea);
+    textarea.focus();
+    textarea.select();
+    try {
+      if (document.execCommand('copy')) {
+        Notification.success(lll('copyToClipboard.success'), '', 1);
+      } else {
+        Notification.error(lll('copyToClipboard.error'));
+      }
+    } catch {
+      Notification.error(lll('copyToClipboard.error'));
+    }
+    document.body.removeChild(textarea);
+  }
+}
+
 /**
  * Module: @typo3/backend/copy-to-clipboard
  *
@@ -60,34 +91,12 @@ export class CopyToClipboard extends LitElement {
   }
 
   private copyToClipboard(): void {
-    if (typeof this.text !== 'string' || !this.text.length) {
+    if (typeof this.text !== 'string') {
       console.warn('No text for copy to clipboard given.');
       Notification.error(lll('copyToClipboard.error'));
       return;
     }
-    if (navigator.clipboard) {
-      navigator.clipboard.writeText(this.text).then((): void => {
-        Notification.success(lll('copyToClipboard.success'), '', 1);
-      }).catch((): void => {
-        Notification.error(lll('copyToClipboard.error'));
-      });
-    } else {
-      const textarea = document.createElement('textarea');
-      textarea.value = this.text;
-      document.body.appendChild(textarea);
-      textarea.focus();
-      textarea.select();
-      try {
-        if (document.execCommand('copy')) {
-          Notification.success(lll('copyToClipboard.success'), '', 1);
-        } else {
-          Notification.error(lll('copyToClipboard.error'));
-        }
-      } catch {
-        Notification.error(lll('copyToClipboard.error'));
-      }
-      document.body.removeChild(textarea);
-    }
+    copyToClipboard(this.text);
   }
 }
 
diff --git a/Build/Sources/TypeScript/backend/settings/editor.ts b/Build/Sources/TypeScript/backend/settings/editor.ts
new file mode 100644
index 000000000000..0c4b81ea3411
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/editor.ts
@@ -0,0 +1,210 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, LitElement, TemplateResult, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators';
+import '@typo3/backend/element/spinner-element';
+import '@typo3/backend/element/icon-element';
+import Notification from '@typo3/backend/notification';
+import AjaxRequest from '@typo3/core/ajax/ajax-request';
+import { copyToClipboard } from '@typo3/backend/copy-to-clipboard';
+import { lll } from '@typo3/core/lit-helper';
+import '@typo3/backend/settings/editor/editable-setting';
+
+// preload known/common types
+import '@typo3/backend/settings/type/bool';
+import '@typo3/backend/settings/type/int';
+import '@typo3/backend/settings/type/number';
+import '@typo3/backend/settings/type/string';
+import '@typo3/backend/settings/type/stringlist';
+
+type ValueType = string|number|boolean|string[]|null;
+
+
+export interface Category {
+  key: string,
+  label: string,
+  description: string,
+  icon: string,
+  settings: EditableSetting[],
+  categories: Category[],
+}
+
+/** @see \TYPO3\CMS\Core\Settings\SettingDefinition */
+export interface SettingDefinition {
+  key: string,
+  type: string,
+  default: ValueType,
+  label: string,
+  description?: string|null,
+  enum: ValueType[],
+  categories: string[],
+  tags: string[],
+}
+
+/** @see \TYPO3\CMS\Backend\Dto\Settings\EditableSetting */
+export interface EditableSetting {
+  definition: SettingDefinition,
+  value: ValueType,
+  systemDefault: ValueType,
+  status: string,
+  warnings: string[],
+  typeImplementation: string,
+}
+
+@customElement('typo3-backend-settings-editor')
+export class SettingsEditorElement extends LitElement {
+
+  @property({ type: Array }) categories: Category[];
+  @property({ type: String, attribute: 'action-url' }) actionUrl: string;
+  @property({ type: String, attribute: 'dump-url' }) dumpUrl: string;
+  @property({ type: String, attribute: 'return-url' }) returnUrl: string;
+
+  @state() activeCategory: string = '';
+
+  visibleCategories: Record<string, boolean> = {};
+  observer: IntersectionObserver = null
+
+  protected createRenderRoot(): HTMLElement | ShadowRoot {
+    return this;
+  }
+
+  protected override firstUpdated(): void {
+    this.observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach(entry => {
+          const key = (entry.target as HTMLElement).dataset.key;
+          this.visibleCategories[key] = entry.isIntersecting;
+        })
+        const flatten = (list: Category[]): string[] => list.reduce((acc, c) => [...acc, c.key, ...flatten(c.categories)], []);
+        const active = flatten(this.categories).filter(key => this.visibleCategories[key])[0] || '';
+        if (active) {
+          this.activeCategory = active;
+        }
+      },
+      {
+        root: document.querySelector('.module'),
+        threshold: 0.1,
+        rootMargin: `-${getComputedStyle(document.querySelector('.module-docheader')).getPropertyValue('min-height')} 0px 0px 0px`
+      }
+    )
+  }
+
+  protected override updated(): void {
+    [...this.renderRoot.querySelectorAll('.settings-category')].map(entry => this.observer?.observe(entry));
+  }
+
+  protected renderCategoryTree(categories: Category[], level: number): TemplateResult {
+    return html`
+      <ul data-level=${level}>
+        ${categories.map(category => html`
+          <li>
+            <a href=${`#category-headline-${category.key}`}
+              @click=${() => this.activeCategory = category.key}
+              class="settings-navigation-item ${this.activeCategory === category.key ? 'active' : ''}">
+              <span class="settings-navigation-item-icon">
+                <typo3-backend-icon identifier=${category.icon ? category.icon : 'actions-dot'} size="small"></typo3-backend-icon>
+              </span>
+              <span class="settings-navigation-item-label">${category.label}</span>
+            </a>
+            ${category.categories.length === 0 ? nothing : html`
+              ${this.renderCategoryTree(category.categories, level + 1)}
+            `}
+          </li>
+        `)}
+      </ul>
+    `;
+  }
+
+  protected renderSettings(categories: Category[], level: number): TemplateResult[] {
+    return categories.map(category => html`
+      <div class="settings-category-list" data-key=${category.key}>
+        <div class="settings-category" data-key=${category.key}>
+          ${this.renderHeadline(Math.min(level + 1, 6), `category-headline-${category.key}`, html`${category.label}`)}
+          ${category.description ? html`<p>${category.description}</p>` : nothing}
+        </div>
+        ${category.settings.map((setting): TemplateResult => html`
+          <typo3-backend-editable-setting .setting=${setting} .dumpuri=${this.dumpUrl}></typo3-backend-editable-setting>
+        `)}
+      </div>
+      ${category.categories.length === 0 ? nothing : html`
+        ${this.renderSettings(category.categories, level + 1)}
+      `}
+    `);
+  }
+
+  protected renderHeadline(level: number, id: string, content: TemplateResult): TemplateResult {
+    switch (level) {
+      case 1:
+        return html`<h1 id=${id}>${content}</h1>`;
+      case 2:
+        return html`<h2 id=${id}>${content}</h2>`;
+      case 3:
+        return html`<h3 id=${id}>${content}</h3>`;
+      case 4:
+        return html`<h4 id=${id}>${content}</h4>`;
+      case 5:
+        return html`<h5 id=${id}>${content}</h5>`;
+      case 6:
+        return html`<h6 id=${id}>${content}</h6>`;
+      default:
+        throw new Error(`Invalid header level: ${level}`);
+    }
+  }
+
+  protected async onSubmit(e: SubmitEvent): Promise<void> {
+    const form = e.target as HTMLFormElement;
+
+    if ((e.submitter as HTMLButtonElement|null)?.value === 'export') {
+      e.preventDefault();
+      const formData = new FormData(form);
+      const response = await new AjaxRequest(this.dumpUrl).post(formData);
+
+      const result = await response.resolve();
+      if (typeof result.yaml === 'string') {
+        copyToClipboard(result.yaml);
+      } else {
+        console.warn('Value can not be copied to clipboard.', typeof result.yaml);
+        Notification.error(lll('copyToClipboard.error'));
+      }
+    }
+  }
+
+  protected render(): TemplateResult {
+    return html`
+      <form class="settings-container"
+            id="sitesettings_form"
+            name="sitesettings_form"
+            action=${this.actionUrl}
+            method="post"
+            @submit=${(e: SubmitEvent) => this.onSubmit(e)}
+      >
+        ${this.returnUrl ? html`<input type="hidden" name="returnUrl" value=${this.returnUrl} />` : nothing}
+        <div class="settings">
+          <div class="settings-navigation">
+            ${this.renderCategoryTree(this.categories ?? [], 1)}
+          </div>
+          <div class="settings-body">
+            ${this.renderSettings(this.categories ?? [], 1)}
+          </div>
+        </div>
+      </form>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-editor': SettingsEditorElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts b/Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts
new file mode 100644
index 000000000000..b431246efca2
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts
@@ -0,0 +1,197 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, LitElement, TemplateResult, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators';
+import { until } from 'lit/directives/until.js';
+import '@typo3/backend/element/spinner-element';
+import '@typo3/backend/element/icon-element';
+import { copyToClipboard } from '@typo3/backend/copy-to-clipboard';
+import Notification from '@typo3/backend/notification';
+import { lll } from '@typo3/core/lit-helper';
+import AjaxRequest from '@typo3/core/ajax/ajax-request';
+import type { BaseElement } from '@typo3/backend/settings/type/base';
+
+type ValueType = string|number|boolean|string[]|null;
+
+/** @see \TYPO3\CMS\Core\Settings\SettingDefinition */
+interface SettingDefinition {
+  key: string,
+  type: string,
+  default: ValueType,
+  label: string,
+  description?: string|null,
+  enum: ValueType[],
+  categories: string[],
+  tags: string[],
+}
+
+/** @see \TYPO3\CMS\Backend\Dto\Settings\EditableSetting */
+interface EditableSetting {
+  definition: SettingDefinition,
+  value: ValueType,
+  systemDefault: ValueType,
+  status: string,
+  warnings: string[],
+  typeImplementation: string,
+}
+
+@customElement('typo3-backend-editable-setting')
+export class EditableSettingElement extends LitElement {
+
+  @property({ type: Object }) setting: EditableSetting;
+  @property({ type: String }) dumpuri: string;
+
+  @state()
+  hasChange: boolean = false;
+
+  typeElement: BaseElement<unknown> = null;
+
+  protected createRenderRoot(): HTMLElement | ShadowRoot {
+    return this;
+  }
+
+  protected render(): TemplateResult {
+    const { value, systemDefault, definition } = this.setting;
+    return html`
+      <div
+        class=${`settings-item settings-item-${definition.type} ${this.hasChange ? 'has-change' : ''}`}
+        tabindex="0"
+        data-status=${JSON.stringify(value) === JSON.stringify(systemDefault) ? 'none' : 'modified'}
+      >
+        <!-- data-status=modified|error|none-->
+        <div class="settings-item-indicator"></div>
+        <div class="settings-item-title">
+          <label for=${`setting-${definition.key}`} class="settings-item-label">${definition.label}</label>
+          <div class="settings-item-description">${definition.description}</div>
+          <div class="settings-item-key">${definition.key}</div>
+        </div>
+        <div class="settings-item-control">
+          ${until(this.renderField(), html`<typo3-backend-spinner></typo3-backend-spinner>`)}
+        </div>
+        <div class="settings-item-message"></div>
+        <div class="settings-item-actions">
+          ${this.renderActions()}
+        </div>
+      </div>
+    `;
+  }
+
+  protected async renderField(): Promise<HTMLElement> {
+    const { definition, value, typeImplementation } = this.setting;
+    let element = this.typeElement
+    if (!element) {
+      const implementation = await import(typeImplementation);
+      if (!('componentName' in implementation)) {
+        throw new Error(`module ${typeImplementation} is missing the "componentName" export`);
+      }
+      element = document.createElement(implementation.componentName);
+      this.typeElement = element;
+
+      element.addEventListener('typo3:setting:changed', (e: CustomEvent) => {
+        this.hasChange = JSON.stringify(this.setting.value) !== JSON.stringify(e.detail.value);
+      });
+    }
+
+    const attributes = {
+      key: definition.key,
+      formid: `setting-${definition.key}`,
+      name: `settings[${definition.key}]`,
+      value: Array.isArray(value) ? JSON.stringify(value) : String(value),
+      default: Array.isArray(definition.default) ? JSON.stringify(definition.default) : String(definition.default),
+    };
+    for (const [key, value] of Object.entries(attributes)) {
+      if (element.getAttribute(key) !== value) {
+        element.setAttribute(key, value);
+      }
+    }
+
+    return element;
+  }
+
+  protected renderActions(): TemplateResult {
+    const { definition } = this.setting;
+    return html`
+      <div class="dropdown">
+        <button class="dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+          <typo3-backend-icon identifier="actions-cog" size="small"></typo3-backend-icon>
+          <span class="visually-hidden">More actions</span>
+        </button>
+        <ul class="dropdown-menu">
+          <li>
+            <button class="dropdown-item dropdown-item-spaced"
+              type="button"
+              @click="${() => this.setToDefaultValue()}">
+              <typo3-backend-icon identifier="actions-undo" size="small"></typo3-backend-icon> ${lll('edit.resetSetting')}
+            </button>
+          </li>
+          <li><hr class="dropdown-divider"></li>
+          <li>
+            <typo3-copy-to-clipboard
+              text=${definition.key}
+              class="dropdown-item dropdown-item-spaced"
+            >
+              <typo3-backend-icon identifier="actions-clipboard" size="small"></typo3-backend-icon> ${lll('edit.copySettingsIdentifier')}
+            </typo3-copy-to-clipboard>
+          </li>
+          ${this.dumpuri ? html`
+            <li>
+              <button class="dropdown-item dropdown-item-spaced"
+                type="button"
+                @click="${() => this.copyAsYaml()}">
+                <typo3-backend-icon identifier="actions-clipboard-paste" size="small"></typo3-backend-icon> ${lll('edit.copyAsYaml')}
+
+              </a>
+            </li>
+          ` : nothing}
+        </ul>
+      </div>
+    `
+  }
+
+  protected setToDefaultValue(): void {
+    if (this.typeElement) {
+      this.typeElement.value = this.setting.systemDefault as unknown;
+    }
+  }
+
+  protected async copyAsYaml(): Promise<void> {
+    const formData = new FormData(this.typeElement.form);
+    const name = `settings[${this.setting.definition.key}]`
+    const value = formData.get(name);
+
+    const data = new FormData();
+    data.append('specificSetting', this.setting.definition.key);
+    data.append(name, value);
+
+    // @todo hookup with NProgress
+    const response = await new AjaxRequest(this.dumpuri).post(
+      data
+    );
+
+    const result = await response.resolve();
+
+    if (typeof result.yaml === 'string') {
+      copyToClipboard(result.yaml);
+    } else {
+      console.warn('Value can not be copied to clipboard.', typeof result.yaml);
+      Notification.error(lll('copyToClipboard.error'));
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-editable-setting': EditableSettingElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/type/base.ts b/Build/Sources/TypeScript/backend/settings/type/base.ts
new file mode 100644
index 000000000000..db6781ae726a
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/base.ts
@@ -0,0 +1,194 @@
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+/* eslint-disable @typescript-eslint/member-ordering */
+
+import { LitElement, PropertyDeclaration, ReactiveElement } from 'lit';
+import { defaultConverter } from '@lit/reactive-element';
+import { property } from 'lit/decorators';
+
+export const internals = Symbol('internals');
+const privateInternals = Symbol('privateInternals');
+export const getFormValue = Symbol('getFormValue');
+export const getFormState = Symbol('getFormState');
+
+/**
+ * Base element class for settings type to act as
+ * a form associated custom element.
+ *
+ * See https://web.dev/articles/more-capable-form-controls#defining_a_form-associated_custom_element
+ */
+export abstract class BaseElement<T = string> extends LitElement {
+
+  @property({ type: String }) key: string;
+  @property({ type: String }) formid: string;
+
+  static readonly formAssociated = true;
+
+  /* @property annotation needs to be provided by extending class */
+  value: T;
+
+  [privateInternals]?: ElementInternals;
+
+  protected createRenderRoot(): HTMLElement | ShadowRoot {
+    return this;
+  }
+
+  protected get [internals]() {
+    // Create internals in getter so that it can be used in methods called on
+    // construction in `ReactiveElement`, such as `requestUpdate()`.
+    if (!this[privateInternals]) {
+      this[privateInternals] = this.attachInternals();
+    }
+
+    return this[privateInternals];
+  }
+
+  public get form() {
+    return this[internals].form;
+  }
+
+  protected get labels() {
+    return this[internals].labels;
+  }
+
+  // Use @property for the `name` and `disabled` properties to add them to the
+  // `observedAttributes` array and trigger `attributeChangedCallback()`.
+  //
+  // We don't use Lit's default getter/setter (`noAccessor: true`) because
+  // the attributes need to be updated synchronously to work with synchronous
+  // form APIs, and Lit updates attributes async by default.
+  @property({ noAccessor: true })
+  public get name() {
+    return this.getAttribute('name') ?? '';
+  }
+  public set name(name: string) {
+    // Note: setting name to null or empty does not remove the attribute.
+    this.setAttribute('name', name);
+    // We don't need to call `requestUpdate()` since it's called synchronously
+    // in `attributeChangedCallback()`.
+  }
+
+  @property({ type: Boolean, noAccessor: true })
+  public get disabled() {
+    return this.hasAttribute('disabled');
+  }
+
+  public set disabled(disabled: boolean) {
+    this.toggleAttribute('disabled', disabled);
+    // We don't need to call `requestUpdate()` since it's called synchronously
+    // in `attributeChangedCallback()`.
+  }
+
+  public override attributeChangedCallback(
+    name: string,
+    old: string | null,
+    value: string | null,
+  ) {
+    // Manually `requestUpdate()` for `name` and `disabled` when their
+    // attribute or property changes.
+    // The properties update their attributes, so this callback is invoked
+    // immediately when the properties are set. We call `requestUpdate()` here
+    // instead of letting Lit set the properties from the attribute change.
+    // That would cause the properties to re-set the attribute and invoke this
+    // callback again in a loop. This leads to stale state when Lit tries to
+    // determine if a property changed or not.
+    if (name === 'name' || name === 'disabled') {
+      // Disabled's value is only false if the attribute is missing and null.
+      const oldValue = name === 'disabled' ? old !== null : old;
+      // Trigger a lit update when the attribute changes.
+      this.requestUpdate(name, oldValue);
+      return;
+    }
+
+    super.attributeChangedCallback(name, old, value);
+  }
+
+  public override requestUpdate(
+    name?: PropertyKey,
+    oldValue?: unknown,
+    options?: PropertyDeclaration,
+  ) {
+    super.requestUpdate(name, oldValue, options);
+    if (name === 'value') {
+      this.dispatchEvent(new CustomEvent('typo3:setting:changed', { detail: { value: this.value } }));
+      // Update the form value synchronously in `requestUpdate()` rather than
+      // `update()` or `updated()`, which are async. This is necessary to ensure
+      // that form data is updated in time for synchronous event listeners.
+      this[internals].setFormValue(this[getFormValue](), this[getFormState]());
+    }
+  }
+
+  public formDisabledCallback(disabled: boolean) {
+    this.disabled = disabled;
+  }
+
+  /**
+   * Callback triggered when <button type=reset> or form.reset() is triggered.
+   */
+  public formResetCallback() {
+    const oldValue = this.value;
+    const defaultValue = this.getAttribute('value');
+
+    // Workaround to trigger string to property conversion
+    this.attributeChangedCallback('value', this.valueToString(oldValue), null);
+    this.attributeChangedCallback('value', null, defaultValue);
+  }
+
+  /**
+   * Callback triggered when form is (re-)loaded by browser-back button.
+   */
+  public formStateRestoreCallback(state: FormValue): void {
+    if (typeof state === 'string') {
+      this.attributeChangedCallback('value', this.valueToString(this.value), null);
+      this.attributeChangedCallback('value', null, state);
+    } else {
+      throw new Error(`formStateRestoreCallback() needs to be implemented for <${this.localName}> for state type "${typeof state}"`);
+    }
+  }
+
+  protected [getFormState](): FormValue | null {
+    return this[getFormValue]();
+  }
+
+  protected [getFormValue](): string {
+    return this.valueToString(this.value);
+  }
+
+  protected valueToString(value: T): string {
+    const ctor = this.constructor as typeof ReactiveElement;
+    const options = ctor.getPropertyOptions('value');
+    const converter = typeof options.converter === 'object' && typeof options.converter?.toAttribute === 'function' ?
+      options.converter.toAttribute : defaultConverter.toAttribute;
+    return converter(value, options.type) as string;
+  }
+}
+
+export type FormValue = File | string | FormData;
+
+/**
+ * A value to be restored for a component's form value. If a component's form
+ * state is a `FormData` object, its entry list of name and values will be
+ * provided.
+ */
+export type FormRestoreState =
+  | File
+  | string
+  | Array<[string, FormDataEntryValue]>;
+
+/**
+ * The reason a form component is being restored for, either `'restore'` for
+ * browser restoration or `'autocomplete'` for restoring user values.
+ */
+export type FormRestoreReason = 'restore' | 'autocomplete';
diff --git a/Build/Sources/TypeScript/backend/settings/type/bool.ts b/Build/Sources/TypeScript/backend/settings/type/bool.ts
new file mode 100644
index 000000000000..782e1d5e9245
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/bool.ts
@@ -0,0 +1,55 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { BaseElement } from './base';
+
+export const componentName = 'typo3-backend-settings-type-bool';
+
+@customElement(componentName)
+export class BoolTypeElement extends BaseElement<boolean> {
+
+  @property({
+    type: Boolean,
+    converter: {
+      toAttribute: (value: boolean): string => {
+        return value ? '1' : '0';
+      },
+      fromAttribute: (value: string): boolean => {
+        return value === '1' || value === 'true';
+      }
+    }
+  }) value: boolean;
+
+  protected render(): TemplateResult {
+    return html`
+      <div class="form-check form-check-type-toggle">
+        <input
+          type="checkbox"
+          id=${this.formid}
+          class="form-check-input"
+          value="1"
+          .checked=${this.value}
+          @change=${(e: InputEvent) => this.value = (e.target as HTMLInputElement).checked ? true : false}
+        />
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-type-bool': BoolTypeElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/type/color.ts b/Build/Sources/TypeScript/backend/settings/type/color.ts
new file mode 100644
index 000000000000..66a591a2fa68
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/color.ts
@@ -0,0 +1,63 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { BaseElement } from './base';
+import Alwan from 'alwan';
+
+export const componentName = 'typo3-backend-settings-type-color';
+
+@customElement(componentName)
+export class ColorTypeElement extends BaseElement<string> {
+
+  @property({ type: String }) value: string;
+
+  private alwan: Alwan|null = null;
+
+  protected firstUpdated(): void {
+    this.alwan = new Alwan(this.querySelector('input'), {
+      position: 'bottom-start',
+      format: 'hex',
+      opacity: false,
+      preset: false,
+      color: this.value,
+    });
+    this.alwan.on('color', (e): void => {
+      this.value = e.hex;
+    });
+  }
+
+  protected updateValue(value: string) {
+    this.value = value;
+    this.alwan?.setColor(value);
+  }
+
+  protected render(): TemplateResult {
+    return html`
+      <input
+        type="text"
+        id=${this.formid}
+        class="form-control"
+        .value=${this.value}
+        @change=${(e: InputEvent) => this.updateValue((e.target as HTMLInputElement).value)}
+      />
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-type-color': ColorTypeElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/type/int.ts b/Build/Sources/TypeScript/backend/settings/type/int.ts
new file mode 100644
index 000000000000..08d807b7ea87
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/int.ts
@@ -0,0 +1,42 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { BaseElement } from './base';
+
+export const componentName = 'typo3-backend-settings-type-int';
+
+@customElement(componentName)
+export class IntTypeElement extends BaseElement<number> {
+
+  @property({ type: Number }) value: number;
+
+  protected render(): TemplateResult {
+    return html`
+      <input
+        type="number"
+        id=${this.formid}
+        class="form-control"
+        .value=${this.value}
+        @change=${(e: InputEvent) => this.value = parseInt((e.target as HTMLInputElement).value, 10)}
+      />
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-type-int': IntTypeElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/type/number.ts b/Build/Sources/TypeScript/backend/settings/type/number.ts
new file mode 100644
index 000000000000..efd06786b17e
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/number.ts
@@ -0,0 +1,43 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { BaseElement } from './base';
+
+export const componentName = 'typo3-backend-settings-type-number';
+
+@customElement(componentName)
+export class NumberTypeElement extends BaseElement<number> {
+
+  @property({ type: Number }) value: number;
+
+  protected render(): TemplateResult {
+    return html`
+      <input
+        type="number"
+        id=${this.formid}
+        class="form-control"
+        step="0.01"
+        .value=${this.value}
+        @change=${(e: InputEvent) => this.value = parseFloat((e.target as HTMLInputElement).value)}
+      />
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-type-number': NumberTypeElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/type/string.ts b/Build/Sources/TypeScript/backend/settings/type/string.ts
new file mode 100644
index 000000000000..f9f500121ef3
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/string.ts
@@ -0,0 +1,42 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { BaseElement } from './base';
+
+export const componentName = 'typo3-backend-settings-type-string';
+
+@customElement(componentName)
+export class StringTypeElement extends BaseElement<string> {
+
+  @property({ type: String }) value: string;
+
+  protected render(): TemplateResult {
+    return html`
+      <input
+        type="text"
+        id=${this.formid}
+        class="form-control"
+        .value=${this.value}
+        @change=${(e: InputEvent) => this.value = (e.target as HTMLInputElement).value}
+      />
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-type-string': StringTypeElement;
+  }
+}
diff --git a/Build/Sources/TypeScript/backend/settings/type/stringlist.ts b/Build/Sources/TypeScript/backend/settings/type/stringlist.ts
new file mode 100644
index 000000000000..d0221ac98c32
--- /dev/null
+++ b/Build/Sources/TypeScript/backend/settings/type/stringlist.ts
@@ -0,0 +1,86 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+import { html, TemplateResult } from 'lit';
+import { customElement, property } from 'lit/decorators';
+import { BaseElement } from './base';
+import { live } from 'lit/directives/live.js';
+
+export const componentName = 'typo3-backend-settings-type-stringlist';
+
+@customElement(componentName)
+export class StringlistTypeElement extends BaseElement<string[]> {
+
+  @property({ type: Array }) value: string[];
+
+  protected updateValue(value: string, index: number) {
+    const copy = [...this.value];
+    copy[index] = value;
+    this.value = copy;
+  }
+
+  protected addValue(index: number, value: string = '') {
+    this.value = this.value.toSpliced(index + 1, 0, value);
+  }
+
+  protected removeValue(index: number) {
+    this.value = this.value.toSpliced(index, 1);
+  }
+
+  protected renderItem(value: string, index: number): TemplateResult {
+    return html`
+      <tr>
+        <td width="99%">
+          <input
+            id=${`${this.formid}${index > 0 ? '-' + index : ''}`}
+            type="text"
+            class="form-control"
+            .value=${live(value)}
+            @change=${(e: InputEvent) => this.updateValue((e.target as HTMLInputElement).value, index)}
+          />
+        </td>
+        <td>
+          <div class="btn-group" role="group">
+            <button class="btn btn-default" type="button" @click=${() => this.addValue(index)}>
+              <typo3-backend-icon identifier="actions-plus" size="small"></typo3-backend-icon>
+            </button>
+            <button class="btn btn-default" type="button" @click=${() => this.removeValue(index)}>
+              <typo3-backend-icon identifier="actions-delete" size="small"></typo3-backend-icon>
+            </button>
+          </div>
+        </td>
+      </tr>
+    `;
+  }
+
+  protected render(): TemplateResult {
+    const value = this.value || [];
+    return html`
+      <div class="form-control-wrap">
+        <div class="table-fit">
+          <table class="table table-hover">
+            <tbody>
+              ${value.map((v, i) => this.renderItem(v, i))}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'typo3-backend-settings-type-stringlist': StringlistTypeElement;
+  }
+}
diff --git a/Build/tests/playwright/accessibility/modules.spec.ts b/Build/tests/playwright/accessibility/modules.spec.ts
index f8631df126aa..0f10c141e5e6 100644
--- a/Build/tests/playwright/accessibility/modules.spec.ts
+++ b/Build/tests/playwright/accessibility/modules.spec.ts
@@ -20,6 +20,10 @@ test.describe('modules', () => {
       'label': 'the info module',
       'route': 'module/web/info',
     },
+    'mod_site_settings': {
+      'label': 'the site settings module',
+      'route': 'module/site/settings',
+    },
     'mod_reports': {
       'label': 'the reports module',
       'route': 'module/system/reports',
diff --git a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php
index b1397c528f04..c8c26e451f7f 100644
--- a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php
+++ b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php
@@ -150,7 +150,9 @@ class SiteConfigurationController
             throw new \RuntimeException('Existing config for site ' . $siteIdentifier . ' not found', 1521561226);
         }
 
-        $returnUrl = $this->uriBuilder->buildUriFromRoute('site_configuration');
+        $returnUrl = GeneralUtility::sanitizeLocalUrl(
+            (string)($request->getQueryParams()['returnUrl'] ?? '')
+        ) ?: $this->uriBuilder->buildUriFromRoute('site_configuration');
 
         $formDataCompilerInput = [
             'request' => $request,
@@ -183,7 +185,7 @@ class SiteConfigurationController
         ]);
 
         $this->pageRenderer->getJavaScriptRenderer()->includeTaggedImports('backend.form');
-        $this->configureEditViewDocHeader($view);
+        $this->configureEditViewDocHeader($view, $siteIdentifier);
         $view->setTitle(
             $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_module.xlf:mlang_tabs_tab'),
             $siteIdentifier ?? ''
@@ -212,11 +214,16 @@ class SiteConfigurationController
 
         $siteTca = GeneralUtility::makeInstance(SiteTcaConfiguration::class)->getTca();
 
-        $overviewRoute = $this->uriBuilder->buildUriFromRoute('site_configuration');
+        $queryParams = $request->getQueryParams();
         $parsedBody = $request->getParsedBody();
+
+        $returnUrl = GeneralUtility::sanitizeLocalUrl(
+            (string)($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '')
+        ) ?: $this->uriBuilder->buildUriFromRoute('site_configuration');
+
         if (isset($parsedBody['closeDoc']) && (int)$parsedBody['closeDoc'] === 1) {
             // Closing means no save, just redirect to overview
-            return new RedirectResponse($overviewRoute);
+            return new RedirectResponse($returnUrl);
         }
         $isSave = $parsedBody['_savedok'] ?? $parsedBody['doSave'] ?? false;
         $isSaveClose = $parsedBody['_saveandclosedok'] ?? false;
@@ -433,7 +440,7 @@ class SiteConfigurationController
 
         $saveRoute = $this->uriBuilder->buildUriFromRoute('site_configuration.edit', ['site' => $siteIdentifier]);
         if ($isSaveClose) {
-            return new RedirectResponse($overviewRoute);
+            return new RedirectResponse($returnUrl);
         }
         return new RedirectResponse($saveRoute);
     }
@@ -678,7 +685,7 @@ class SiteConfigurationController
     /**
      * Create document header buttons of "edit" action
      */
-    protected function configureEditViewDocHeader(ModuleTemplate $view): void
+    protected function configureEditViewDocHeader(ModuleTemplate $view, ?string $siteIdentifier): void
     {
         $buttonBar = $view->getDocHeaderComponent()->getButtonBar();
         $lang = $this->getLanguageService();
@@ -697,6 +704,19 @@ class SiteConfigurationController
             ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL));
         $buttonBar->addButton($closeButton);
         $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
+        if ($siteIdentifier) {
+            $exportButton = $buttonBar->makeLinkButton()
+                ->setTitle($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:edit.editSiteSettings'))
+                ->setIcon($this->iconFactory->getIcon('actions-cog', IconSize::SMALL))
+                ->setShowLabelText(true)
+                ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_settings.edit', [
+                    'site' => $siteIdentifier,
+                    'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_configuration.edit', [
+                        'site' => $siteIdentifier,
+                    ]),
+                ]));
+            $buttonBar->addButton($exportButton, ButtonBar::BUTTON_POSITION_RIGHT, 2);
+        }
     }
 
     /**
diff --git a/typo3/sysext/backend/Classes/Controller/SiteSettingsController.php b/typo3/sysext/backend/Classes/Controller/SiteSettingsController.php
new file mode 100644
index 000000000000..778c1165f003
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Controller/SiteSettingsController.php
@@ -0,0 +1,326 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Controller;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Backend\Attribute\AsController;
+use TYPO3\CMS\Backend\Dto\Settings\EditableSetting;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Backend\Template\Components\ButtonBar;
+use TYPO3\CMS\Backend\Template\ModuleTemplate;
+use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Http\JsonResponse;
+use TYPO3\CMS\Core\Http\RedirectResponse;
+use TYPO3\CMS\Core\Imaging\IconFactory;
+use TYPO3\CMS\Core\Imaging\IconSize;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Core\Messaging\FlashMessageService;
+use TYPO3\CMS\Core\Page\PageRenderer;
+use TYPO3\CMS\Core\Settings\Category;
+use TYPO3\CMS\Core\Settings\SettingDefinition;
+use TYPO3\CMS\Core\Settings\SettingsTypeRegistry;
+use TYPO3\CMS\Core\Site\Entity\Site;
+use TYPO3\CMS\Core\Site\Set\CategoryRegistry;
+use TYPO3\CMS\Core\Site\SiteFinder;
+use TYPO3\CMS\Core\Site\SiteSettingsService;
+use TYPO3\CMS\Core\SysLog\Action\Setting as SettingAction;
+use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
+use TYPO3\CMS\Core\SysLog\Type;
+use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
+use TYPO3\CMS\Core\Utility\ArrayUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Backend controller: The "Site settings" module
+ *
+ * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
+ */
+#[AsController]
+readonly class SiteSettingsController
+{
+    public function __construct(
+        protected ModuleTemplateFactory $moduleTemplateFactory,
+        protected SiteFinder $siteFinder,
+        protected SiteSettingsService $siteSettingsService,
+        protected SettingsTypeRegistry $settingsTypeRegistry,
+        protected CategoryRegistry $categoryRegistry,
+        protected UriBuilder $uriBuilder,
+        protected PageRenderer $pageRenderer,
+        protected FlashMessageService $flashMessageService,
+        protected IconFactory $iconFactory,
+    ) {}
+
+    public function overviewAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $view = $this->moduleTemplateFactory->create($request);
+        $view->assign('sites', array_map(
+            fn(Site $site): array => [
+                'site' => $site,
+                'siteTitle' => $this->getSiteTitle($site),
+                'hasSettingsDefinitions' => $this->siteSettingsService->hasSettingsDefinitions($site),
+                'localSettings' => $this->siteSettingsService->getLocalSettings($site),
+            ],
+            array_filter(
+                $this->siteFinder->getAllSites(),
+                static fn(Site $site): bool => $site->getSets() !== []
+            )
+        ));
+
+        return $view->renderResponse('SiteSettings/Overview');
+    }
+
+    public function editAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $identifier = $request->getQueryParams()['site'] ?? null;
+        if ($identifier === null) {
+            throw new \RuntimeException('Site identifier to edit must be set', 1713394528);
+        }
+
+        $returnUrl = GeneralUtility::sanitizeLocalUrl(
+            (string)($request->getQueryParams()['returnUrl'] ?? '')
+        ) ?: null;
+        $overviewUrl = (string)$this->uriBuilder->buildUriFromRoute('site_settings');
+
+        $site = $this->siteFinder->getSiteByIdentifier($identifier);
+        $view = $this->moduleTemplateFactory->create($request);
+
+        $settings = $this->siteSettingsService->getUncachedSettings($site);
+        $setSettings = $this->siteSettingsService->getSetSettings($site);
+
+        $categoryEnhancer = function (Category $category) use (&$categoryEnhancer, $settings, $setSettings): Category {
+            return new Category(...[
+                ...get_object_vars($category),
+                'label' => $this->getLanguageService()->sL($category->label),
+                'description' => $category->description !== null ? $this->getLanguageService()->sL($category->description) : $category->description,
+                'categories' => array_map($categoryEnhancer, $category->categories),
+                'settings' => array_map(
+                    fn(SettingDefinition $definition): EditableSetting => new EditableSetting(
+                        definition: $this->resolveSettingLabels($definition),
+                        value: $settings->get($definition->key),
+                        systemDefault: $setSettings->get($definition->key),
+                        typeImplementation: $this->settingsTypeRegistry->get($definition->type)->getJavaScriptModule(),
+                    ),
+                    $category->settings
+                ),
+            ]);
+        };
+
+        $categories = array_map(
+            $categoryEnhancer,
+            $this->categoryRegistry->getCategories(...$site->getSets())
+        );
+        $hasSettings = count($categories) > 0;
+
+        $this->addDocHeaderCloseAndSaveButtons($view, $site, $returnUrl ?? $overviewUrl, $hasSettings);
+        if ($hasSettings) {
+            $this->addDocHeaderExportButton($view, $site);
+        }
+
+        $this->addDocHeaderSiteConfigurationButton($view, $site);
+        $this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_copytoclipboard.xlf');
+        $this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf');
+
+        $view->assign('siteIdentifier', $site->getIdentifier());
+        $view->assign('siteTitle', $this->getSiteTitle($site));
+        $view->assign('rootPageId', $site->getRootPageId());
+
+        $view->assign('actionUrl', (string)$this->uriBuilder->buildUriFromRoute('site_settings.save', array_filter([
+            'site' => $site->getIdentifier(),
+            'returnUrl' => $returnUrl,
+        ], static fn(?string $v): bool => $v !== null)));
+        $view->assign('returnUrl', $returnUrl);
+        $view->assign('dumpUrl', (string)$this->uriBuilder->buildUriFromRoute('site_settings.dump', ['site' => $site->getIdentifier()]));
+        $view->assign('categories', $categories);
+
+        return $view->renderResponse('SiteSettings/Edit');
+    }
+
+    private function resolveSettingLabels(SettingDefinition $definition): SettingDefinition
+    {
+        $languageService = $this->getLanguageService();
+        return new SettingDefinition(...[
+            ...get_object_vars($definition),
+            'label' => $languageService->sL($definition->label),
+            'description' => $definition->description !== null ? $languageService->sL($definition->description) : null,
+        ]);
+    }
+
+    public function saveAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $identifier = $request->getQueryParams()['site'] ?? null;
+        if ($identifier === null) {
+            throw new \RuntimeException('Site identifier to edit must be set', 1713394529);
+        }
+
+        $site = $this->siteFinder->getSiteByIdentifier($identifier);
+
+        $view = $this->moduleTemplateFactory->create($request);
+
+        $parsedBody = $request->getParsedBody();
+
+        $returnUrl = GeneralUtility::sanitizeLocalUrl(
+            (string)($parsedBody['returnUrl'])
+        ) ?: null;
+        $overviewUrl = $this->uriBuilder->buildUriFromRoute('site_settings');
+        $CMD = $parsedBody['CMD'] ?? '';
+        $isSave = $CMD === 'save' || $CMD === 'saveclose';
+        $isSaveClose = $parsedBody['CMD'] === 'saveclose';
+        if (!$isSave) {
+            return new RedirectResponse($returnUrl ?? $overviewUrl);
+        }
+
+        $changes = $this->siteSettingsService->computeSettingsDiff($site, $parsedBody['settings'] ?? []);
+        $this->siteSettingsService->writeSettings($site, $changes['settings']);
+
+        if ($changes['changes'] !== [] || $changes['deletions'] !== []) {
+            $this->getBackendUser()->writelog(
+                Type::SITE,
+                SettingAction::CHANGE,
+                SystemLogErrorClassification::MESSAGE,
+                0,
+                'Site settings changed for \'%s\': %s',
+                [$site->getIdentifier(), json_encode($changes)],
+                'site'
+            );
+
+            $languageService = $this->getLanguageService();
+            $message = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:save.message.updated');
+            $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $message, '', ContextualFeedbackSeverity::OK, true);
+            $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
+            $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
+            $defaultFlashMessageQueue->enqueue($flashMessage);
+        }
+
+        if ($isSaveClose) {
+            return new RedirectResponse($returnUrl ?? $overviewUrl);
+        }
+        $editRoute = $this->uriBuilder->buildUriFromRoute('site_settings.edit', array_filter([
+            'site' => $site->getIdentifier(),
+            'returnUrl' => $returnUrl,
+        ], static fn(?string $v): bool => $v !== null));
+        return new RedirectResponse($editRoute);
+    }
+
+    public function dumpAction(ServerRequestInterface $request): ResponseInterface
+    {
+        $identifier = $request->getQueryParams()['site'] ?? null;
+        if ($identifier === null) {
+            throw new \RuntimeException('Site identifier to edit must be set', 1724772561);
+        }
+
+        $site = $this->siteFinder->getSiteByIdentifier($identifier);
+        $parsedBody = $request->getParsedBody();
+        $specificSetting = (string)($parsedBody['specificSetting'] ?? '');
+
+        $minify = $specificSetting !== '' ? false : true;
+        $changes = $this->siteSettingsService->computeSettingsDiff($site, $parsedBody['settings'] ?? [], $minify);
+        $settings = $changes['settings'];
+        if ($specificSetting !== '') {
+            $value = ArrayUtility::getValueByPath($settings, $specificSetting, '.');
+            $settings = ArrayUtility::setValueByPath([], $specificSetting, $value, '.');
+        }
+
+        $yamlContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP);
+
+        return new JsonResponse([
+            'yaml' => $yamlContents,
+        ]);
+    }
+
+    protected function addDocHeaderCloseAndSaveButtons(ModuleTemplate $moduleTemplate, Site $site, string $closeUrl, bool $saveEnabled): void
+    {
+        $languageService = $this->getLanguageService();
+        $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar();
+        $closeButton = $buttonBar->makeLinkButton()
+            ->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:close'))
+            ->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL))
+            ->setShowLabelText(true)
+            ->setHref($closeUrl);
+        $buttonBar->addButton($closeButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
+        $saveButton = $buttonBar->makeInputButton()
+            ->setName('CMD')
+            ->setValue('save')
+            ->setForm('sitesettings_form')
+            ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL))
+            ->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:save'))
+            ->setShowLabelText(true)
+            ->setDisabled(!$saveEnabled);
+        $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 4);
+    }
+
+    protected function addDocHeaderExportButton(ModuleTemplate $moduleTemplate, Site $site): void
+    {
+        $languageService = $this->getLanguageService();
+        $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar();
+        $exportButton = $buttonBar->makeInputButton()
+            ->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.yamlExport'))
+            ->setIcon($this->iconFactory->getIcon('actions-database-export', IconSize::SMALL))
+            ->setShowLabelText(true)
+            ->setName('CMD')
+            ->setValue('export')
+            ->setForm('sitesettings_form');
+        $buttonBar->addButton($exportButton, ButtonBar::BUTTON_POSITION_RIGHT, 2);
+    }
+
+    protected function addDocHeaderSiteConfigurationButton(ModuleTemplate $moduleTemplate, Site $site): void
+    {
+        $languageService = $this->getLanguageService();
+        $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar();
+        $exportButton = $buttonBar->makeLinkButton()
+            ->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.editSiteConfiguration'))
+            ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL))
+            ->setShowLabelText(true)
+            ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_configuration.edit', [
+                'site' => $site->getIdentifier(),
+                'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_settings.edit', [
+                    'site' => $site->getIdentifier(),
+                ]),
+            ]));
+        $buttonBar->addButton($exportButton, ButtonBar::BUTTON_POSITION_RIGHT, 3);
+    }
+
+    protected function getSiteTitle(Site $site): string
+    {
+        $websiteTitle = $site->getConfiguration()['websiteTitle'] ?? '';
+        if ($websiteTitle !== '') {
+            return $websiteTitle;
+        }
+        $rootPage = BackendUtility::getRecord('pages', $site->getRootPageId());
+        $title = $rootPage['title'] ?? '';
+        if ($title !== '') {
+            return $title;
+        }
+
+        return '(unkown)';
+    }
+
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+
+    protected function getBackendUser(): BackendUserAuthentication
+    {
+        return $GLOBALS['BE_USER'];
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php b/typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php
new file mode 100644
index 000000000000..1059956500ba
--- /dev/null
+++ b/typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Backend\Dto\Settings;
+
+use TYPO3\CMS\Core\Settings\SettingDefinition;
+
+/**
+ * @internal
+ */
+final readonly class EditableSetting implements \JsonSerializable
+{
+    public function __construct(
+        public SettingDefinition $definition,
+        public string|int|float|bool|array|null $value,
+        public string|int|float|bool|array|null $systemDefault,
+        public string $typeImplementation,
+    ) {}
+
+    public function jsonSerialize(): array
+    {
+        return get_object_vars($this);
+    }
+}
diff --git a/typo3/sysext/backend/Configuration/Backend/Modules.php b/typo3/sysext/backend/Configuration/Backend/Modules.php
index a28cf40c8833..57b174d903ae 100644
--- a/typo3/sysext/backend/Configuration/Backend/Modules.php
+++ b/typo3/sysext/backend/Configuration/Backend/Modules.php
@@ -7,6 +7,7 @@ use TYPO3\CMS\Backend\Controller\PageTsConfig\PageTsConfigIncludesController;
 use TYPO3\CMS\Backend\Controller\PageTsConfig\PageTsConfigRecordsOverviewController;
 use TYPO3\CMS\Backend\Controller\RecordListController;
 use TYPO3\CMS\Backend\Controller\SiteConfigurationController;
+use TYPO3\CMS\Backend\Controller\SiteSettingsController;
 use TYPO3\CMS\Backend\Security\ContentSecurityPolicy\CspModuleController;
 
 /**
@@ -73,6 +74,31 @@ return [
             ],
         ],
     ],
+    'site_settings' => [
+        'parent' => 'site',
+        'position' => ['after' => 'site_configuration'],
+        // @todo implement access=user
+        'access' => 'admin',
+        'path' => '/module/site/settings',
+        'iconIdentifier' => 'module-site-settings',
+        'labels' => 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings_module.xlf',
+        'routes' => [
+            '_default' => [
+                'target' => SiteSettingsController::class . '::overviewAction',
+            ],
+            'edit' => [
+                'target' => SiteSettingsController::class . '::editAction',
+            ],
+            'save' => [
+                'target' => SiteSettingsController::class . '::saveAction',
+                'methods' => ['POST'],
+            ],
+            'dump' => [
+                'target' => SiteSettingsController::class . '::dumpAction',
+                'methods' => ['POST'],
+            ],
+        ],
+    ],
     'about' => [
         'parent' => 'help',
         'position' => ['before' => '*'],
diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf
index e95c73ad861c..a8e36e11cece 100644
--- a/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf
+++ b/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf
@@ -27,6 +27,12 @@
 			<trans-unit id="overview.editSiteConfiguration" resname="overview.editSiteConfiguration">
 				<source>Edit site configuration</source>
 			</trans-unit>
+			<trans-unit id="overview.editSiteSettings" resname="overview.editSiteSettings">
+				<source>Edit site settings</source>
+			</trans-unit>
+			<trans-unit id="overview.editSiteSettingsUnavailable" resname="overview.editSiteSettingsUnavailable">
+				<source>This site provides no configurable settings</source>
+			</trans-unit>
 			<trans-unit id="overview.deleteSiteConfiguration" resname="overview.deleteSiteConfiguration">
 				<source>Delete site configuration</source>
 			</trans-unit>
@@ -51,6 +57,9 @@
 			<trans-unit id="overview.duplicatedRootPage.message" resname="overview.duplicatedRootPage.message">
 				<source>The page with ID "%1s" is used in the following site configurations:</source>
 			</trans-unit>
+			<trans-unit id="edit.editSiteSettings" resname="edit.editSiteSettings">
+				<source>Edit site settings</source>
+			</trans-unit>
 			<trans-unit id="validation.identifierRenamed.title" resname="validation.identifierRenamed.title">
 				<source>Renamed identifier</source>
 			</trans-unit>
diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf
new file mode 100644
index 000000000000..9263bf44ae51
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
+	<file source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf" date="2024-09-10T12:13:00Z" product-name="backend">
+		<header/>
+		<body>
+			<trans-unit id="overview.title" resname="overview.title">
+				<source>Site Settings Overview</source>
+			</trans-unit>
+			<trans-unit id="overview.setSummary" resname="overview.setSummary">
+				<source>Sets (%d)</source>
+			</trans-unit>
+			<trans-unit id="overview.customSettingsSummary" resname="overview.customSettingsSummary">
+				<source>Custom Settings (%d)</source>
+			</trans-unit>
+			<trans-unit id="overview.editSettings" resname="overview.editSettings">
+				<source>Edit Settings</source>
+			</trans-unit>
+			<trans-unit id="overview.message.notEditable" resname="overview.message.notEditable">
+				<source>This sets of this sites do not provide any configurable settings.</source>
+			</trans-unit>
+			<trans-unit id="edit.title" resname="edit.noSettings.title">
+				<source>Site Settings for "{siteTitle}"</source>
+			</trans-unit>
+			<trans-unit id="edit.noSettings.title" resname="edit.noSettings.title">
+				<source>No Settings available</source>
+			</trans-unit>
+			<trans-unit id="edit.noSettings.message" resname="edit.noSettings.message">
+				<source>The site '%s' does provide configurable settings.</source>
+			</trans-unit>
+			<trans-unit id="edit.yamlExport" resname="edit.yamlExport">
+				<source>YAML export</source>
+			</trans-unit>
+			<trans-unit id="edit.editSiteConfiguration" resname="edit.editSiteConfiguration">
+				<source>Edit site configuration</source>
+			</trans-unit>
+			<trans-unit id="edit.resetSetting" resname="edit.resetSetting">
+				<source>Reset setting</source>
+			</trans-unit>
+			<trans-unit id="edit.copySettingsIdentifier" resname="edit.copySettingsIdentifier">
+				<source>Copy identifier</source>
+			</trans-unit>
+			<trans-unit id="edit.copyAsYaml" resname="edit.copyAsYaml">
+				<source>Copy as YAML</source>
+			</trans-unit>
+			<trans-unit id="save.message.updated" resname="save.message.updated">
+				<source>Settings updated.</source>
+			</trans-unit>
+			<trans-unit id="categories.other" resname="categories.other">
+				<source>Other</source>
+			</trans-unit>
+		</body>
+	</file>
+</xliff>
diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf
new file mode 100644
index 000000000000..e87a0f3dedb6
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
+	<file source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_sitesettings_module.xlf" date="2024-02-28T22:22:22Z" product-name="backend">
+		<header/>
+		<body>
+			<trans-unit id="mlang_tabs_tab" resname="mlang_tabs_tab">
+				<source>Settings</source>
+			</trans-unit>
+			<trans-unit id="mlang_labels_tablabel" resname="mlang_labels_tablabel">
+				<source>Site settings</source>
+			</trans-unit>
+			<trans-unit id="mlang_labels_tabdescr" resname="mlang_labels_tabdescr">
+				<source>This module allows you to configure site settings.</source>
+			</trans-unit>
+		</body>
+	</file>
+</xliff>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html b/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html
index 17b1308879d7..ef7368d5c864 100644
--- a/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html
+++ b/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html
@@ -148,6 +148,31 @@
                                             <f:be.link route="site_configuration.edit" parameters="{site: page.siteIdentifier}" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:overview.editSiteConfiguration')}" class="btn btn-default">
                                                 <core:icon identifier="actions-open" />
                                             </f:be.link>
+
+                                            <f:if condition="{page.siteConfiguration.sets->f:count()} > 0">
+                                                <f:then>
+                                                    <f:variable name="returnUrl">{f:be.uri(route: 'site_configuration')}</f:variable>
+                                                    <f:be.link
+                                                        route="site_settings.edit"
+                                                        parameters="{site: page.siteIdentifier, returnUrl: returnUrl}"
+                                                        title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:overview.editSiteSettings')}"
+                                                        class="btn btn-default"
+                                                    >
+                                                        <core:icon identifier="actions-cog" />
+                                                    </f:be.link>
+                                                </f:then>
+                                                <f:else>
+                                                    <button
+                                                        disabled
+                                                        type="button"
+                                                        class="btn btn-default"
+                                                        title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:overview.editSiteSettingsUnavailable')}"
+                                                    >
+                                                        <core:icon identifier="actions-cog" />
+                                                    </button>
+                                                </f:else>
+                                            </f:if>
+
                                             <button
                                                 type="submit"
                                                 class="btn btn-default t3js-modal-trigger"
diff --git a/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html
new file mode 100644
index 000000000000..ed8dce6871e3
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html
@@ -0,0 +1,35 @@
+<f:layout name="Module" />
+
+<f:section name="Content">
+
+    <f:be.pageRenderer includeJavaScriptModules="{0: '@typo3/backend/settings/editor.js'}"/>
+
+    <h1>
+        Site Settings for "{siteTitle}"
+        <br>
+        <small class="text-muted">{siteIdentifier} <code>[pid: {rootPageId}]</code></small>
+    </h1>
+
+    <f:if condition="{categories->f:count()} == 0">
+        <f:then>
+            <f:be.infobox
+                state="{f:constant(name: 'TYPO3\CMS\Fluid\ViewHelpers\Be\InfoboxViewHelper::STATE_INFO')}"
+                title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.noSettings.title')}"
+            >
+                <f:translate
+                    key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.noSettings.message"
+                    arguments="{0: siteIdentifier}"
+                />
+        </f:be.infobox>
+        </f:then>
+        <f:else>
+            <typo3-backend-settings-editor
+                action-url="{actionUrl}"
+                return-url="{returnUrl}"
+                dump-url="{dumpUrl}"
+                categories="{categories->f:format.json()}"
+            ></typo3-backend-settings-editor>
+
+        </f:else>
+    </f:if>
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html
new file mode 100644
index 000000000000..1c2f3abcba87
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html
@@ -0,0 +1,54 @@
+<f:layout name="Module" />
+
+<f:section name="Content">
+
+    <h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.title"/></h1>
+
+    <div class="card-container">
+        <f:for each="{sites}" as="c">
+            <div class="card card-size-small">
+                <div class="card-header">
+                    <div class="card-header-body">
+                        <h2 class="card-title">{c.siteTitle}</h2>
+                        <span class="card-subtitle">{c.site.identifier} [pid: {c.site.rootPageId}]</span>
+                    </div>
+                </div>
+                <div class="card-body">
+                    <details open name="details-{c.site.identifier}">
+                        <summary><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.setSummary" arguments="{0: '{c.site.sets->f:count()}'}"/></summary>
+                        <p>
+                            <f:for each="{c.site.sets}" as="set" iteration="i">
+                                <code>{set}</code><f:if condition="{i.isLast}"><f:else><br></f:else></f:if>
+                            </f:for>
+                        </p>
+                    </details>
+
+                    <f:if condition="{c.localSettings.allFlat->f:count()} > 0">
+                        <details open name="details-{c.site.identifier}">
+                            <summary><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.customSettingsSummary" arguments="{0: '{c.localSettings.map->f:count()}'}"/></summary>
+                            <p>
+                                <f:for each="{c.localSettings.map}" as="setting" key="key" iteration="i">
+                                    <code>{key}: <strong>{setting->f:format.json()}</strong></code><br>
+                                </f:for>
+                            </p>
+                        </details>
+                    </f:if>
+                </div>
+                <f:if condition="{c.hasSettingsDefinitions}">
+                    <f:then>
+                        <div class="card-footer">
+                            <f:be.link route="site_settings.edit" parameters="{site: c.site.identifier}" class="btn btn-default">
+                                <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.editSettings"/></h1>
+                            </f:be.link>
+                        </div>
+                    </f:then>
+                    <f:else>
+                        <div class="card-footer text-body-secondary">
+                            <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.message.notEditable"/></h1>
+                        </div>
+                    </f:else>
+                </f:if>
+            </div>
+        </f:for>
+    </div>
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Public/Css/backend.css b/typo3/sysext/backend/Resources/Public/Css/backend.css
index 6d410ff2ac14..1aa6f7e3bc1d 100644
--- a/typo3/sysext/backend/Resources/Public/Css/backend.css
+++ b/typo3/sysext/backend/Resources/Public/Css/backend.css
@@ -3655,6 +3655,64 @@ typo3-backend-live-search-result-item-action>* .livesearch-result-item-title .sm
 .indent{--indent-base:16px;--indent-level:0;margin-inline-start:calc(var(--indent-base) * var(--indent-level))}
 .indent-inline-block{display:inline-block}
 .pagination{flex-wrap:wrap;row-gap:4px}
+:root{--settings-color:var(--typo3-component-color);--settings-padding:calc(var(--typo3-spacing) * 2);--settings-bg:var(--typo3-component-bg);--settings-border-width:var(--typo3-component-border-width);--settings-border-color:var(--typo3-component-border-color);--settings-border-radius:var(--typo3-component-border-radius);--settings-box-shadow:var(--typo3-component-box-shadow);--settings-highlight:var(--typo3-component-primary-color);--settings-indicator-bg:transparent;--settings-item-color:var(--settings-color);--settings-item-bg:var(--settings-bg)}
+.module[data-module-name=site_settings] .module-body{width:100%;max-width:1320px;margin:0 auto}
+.settings-container{container-type:inline-size}
+.settings{display:flex;align-items:flex-start;flex-wrap:wrap;color:var(--settings-color);box-shadow:var(--settings-box-shadow)}
+.settings-body,.settings-navigation{padding:var(--settings-padding)}
+.settings-body{background:var(--settings-bg);border:var(--settings-border-width) solid var(--settings-border-color);border-radius:var(--settings-border-radius)}
+.settings-navigation{border-inline-start:var(--settings-border-width) solid var(--settings-border-color);border-inline-end:var(--settings-border-width) solid var(--settings-border-color);border-top:var(--settings-border-width) solid var(--settings-border-color);border-bottom:0;flex:1 1 auto}
+
+@container (min-width: 780px) {
+  .settings {
+    flex-wrap: nowrap;
+    box-shadow: none;
+  }
+  .settings-body {
+    flex: 1 1 auto;
+    border-inline-start: var(--settings-border-width) solid var(--settings-border-color);
+    border-start-start-radius: 0;
+    box-shadow: var(--settings-box-shadow);
+  }
+  .settings-navigation {
+    box-shadow: var(--settings-box-shadow);
+    flex: 0 0 300px;
+    position: sticky;
+    top: calc(var(--module-body-padding-y) + var(--module-docheader-height));
+    border-inline-end: 0;
+    border-inline-start: var(--settings-border-width) solid var(--settings-border-color);
+    border-top: var(--settings-border-width) solid var(--settings-border-color);
+    border-bottom: var(--settings-border-width) solid var(--settings-border-color);
+  }
+}
+.settings-navigation ul{list-style:none;margin:0;padding:0}
+.settings-navigation ul ul{padding-inline-start:1rem}
+.settings-navigation-item{color:var(--typo3-component-color);position:relative;display:flex;border-radius:calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));gap:.5em;padding:var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);cursor:pointer;text-decoration:none}
+.settings-navigation-item.active,.settings-navigation-item:focus,.settings-navigation-item:hover{z-index:1;outline-offset:-1px}
+.settings-navigation-item:hover{color:var(--typo3-component-hover-color);background-color:var(--typo3-component-hover-bg);outline:1px solid var(--typo3-component-hover-border-color)}
+.settings-navigation-item.active,.settings-navigation-item:focus{color:var(--typo3-component-focus-color);background-color:var(--typo3-component-focus-bg);outline:1px solid var(--typo3-component-focus-border-color)}
+.settings-navigation-item-icon{-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;flex-grow:0;width:var(--icon-size-small)}
+.settings-navigation-item-label{-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-grow:1}
+.settings-category{max-width:600px;text-wrap:balance}
+.settings-category-list+.settings-category-list{margin-top:calc(var(--typo3-spacing) * 2)}
+.settings-item{position:relative;color:var(--settings-item-color);background:var(--settings-item-bg);border-radius:calc(var(--settings-border-radius)/ 2);padding-block:var(--typo3-component-padding-y);padding-inline-start:calc(var(--typo3-component-padding-x) + 4px);padding-inline-end:calc(var(--typo3-component-padding-x) + 3rem);margin-inline-start:calc(-1 * var(--typo3-component-padding-x));margin-inline-end:calc(-1 * var(--typo3-component-padding-x))}
+.settings-item:focus-within,.settings-item:focus-within *{--settings-item-bg:var(--typo3-component-focus-bg);--settings-item-color:var(--typo3-component-focus-color)}
+.settings-item:focus-within{outline-offset:-1px;outline:1px solid var(--typo3-component-focus-border-color)}
+.settings-item:focus .settings-item-actions,.settings-item:focus-within .settings-item-actions,.settings-item:hover .settings-item-actions{opacity:1}
+.settings-item-indicator{position:absolute;background:var(--settings-indicator-bg);inset-inline-start:1px;inset-block-start:1px;inset-block-end:1px;width:4px;border-radius:0}
+.settings-item[data-status=modified],.settings-item[data-status=modified] *{--settings-indicator-bg:#abdced}
+.settings-item[data-status=error],.settings-item[data-status=error] *{--settings-indicator-bg:#d13a2e}
+.settings-item-actions{opacity:0;position:absolute;display:flex;justify-content:center;inset-inline-end:0;inset-block-start:0;inset-block-end:0;padding-block:var(--typo3-component-padding-y);width:3rem;transition:opacity .3s ease-in-out}
+.settings-item-actions>.dropdown>button{display:flex;justify-content:center;align-items:center;background-color:transparent;border:none;color:inherit;outline:0;width:32px;height:32px;padding:0;margin-top:-4px;border-radius:50%}
+.settings-item-actions>.dropdown>button:hover{background:color-mix(in srgb,var(--settings-item-bg),var(--settings-item-color) 10%)}
+.settings-item-actions>.dropdown>button:focus{background:color-mix(in srgb,var(--typo3-component-focus-bg),var(--typo3-component-focus-border-color) 20%);color:var(--typo3-component-focus-color)}
+.settings-item-actions>.dropdown>button:after{display:none}
+.settings-item-title{margin-bottom:calc(var(--typo3-spacing)/ 2)}
+.settings-item-label{font-weight:700}
+.settings-item-description{color:color-mix(in srgb,var(--settings-color),var(--settings-bg) 25%)}
+.settings-item-key{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--typo3-font-family-code);color:var(--settings-highlight)}
+.settings-item-message{margin-top:calc(var(--typo3-spacing)/ 2)}
+.settings-item-message:empty{display:none}
 .example{color:var(--typo3-component-color);background-color:var(--typo3-surface-base);border:var(--typo3-component-border-width) solid var(--typo3-component-border-color);border-radius:var(--typo3-component-border-radius);padding:var(--typo3-spacing);margin-bottom:var(--typo3-spacing)}
 .example+:not(.example){margin-top:var(--typo3-component-spacing)}
 .example>:last-child{margin-bottom:0}
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js b/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js
index 68ec8919e6d1..2a0592b52960 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-var __decorate=function(t,o,e,r){var i,c=arguments.length,l=c<3?o:null===r?r=Object.getOwnPropertyDescriptor(o,e):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(t,o,e,r);else for(var p=t.length-1;p>=0;p--)(i=t[p])&&(l=(c<3?i(l):c>3?i(o,e,l):i(o,e))||l);return c>3&&l&&Object.defineProperty(o,e,l),l};import{html,css,LitElement}from"lit";import{customElement,property}from"lit/decorators.js";import Notification from"@typo3/backend/notification.js";import{lll}from"@typo3/core/lit-helper.js";let CopyToClipboard=class extends LitElement{constructor(){super(),this.addEventListener("click",(t=>{t.preventDefault(),this.copyToClipboard()})),this.addEventListener("keydown",(t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),this.copyToClipboard())}))}connectedCallback(){this.hasAttribute("role")||this.setAttribute("role","button"),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0")}render(){return html`<slot></slot>`}copyToClipboard(){if("string"!=typeof this.text||!this.text.length)return console.warn("No text for copy to clipboard given."),void Notification.error(lll("copyToClipboard.error"));if(navigator.clipboard)navigator.clipboard.writeText(this.text).then((()=>{Notification.success(lll("copyToClipboard.success"),"",1)})).catch((()=>{Notification.error(lll("copyToClipboard.error"))}));else{const t=document.createElement("textarea");t.value=this.text,document.body.appendChild(t),t.focus(),t.select();try{document.execCommand("copy")?Notification.success(lll("copyToClipboard.success"),"",1):Notification.error(lll("copyToClipboard.error"))}catch{Notification.error(lll("copyToClipboard.error"))}document.body.removeChild(t)}}};CopyToClipboard.styles=[css`:host { cursor: pointer; appearance: button; }`],__decorate([property({type:String})],CopyToClipboard.prototype,"text",void 0),CopyToClipboard=__decorate([customElement("typo3-copy-to-clipboard")],CopyToClipboard);export{CopyToClipboard};
\ No newline at end of file
+var __decorate=function(o,t,e,r){var i,c=arguments.length,l=c<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,e):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(o,t,e,r);else for(var p=o.length-1;p>=0;p--)(i=o[p])&&(l=(c<3?i(l):c>3?i(t,e,l):i(t,e))||l);return c>3&&l&&Object.defineProperty(t,e,l),l};import{html,css,LitElement}from"lit";import{customElement,property}from"lit/decorators.js";import Notification from"@typo3/backend/notification.js";import{lll}from"@typo3/core/lit-helper.js";export function copyToClipboard(o){if(!o.length)return console.warn("No text for copy to clipboard given."),void Notification.error(lll("copyToClipboard.error"));if(navigator.clipboard)navigator.clipboard.writeText(o).then((()=>{Notification.success(lll("copyToClipboard.success"),"",1)})).catch((()=>{Notification.error(lll("copyToClipboard.error"))}));else{const t=document.createElement("textarea");t.value=o,document.body.appendChild(t),t.focus(),t.select();try{document.execCommand("copy")?Notification.success(lll("copyToClipboard.success"),"",1):Notification.error(lll("copyToClipboard.error"))}catch{Notification.error(lll("copyToClipboard.error"))}document.body.removeChild(t)}}let CopyToClipboard=class extends LitElement{constructor(){super(),this.addEventListener("click",(o=>{o.preventDefault(),this.copyToClipboard()})),this.addEventListener("keydown",(o=>{"Enter"!==o.key&&" "!==o.key||(o.preventDefault(),this.copyToClipboard())}))}connectedCallback(){this.hasAttribute("role")||this.setAttribute("role","button"),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0")}render(){return html`<slot></slot>`}copyToClipboard(){if("string"!=typeof this.text)return console.warn("No text for copy to clipboard given."),void Notification.error(lll("copyToClipboard.error"));copyToClipboard(this.text)}};CopyToClipboard.styles=[css`:host { cursor: pointer; appearance: button; }`],__decorate([property({type:String})],CopyToClipboard.prototype,"text",void 0),CopyToClipboard=__decorate([customElement("typo3-copy-to-clipboard")],CopyToClipboard);export{CopyToClipboard};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js
new file mode 100644
index 000000000000..61a41bd6c761
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js
@@ -0,0 +1,62 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(t,e,r,i){var o,n=arguments.length,s=n<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,r,i);else for(var a=t.length-1;a>=0;a--)(o=t[a])&&(s=(n<3?o(s):n>3?o(e,r,s):o(e,r))||s);return n>3&&s&&Object.defineProperty(e,r,s),s};import{html,LitElement,nothing}from"lit";import{customElement,property,state}from"lit/decorators.js";import"@typo3/backend/element/spinner-element.js";import"@typo3/backend/element/icon-element.js";import Notification from"@typo3/backend/notification.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import{copyToClipboard}from"@typo3/backend/copy-to-clipboard.js";import{lll}from"@typo3/core/lit-helper.js";import"@typo3/backend/settings/editor/editable-setting.js";import"@typo3/backend/settings/type/bool.js";import"@typo3/backend/settings/type/int.js";import"@typo3/backend/settings/type/number.js";import"@typo3/backend/settings/type/string.js";import"@typo3/backend/settings/type/stringlist.js";let SettingsEditorElement=class extends LitElement{constructor(){super(...arguments),this.activeCategory="",this.visibleCategories={},this.observer=null}createRenderRoot(){return this}firstUpdated(){this.observer=new IntersectionObserver((t=>{t.forEach((t=>{const e=t.target.dataset.key;this.visibleCategories[e]=t.isIntersecting}));const e=t=>t.reduce(((t,r)=>[...t,r.key,...e(r.categories)]),[]),r=e(this.categories).filter((t=>this.visibleCategories[t]))[0]||"";r&&(this.activeCategory=r)}),{root:document.querySelector(".module"),threshold:.1,rootMargin:`-${getComputedStyle(document.querySelector(".module-docheader")).getPropertyValue("min-height")} 0px 0px 0px`})}updated(){[...this.renderRoot.querySelectorAll(".settings-category")].map((t=>this.observer?.observe(t)))}renderCategoryTree(t,e){return html`
+      <ul data-level=${e}>
+        ${t.map((t=>html`
+          <li>
+            <a href=${`#category-headline-${t.key}`}
+              @click=${()=>this.activeCategory=t.key}
+              class="settings-navigation-item ${this.activeCategory===t.key?"active":""}">
+              <span class="settings-navigation-item-icon">
+                <typo3-backend-icon identifier=${t.icon?t.icon:"actions-dot"} size="small"></typo3-backend-icon>
+              </span>
+              <span class="settings-navigation-item-label">${t.label}</span>
+            </a>
+            ${0===t.categories.length?nothing:html`
+              ${this.renderCategoryTree(t.categories,e+1)}
+            `}
+          </li>
+        `))}
+      </ul>
+    `}renderSettings(t,e){return t.map((t=>html`
+      <div class="settings-category-list" data-key=${t.key}>
+        <div class="settings-category" data-key=${t.key}>
+          ${this.renderHeadline(Math.min(e+1,6),`category-headline-${t.key}`,html`${t.label}`)}
+          ${t.description?html`<p>${t.description}</p>`:nothing}
+        </div>
+        ${t.settings.map((t=>html`
+          <typo3-backend-editable-setting .setting=${t} .dumpuri=${this.dumpUrl}></typo3-backend-editable-setting>
+        `))}
+      </div>
+      ${0===t.categories.length?nothing:html`
+        ${this.renderSettings(t.categories,e+1)}
+      `}
+    `))}renderHeadline(t,e,r){switch(t){case 1:return html`<h1 id=${e}>${r}</h1>`;case 2:return html`<h2 id=${e}>${r}</h2>`;case 3:return html`<h3 id=${e}>${r}</h3>`;case 4:return html`<h4 id=${e}>${r}</h4>`;case 5:return html`<h5 id=${e}>${r}</h5>`;case 6:return html`<h6 id=${e}>${r}</h6>`;default:throw new Error(`Invalid header level: ${t}`)}}async onSubmit(t){const e=t.target;if("export"===t.submitter?.value){t.preventDefault();const r=new FormData(e),i=await new AjaxRequest(this.dumpUrl).post(r),o=await i.resolve();"string"==typeof o.yaml?copyToClipboard(o.yaml):(console.warn("Value can not be copied to clipboard.",typeof o.yaml),Notification.error(lll("copyToClipboard.error")))}}render(){return html`
+      <form class="settings-container"
+            id="sitesettings_form"
+            name="sitesettings_form"
+            action=${this.actionUrl}
+            method="post"
+            @submit=${t=>this.onSubmit(t)}
+      >
+        ${this.returnUrl?html`<input type="hidden" name="returnUrl" value=${this.returnUrl} />`:nothing}
+        <div class="settings">
+          <div class="settings-navigation">
+            ${this.renderCategoryTree(this.categories??[],1)}
+          </div>
+          <div class="settings-body">
+            ${this.renderSettings(this.categories??[],1)}
+          </div>
+        </div>
+      </form>
+    `}};__decorate([property({type:Array})],SettingsEditorElement.prototype,"categories",void 0),__decorate([property({type:String,attribute:"action-url"})],SettingsEditorElement.prototype,"actionUrl",void 0),__decorate([property({type:String,attribute:"dump-url"})],SettingsEditorElement.prototype,"dumpUrl",void 0),__decorate([property({type:String,attribute:"return-url"})],SettingsEditorElement.prototype,"returnUrl",void 0),__decorate([state()],SettingsEditorElement.prototype,"activeCategory",void 0),SettingsEditorElement=__decorate([customElement("typo3-backend-settings-editor")],SettingsEditorElement);export{SettingsEditorElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js
new file mode 100644
index 000000000000..08813c02d256
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js
@@ -0,0 +1,69 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(t,e,i,n){var o,s=arguments.length,a=s<3?e:null===n?n=Object.getOwnPropertyDescriptor(e,i):n;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,i,n);else for(var l=t.length-1;l>=0;l--)(o=t[l])&&(a=(s<3?o(a):s>3?o(e,i,a):o(e,i))||a);return s>3&&a&&Object.defineProperty(e,i,a),a};import{html,LitElement,nothing}from"lit";import{customElement,property,state}from"lit/decorators.js";import{until}from"lit/directives/until.js";import"@typo3/backend/element/spinner-element.js";import"@typo3/backend/element/icon-element.js";import{copyToClipboard}from"@typo3/backend/copy-to-clipboard.js";import Notification from"@typo3/backend/notification.js";import{lll}from"@typo3/core/lit-helper.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";let EditableSettingElement=class extends LitElement{constructor(){super(...arguments),this.hasChange=!1,this.typeElement=null}createRenderRoot(){return this}render(){const{value:t,systemDefault:e,definition:i}=this.setting;return html`
+      <div
+        class=${`settings-item settings-item-${i.type} ${this.hasChange?"has-change":""}`}
+        tabindex="0"
+        data-status=${JSON.stringify(t)===JSON.stringify(e)?"none":"modified"}
+      >
+        <!-- data-status=modified|error|none-->
+        <div class="settings-item-indicator"></div>
+        <div class="settings-item-title">
+          <label for=${`setting-${i.key}`} class="settings-item-label">${i.label}</label>
+          <div class="settings-item-description">${i.description}</div>
+          <div class="settings-item-key">${i.key}</div>
+        </div>
+        <div class="settings-item-control">
+          ${until(this.renderField(),html`<typo3-backend-spinner></typo3-backend-spinner>`)}
+        </div>
+        <div class="settings-item-message"></div>
+        <div class="settings-item-actions">
+          ${this.renderActions()}
+        </div>
+      </div>
+    `}async renderField(){const{definition:t,value:e,typeImplementation:i}=this.setting;let n=this.typeElement;if(!n){const t=await import(i);if(!("componentName"in t))throw new Error(`module ${i} is missing the "componentName" export`);n=document.createElement(t.componentName),this.typeElement=n,n.addEventListener("typo3:setting:changed",(t=>{this.hasChange=JSON.stringify(this.setting.value)!==JSON.stringify(t.detail.value)}))}const o={key:t.key,formid:`setting-${t.key}`,name:`settings[${t.key}]`,value:Array.isArray(e)?JSON.stringify(e):String(e),default:Array.isArray(t.default)?JSON.stringify(t.default):String(t.default)};for(const[t,e]of Object.entries(o))n.getAttribute(t)!==e&&n.setAttribute(t,e);return n}renderActions(){const{definition:t}=this.setting;return html`
+      <div class="dropdown">
+        <button class="dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+          <typo3-backend-icon identifier="actions-cog" size="small"></typo3-backend-icon>
+          <span class="visually-hidden">More actions</span>
+        </button>
+        <ul class="dropdown-menu">
+          <li>
+            <button class="dropdown-item dropdown-item-spaced"
+              type="button"
+              @click="${()=>this.setToDefaultValue()}">
+              <typo3-backend-icon identifier="actions-undo" size="small"></typo3-backend-icon> ${lll("edit.resetSetting")}
+            </button>
+          </li>
+          <li><hr class="dropdown-divider"></li>
+          <li>
+            <typo3-copy-to-clipboard
+              text=${t.key}
+              class="dropdown-item dropdown-item-spaced"
+            >
+              <typo3-backend-icon identifier="actions-clipboard" size="small"></typo3-backend-icon> ${lll("edit.copySettingsIdentifier")}
+            </typo3-copy-to-clipboard>
+          </li>
+          ${this.dumpuri?html`
+            <li>
+              <button class="dropdown-item dropdown-item-spaced"
+                type="button"
+                @click="${()=>this.copyAsYaml()}">
+                <typo3-backend-icon identifier="actions-clipboard-paste" size="small"></typo3-backend-icon> ${lll("edit.copyAsYaml")}
+
+              </a>
+            </li>
+          `:nothing}
+        </ul>
+      </div>
+    `}setToDefaultValue(){this.typeElement&&(this.typeElement.value=this.setting.systemDefault)}async copyAsYaml(){const t=new FormData(this.typeElement.form),e=`settings[${this.setting.definition.key}]`,i=t.get(e),n=new FormData;n.append("specificSetting",this.setting.definition.key),n.append(e,i);const o=await new AjaxRequest(this.dumpuri).post(n),s=await o.resolve();"string"==typeof s.yaml?copyToClipboard(s.yaml):(console.warn("Value can not be copied to clipboard.",typeof s.yaml),Notification.error(lll("copyToClipboard.error")))}};__decorate([property({type:Object})],EditableSettingElement.prototype,"setting",void 0),__decorate([property({type:String})],EditableSettingElement.prototype,"dumpuri",void 0),__decorate([state()],EditableSettingElement.prototype,"hasChange",void 0),EditableSettingElement=__decorate([customElement("typo3-backend-editable-setting")],EditableSettingElement);export{EditableSettingElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js
new file mode 100644
index 000000000000..b0594e2ad7ce
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js
@@ -0,0 +1,13 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(t,e,r,a){var l,o=arguments.length,n=o<3?e:null===a?a=Object.getOwnPropertyDescriptor(e,r):a;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,r,a);else for(var s=t.length-1;s>=0;s--)(l=t[s])&&(n=(o<3?l(n):o>3?l(e,r,n):l(e,r))||n);return o>3&&n&&Object.defineProperty(e,r,n),n};import{LitElement}from"lit";import{defaultConverter}from"@lit/reactive-element";import{property}from"lit/decorators.js";export const internals=Symbol("internals");const privateInternals=Symbol("privateInternals");export const getFormValue=Symbol("getFormValue");export const getFormState=Symbol("getFormState");export class BaseElement extends LitElement{createRenderRoot(){return this}get[internals](){return this[privateInternals]||(this[privateInternals]=this.attachInternals()),this[privateInternals]}get form(){return this[internals].form}get labels(){return this[internals].labels}get name(){return this.getAttribute("name")??""}set name(t){this.setAttribute("name",t)}get disabled(){return this.hasAttribute("disabled")}set disabled(t){this.toggleAttribute("disabled",t)}attributeChangedCallback(t,e,r){if("name"!==t&&"disabled"!==t)super.attributeChangedCallback(t,e,r);else{const r="disabled"===t?null!==e:e;this.requestUpdate(t,r)}}requestUpdate(t,e,r){super.requestUpdate(t,e,r),"value"===t&&(this.dispatchEvent(new CustomEvent("typo3:setting:changed",{detail:{value:this.value}})),this[internals].setFormValue(this[getFormValue](),this[getFormState]()))}formDisabledCallback(t){this.disabled=t}formResetCallback(){const t=this.value,e=this.getAttribute("value");this.attributeChangedCallback("value",this.valueToString(t),null),this.attributeChangedCallback("value",null,e)}formStateRestoreCallback(t){if("string"!=typeof t)throw new Error(`formStateRestoreCallback() needs to be implemented for <${this.localName}> for state type "${typeof t}"`);this.attributeChangedCallback("value",this.valueToString(this.value),null),this.attributeChangedCallback("value",null,t)}[getFormState](){return this[getFormValue]()}[getFormValue](){return this.valueToString(this.value)}valueToString(t){const e=this.constructor.getPropertyOptions("value");return("object"==typeof e.converter&&"function"==typeof e.converter?.toAttribute?e.converter.toAttribute:defaultConverter.toAttribute)(t,e.type)}}BaseElement.formAssociated=!0,__decorate([property({type:String})],BaseElement.prototype,"key",void 0),__decorate([property({type:String})],BaseElement.prototype,"formid",void 0),__decorate([property({noAccessor:!0})],BaseElement.prototype,"name",null),__decorate([property({type:Boolean,noAccessor:!0})],BaseElement.prototype,"disabled",null);
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js
new file mode 100644
index 000000000000..847539a455f7
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js
@@ -0,0 +1,24 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(e,t,o,r){var c,l=arguments.length,n=l<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,o,r);else for(var p=e.length-1;p>=0;p--)(c=e[p])&&(n=(l<3?c(n):l>3?c(t,o,n):c(t,o))||n);return l>3&&n&&Object.defineProperty(t,o,n),n};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-bool";let BoolTypeElement=class extends BaseElement{render(){return html`
+      <div class="form-check form-check-type-toggle">
+        <input
+          type="checkbox"
+          id=${this.formid}
+          class="form-check-input"
+          value="1"
+          .checked=${this.value}
+          @change=${e=>this.value=!!e.target.checked}
+        />
+      </div>
+    `}};__decorate([property({type:Boolean,converter:{toAttribute:e=>e?"1":"0",fromAttribute:e=>"1"===e||"true"===e}})],BoolTypeElement.prototype,"value",void 0),BoolTypeElement=__decorate([customElement(componentName)],BoolTypeElement);export{BoolTypeElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js
new file mode 100644
index 000000000000..4bff7e97bc02
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js
@@ -0,0 +1,21 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(e,t,o,r){var l,a=arguments.length,n=a<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,o,r);else for(var p=e.length-1;p>=0;p--)(l=e[p])&&(n=(a<3?l(n):a>3?l(t,o,n):l(t,o))||n);return a>3&&n&&Object.defineProperty(t,o,n),n};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";import Alwan from"alwan";export const componentName="typo3-backend-settings-type-color";let ColorTypeElement=class extends BaseElement{constructor(){super(...arguments),this.alwan=null}firstUpdated(){this.alwan=new Alwan(this.querySelector("input"),{position:"bottom-start",format:"hex",opacity:!1,preset:!1,color:this.value}),this.alwan.on("color",(e=>{this.value=e.hex}))}updateValue(e){this.value=e,this.alwan?.setColor(e)}render(){return html`
+      <input
+        type="text"
+        id=${this.formid}
+        class="form-control"
+        .value=${this.value}
+        @change=${e=>this.updateValue(e.target.value)}
+      />
+    `}};__decorate([property({type:String})],ColorTypeElement.prototype,"value",void 0),ColorTypeElement=__decorate([customElement(componentName)],ColorTypeElement);export{ColorTypeElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js
new file mode 100644
index 000000000000..c0096f2e1190
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js
@@ -0,0 +1,21 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(e,t,r,o){var n,l=arguments.length,p=l<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)p=Reflect.decorate(e,t,r,o);else for(var m=e.length-1;m>=0;m--)(n=e[m])&&(p=(l<3?n(p):l>3?n(t,r,p):n(t,r))||p);return l>3&&p&&Object.defineProperty(t,r,p),p};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-int";let IntTypeElement=class extends BaseElement{render(){return html`
+      <input
+        type="number"
+        id=${this.formid}
+        class="form-control"
+        .value=${this.value}
+        @change=${e=>this.value=parseInt(e.target.value,10)}
+      />
+    `}};__decorate([property({type:Number})],IntTypeElement.prototype,"value",void 0),IntTypeElement=__decorate([customElement(componentName)],IntTypeElement);export{IntTypeElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js
new file mode 100644
index 000000000000..4631762a071d
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js
@@ -0,0 +1,22 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(e,t,r,o){var n,m=arguments.length,l=m<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(e,t,r,o);else for(var p=e.length-1;p>=0;p--)(n=e[p])&&(l=(m<3?n(l):m>3?n(t,r,l):n(t,r))||l);return m>3&&l&&Object.defineProperty(t,r,l),l};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-number";let NumberTypeElement=class extends BaseElement{render(){return html`
+      <input
+        type="number"
+        id=${this.formid}
+        class="form-control"
+        step="0.01"
+        .value=${this.value}
+        @change=${e=>this.value=parseFloat(e.target.value)}
+      />
+    `}};__decorate([property({type:Number})],NumberTypeElement.prototype,"value",void 0),NumberTypeElement=__decorate([customElement(componentName)],NumberTypeElement);export{NumberTypeElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js
new file mode 100644
index 000000000000..22196ed140c1
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js
@@ -0,0 +1,21 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(e,t,r,o){var n,l=arguments.length,p=l<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)p=Reflect.decorate(e,t,r,o);else for(var i=e.length-1;i>=0;i--)(n=e[i])&&(p=(l<3?n(p):l>3?n(t,r,p):n(t,r))||p);return l>3&&p&&Object.defineProperty(t,r,p),p};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-string";let StringTypeElement=class extends BaseElement{render(){return html`
+      <input
+        type="text"
+        id=${this.formid}
+        class="form-control"
+        .value=${this.value}
+        @change=${e=>this.value=e.target.value}
+      />
+    `}};__decorate([property({type:String})],StringTypeElement.prototype,"value",void 0),StringTypeElement=__decorate([customElement(componentName)],StringTypeElement);export{StringTypeElement};
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js
new file mode 100644
index 000000000000..2cb792e936b3
--- /dev/null
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js
@@ -0,0 +1,45 @@
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+var __decorate=function(t,e,l,o){var i,r=arguments.length,n=r<3?e:null===o?o=Object.getOwnPropertyDescriptor(e,l):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,l,o);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(n=(r<3?i(n):r>3?i(e,l,n):i(e,l))||n);return r>3&&n&&Object.defineProperty(e,l,n),n};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";import{live}from"lit/directives/live.js";export const componentName="typo3-backend-settings-type-stringlist";let StringlistTypeElement=class extends BaseElement{updateValue(t,e){const l=[...this.value];l[e]=t,this.value=l}addValue(t,e=""){this.value=this.value.toSpliced(t+1,0,e)}removeValue(t){this.value=this.value.toSpliced(t,1)}renderItem(t,e){return html`
+      <tr>
+        <td width="99%">
+          <input
+            id=${`${this.formid}${e>0?"-"+e:""}`}
+            type="text"
+            class="form-control"
+            .value=${live(t)}
+            @change=${t=>this.updateValue(t.target.value,e)}
+          />
+        </td>
+        <td>
+          <div class="btn-group" role="group">
+            <button class="btn btn-default" type="button" @click=${()=>this.addValue(e)}>
+              <typo3-backend-icon identifier="actions-plus" size="small"></typo3-backend-icon>
+            </button>
+            <button class="btn btn-default" type="button" @click=${()=>this.removeValue(e)}>
+              <typo3-backend-icon identifier="actions-delete" size="small"></typo3-backend-icon>
+            </button>
+          </div>
+        </td>
+      </tr>
+    `}render(){const t=this.value||[];return html`
+      <div class="form-control-wrap">
+        <div class="table-fit">
+          <table class="table table-hover">
+            <tbody>
+              ${t.map(((t,e)=>this.renderItem(t,e)))}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    `}};__decorate([property({type:Array})],StringlistTypeElement.prototype,"value",void 0),StringlistTypeElement=__decorate([customElement(componentName)],StringlistTypeElement);export{StringlistTypeElement};
\ No newline at end of file
diff --git a/typo3/sysext/core/Classes/Configuration/SiteWriter.php b/typo3/sysext/core/Classes/Configuration/SiteWriter.php
index a25919f451a0..c1ffb446711a 100644
--- a/typo3/sysext/core/Classes/Configuration/SiteWriter.php
+++ b/typo3/sysext/core/Classes/Configuration/SiteWriter.php
@@ -96,7 +96,15 @@ class SiteWriter
     public function writeSettings(string $siteIdentifier, array $settings): void
     {
         $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
-        $yamlFileContents = Yaml::dump($settings, 99, 2);
+        if ($settings === []) {
+            if (!is_file($fileName)) {
+                return;
+            }
+            if (is_writable($fileName) && @unlink($fileName)) {
+                return;
+            }
+        }
+        $yamlFileContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP);
         if (!GeneralUtility::writeFile($fileName, $yamlFileContents)) {
             throw new SiteConfigurationWriteException('Unable to write site settings in sites/' . $siteIdentifier . '/' . $this->configFileName, 1590487411);
         }
diff --git a/typo3/sysext/core/Classes/Settings/Category.php b/typo3/sysext/core/Classes/Settings/Category.php
new file mode 100644
index 000000000000..df74b7a3b2ef
--- /dev/null
+++ b/typo3/sysext/core/Classes/Settings/Category.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Settings;
+
+/**
+ * @internal
+ */
+class Category
+{
+    public function __construct(
+        public string $key,
+        public string $label,
+        public ?string $description = null,
+        public ?string $icon = null,
+        public array $settings = [],
+        public array $categories = [],
+    ) {}
+
+    public function toArray(): array
+    {
+        return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []);
+    }
+
+    public static function __set_state(array $state): self
+    {
+        return new self(...$state);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Settings/CategoryAccumulator.php b/typo3/sysext/core/Classes/Settings/CategoryAccumulator.php
new file mode 100644
index 000000000000..72d31756d2c1
--- /dev/null
+++ b/typo3/sysext/core/Classes/Settings/CategoryAccumulator.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Settings;
+
+class CategoryAccumulator
+{
+    /**
+     * Retrieve list of ordered sets, matched by
+     * $setNames, including their dependencies (recursive)
+     *
+     * @param CategoryDefinition[] $categoryDefinitions
+     * @param SettingDefinition[] $settingsDefinitions
+     * @return list<Category>
+     */
+    public function getCategories(iterable $categoryDefinitions, iterable $settingsDefinitions): array
+    {
+        $categories = [];
+        foreach ($categoryDefinitions as $category) {
+            $data = $category->toArray();
+            $parent = $data['parent'] ?? null;
+            unset($data['parent']);
+            $categories[$category->key] = [
+                'children' => [],
+                'parent' => $parent,
+                'data' => $data,
+            ];
+        }
+        foreach ($categoryDefinitions as $category) {
+            if ($category->parent === null) {
+                continue;
+            }
+            if (!isset($categories[$category->parent])) {
+                throw new \RuntimeException('Missing parent category: ' . $category->parent, 1716291554);
+            }
+            $categories[$category->parent]['children'][] = $category->key;
+        }
+
+        $categorizedSettings = [];
+        foreach ($settingsDefinitions as $definition) {
+            $category = $definition->category ?? null;
+            $categorizedSettings[isset($categories[$category]) ? $category : 'other'][] = $definition;
+        }
+
+        if (isset($categorizedSettings['other'])) {
+            $categories['other'] = [
+                'children' => [],
+                'parent' => null,
+                'data' => [
+                    'key' => 'other',
+                    'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:categories.other',
+                    'description' => '',
+                ],
+            ];
+        }
+
+        $instances = [];
+        foreach ($categories as $key => $category) {
+            if ($category['parent'] === null) {
+                $instances[] = $this->createInstance($categories, $key, $categorizedSettings);
+            }
+        }
+
+        return $instances;
+    }
+
+    private function createInstance(array $categories, string $key, array $categorizedSettings): Category
+    {
+        try {
+            return new Category(...[
+                ...$categories[$key]['data'],
+                'settings' => $categorizedSettings[$key] ?? [],
+                'categories' => array_map(fn($key) => $this->createInstance($categories, $key, $categorizedSettings), $categories[$key]['children']),
+            ]);
+        } catch (\Error $e) {
+            throw new \Exception('Invalid category definition: ' . json_encode($categories[$key]['data']), 1720528084, $e);
+        }
+    }
+}
diff --git a/typo3/sysext/core/Classes/Settings/CategoryDefinition.php b/typo3/sysext/core/Classes/Settings/CategoryDefinition.php
new file mode 100644
index 000000000000..4e1762711547
--- /dev/null
+++ b/typo3/sysext/core/Classes/Settings/CategoryDefinition.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Settings;
+
+/**
+ * @internal
+ */
+readonly class CategoryDefinition
+{
+    public function __construct(
+        public string $key,
+        public string $label,
+        public ?string $description = null,
+        public ?string $icon = null,
+        public ?string $parent = null,
+    ) {}
+
+    public function toArray(): array
+    {
+        return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []);
+    }
+
+    public static function __set_state(array $state): self
+    {
+        return new self(...$state);
+    }
+}
diff --git a/typo3/sysext/core/Classes/Settings/SettingDefinition.php b/typo3/sysext/core/Classes/Settings/SettingDefinition.php
index 99238f17ebf1..f74cb8975685 100644
--- a/typo3/sysext/core/Classes/Settings/SettingDefinition.php
+++ b/typo3/sysext/core/Classes/Settings/SettingDefinition.php
@@ -29,7 +29,7 @@ readonly class SettingDefinition
         public string $label,
         public ?string $description = null,
         public array $enum = [],
-        public array $categories = [],
+        public ?string $category = null,
         public array $tags = [],
     ) {}
 
diff --git a/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php b/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php
index 43529a174689..18e8ea11ccbd 100644
--- a/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php
+++ b/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php
@@ -28,4 +28,6 @@ interface SettingsTypeInterface
     public function validate(mixed $value, SettingDefinition $definition): bool;
 
     public function transformValue(mixed $value, SettingDefinition $definition): mixed;
+
+    public function getJavaScriptModule(): string;
 }
diff --git a/typo3/sysext/core/Classes/Settings/Type/BoolType.php b/typo3/sysext/core/Classes/Settings/Type/BoolType.php
index 3306cc3ee992..64f25acd0e39 100644
--- a/typo3/sysext/core/Classes/Settings/Type/BoolType.php
+++ b/typo3/sysext/core/Classes/Settings/Type/BoolType.php
@@ -74,4 +74,9 @@ readonly class BoolType implements SettingsTypeInterface
         }
         return false;
     }
+
+    public function getJavaScriptModule(): string
+    {
+        return '@typo3/backend/settings/type/bool.js';
+    }
 }
diff --git a/typo3/sysext/core/Classes/Settings/Type/ColorType.php b/typo3/sysext/core/Classes/Settings/Type/ColorType.php
index 034dc17d57b8..06d229234da3 100644
--- a/typo3/sysext/core/Classes/Settings/Type/ColorType.php
+++ b/typo3/sysext/core/Classes/Settings/Type/ColorType.php
@@ -141,4 +141,9 @@ readonly class ColorType implements SettingsTypeInterface
 
         return '#' . $values;
     }
+
+    public function getJavaScriptModule(): string
+    {
+        return '@typo3/backend/settings/type/color.js';
+    }
 }
diff --git a/typo3/sysext/core/Classes/Settings/Type/IntType.php b/typo3/sysext/core/Classes/Settings/Type/IntType.php
index ecda4f255d12..8601ff0c6db2 100644
--- a/typo3/sysext/core/Classes/Settings/Type/IntType.php
+++ b/typo3/sysext/core/Classes/Settings/Type/IntType.php
@@ -52,4 +52,9 @@ readonly class IntType implements SettingsTypeInterface
 
         return (int)$value;
     }
+
+    public function getJavaScriptModule(): string
+    {
+        return '@typo3/backend/settings/type/int.js';
+    }
 }
diff --git a/typo3/sysext/core/Classes/Settings/Type/NumberType.php b/typo3/sysext/core/Classes/Settings/Type/NumberType.php
index 1b76dbc46e45..f3528741cbe0 100644
--- a/typo3/sysext/core/Classes/Settings/Type/NumberType.php
+++ b/typo3/sysext/core/Classes/Settings/Type/NumberType.php
@@ -64,4 +64,9 @@ readonly class NumberType implements SettingsTypeInterface
         }
         return $value;
     }
+
+    public function getJavaScriptModule(): string
+    {
+        return '@typo3/backend/settings/type/number.js';
+    }
 }
diff --git a/typo3/sysext/core/Classes/Settings/Type/StringListType.php b/typo3/sysext/core/Classes/Settings/Type/StringListType.php
index fb96faa66ca2..511818c9aee1 100644
--- a/typo3/sysext/core/Classes/Settings/Type/StringListType.php
+++ b/typo3/sysext/core/Classes/Settings/Type/StringListType.php
@@ -40,6 +40,15 @@ readonly class StringListType implements SettingsTypeInterface
     public function transformValue(mixed $value, SettingDefinition $definition): array
     {
         $stringType = new StringType($this->logger);
+        if (is_string($value)) {
+            // A json-encoded stringlist only needs 2-levels
+            $depth = 2;
+            try {
+                $value = json_decode($value, false, 2, JSON_THROW_ON_ERROR);
+            } catch (\JsonException) {
+                // invalid json, ignore and handle below
+            }
+        }
         if (!is_array($value) || !$this->doValidate($stringType, $value, $definition)) {
             $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]);
             return $definition->default;
@@ -60,4 +69,9 @@ readonly class StringListType implements SettingsTypeInterface
         }
         return true;
     }
+
+    public function getJavaScriptModule(): string
+    {
+        return '@typo3/backend/settings/type/stringlist.js';
+    }
 }
diff --git a/typo3/sysext/core/Classes/Settings/Type/StringType.php b/typo3/sysext/core/Classes/Settings/Type/StringType.php
index 4ee3f7bd5207..dca3c1c6c996 100644
--- a/typo3/sysext/core/Classes/Settings/Type/StringType.php
+++ b/typo3/sysext/core/Classes/Settings/Type/StringType.php
@@ -51,4 +51,9 @@ readonly class StringType implements SettingsTypeInterface
         }
         return (string)$value;
     }
+
+    public function getJavaScriptModule(): string
+    {
+        return '@typo3/backend/settings/type/string.js';
+    }
 }
diff --git a/typo3/sysext/core/Classes/Site/Entity/Site.php b/typo3/sysext/core/Classes/Site/Entity/Site.php
index e237fbaeb270..8f41763774da 100644
--- a/typo3/sysext/core/Classes/Site/Entity/Site.php
+++ b/typo3/sysext/core/Classes/Site/Entity/Site.php
@@ -67,6 +67,12 @@ class Site implements SiteInterface
      */
     protected $configuration;
 
+    /**
+     * Raw attributes for this site
+     * @var array
+     */
+    protected $rawConfiguration;
+
     /**
      * @var array<LanguageRef, SiteLanguage>
      */
@@ -102,6 +108,7 @@ class Site implements SiteInterface
         $this->settings = $settings;
         $this->typoscript = $typoscript;
         $this->tsConfig = $tsConfig;
+        $this->rawConfiguration = $configuration;
         // Merge settings back in configuration for backwards-compatibility
         $configuration['settings'] = $this->settings->getAll();
         $this->configuration = $configuration;
@@ -329,6 +336,11 @@ class Site implements SiteInterface
         return $this->configuration;
     }
 
+    public function getRawConfiguration(): array
+    {
+        return $this->rawConfiguration;
+    }
+
     public function getSettings(): SiteSettings
     {
         return $this->settings;
diff --git a/typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php b/typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php
new file mode 100644
index 000000000000..5f8e373cecf3
--- /dev/null
+++ b/typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Site\Set;
+
+use TYPO3\CMS\Core\Settings\Category;
+use TYPO3\CMS\Core\Settings\CategoryAccumulator;
+
+class CategoryRegistry
+{
+    public function __construct(
+        protected SetRegistry $setRegistry,
+    ) {}
+
+    /**
+     * Retrieve list of instantiated categories for the list of
+     * provided $setNames, including their dependencies (recursive)
+     *
+     * @return list<Category>
+     */
+    public function getCategories(string ...$setNames): array
+    {
+        $sets = $this->setRegistry->getSets(...$setNames);
+        $categories = [];
+
+        $categoryDefinitions = [];
+        foreach ($sets as $set) {
+            foreach ($set->categoryDefinitions as $definition) {
+                $categoryDefinitions[] = $definition;
+            }
+        }
+        $settingsDefinitions = [];
+        foreach ($sets as $set) {
+            foreach ($set->settingsDefinitions as $definition) {
+                $settingsDefinitions[] = $definition;
+            }
+        }
+
+        $cateryAccumulator = new CategoryAccumulator();
+        return $cateryAccumulator->getCategories(
+            $categoryDefinitions,
+            $settingsDefinitions,
+        );
+    }
+}
diff --git a/typo3/sysext/core/Classes/Site/Set/SetDefinition.php b/typo3/sysext/core/Classes/Site/Set/SetDefinition.php
index f62864e557a1..7e80e03993a4 100644
--- a/typo3/sysext/core/Classes/Site/Set/SetDefinition.php
+++ b/typo3/sysext/core/Classes/Site/Set/SetDefinition.php
@@ -17,6 +17,7 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Core\Site\Set;
 
+use TYPO3\CMS\Core\Settings\CategoryDefinition;
 use TYPO3\CMS\Core\Settings\SettingDefinition;
 
 readonly class SetDefinition
@@ -24,6 +25,7 @@ readonly class SetDefinition
     /**
      * @param list<string> $dependencies
      * @param SettingDefinition[] $settingsDefinitions
+     * @param CategoryDefinition[] $categoryDefinitions
      */
     public function __construct(
         public string $name,
@@ -31,6 +33,7 @@ readonly class SetDefinition
         public array $dependencies = [],
         public array $optionalDependencies = [],
         public array $settingsDefinitions = [],
+        public array $categoryDefinitions = [],
         public ?string $typoscript = null,
         public ?string $pagets = null,
         public array $settings = [],
diff --git a/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php b/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php
index 1d7e48b416f3..0a20d7aca78a 100644
--- a/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php
+++ b/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php
@@ -20,6 +20,7 @@ namespace TYPO3\CMS\Core\Site\Set;
 use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
 use Symfony\Component\Yaml\Exception\ParseException;
 use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Core\Settings\CategoryDefinition;
 use TYPO3\CMS\Core\Settings\SettingDefinition;
 
 /**
@@ -66,7 +67,8 @@ class YamlSetDefinitionProvider
             if (!is_array($settingsDefinitions['settings'] ?? null)) {
                 throw new \RuntimeException('Missing "settings" key in settings definitions. Filename: ' . $settingsDefinitionsFile, 1711024378);
             }
-            $set['settingsDefinitions'] = $settingsDefinitions['settings'];
+            $set['settingsDefinitions'] = $settingsDefinitions['settings'] ?? [];
+            $set['categoryDefinitions'] = $settingsDefinitions['categories'] ?? [];
         }
 
         $settingsFile = $path . '/settings.yaml';
@@ -115,9 +117,25 @@ class YamlSetDefinitionProvider
                 }
                 $settingsDefinitions[] = $definition;
             }
+
+            $categoryDefinitions = [];
+            foreach (($set['categoryDefinitions'] ?? []) as $category => $options) {
+                if ($labels) {
+                    $options['label'] ??= 'LLL:' . $labels . ':categories.' . $category;
+                    $options['description'] ??= 'LLL:' . $labels . ':categories.description.' . $category;
+                }
+                try {
+                    $definition = new CategoryDefinition(...[...['key' => $category], ...$options]);
+                } catch (\Error $e) {
+                    throw new \Exception('Invalid category-category definition: ' . json_encode($options), 1702623313, $e);
+                }
+                $categoryDefinitions[] = $definition;
+            }
+
             $setData = [
                 ...$set,
                 'settingsDefinitions' => $settingsDefinitions,
+                'categoryDefinitions' => $categoryDefinitions,
             ];
             $setData['typoscript'] ??= $basePath;
             $setData['pagets'] ??= $basePath . '/page.tsconfig';
diff --git a/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php b/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php
index 5413f36bbabf..2507ffab844c 100644
--- a/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php
+++ b/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php
@@ -135,6 +135,31 @@ readonly class SiteSettingsFactory
         );
     }
 
+    public function createSettingsForKeys(array $settingKeys, string $siteIdentifier, array $inlineSettings = []): SiteSettings
+    {
+        $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName;
+        if (file_exists($fileName)) {
+            $settingsTree = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName));
+        } else {
+            $settingsTree = $inlineSettings;
+        }
+
+        /** @var array<string, string|int|float|bool|array|null> $settingsMap */
+        $settingsMap = [];
+        foreach ($settingKeys as $key) {
+            if (!ArrayUtility::isValidPath($settingsTree, $key, '.')) {
+                continue;
+            }
+            $settingsMap[$key] = ArrayUtility::getValueByPath($settingsTree, $key, '.');
+        }
+        $flatSettings = $settingsTree === [] ? [] : ArrayUtility::flattenPlain($settingsTree);
+        return new SiteSettings(
+            settings: $settingsMap,
+            settingsTree: $settingsTree,
+            flatSettings: $flatSettings,
+        );
+    }
+
     protected function validateSettings(array $settings, array $definitions): array
     {
         foreach ($definitions as $definition) {
diff --git a/typo3/sysext/core/Classes/Site/SiteSettingsService.php b/typo3/sysext/core/Classes/Site/SiteSettingsService.php
new file mode 100644
index 000000000000..8117e463cddd
--- /dev/null
+++ b/typo3/sysext/core/Classes/Site/SiteSettingsService.php
@@ -0,0 +1,180 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Core\Site;
+
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
+use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
+use TYPO3\CMS\Core\Configuration\SiteWriter;
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Core\Messaging\FlashMessageService;
+use TYPO3\CMS\Core\Settings\SettingDefinition;
+use TYPO3\CMS\Core\Settings\SettingsTypeRegistry;
+use TYPO3\CMS\Core\Site\Entity\Site;
+use TYPO3\CMS\Core\Site\Entity\SiteSettings;
+use TYPO3\CMS\Core\Site\Set\SetRegistry;
+use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
+use TYPO3\CMS\Core\Utility\ArrayUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal
+ */
+readonly class SiteSettingsService
+{
+    public function __construct(
+        protected SiteWriter $siteWriter,
+        #[Autowire(service: 'cache.core')]
+        protected PhpFrontend $codeCache,
+        protected SetRegistry $setRegistry,
+        protected SiteSettingsFactory $siteSettingsFactory,
+        protected SettingsTypeRegistry $settingsTypeRegistry,
+        protected FlashMessageService $flashMessageService,
+    ) {}
+
+    public function hasSettingsDefinitions(Site $site): bool
+    {
+        return count($this->getDefinitions($site)) > 0;
+    }
+
+    public function getUncachedSettings(Site $site): SiteSettings
+    {
+        // create a fresh Settings instance instead of using
+        // $site->getSettings() which may have been loaded from cache
+        return $settings = $this->siteSettingsFactory->createSettings(
+            $site->getSets(),
+            $site->getIdentifier(),
+            $site->getRawConfiguration()['settings'] ?? [],
+        );
+    }
+
+    public function getSetSettings(Site $site): SiteSettings
+    {
+        return $this->siteSettingsFactory->createSettings($site->getSets());
+    }
+
+    public function getLocalSettings(Site $site): SiteSettings
+    {
+        $definitions = $this->getDefinitions($site);
+        return $this->siteSettingsFactory->createSettingsForKeys(
+            array_map(static fn(SettingDefinition $d) => $d->key, $definitions),
+            $site->getIdentifier(),
+            $site->getRawConfiguration()['settings'] ?? []
+        );
+    }
+
+    public function computeSettingsDiff(Site $site, array $rawSettings, bool $minify = true): array
+    {
+        $settings = [];
+        $localSettings = [];
+
+        $definitions = $this->getDefinitions($site);
+        foreach ($rawSettings as $key => $value) {
+            $definition = $definitions[$key] ?? null;
+            if ($definition === null) {
+                throw new \RuntimeException('Unexpected setting ' . $key . ' is not defined', 1724067004);
+            }
+            $type = $this->settingsTypeRegistry->get($definition->type);
+            $settings[$key] = $type->transformValue($value, $definition);
+        }
+
+        // Settings from sets – setting values without config/sites/*/settings.yaml applied
+        $setSettings = $this->siteSettingsFactory->createSettings($site->getSets());
+        // Settings from config/sites/*/settings.yaml only (our persistence target)
+        $localSettings = $this->siteSettingsFactory->createSettingsForKeys(
+            array_map(static fn(SettingDefinition $d) => $d->key, $definitions),
+            $site->getIdentifier(),
+            $site->getRawConfiguration()['settings'] ?? []
+        );
+
+        // Read existing settings, as we *must* not remove any settings that may be present because of
+        //  * "undefined" settings that were supported since TYPO3 v12
+        //  * (temporary) inactive sets
+        $settingsTree = $localSettings->getAll();
+
+        // Merge incoming settings into current settingsTree
+        $changes = [];
+        $deletions = [];
+        foreach ($settings as $key => $value) {
+            if ($minify && $value === $setSettings->get($key)) {
+                if (ArrayUtility::isValidPath($settingsTree, $key, '.')) {
+                    $settingsTree = $this->removeByPathWithAncestors($settingsTree, $key, '.');
+                    $deletions[] = $key;
+                }
+                continue;
+            }
+            $settingsTree = ArrayUtility::setValueByPath($settingsTree, $key, $value, '.');
+            $changes[] = $key;
+        }
+        return [
+            'settings' => $settingsTree,
+            'changes' => $changes,
+            'deletions' => $deletions,
+        ];
+    }
+
+    public function writeSettings(Site $site, array $settings): void
+    {
+        try {
+            $this->siteWriter->writeSettings($site->getIdentifier(), $settings);
+        } catch (SiteConfigurationWriteException $e) {
+            $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $e->getMessage(), '', ContextualFeedbackSeverity::ERROR, true);
+            $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
+            $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier();
+            $defaultFlashMessageQueue->enqueue($flashMessage);
+        }
+        // SiteWriter currently does not invalidate the code cache, see #103804
+        $this->codeCache->flush();
+    }
+
+    private function getDefinitions(Site $site): array
+    {
+        $sets = $this->setRegistry->getSets(...$site->getSets());
+        $definitions = [];
+        foreach ($sets as $set) {
+            foreach ($set->settingsDefinitions as $settingDefinition) {
+                $definitions[$settingDefinition->key] = $settingDefinition;
+            }
+        }
+        return $definitions;
+    }
+
+    private function removeByPathWithAncestors(array $array, string $path, string $delimiter): array
+    {
+        if ($path === '') {
+            return $array;
+        }
+        if (!ArrayUtility::isValidPath($array, $path, $delimiter)) {
+            return $array;
+        }
+
+        $array = ArrayUtility::removeByPath($array, $path, $delimiter);
+        $parts = explode($delimiter, $path);
+        array_pop($parts);
+        $parentPath = implode($delimiter, $parts);
+
+        if ($parentPath !== '' && ArrayUtility::isValidPath($array, $parentPath, $delimiter)) {
+            $parent = ArrayUtility::getValueByPath($array, $parentPath, $delimiter);
+            if ($parent === []) {
+                return $this->removeByPathWithAncestors($array, $parentPath, $delimiter);
+            }
+        }
+        return $array;
+    }
+}
diff --git a/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst
new file mode 100644
index 000000000000..ff55fa8f52ee
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst
@@ -0,0 +1,81 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-104794-1725980585:
+
+=================================================
+Feature: #104794 - Introduce Site Settings Editor
+=================================================
+
+See :issue:`104794`
+
+Description
+===========
+
+A new Site Settings editor has been introduced that allows to configure per-site
+settings in file:`config/sites/*/settings.yaml`.
+
+The new backend module :guilabel:`Site Management > Settings`
+provides an overview of sites which offer configurable settings and makes
+them editable based on
+:doc:`Site Set provided Settings Definitions <../13.1/Feature-103437-IntroduceSiteSets>`.
+
+The editor shows a list of settings categories and respective settings. It will
+persist all settings into file:`config/sites/*/settings.yaml`. The module will
+only persist settings that deviate from the site-scoped default value. That
+means it will only change the minimal difference to the settings set defined
+by the active sets for the respective site.
+
+The backend module is currently available for administrators only, but will
+likely be extended to be made available for editors in future.
+
+Anonymous (undefined) site settings – as supported since TYPO3 v10 –
+will not be made editable, but will be preserved as-is when persisting changes
+through the settings editor.
+
+
+Categorization
+--------------
+
+Sets can define categories and settings definitions can reference the category
+by ID in order to assign a setting to a specific category.
+These definitions are placed in :file:`settings.definitions.yaml`
+next to the site set file :file:`config.yaml`.
+
+..  code-block:: yaml
+    :caption: EXT:my_extension/Configuration/Sets/MySet/settings.definitions.yaml
+
+    categories:
+      myCategory:
+        label: 'My Category'
+
+    settings:
+      my.example.setting:
+        label: 'My example setting'
+        category: myCategory
+        type: string
+        default: ''
+
+      my.seoRelevantSetting:
+        label: 'My SEO relevant setting'
+        # show in EXT:seo provided category "seo"
+        category: seo
+        type: int
+        default: 5
+
+The settings ordering is defined through the loading order of extensions and by
+the order of categories. Uncategorized settings will be grouped into a virtual
+"Other" category and shown at the end of the list of available settings.
+
+
+Impact
+======
+
+Site-scoped settings will most likely be the place to configure site-wide
+configuration, which was previously only possible to modify via Constant Editor,
+modifying TypoScript constants.
+
+It is recommended to use site-sets and their UI configuration in favor of
+Typoscript Constants in the future.
+
+
+.. index:: Backend, Frontend, YAML, ext:backend
diff --git a/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf b/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf
index 6209e971b1d3..fd462d9ab14a 100644
--- a/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf
+++ b/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf
@@ -6,6 +6,11 @@
 			<trans-unit id="label" resname="label">
 				<source>Frontend Login</source>
 			</trans-unit>
+
+			<trans-unit id="categories.felogin" resname="categories.felogin">
+				<source>Frontend Login</source>
+			</trans-unit>
+
 			<trans-unit id="settings.felogin.pid" resname="settings.felogin.pid">
 				<source>User Storage Page</source>
 			</trans-unit>
diff --git a/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml b/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml
index 4f0f94cf1254..1c5987de4f75 100644
--- a/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml
+++ b/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml
@@ -1,7 +1,11 @@
+categories:
+  felogin: ~
+
 settings:
   felogin.pid:
     default: '0'
     type: string
+    category: felogin
   felogin.recursive:
     default: '0'
     type: string
@@ -12,72 +16,96 @@ settings:
       '3': '3'
       '4': '4'
       '255': '255'
+    category: felogin
   felogin.showForgotPassword:
     default: false
     type: bool
+    category: felogin
   felogin.showPermaLogin:
     default: false
     type: bool
+    category: felogin
   felogin.showLogoutFormAfterLogin:
     default: false
     type: bool
+    category: felogin
   felogin.emailFrom:
     default: ''
     type: string
+    category: felogin
   felogin.emailFromName:
     default: ''
     type: string
+    category: felogin
   felogin.replyToEmail:
     default: ''
     type: string
+    category: felogin
   felogin.dateFormat:
     default: 'Y-m-d H:i'
     type: string
+    category: felogin
   felogin.email.layoutRootPath:
     default: ''
     type: string
+    category: felogin
   felogin.email.templateRootPath:
     default: 'EXT:felogin/Resources/Private/Email/Templates/'
     type: string
+    category: felogin
   felogin.email.partialRootPath:
     default: ''
     type: string
+    category: felogin
   felogin.email.templateName:
     default: PasswordRecovery
     type: string
+    category: felogin
   felogin.redirectMode:
     default: ''
     type: string
+    category: felogin
   felogin.redirectFirstMethod:
     default: false
     type: bool
+    category: felogin
   felogin.redirectPageLogin:
     default: 0
     type: int
+    category: felogin
   felogin.redirectPageLoginError:
     default: 0
     type: int
+    category: felogin
   felogin.redirectPageLogout:
     default: 0
     type: int
+    category: felogin
   felogin.redirectDisable:
     default: false
     type: bool
+    category: felogin
   felogin.forgotLinkHashValidTime:
     default: 12
     type: int
+    category: felogin
   felogin.domains:
     default: ''
     type: string
+    category: felogin
   felogin.exposeNonexistentUserInForgotPasswordDialog:
     default: false
     type: bool
+    category: felogin
   felogin.view.templateRootPath:
     default: ''
     type: string
+    category: felogin
   felogin.view.partialRootPath:
     default: ''
     type: string
+    category: felogin
   felogin.view.layoutRootPath:
     default: ''
     type: string
+    category: felogin
diff --git a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf
index e418034c8c34..eec39bda99b5 100644
--- a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf
+++ b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf
@@ -6,6 +6,17 @@
 			<trans-unit id="label" resname="label">
 				<source>Fluid Styled Content</source>
 			</trans-unit>
+
+			<trans-unit id="categories.fsc" resname="categories.fsc">
+				<source>Fluid Styled Content</source>
+			</trans-unit>
+			<trans-unit id="categories.fsc.templates" resname="categories.fsc.templates">
+				<source>Templates</source>
+			</trans-unit>
+			<trans-unit id="categories.fsc.content" resname="categories.fsc.content">
+				<source>Content Elements</source>
+			</trans-unit>
+
 			<trans-unit id="settings.styles.content.defaultHeaderType" resname="settings.styles.content.defaultHeaderType">
 				<source>Default Header type</source>
 			</trans-unit>
diff --git a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml
index da97c227c184..b9b960857242 100644
--- a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml
+++ b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml
@@ -1,13 +1,23 @@
+categories:
+  fsc: ~
+  fsc.templates:
+    parent: fsc
+  fsc.content:
+    parent: fsc
+
 settings:
   styles.content.defaultHeaderType:
     default: 2
     type: int
+    category: fsc.content
   styles.content.shortcut.tables:
     default: tt_content
     type: string
+    category: fsc.content
   styles.content.allowTags:
     default: 'a, abbr, acronym, address, article, aside, b, bdo, big, blockquote, br, caption, center, cite, code, col, colgroup, dd, del, dfn, dl, div, dt, em, figure, font, footer, header, h1, h2, h3, h4, h5, h6, hr, i, img, ins, kbd, label, li, link, mark, meta, nav, ol, p, pre, q, s, samp, sdfield, section, small, span, strike, strong, style, sub, sup, table, thead, tbody, tfoot, td, th, tr, title, tt, u, ul, var'
     type: string
+    category: fsc.content
   styles.content.image.lazyLoading:
     default: lazy
     type: string
@@ -15,6 +25,7 @@ settings:
       lazy: 'Lazy'
       eager: 'Eager'
       auto: 'Auto'
+    category: fsc.content
   styles.content.image.imageDecoding:
     default: ''
     type: string
@@ -22,60 +33,80 @@ settings:
       sync: 'Sync'
       async: 'Asynchronous'
       auto: 'Auto'
+    category: fsc.content
   styles.content.textmedia.maxW:
     default: 600
     type: int
+    category: fsc.content
   styles.content.textmedia.maxWInText:
     default: 300
     type: int
+    category: fsc.content
   styles.content.textmedia.columnSpacing:
     default: 10
     type: int
+    category: fsc.content
   styles.content.textmedia.rowSpacing:
     default: 10
     type: int
+    category: fsc.content
   styles.content.textmedia.textMargin:
     default: 10
     type: int
+    category: fsc.content
   styles.content.textmedia.borderColor:
     default: '#000000'
     type: color
+    category: fsc.content
   styles.content.textmedia.borderWidth:
     default: 2
     type: int
+    category: fsc.content
   styles.content.textmedia.borderPadding:
     default: 0
     type: int
+    category: fsc.content
   styles.content.textmedia.linkWrap.width:
     default: 800m
     type: string
+    category: fsc.content
   styles.content.textmedia.linkWrap.height:
     default: 600m
     type: string
+    category: fsc.content
   styles.content.textmedia.linkWrap.newWindow:
     default: false
     type: bool
+    category: fsc.content
   styles.content.textmedia.linkWrap.lightboxEnabled:
     default: false
     type: bool
+    category: fsc.content
   styles.content.textmedia.linkWrap.lightboxCssClass:
     default: lightbox
     type: string
+    category: fsc.content
   styles.content.textmedia.linkWrap.lightboxRelAttribute:
     default: 'lightbox[{field:uid}]'
     type: string
+    category: fsc.content
   styles.content.links.extTarget:
     default: _blank
     type: string
+    category: fsc.content
   styles.content.links.keep:
     default: path
     type: string
+    category: fsc.content
   styles.templates.templateRootPath:
     default: ''
     type: string
+    category: fsc.templates
   styles.templates.partialRootPath:
     default: ''
     type: string
+    category: fsc.templates
   styles.templates.layoutRootPath:
     default: ''
     type: string
+    category: fsc.templates
diff --git a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf
index 3531deaf42d9..5690f89050c8 100644
--- a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf
+++ b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf
@@ -7,6 +7,13 @@
 				<source>Indexed Search</source>
 			</trans-unit>
 
+			<trans-unit id="categories.indexedsearch" resname="categories.indexedsearch">
+				<source>Indexed Search</source>
+			</trans-unit>
+			<trans-unit id="categories.indexedsearch.templates" resname="categories.indexedsearch.templates">
+				<source>Templates</source>
+			</trans-unit>
+
 			<trans-unit id="settings.indexedsearch.view.templateRootPath" resname="settings.indexedsearch.view.templateRootPath">
 				<source>Path to template root (FE)</source>
 			</trans-unit>
diff --git a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml
index 26e579f799bc..621cb185125c 100644
--- a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml
+++ b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml
@@ -1,16 +1,26 @@
+categories:
+  indexedsearch: ~
+  indexedsearch.templates:
+    parent: indexedsearch
+
 settings:
   indexedsearch.view.templateRootPath:
     default: 'EXT:indexed_search/Resources/Private/Templates/'
     type: string
+    category: indexedsearch.templates
   indexedsearch.view.partialRootPath:
     default: 'EXT:indexed_search/Resources/Private/Partials/'
     type: string
+    category: indexedsearch.templates
   indexedsearch.view.layoutRootPath:
     default: 'EXT:indexed_search/Resources/Private/Layouts/'
     type: string
+    category: indexedsearch.templates
   indexedsearch.targetPid:
     default: 0
     type: int
+    category: indexedsearch
   indexedsearch.rootPidList:
     default: ''
     type: string
+    category: indexedsearch
diff --git a/typo3/sysext/redirects/Configuration/Backend/Modules.php b/typo3/sysext/redirects/Configuration/Backend/Modules.php
index a54ebe9cc696..548d1df1e73e 100644
--- a/typo3/sysext/redirects/Configuration/Backend/Modules.php
+++ b/typo3/sysext/redirects/Configuration/Backend/Modules.php
@@ -8,7 +8,7 @@ use TYPO3\CMS\Redirects\Controller\ManagementController;
 return [
     'site_redirects' => [
         'parent' => 'site',
-        'position' => ['after' => 'site_configuration'],
+        'position' => ['after' => 'site_settings'],
         'access' => 'user',
         'path' => '/module/site/redirects',
         'iconIdentifier' => 'module-redirects',
diff --git a/typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml b/typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml
new file mode 100644
index 000000000000..0bdb82bff694
--- /dev/null
+++ b/typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml
@@ -0,0 +1 @@
+name: typo3/redirects
diff --git a/typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf b/typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf
new file mode 100644
index 000000000000..667f44fb7671
--- /dev/null
+++ b/typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
+	<file source-language="en" datatype="plaintext" original="EXT:redirects/Configuration/Sets/redirects/labels.xlf" date="2024-09-10T12:30:00Z"
+		  product-name="redirects">
+		<header/>
+		<body>
+			<trans-unit id="label" resname="label">
+				<source>Redirects</source>
+			</trans-unit>
+
+			<trans-unit id="categories.redirects" resname="categories.redirects">
+				<source>Redirects</source>
+			</trans-unit>
+
+			<trans-unit id="settings.redirects.autoUpdateSlugs" resname="settings.redirects.autoUpdateSlugs">
+				<source>Automatically update slugs of all sub pages</source>
+			</trans-unit>
+
+			<trans-unit id="settings.redirects.autoCreateRedirects" resname="settings.redirects.autoCreateRedirects">
+				<source>Automatically create redirects for pages with a new slug (works only in LIVE workspace)</source>
+			</trans-unit>
+			<trans-unit id="settings.description.redirects.autoCreateRedirects" resname="settings.description.redirects.autoCreateRedirects">
+				<source>This feature works only in LIVE workspace.</source>
+			</trans-unit>
+
+			<trans-unit id="settings.redirects.redirectTTL" resname="settings.redirects.redirectTTL">
+				<source>Time To Live in days for redirect records to be created - `0` disables TTL, no expiration</source>
+			</trans-unit>
+
+			<trans-unit id="settings.redirects.httpStatusCode" resname="settings.redirects.httpStatusCode">
+				<source>HTTP status code for automatically created redirects</source>
+			</trans-unit>
+
+			<trans-unit id="settings.description.redirects.httpStatusCode" resname="settings.description.redirects.httpStatusCode">
+				<source>See https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections for more information.</source>
+			</trans-unit>
+		</body>
+	</file>
+</xliff>
diff --git a/typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml b/typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml
new file mode 100644
index 000000000000..be227986169b
--- /dev/null
+++ b/typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml
@@ -0,0 +1,20 @@
+categories:
+  redirects: ~
+
+settings:
+  redirects.autoUpdateSlugs:
+    type: bool
+    default: true
+    category: redirects
+  redirects.autoCreateRedirects:
+    type: bool
+    default: true
+    category: redirects
+  redirects.redirectTTL:
+    type: int
+    default: 0
+    category: redirects
+  redirects.httpStatusCode:
+    type: int
+    default: 307
+    category: redirects
diff --git a/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf b/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf
index 9696019fb34b..8648dc1af831 100644
--- a/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf
+++ b/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf
@@ -8,6 +8,13 @@
 				<source>SEO Sitemap</source>
 			</trans-unit>
 
+			<trans-unit id="categories.seo" resname="categories.seo">
+				<source>SEO Sitemap</source>
+			</trans-unit>
+			<trans-unit id="categories.seo.templates" resname="categories.seo.templates">
+				<source>Template Paths</source>
+			</trans-unit>
+
 			<trans-unit id="settings.seo.sitemap.view.templateRootPath" resname="settings.seo.sitemap.view.templateRootPath">
 				<source>Path to template root (FE)</source>
 			</trans-unit>
diff --git a/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml b/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml
index 8d55e56eb8ad..52e69f005bcf 100644
--- a/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml
+++ b/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml
@@ -1,19 +1,30 @@
+categories:
+  seo: ~
+  seo.templates:
+    parent: seo
+
 settings:
   seo.sitemap.view.templateRootPath:
     default: 'EXT:seo/Resources/Private/Templates/'
     type: string
+    category: seo.templates
   seo.sitemap.view.partialRootPath:
     default: 'EXT:seo/Resources/Private/Partials/'
     type: string
+    category: seo.templates
   seo.sitemap.view.layoutRootPath:
     default: 'EXT:seo/Resources/Private/Layouts/'
     type: string
+    category: seo.templates
   seo.sitemap.pages.excludedDoktypes:
     default: '3, 4, 6, 7, 199, 254'
     type: string
+    category: seo
   seo.sitemap.pages.excludePagesRecursive:
     default: ''
     type: string
+    category: seo
   seo.sitemap.pages.additionalWhere:
     default: "{#no_index} = 0 AND {#canonical_link} = ''"
     type: string
+    category: seo
-- 
GitLab