diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f3096a5 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/src/main/java/com/example/components/AxisChart.java b/src/main/java/com/example/components/AxisChart.java index 3c09cad..1efd490 100644 --- a/src/main/java/com/example/components/AxisChart.java +++ b/src/main/java/com/example/components/AxisChart.java @@ -10,11 +10,13 @@ public abstract class AxisChart extends ApexChart { } public void setData(String seriesName, List values, List categories) { - Map options = Map.of( - "chart", Map.of("type", getChartType(), "height", "100%"), - "series", List.of(Map.of("name", seriesName, "data", values)), - "xaxis", Map.of("categories", categories) - ); + Map options = DialectTheme.baseOptions(getChartType()); + options.put("series", List.of(Map.of("name", seriesName, "data", values))); + options.put("xaxis", Map.of( + "categories", categories, + "axisBorder", Map.of("show", false), + "axisTicks", Map.of("show", false) + )); sendOptions(options); } diff --git a/src/main/java/com/example/components/DialectTheme.java b/src/main/java/com/example/components/DialectTheme.java new file mode 100644 index 0000000..1eba763 --- /dev/null +++ b/src/main/java/com/example/components/DialectTheme.java @@ -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 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 baseOptions(String chartType) { + Map options = new LinkedHashMap<>(); + + Map 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; + } +} diff --git a/src/main/java/com/example/components/PieChart.java b/src/main/java/com/example/components/PieChart.java index 8a341c4..d47f836 100644 --- a/src/main/java/com/example/components/PieChart.java +++ b/src/main/java/com/example/components/PieChart.java @@ -10,11 +10,9 @@ public class PieChart extends ApexChart { } public void setData(List values, List labels) { - Map options = Map.of( - "chart", Map.of("type", "pie", "height", "100%"), - "series", values, - "labels", labels - ); + Map options = DialectTheme.baseOptions("pie"); + options.put("series", values); + options.put("labels", labels); sendOptions(options); } diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 00a98d7..d2a531c 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -3,16 +3,22 @@ package com.example.views; import com.example.components.BarChart; import com.example.components.LineChart; 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.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import java.util.List; @Route("") +@PageTitle("Dashboard") public class DashboardView extends VerticalLayout { public DashboardView() { + addClassName("dialect-content"); + LineChart lineChart = new LineChart(); lineChart.setWidthFull(); lineChart.setHeight("400px"); @@ -45,6 +51,20 @@ public class DashboardView extends VerticalLayout { 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; } } diff --git a/src/main/java/com/example/views/FormView.java b/src/main/java/com/example/views/FormView.java new file mode 100644 index 0000000..b401c8c --- /dev/null +++ b/src/main/java/com/example/views/FormView.java @@ -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 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 land = new ComboBox<>("Land"); + land.setItems("Deutschland", "Österreich", "Schweiz", "Frankreich", "Niederlande"); + + Select abteilung = new Select<>(); + abteilung.setLabel("Abteilung"); + abteilung.setItems("Vertrieb", "Marketing", "Entwicklung", "Support", "Geschäftsführung"); + + CheckboxGroup interessen = new CheckboxGroup<>(); + interessen.setLabel("Interessen"); + interessen.setItems("Newsletter-Themen", "Webinare", "Produkt-Updates", "Events"); + + RadioButtonGroup 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; + } +} diff --git a/src/main/java/com/example/views/MainLayout.java b/src/main/java/com/example/views/MainLayout.java new file mode 100644 index 0000000..97f6d10 --- /dev/null +++ b/src/main/java/com/example/views/MainLayout.java @@ -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); + } +} diff --git a/src/main/java/com/example/views/Registration.java b/src/main/java/com/example/views/Registration.java new file mode 100644 index 0000000..b2795cd --- /dev/null +++ b/src/main/java/com/example/views/Registration.java @@ -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 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 getInteressen() { + return interessen; + } + + public void setInteressen(Set 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; + } +} diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index 4eecf84..b029ffd 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -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; +}