Feature: Appearance switcher 

Description 

The theme now ships the script and the control that let a visitor change appearance, palette, and the main menu - the piece Feature: Navigation shipped without. Three new TypoScript constants under theme.appearance, in Configuration/TypoScript/Appearance.typoscript, set the server-rendered default; an inline script and Resources/Public/JavaScript/theme.js apply a visitor's stored choice and operate the controls from there.

Constant Values Default
theme.appearance.default auto, light, dark auto
theme.appearance.palette neutral, ember, ocean, moss, violet neutral
theme.appearance.contentOutline on, off on

These are constants, not Site Settings. A constant is read identically by the site set and by the classic sys_template static include, which is the property this theme is built around; a settings.definitions.yaml would only serve the set and introduce a second source of truth for the same value. A site package that wants these editable in the Site Settings UI can still declare them in its own set.

Server-rendered attributes 

config.htmlTag.attributes.* writes every value onto the <html> tag raw - RequestHandler::generateHtmlTag() only applies stdWrap when a matching . sub-array is configured, and none is here - so a constant is the only thing that can go on this path, never a cObject:

config.htmlTag.attributes.data-palette = {$theme.appearance.palette}
config.htmlTag.attributes.data-theme-content-outline = {$theme.appearance.contentOutline}

["{$theme.appearance.default}" != "auto"]
    config.htmlTag.attributes.data-theme = {$theme.appearance.default}
[END]
Copied!

data-theme is behind that condition rather than assigned directly because auto means the absence of the attribute, not a value for it - no selector matches data-theme="auto", and rendering it would look like a working default while doing nothing.

The no-flash script 

An inline script in the document head applies a stored appearance and palette before first paint. It is emitted through page.headerData rather than f:asset.script, because the asset collector may move a script to the end of the body, and a stored dark appearance would then paint light first - the exact flash this script exists to prevent.

It sets data-js on the root first and unconditionally, then reads localStorage for the stored appearance and palette, each inside its own try/catch. localStorage throws rather than returning null in Safari's private mode and when cookies are blocked, and an uncaught throw would abort the script before the marker was set - which is why the marker comes first, not after. Losing it costs more than a missed colour choice: data-js is also what the navigation collapse and the switcher's visibility are gated behind, so a page that lost it keeps the always-expanded navigation and a permanently hidden switcher for the rest of that visit.

The switcher itself is hidden until data-js is set, for the same reason: a control that cannot work yet is a visible promise the page cannot keep, not a degraded one.

The module script 

Resources/Public/JavaScript/theme.js, loaded with page.includeJSFooter and .type = module. A module is deferred by specification, so it needs no defer attribute, and it runs in the footer rather than the head because it only wires up listeners on elements that already exist by then. It owns the appearance control, the palette control, and - now that its script has arrived - the main menu toggle that Feature: Navigation shipped inert.

The file uses no optional chaining (?.) anywhere. A browser that does not recognise type="module" skips the element unparsed and can never fail on syntax inside it, but "recognises modules" and "supports optional chaining" are not the same floor - module support landed 2017-2018, optional chaining in 2020 - and a syntax error anywhere in a module aborts the whole file, with no per-statement fallback the way a classic script has. Plain if checks cost nothing and remove that failure mode entirely.

Palette swatches 

Each palette button in the switcher carries a small swatch in that palette's primary colour. The swatch colour is the one duplicated colour in the stylesheet: every palette lives entirely inside its own [data-palette='…'] selector, and CSS has no mechanism to ask what a custom property would resolve to under a different attribute value, so the swatch cannot reference the token live. ComponentLibraryTest::everyPaletteHasASwatchInTheSwitcher is what keeps the copy from drifting out of step with abstracts/_palettes.scss.

Impact 

No cookie is set anywhere in this mechanism, and nothing is persisted server side. localStorage is the whole story - two keys, theme-appearance and theme-palette - which is also why there is no consent question to design around: the choice never leaves the browser it was made in.

The main menu toggle, shipped inert in Feature: Navigation, is now fully wired: clicking it flips aria-expanded, Escape and an outside click close the menu again. Nothing about the navigation's no-JavaScript layout changes - the collapse remains gated behind data-js, exactly as before.