From 3e209dfcbc1f14635284e6fb32af835b6dd3e47a Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Sat, 8 Aug 2026 15:47:25 +0200 Subject: [PATCH] Add custom Solstice theme with a dark/light toggle Introduces a custom Aura theme variant (amber daylight / aurora cyan midnight, via -light/-dark token pairs) and a header toggle button to switch between them at runtime, using the Vaadin 25 ColorScheme API (Page#setColorScheme, @ColorScheme) rather than the older Lumo-only theme-attribute pattern. The choice is persisted in a cookie so it survives reloads and future sessions without any client-side script, and is restored flash-free on the next visit via an IndexHtmlRequestListener. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BNL5syWGwM7KnFcuZeEKQ1 --- AGENTS.md | 4 +- src/main/java/com/example/Application.java | 5 ++ .../java/com/example/base/ui/MainLayout.java | 11 +++- .../java/com/example/base/ui/ThemeCookie.java | 53 +++++++++++++++++ .../com/example/base/ui/ThemeInitializer.java | 58 +++++++++++++++++++ .../com/example/base/ui/ThemePreference.java | 48 +++++++++++++++ .../java/com/example/base/ui/ThemeToggle.java | 42 ++++++++++++++ .../resources/META-INF/resources/solstice.css | 49 ++++++++++++++++ .../com/example/base/ui/ThemeToggleTest.java | 49 ++++++++++++++++ .../example/base/ui/ThemeToggleTestView.java | 22 +++++++ 10 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/base/ui/ThemeCookie.java create mode 100644 src/main/java/com/example/base/ui/ThemeInitializer.java create mode 100644 src/main/java/com/example/base/ui/ThemePreference.java create mode 100644 src/main/java/com/example/base/ui/ThemeToggle.java create mode 100644 src/main/resources/META-INF/resources/solstice.css create mode 100644 src/test/java/com/example/base/ui/ThemeToggleTest.java create mode 100644 src/test/java/com/example/base/ui/ThemeToggleTestView.java diff --git a/AGENTS.md b/AGENTS.md index 61121ea..a77be0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,9 @@ Port defaults to 8080 (override via `server.port` in `src/main/resources/applica **Schema management**: `spring.jpa.hibernate.ddl-auto=update` is used for local dev convenience only. This is explicitly not appropriate for production — the app is meant to move to Flyway (or similar) for real schema migrations before shipping. -**Frontend theming**: `Application` sets the app-shell config (`@Push`, `Aura` theme via `@StyleSheet(Aura.STYLESHEET)`, plus custom `styles.css`/`view-title.css` under `src/main/resources/META-INF/resources/`). No separate frontend build step is needed for typical UI work since Flow generates the client bundle; the `vaadin-maven-plugin` `build-frontend` goal runs as part of the Maven build. +**Frontend theming**: `Application` sets the app-shell config (`@Push`, `Aura` theme via `@StyleSheet(Aura.STYLESHEET)`, plus custom `styles.css`/`view-title.css`/`solstice.css` under `src/main/resources/META-INF/resources/`). No separate frontend build step is needed for typical UI work since Flow generates the client bundle; the `vaadin-maven-plugin` `build-frontend` goal runs as part of the Maven build. + +**Custom theme ("Solstice") and dark/light mode**: `solstice.css` overrides Aura's `-light`/`-dark` suffixed design tokens (e.g. `--aura-accent-color-light/-dark`, `--aura-background-color-light/-dark`) rather than the unsuffixed ones — those are read-only, computed via the native CSS `light-dark()` function from the pair. Everything else should keep consuming the unsuffixed tokens (`--aura-accent-color`, `--vaadin-text-color`, ...) so it follows whichever scheme is active automatically. Switching schemes at runtime uses the Vaadin 25 `ColorScheme` API (`Application` declares `@ColorScheme(ColorScheme.Value.SYSTEM)` as the default; `Page#setColorScheme(...)` changes it at runtime) — do not use the older Lumo-only `setAttribute("theme", ...)` pattern. The feature lives in `com.example.base.ui`: `ThemePreference` (session-scoped) holds the current choice and applies it; `ThemeToggle` is the header button; `ThemeInitializer` restores the choice from a cookie (`ThemeCookie`) on every request/UI-init, both for a flash-free reload and to seed a fresh session, since a plain `@ColorScheme` annotation can't read per-visitor state on its own. ## Testing diff --git a/src/main/java/com/example/Application.java b/src/main/java/com/example/Application.java index 0b37f3d..b6470af 100644 --- a/src/main/java/com/example/Application.java +++ b/src/main/java/com/example/Application.java @@ -6,11 +6,16 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import com.vaadin.flow.component.dependency.StyleSheet; import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.ColorScheme; import com.vaadin.flow.component.page.Push; @SpringBootApplication @StyleSheet(Aura.STYLESHEET) @StyleSheet("styles.css") // Your custom styles +@StyleSheet("solstice.css") // Custom Solstice theme: amber daylight / aurora midnight +// Default for first-ever visits, before a preference cookie exists (see ThemeInitializer): +// follow the OS light/dark setting. com.example.base.ui.ThemeToggle lets a user override it. +@ColorScheme(ColorScheme.Value.SYSTEM) @Push public class Application implements AppShellConfigurator { diff --git a/src/main/java/com/example/base/ui/MainLayout.java b/src/main/java/com/example/base/ui/MainLayout.java index 8870f44..fe9d1d7 100644 --- a/src/main/java/com/example/base/ui/MainLayout.java +++ b/src/main/java/com/example/base/ui/MainLayout.java @@ -24,9 +24,11 @@ public final class MainLayout extends AppLayout { // AuthenticationContext is not Serializable by design, so this field must stay transient. private final transient AuthenticationContext authenticationContext; + private final ThemePreference themePreference; - MainLayout(AuthenticationContext authenticationContext) { + MainLayout(AuthenticationContext authenticationContext, ThemePreference themePreference) { this.authenticationContext = authenticationContext; + this.themePreference = themePreference; setPrimarySection(Section.DRAWER); addToDrawer(createApplicationHeader(), createApplicationDrawer(), createApplicationFooter()); } @@ -40,8 +42,13 @@ public final class MainLayout extends AppLayout { var appName = new Span("My Application"); appName.addClassName("app-name"); - var header = new HorizontalLayout(appLogo, appName); + var nameGroup = new HorizontalLayout(appLogo, appName); + nameGroup.setAlignItems(FlexComponent.Alignment.CENTER); + + var header = new HorizontalLayout(nameGroup, new ThemeToggle(themePreference)); header.setAlignItems(FlexComponent.Alignment.CENTER); + header.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN); + header.setWidthFull(); header.setPadding(true); return header; } diff --git a/src/main/java/com/example/base/ui/ThemeCookie.java b/src/main/java/com/example/base/ui/ThemeCookie.java new file mode 100644 index 0000000..4085c8f --- /dev/null +++ b/src/main/java/com/example/base/ui/ThemeCookie.java @@ -0,0 +1,53 @@ +package com.example.base.ui; + +import com.vaadin.flow.component.page.ColorScheme; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinResponse; +import jakarta.servlet.http.Cookie; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; + +/** + * Reads and writes the visitor's chosen {@link ColorScheme.Value} as a cookie, so it survives + * page reloads and future sessions without needing any client-side script. Kept as a plain + * utility class (rather than folded into {@link ThemePreference}) so both {@link ThemeInitializer} + * (which only ever has a raw request/response, not a session-scoped bean) and + * {@link ThemePreference} can share the exact same encoding. + */ +final class ThemeCookie { + + static final String NAME = "app-theme"; + + private static final int MAX_AGE_SECONDS = (int) Duration.ofDays(365).toSeconds(); + + private ThemeCookie() { + } + + /** Returns the stored preference, if any cookie was sent with the request. */ + static Optional read(@Nullable VaadinRequest request) { + if (request == null || request.getCookies() == null) { + return Optional.empty(); + } + return Arrays.stream(request.getCookies()) + .filter(cookie -> NAME.equals(cookie.getName())) + .map(Cookie::getValue) + .map(ColorScheme.Value::fromString) + .filter(value -> value != ColorScheme.Value.NORMAL) + .findFirst(); + } + + /** Persists the given preference for a year, scoped to the whole application. */ + static void write(@Nullable VaadinResponse response, ColorScheme.Value value) { + if (response == null) { + return; + } + var cookie = new Cookie(NAME, value.getValue()); + cookie.setPath("/"); + cookie.setMaxAge(MAX_AGE_SECONDS); + cookie.setHttpOnly(true); + response.addCookie(cookie); + } +} diff --git a/src/main/java/com/example/base/ui/ThemeInitializer.java b/src/main/java/com/example/base/ui/ThemeInitializer.java new file mode 100644 index 0000000..974fe67 --- /dev/null +++ b/src/main/java/com/example/base/ui/ThemeInitializer.java @@ -0,0 +1,58 @@ +package com.example.base.ui; + +import com.vaadin.flow.component.page.ColorScheme; +import com.vaadin.flow.server.ServiceInitEvent; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinServiceInitListener; +import org.jsoup.nodes.Element; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +/** + * Restores a visitor's stored light/dark preference (see {@link ThemeCookie}) on every visit. + * Two things happen, mirroring what {@code @ColorScheme} plus {@code Page#setColorScheme} do + * internally (see {@code IndexHtmlRequestHandler#applyColorScheme} / {@code Page#setColorScheme} + * in flow-server 25.2.5), so a returning visitor never sees a flash of the wrong theme: + *
    + *
  • Before the page is sent, the {@code } element's {@code theme} attribute and + * inline {@code color-scheme} style are overwritten from the cookie, taking precedence over + * the {@code @ColorScheme(SYSTEM)} default declared on {@code Application}.
  • + *
  • On UI init, the same cookie seeds the session's {@link ThemePreference} bean, so + * {@link ThemeToggle} renders the right icon/label immediately, with no client round-trip.
  • + *
+ * First-ever visits (no cookie yet) are left alone and simply follow {@code @ColorScheme(SYSTEM)}. + *

+ * Wired the same way as {@code com.example.security.TwoFactorNavigationGuard}: a + * {@code VaadinServiceInitListener} bean registering a listener in {@code serviceInit}. + *

+ * {@link ThemePreference} is looked up lazily through an {@link ObjectProvider} rather than + * injected directly: this class is an application-wide singleton created at startup, well before + * any Vaadin session exists, whereas {@code ThemePreference} only resolves inside one - the + * lookup has to happen inside the UI-init callback, once a session is actually active. + */ +@Component +class ThemeInitializer implements VaadinServiceInitListener { + + private final ObjectProvider themePreference; + + ThemeInitializer(ObjectProvider themePreference) { + this.themePreference = themePreference; + } + + @Override + public void serviceInit(ServiceInitEvent event) { + event.addIndexHtmlRequestListener(response -> ThemeCookie.read(response.getVaadinRequest()) + .ifPresent(value -> applyToDocumentRoot(response.getDocument().head().parent(), value))); + + event.getSource().addUIInitListener(uiEvent -> ThemeCookie.read(VaadinRequest.getCurrent()) + .ifPresent(value -> themePreference.getObject().seed(value))); + } + + private void applyToDocumentRoot(Element html, ColorScheme.Value value) { + if (html == null) { + return; + } + html.attr("theme", value.getThemeValue()); + html.attr("style", "color-scheme: " + value.getValue() + ";"); + } +} diff --git a/src/main/java/com/example/base/ui/ThemePreference.java b/src/main/java/com/example/base/ui/ThemePreference.java new file mode 100644 index 0000000..dde8c60 --- /dev/null +++ b/src/main/java/com/example/base/ui/ThemePreference.java @@ -0,0 +1,48 @@ +package com.example.base.ui; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.page.ColorScheme; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.spring.annotation.VaadinSessionScope; +import org.springframework.stereotype.Component; + +/** + * Tracks the current visitor's light/dark preference for the session and applies changes to it. + * Same shape as {@code com.example.security.TwoFactorAttemptTracker}: a session-scoped bean + * holding small mutable UI state. Public: injected into {@link MainLayout} and {@link ThemeToggle}. + *

+ * The preference is also written to a cookie (see {@link ThemeCookie}) so it survives beyond the + * session too. {@link ThemeInitializer} reads that cookie back on the next visit, both to render + * the correct theme before first paint and to seed this bean for the new session. + */ +@Component +@VaadinSessionScope +public class ThemePreference { + + private ColorScheme.Value current = ColorScheme.Value.SYSTEM; + + public ColorScheme.Value get() { + return current; + } + + /** True once the visitor is explicitly in dark mode (as opposed to SYSTEM/LIGHT). */ + public boolean isDark() { + return current == ColorScheme.Value.DARK; + } + + /** Flips between light and dark; an undecided SYSTEM preference is treated as light. */ + public void toggle() { + apply(isDark() ? ColorScheme.Value.LIGHT : ColorScheme.Value.DARK); + } + + /** Used by {@link ThemeInitializer} to seed this bean from the stored cookie, without re-applying it. */ + void seed(ColorScheme.Value value) { + current = value; + } + + private void apply(ColorScheme.Value value) { + current = value; + UI.getCurrent().getPage().setColorScheme(value); + ThemeCookie.write(VaadinService.getCurrentResponse(), value); + } +} diff --git a/src/main/java/com/example/base/ui/ThemeToggle.java b/src/main/java/com/example/base/ui/ThemeToggle.java new file mode 100644 index 0000000..47f499d --- /dev/null +++ b/src/main/java/com/example/base/ui/ThemeToggle.java @@ -0,0 +1,42 @@ +package com.example.base.ui; + +import com.vaadin.flow.component.Composite; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.icon.VaadinIcon; + +/** + * An icon-only button in the application header that flips the app between light and dark mode. + * Built the same way as {@link OtpField} / {@link ViewTitle}: a small server-side Java composite. + *

+ * There's no dedicated toggle/switch component in Vaadin 25 (Aura has no {@code ToggleButton}), + * so a tertiary icon {@link Button} is the idiomatic stand-in for a header action like this. + */ +class ThemeToggle extends Composite