[FEATURE] Customn Theme
CI / build-and-test (pull_request) Successful in 10m26s

This commit is contained in:
Pit Friedrich
2026-07-07 19:55:42 +02:00
parent 5ed6937c88
commit d947833214
9 changed files with 528 additions and 12 deletions
+39
View File
@@ -0,0 +1,39 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
./mvnw spring-boot:run # run app (default goal) — http://localhost:8080, ~30s first start
./mvnw compile # compile only
./mvnw package # production build → target/*.jar
java -jar target/*.jar # run production jar
```
No test sources exist yet (`src/test` is empty) — `spring-boot-starter-test` and `browserless-test-spring` are on the classpath but unused.
**Java 25 toolchain required** (`pom.xml` sets `java.version=25`). If the default JDK on PATH is older (check `java -version`), point `JAVA_HOME` at a Java 25 install for the Maven build, e.g.:
```bash
JAVA_HOME="/path/to/jdk-25" ./mvnw compile
```
Port 8080 conflicts: a prior `spring-boot:run` left running in the background is the usual cause (Vaadin dev mode keeps a second process/thread alive under a different PID than the launching Maven process — killing the Maven process alone may not free the port). Find and stop the actual listener before restarting.
## Architecture
Vaadin Flow (server-side Java UI, no hand-written HTML/JS for views) on Spring Boot 4.1, using the **Aura** theme (not Lumo — no `@Theme` annotation; Aura is wired via `@StyleSheet(Aura.STYLESHEET)` in `Application.java`).
**Charts are ApexCharts, not Vaadin Charts.** The bridge lives in `components/`:
- `ApexChart` (abstract, `@Tag("apex-chart")`) — owns the JS module (`frontend/components/apex-chart.ts`, a Lit element rendering into light DOM), serializes an options `Map` to JSON via Jackson and calls `renderChart` client-side, and exposes point-click events back to the server via `@ClientCallable`.
- `AxisChart` (abstract) — builds `series`/`xaxis.categories` options for line/bar.
- `LineChart`, `BarChart` extend `AxisChart`; `PieChart` extends `ApexChart` directly (`series`/`labels` instead of axis-based).
- `DialectTheme` — single source of chart styling (categorical color palette, grid/legend/stroke option fragments). Every chart's `setData(...)` starts from `DialectTheme.baseOptions(chartType)` and merges in its data. Add new chart types here, not by duplicating option maps.
Since `apex-chart.ts` renders into light DOM (`createRenderRoot()` returns `this`), global CSS can reach into the chart markup — but series/legend/grid colors are driven entirely by the options JSON, not CSS, because ApexCharts renders its own SVG/canvas.
**Views** (`views/`): `MainLayout` (`@Layout`, applies to all routes) is the `AppLayout` shell — navbar + `SideNav` drawer. `DashboardView` (`@Route("")`) is the only route; it wraps each chart in a card via a local `card(title, chart)` helper rather than adding charts directly.
**Styling**: `src/main/resources/META-INF/resources/styles.css` is the one project-level stylesheet (loaded via `@StyleSheet("styles.css")` in `Application.java`). It defines `--dialect-*` design tokens (Dialect design system: primary orange `#E86C00`, cool-gray background, card radius/shadow) and aliases them onto Aura's own CSS custom properties (`--aura-accent-color-*`, `--aura-background-color-*`, `--aura-orange`, `--aura-yellow`) rather than fighting the theme. Aura tokens use OKLCH + relative-color syntax and accept plain hex overrides. When restyling, prefer extending this alias layer over hardcoding new colors in components.
UI copy/data (chart labels, notifications) is in German.
@@ -10,11 +10,13 @@ public abstract class AxisChart extends ApexChart {
} }
public void setData(String seriesName, List<Double> values, List<String> categories) { public void setData(String seriesName, List<Double> values, List<String> categories) {
Map<String, Object> options = Map.of( Map<String, Object> options = DialectTheme.baseOptions(getChartType());
"chart", Map.of("type", getChartType(), "height", "100%"), options.put("series", List.of(Map.of("name", seriesName, "data", values)));
"series", List.of(Map.of("name", seriesName, "data", values)), options.put("xaxis", Map.of(
"xaxis", Map.of("categories", categories) "categories", categories,
); "axisBorder", Map.of("show", false),
"axisTicks", Map.of("show", false)
));
sendOptions(options); sendOptions(options);
} }
@@ -0,0 +1,82 @@
package com.example.components;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Shared ApexCharts styling for the Dialect design system
* (https://www.figma.com/design/6DRThj0vmRgfjMIdsmkaIy).
*/
public final class DialectTheme {
/** Categorical palette, primary orange first. */
public static final List<String> COLORS = List.of(
"#E86C00", // primary orange
"#4C9BE8", // blue
"#2FB7A6", // teal
"#F6B93B", // gold
"#8E7CD6", // purple
"#E5544B", // red
"#3F51B5" // indigo
);
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() {
}
/** Common option fragments merged into every chart before series/labels are added. */
public static Map<String, Object> baseOptions(String chartType) {
Map<String, Object> options = new LinkedHashMap<>();
Map<String, Object> chart = new LinkedHashMap<>();
chart.put("type", chartType);
chart.put("height", "100%");
chart.put("fontFamily", FONT_FAMILY);
chart.put("toolbar", Map.of("show", false));
options.put("chart", chart);
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)
));
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)) {
options.put("stroke", Map.of("width", 0));
options.put("plotOptions", Map.of(
"bar", Map.of(
"borderRadius", 6,
"borderRadiusApplication", "end",
"columnWidth", "55%"
)
));
}
return options;
}
}
@@ -10,11 +10,9 @@ public class PieChart extends ApexChart {
} }
public void setData(List<Double> values, List<String> labels) { public void setData(List<Double> values, List<String> labels) {
Map<String, Object> options = Map.of( Map<String, Object> options = DialectTheme.baseOptions("pie");
"chart", Map.of("type", "pie", "height", "100%"), options.put("series", values);
"series", values, options.put("labels", labels);
"labels", labels
);
sendOptions(options); sendOptions(options);
} }
@@ -3,16 +3,22 @@ package com.example.views;
import com.example.components.BarChart; import com.example.components.BarChart;
import com.example.components.LineChart; import com.example.components.LineChart;
import com.example.components.PieChart; import com.example.components.PieChart;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import java.util.List; import java.util.List;
@Route("") @Route("")
@PageTitle("Dashboard")
public class DashboardView extends VerticalLayout { public class DashboardView extends VerticalLayout {
public DashboardView() { public DashboardView() {
addClassName("dialect-content");
LineChart lineChart = new LineChart(); LineChart lineChart = new LineChart();
lineChart.setWidthFull(); lineChart.setWidthFull();
lineChart.setHeight("400px"); lineChart.setHeight("400px");
@@ -45,6 +51,20 @@ public class DashboardView extends VerticalLayout {
Notification.show("Slice %d".formatted(e.getDataPointIndex())); Notification.show("Slice %d".formatted(e.getDataPointIndex()));
}); });
add(lineChart, barChart, pieChart); add(card("Umsatz-Entwicklung", lineChart),
card("Umsatz nach Monat", barChart),
card("Umsatz nach Region", pieChart));
}
private Component card(String title, Component chart) {
H3 heading = new H3(title);
heading.addClassName("dialect-card__title");
VerticalLayout card = new VerticalLayout(heading, chart);
card.addClassName("dialect-card");
card.setWidthFull();
card.setPadding(false);
card.setSpacing(false);
return card;
} }
} }
@@ -0,0 +1,161 @@
package com.example.views;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.checkbox.CheckboxGroup;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.radiobutton.RadioButtonGroup;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.BigDecimalField;
import com.vaadin.flow.component.textfield.EmailField;
import com.vaadin.flow.component.textfield.IntegerField;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.timepicker.TimePicker;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.validator.EmailValidator;
import com.vaadin.flow.data.validator.IntegerRangeValidator;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
/**
* Example view demonstrating a broad set of Vaadin form controls bound
* to {@link Registration} via {@link Binder}.
*/
@Route("formular")
@PageTitle("Formular")
public class FormView extends VerticalLayout {
private final Binder<Registration> binder = new Binder<>(Registration.class);
public FormView() {
addClassName("dialect-content");
add(card("Registrierung", buildForm()));
}
private Component buildForm() {
TextField vorname = new TextField("Vorname");
TextField nachname = new TextField("Nachname");
EmailField email = new EmailField("E-Mail");
PasswordField passwort = new PasswordField("Passwort");
IntegerField alter = new IntegerField("Alter");
BigDecimalField gehalt = new BigDecimalField("Gehalt (€)");
DatePicker geburtsdatum = new DatePicker("Geburtsdatum");
TimePicker uhrzeit = new TimePicker("Bevorzugte Uhrzeit");
ComboBox<String> land = new ComboBox<>("Land");
land.setItems("Deutschland", "Österreich", "Schweiz", "Frankreich", "Niederlande");
Select<String> abteilung = new Select<>();
abteilung.setLabel("Abteilung");
abteilung.setItems("Vertrieb", "Marketing", "Entwicklung", "Support", "Geschäftsführung");
CheckboxGroup<String> interessen = new CheckboxGroup<>();
interessen.setLabel("Interessen");
interessen.setItems("Newsletter-Themen", "Webinare", "Produkt-Updates", "Events");
RadioButtonGroup<String> geschlecht = new RadioButtonGroup<>();
geschlecht.setLabel("Anrede");
geschlecht.setItems("Frau", "Herr", "Divers", "Keine Angabe");
Checkbox newsletter = new Checkbox("Newsletter abonnieren");
TextArea bemerkung = new TextArea("Bemerkung");
bemerkung.setMaxLength(500);
binder.forField(vorname)
.asRequired("Vorname wird benötigt")
.bind(Registration::getVorname, Registration::setVorname);
binder.forField(nachname)
.asRequired("Nachname wird benötigt")
.bind(Registration::getNachname, Registration::setNachname);
binder.forField(email)
.asRequired("E-Mail wird benötigt")
.withValidator(new EmailValidator("Ungültige E-Mail-Adresse"))
.bind(Registration::getEmail, Registration::setEmail);
binder.forField(passwort)
.bind(Registration::getPasswort, Registration::setPasswort);
binder.forField(alter)
.withValidator(new IntegerRangeValidator("Alter muss zwischen 0 und 120 liegen", 0, 120))
.bind(Registration::getAlter, Registration::setAlter);
binder.forField(gehalt)
.bind(Registration::getGehalt, Registration::setGehalt);
binder.forField(geburtsdatum)
.bind(Registration::getGeburtsdatum, Registration::setGeburtsdatum);
binder.forField(uhrzeit)
.bind(Registration::getUhrzeit, Registration::setUhrzeit);
binder.forField(land)
.bind(Registration::getLand, Registration::setLand);
binder.forField(abteilung)
.bind(Registration::getAbteilung, Registration::setAbteilung);
binder.forField(interessen)
.bind(Registration::getInteressen, Registration::setInteressen);
binder.forField(geschlecht)
.bind(Registration::getGeschlecht, Registration::setGeschlecht);
binder.forField(newsletter)
.bind(Registration::isNewsletter, Registration::setNewsletter);
binder.forField(bemerkung)
.bind(Registration::getBemerkung, Registration::setBemerkung);
Registration registration = new Registration();
binder.readBean(registration);
FormLayout formLayout = new FormLayout();
formLayout.setResponsiveSteps(
new FormLayout.ResponsiveStep("0", 1),
new FormLayout.ResponsiveStep("500px", 2));
formLayout.add(vorname, nachname, email, passwort, alter, gehalt,
geburtsdatum, uhrzeit, land, abteilung);
formLayout.setColspan(interessen, 2);
formLayout.setColspan(geschlecht, 2);
formLayout.setColspan(newsletter, 2);
formLayout.setColspan(bemerkung, 2);
formLayout.add(interessen, geschlecht, newsletter, bemerkung);
Button speichern = new Button("Speichern", e -> {
Registration ziel = new Registration();
if (binder.writeBeanIfValid(ziel)) {
Notification erfolg = Notification.show(
"Gespeichert: %s %s".formatted(ziel.getVorname(), ziel.getNachname()));
erfolg.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
} else {
Notification fehler = Notification.show("Bitte Eingaben prüfen");
fehler.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
speichern.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button zuruecksetzen = new Button("Zurücksetzen",
e -> binder.readBean(new Registration()));
HorizontalLayout buttons = new HorizontalLayout(speichern, zuruecksetzen);
VerticalLayout wrapper = new VerticalLayout(formLayout, buttons);
wrapper.setPadding(false);
wrapper.setSpacing(true);
return wrapper;
}
private Component card(String title, Component body) {
H3 heading = new H3(title);
heading.addClassName("dialect-card__title");
VerticalLayout card = new VerticalLayout(heading, body);
card.addClassName("dialect-card");
card.setWidthFull();
card.setPadding(false);
card.setSpacing(false);
return card;
}
}
@@ -0,0 +1,30 @@
package com.example.views;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout;
@Layout
public class MainLayout extends AppLayout {
public MainLayout() {
DrawerToggle toggle = new DrawerToggle();
Span title = new Span("Chart App");
title.addClassName("dialect-title");
addToNavbar(toggle, title);
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class,
VaadinIcon.DASHBOARD.create()));
nav.addItem(new SideNavItem("Formular", FormView.class,
VaadinIcon.FORM.create()));
addToDrawer(nav);
}
}
@@ -0,0 +1,142 @@
package com.example.views;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.HashSet;
import java.util.Set;
/**
* Plain POJO backing the example form on {@link FormView}. Bound via
* {@link com.vaadin.flow.data.binder.Binder} — one field per showcased
* Vaadin control type.
*/
public class Registration {
private String vorname;
private String nachname;
private String email;
private String passwort;
private Integer alter;
private BigDecimal gehalt;
private LocalDate geburtsdatum;
private LocalTime uhrzeit;
private String land;
private String abteilung;
private Set<String> interessen = new HashSet<>();
private String geschlecht;
private boolean newsletter;
private String bemerkung;
public String getVorname() {
return vorname;
}
public void setVorname(String vorname) {
this.vorname = vorname;
}
public String getNachname() {
return nachname;
}
public void setNachname(String nachname) {
this.nachname = nachname;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswort() {
return passwort;
}
public void setPasswort(String passwort) {
this.passwort = passwort;
}
public Integer getAlter() {
return alter;
}
public void setAlter(Integer alter) {
this.alter = alter;
}
public BigDecimal getGehalt() {
return gehalt;
}
public void setGehalt(BigDecimal gehalt) {
this.gehalt = gehalt;
}
public LocalDate getGeburtsdatum() {
return geburtsdatum;
}
public void setGeburtsdatum(LocalDate geburtsdatum) {
this.geburtsdatum = geburtsdatum;
}
public LocalTime getUhrzeit() {
return uhrzeit;
}
public void setUhrzeit(LocalTime uhrzeit) {
this.uhrzeit = uhrzeit;
}
public String getLand() {
return land;
}
public void setLand(String land) {
this.land = land;
}
public String getAbteilung() {
return abteilung;
}
public void setAbteilung(String abteilung) {
this.abteilung = abteilung;
}
public Set<String> getInteressen() {
return interessen;
}
public void setInteressen(Set<String> interessen) {
this.interessen = interessen;
}
public String getGeschlecht() {
return geschlecht;
}
public void setGeschlecht(String geschlecht) {
this.geschlecht = geschlecht;
}
public boolean isNewsletter() {
return newsletter;
}
public void setNewsletter(boolean newsletter) {
this.newsletter = newsletter;
}
public String getBemerkung() {
return bemerkung;
}
public void setBemerkung(String bemerkung) {
this.bemerkung = bemerkung;
}
}
@@ -1 +1,43 @@
/* Add your styles here */ /* Dialect design system tokens (https://www.figma.com/design/6DRThj0vmRgfjMIdsmkaIy) */
:root {
--dialect-primary: #e86c00;
--dialect-secondary: #f6b93b;
--dialect-bg: #f4f6f9;
--dialect-surface: #ffffff;
--dialect-ink: #14171f;
--dialect-border: #e6eaf0;
--dialect-radius: 14px;
--dialect-shadow: 0 6px 20px rgba(20, 23, 31, 0.08), 0 1px 2px rgba(20, 23, 31, 0.06);
/* 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-orange: var(--dialect-primary);
--aura-yellow: var(--dialect-secondary);
}
.dialect-title {
font-weight: 600;
color: var(--dialect-ink);
}
.dialect-content {
max-width: 960px;
margin: 0 auto;
padding: var(--lumo-space-l, 24px);
gap: 24px;
}
.dialect-card {
background: var(--dialect-surface);
border-radius: var(--dialect-radius);
box-shadow: var(--dialect-shadow);
padding: 20px;
}
.dialect-card__title {
font-weight: 600;
color: var(--dialect-ink);
margin: 0 0 12px 0;
}