[FEATURE] Dark Theme

This commit is contained in:
Pit Friedrich
2026-07-11 22:31:08 +02:00
parent aa8fbb4838
commit 9df764d065
7 changed files with 140 additions and 52 deletions
+3 -21
View File
@@ -1,30 +1,12 @@
FROM ghcr.io/jqlang/jq:latest AS jq-stage
FROM eclipse-temurin:21-jdk AS build
COPY --from=jq-stage /jq /usr/bin/jq
# Test that jq works after copying
RUN jq --version
FROM eclipse-temurin:25-jdk AS build
ENV HOME=/app
RUN mkdir -p $HOME
WORKDIR $HOME
COPY . $HOME
# If you have a Vaadin Pro key, pass it as a secret with id "proKey":
#
# $ docker build --secret id=proKey,src=$HOME/.vaadin/proKey .
#
# If you have a Vaadin Offline key, pass it as a secret with id "offlineKey":
#
# $ docker build --secret id=offlineKey,src=$HOME/.vaadin/offlineKey .
RUN --mount=type=cache,target=/root/.m2 \
--mount=type=secret,id=proKey \
--mount=type=secret,id=offlineKey \
sh -c 'PRO_KEY=$(jq -r ".proKey // empty" /run/secrets/proKey 2>/dev/null || echo "") && \
OFFLINE_KEY=$(cat /run/secrets/offlineKey 2>/dev/null || echo "") && \
./mvnw clean package -DskipTests -Dvaadin.proKey=${PRO_KEY} -Dvaadin.offlineKey=${OFFLINE_KEY}'
./mvnw clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:25-jre-alpine
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=prod"]
Binary file not shown.
+70 -2
View File
@@ -2,14 +2,78 @@ import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import ApexCharts from 'apexcharts';
/**
* Resolves a CSS custom property (which may be a `light-dark()` value) to its
* currently active value for the given scheme. Reading the custom property
* directly (e.g. via getComputedStyle on an element that just has the var set)
* returns the raw `light-dark(...)` text, not the resolved color — resolution
* only happens when the property is used as an actual style value, hence the
* throwaway probe element.
*/
function resolveColor(varName: string): string {
const probe = document.createElement('span');
probe.style.color = `var(${varName})`;
probe.style.display = 'none';
document.documentElement.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return resolved;
}
function isDarkScheme(): boolean {
return getComputedStyle(document.documentElement).colorScheme.includes('dark');
}
/** Overlays theme-varying option keys with values resolved from the current
* --dialect-* CSS tokens, so charts follow the light/dark toggle without a
* server round-trip. */
function applyThemeOverlay(options: any): any {
const borderColor = resolveColor('--dialect-border');
const inkColor = resolveColor('--dialect-ink');
const dark = isDarkScheme();
options.grid = { ...options.grid, borderColor };
options.legend = {
...options.legend,
labels: { ...options.legend?.labels, colors: inkColor },
};
options.tooltip = { ...options.tooltip, theme: dark ? 'dark' : 'light' };
if (options.xaxis) {
options.xaxis = {
...options.xaxis,
labels: { ...options.xaxis.labels, style: { ...options.xaxis.labels?.style, colors: inkColor } },
};
}
if (options.yaxis) {
options.yaxis = {
...options.yaxis,
labels: { ...options.yaxis.labels, style: { ...options.yaxis.labels?.style, colors: inkColor } },
};
}
return options;
}
@customElement('apex-chart')
export class ApexChart extends LitElement {
private chart?: ApexCharts;
private lastOptions?: any;
private readonly onThemeChange = () => {
if (!this.chart || !this.lastOptions) return;
this.chart.updateOptions(applyThemeOverlay(this.lastOptions));
};
protected createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('dialect-theme-change', this.onThemeChange);
}
render() {
return html`<div id="container" style="width:100%;height:100%"></div>`;
}
@@ -27,17 +91,21 @@ export class ApexChart extends LitElement {
},
};
this.lastOptions = options;
const themed = applyThemeOverlay(options);
const container = this.querySelector('#container') as HTMLElement;
if (this.chart) {
await this.chart.updateOptions(options);
await this.chart.updateOptions(themed);
} else {
this.chart = new ApexCharts(container, options);
this.chart = new ApexCharts(container, themed);
await this.chart.render();
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('dialect-theme-change', this.onThemeChange);
this.chart?.destroy();
}
}
@@ -24,16 +24,6 @@ public final class DialectTheme {
private static final String FONT_FAMILY =
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
/**
* Mirrors {@code --dialect-border} in styles.css. Charts render via JS/SVG,
* not CSS, so this can't read the custom property directly — keep both in sync
* by hand if the token changes.
*/
private static final String BORDER_COLOR = "#E6EAF0";
/** Mirrors {@code --dialect-ink} in styles.css. See {@link #BORDER_COLOR} note. */
private static final String INK_COLOR = "#14171F";
private DialectTheme() {
}
@@ -50,20 +40,13 @@ public final class DialectTheme {
options.put("colors", COLORS);
options.put("grid", Map.of(
"borderColor", BORDER_COLOR,
"strokeDashArray", 4
));
options.put("legend", Map.of(
"position", "bottom",
"labels", Map.of("colors", INK_COLOR)
));
// grid.borderColor, legend.labels.colors and tooltip.theme are theme-varying
// (light vs. dark) and get overlaid client-side from the --dialect-* CSS
// tokens — see applyThemeOverlay() in apex-chart.ts.
options.put("grid", Map.of("strokeDashArray", 4));
options.put("legend", Map.of("position", "bottom"));
options.put("dataLabels", Map.of("enabled", false));
options.put("tooltip", Map.of("theme", "light"));
if ("line".equals(chartType)) {
options.put("stroke", Map.of("curve", "smooth", "width", 3));
} else if ("bar".equals(chartType)) {
@@ -0,0 +1,25 @@
package com.example.js;
public final class CommonJS {
private CommonJS() {}
/** Sets color-scheme on <html> from the saved choice, falling back to the OS
* preference (prefers-color-scheme) when no choice was saved, and
* returns "light"/"dark" so the button's initial icon can match. */
public static final String INIT_THEME_JS = """
const saved = localStorage.getItem('dialect-theme');
const theme = saved
|| (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.style.colorScheme = theme;
return theme;
""";
public static final String TOGGLE_THEME_JS = """
const next = document.documentElement.style.colorScheme === 'dark' ? 'light' : 'dark';
document.documentElement.style.colorScheme = next;
localStorage.setItem('dialect-theme', next);
window.dispatchEvent(new CustomEvent('dialect-theme-change'));
return next;
""";
}
@@ -1,7 +1,10 @@
package com.example.views;
import com.example.js.CommonJS;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.sidenav.SideNav;
@@ -11,13 +14,25 @@ import com.vaadin.flow.router.Layout;
@Layout
public class MainLayout extends AppLayout {
private final Button themeToggle = new Button(VaadinIcon.MOON.create());
public MainLayout() {
DrawerToggle toggle = new DrawerToggle();
Span title = new Span("Chart App");
title.addClassName("dialect-title");
addToNavbar(toggle, title);
Span spacer = new Span();
spacer.getStyle().set("flex-grow", "1");
themeToggle.addThemeVariants(ButtonVariant.TERTIARY, ButtonVariant.LUMO_ICON);
themeToggle.setAriaLabel("Farbschema wechseln");
themeToggle.addClickListener(e ->
getElement().executeJs(CommonJS.TOGGLE_THEME_JS).then(String.class, this::applyIconFor));
addToNavbar(toggle, title, spacer, themeToggle);
getElement().executeJs(CommonJS.INIT_THEME_JS).then(String.class, this::applyIconFor);
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class,
@@ -27,4 +42,8 @@ public class MainLayout extends AppLayout {
addToDrawer(nav);
}
private void applyIconFor(String theme) {
themeToggle.setIcon("dark".equals(theme) ? VaadinIcon.SUN_O.create() : VaadinIcon.MOON.create());
}
}
@@ -1,18 +1,29 @@
/* Dialect design system tokens (https://www.figma.com/design/6DRThj0vmRgfjMIdsmkaIy) */
:root {
/* Support both schemes; actual scheme is picked by the theme toggle setting
`color-scheme` on <html> (see MainLayout's theme switch). */
color-scheme: light dark;
--dialect-primary: #e86c00;
--dialect-secondary: #f6b93b;
--dialect-bg: #f4f6f9;
--dialect-surface: #ffffff;
--dialect-ink: #14171f;
--dialect-border: #e6eaf0;
--dialect-bg: light-dark(#f4f6f9, #14171f);
--dialect-surface: light-dark(#ffffff, #1c2029);
--dialect-ink: light-dark(#14171f, #e6eaf0);
--dialect-border: light-dark(#e6eaf0, #2b303b);
--dialect-radius: 14px;
--dialect-shadow: 0 6px 20px rgba(20, 23, 31, 0.08), 0 1px 2px rgba(20, 23, 31, 0.06);
/* light-dark() only takes 2 args, so each shadow layer gets its own light-dark()
call and the layers are joined below (var() is a text substitution, so the
comma-separated shadow list still parses correctly). */
--dialect-shadow-1: light-dark(0 6px 20px rgba(20, 23, 31, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35));
--dialect-shadow-2: light-dark(0 1px 2px rgba(20, 23, 31, 0.06), 0 1px 2px rgba(0, 0, 0, 0.3));
--dialect-shadow: var(--dialect-shadow-1), var(--dialect-shadow-2);
--dialect-navbar-shadow: light-dark(0 1px 3px rgba(20, 23, 31, 0.05), 0 1px 3px rgba(0, 0, 0, 0.3));
/* Alias onto Aura's theming hooks */
--aura-accent-color-light: var(--dialect-primary);
--aura-accent-color-dark: var(--dialect-primary);
--aura-background-color-light: var(--dialect-bg);
--aura-background-color-dark: var(--dialect-bg);
--aura-orange: var(--dialect-primary);
--aura-yellow: var(--dialect-secondary);
@@ -59,7 +70,7 @@ vaadin-app-layout::part(navbar) {
-webkit-backdrop-filter: none;
backdrop-filter: none;
border-bottom: 1px solid var(--dialect-border);
box-shadow: 0 1px 3px rgba(20, 23, 31, 0.05);
box-shadow: var(--dialect-navbar-shadow);
}
/* Sidebar: solid white, right divider separates it from the canvas */