diff --git a/Elternbeirat.Web.Tests/IconSetTests.cs b/Elternbeirat.Web.Tests/IconSetTests.cs
new file mode 100644
index 0000000..aad46c8
--- /dev/null
+++ b/Elternbeirat.Web.Tests/IconSetTests.cs
@@ -0,0 +1,30 @@
+using Elternbeirat.Web.Shared;
+
+namespace Elternbeirat.Web.Tests;
+
+///
+/// Unit tests for the icon lookup behind the Icon component. Pure logic, no
+/// PocketBase needed.
+///
+public class IconSetTests
+{
+ [Theory]
+ [InlineData("calendar")]
+ [InlineData("map-pin")]
+ [InlineData("help-circle")] // kept under its old name although Lucide renamed it
+ public void Known_name_returns_svg_markup(string name)
+ {
+ var markup = IconSet.Find(name);
+
+ markup.ShouldNotBeNullOrWhiteSpace();
+ markup.ShouldStartWith("<"); // SVG child elements, ready to inline
+ }
+
+ [Theory]
+ [InlineData("does-not-exist")]
+ [InlineData("Calendar")] // lookup is case-sensitive, names are code identifiers
+ [InlineData("")]
+ [InlineData(null)]
+ public void Unknown_name_returns_null(string? name) =>
+ IconSet.Find(name).ShouldBeNull();
+}
diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
index d483a2f..9393910 100644
--- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs
+++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
@@ -1,4 +1,5 @@
using System.Net;
+using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Elternbeirat.Web.Tests;
@@ -15,7 +16,7 @@ namespace Elternbeirat.Web.Tests;
///
///
[Collection(PocketBaseTestGroup.Name)]
-public sealed class RouteSmokeTests : IDisposable
+public sealed partial class RouteSmokeTests : IDisposable
{
// WithWebHostBuilder returns a new factory that wraps the base one; both are
// disposable, so both are held and disposed to avoid leaking either.
@@ -158,6 +159,39 @@ public sealed class RouteSmokeTests : IDisposable
events.ShouldContain("Sitzungen und Veranstaltungen auf einen Blick."); // the page body
}
+ [Theory]
+ [InlineData("/app.css")]
+ [InlineData("/")]
+ public async Task No_resource_is_loaded_from_a_foreign_host(string route)
+ {
+ // Privacy rule (docs/recht.md): nothing may make the visitor's browser contact
+ // a third-party host -- no Google Fonts, no CDN. Check the stylesheet and the
+ // rendered home page for anything that LOADS from an absolute URL (src,
+ // srcset, , CSS url() and @import). Plain links are left
+ // alone on purpose: an editor may link to another site, and a link loads
+ // nothing until the visitor clicks it.
+ var client = _factory.CreateClient();
+
+ var body = await client.GetStringAsync(new Uri(route, UriKind.Relative));
+
+ ForeignResource().Matches(body).Select(match => match.Value).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Site_font_is_self_hosted()
+ {
+ // The @font-face must point at our own wwwroot with a relative URL, and the
+ // file must actually be served -- otherwise every visitor silently falls back
+ // to the system font.
+ var client = _factory.CreateClient();
+
+ var css = await client.GetStringAsync(new Uri("/app.css", UriKind.Relative));
+ css.ShouldContain("url(\"fonts/nunito-latin-wght.woff2\")");
+
+ var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
+ font.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
[Fact]
public async Task Health_returns_200_when_PocketBase_is_reachable()
{
@@ -187,4 +221,13 @@ public sealed class RouteSmokeTests : IDisposable
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
}
+
+ ///
+ /// Matches a resource reference that points at an absolute URL, with or without
+ /// scheme (https://, http:// or protocol-relative //): the
+ /// src/srcset attributes, a <link>'s href, CSS
+ /// url() and @import.
+ ///
+ [GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
+ private static partial Regex ForeignResource();
}
diff --git a/Elternbeirat.Web/Shared/Icon.razor b/Elternbeirat.Web/Shared/Icon.razor
new file mode 100644
index 0000000..e913425
--- /dev/null
+++ b/Elternbeirat.Web/Shared/Icon.razor
@@ -0,0 +1,13 @@
+@*
+ Renders one icon from IconSet as inline SVG. Purely decorative: aria-hidden hides
+ it from screen readers (the text next to it carries the meaning) and
+ focusable="false" keeps old Edge/IE from putting it in the tab order. The stroke
+ is currentColor, so the icon takes the colour of the surrounding text. An unknown
+ name renders nothing at all.
+*@
+@if (_markup is not null)
+{
+
+}
diff --git a/Elternbeirat.Web/Shared/Icon.razor.cs b/Elternbeirat.Web/Shared/Icon.razor.cs
new file mode 100644
index 0000000..d72944b
--- /dev/null
+++ b/Elternbeirat.Web/Shared/Icon.razor.cs
@@ -0,0 +1,46 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Elternbeirat.Web.Shared;
+
+///
+/// Renders a decorative line icon from as inline SVG.
+///
+///
+/// Inline SVG, not an <img> or icon font: no extra request, nothing
+/// from a third-party host, and the icon follows the text colour (including dark
+/// mode) through currentColor. The icon is hidden from assistive technology,
+/// so it must never be the only carrier of meaning.
+///
+///
+///
+/// <Icon Name="map-pin" /> Aula
+/// <Icon Name="calendar" Size="16" />
+///
+///
+///
+public partial class Icon
+{
+ ///
+ /// The inner SVG markup of the current icon, or when the
+ /// name is unknown and nothing should render.
+ ///
+ private MarkupString? _markup;
+
+ ///
+ /// The icon name, e.g. calendar; see for the set.
+ ///
+ [Parameter]
+ [EditorRequired]
+ public string Name { get; set; } = "";
+
+ ///
+ /// Width and height in CSS pixels. Defaults to 20, which sits well next
+ /// to body text.
+ ///
+ [Parameter]
+ public int Size { get; set; } = 20;
+
+ ///
+ protected override void OnParametersSet() =>
+ _markup = IconSet.Find(Name) is { } markup ? new MarkupString(markup) : null;
+}
diff --git a/Elternbeirat.Web/Shared/IconSet.cs b/Elternbeirat.Web/Shared/IconSet.cs
new file mode 100644
index 0000000..1bb863b
--- /dev/null
+++ b/Elternbeirat.Web/Shared/IconSet.cs
@@ -0,0 +1,83 @@
+using System.Collections.Frozen;
+
+namespace Elternbeirat.Web.Shared;
+
+///
+/// The fixed set of line icons the site can show, keyed by name.
+///
+///
+/// The icons are inlined as SVG markup instead of being loaded from an icon font or
+/// a CDN: the site loads nothing from third-party hosts (see docs/recht.md),
+/// and inline SVG needs no extra request and inherits the text colour.
+///
+/// Source: Lucide (lucide.dev), ISC license; the icons derived from
+/// Feather (calendar, clock, download,
+/// external-link, help-circle, info, arrow-right)
+/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that
+/// the notice travels with the copy, which this comment does. Only the inner
+/// elements are stored; supplies the shared
+/// <svg> wrapper (24x24 view box, 2px round stroke).
+///
+///
+/// Lucide renamed help-circle to circle-question-mark; the old
+/// name is kept here because it is the one the design work refers to.
+///
+///
+///
+public static class IconSet
+{
+ ///
+ /// Inner SVG markup per icon name. Ordinal keys: names are code identifiers,
+ /// not visitor text, so Calendar is deliberately not calendar.
+ ///
+ private static readonly FrozenDictionary Icons =
+ new Dictionary
+ {
+ ["calendar"] =
+ """""",
+ ["calendar-plus"] =
+ """""",
+ ["clock"] =
+ """""",
+ ["map-pin"] =
+ """""",
+ ["file-text"] =
+ """""",
+ ["download"] =
+ """""",
+ ["mail"] =
+ """""",
+ ["users"] =
+ """""",
+ ["heart-handshake"] =
+ """""",
+ ["help-circle"] =
+ """""",
+ ["newspaper"] =
+ """""",
+ ["arrow-right"] =
+ """""",
+ ["external-link"] =
+ """""",
+ ["info"] =
+ """""",
+ }.ToFrozenDictionary(StringComparer.Ordinal);
+
+ ///
+ /// Looks up the inner SVG markup of an icon.
+ ///
+ ///
+ /// The icon name, e.g. calendar or map-pin. Case-sensitive.
+ ///
+ ///
+ /// The SVG child elements (paths, circles, rects) to place inside an
+ /// <svg>, or if is
+ /// or not part of the set.
+ ///
+ ///
+ /// An unknown name yields rather than an exception, so a
+ /// typo costs a missing decoration, never a broken page.
+ ///
+ public static string? Find(string? name) =>
+ name is null ? null : Icons.GetValueOrDefault(name);
+}
diff --git a/Elternbeirat.Web/wwwroot/app.css b/Elternbeirat.Web/wwwroot/app.css
index d77750e..e3ca9b3 100644
--- a/Elternbeirat.Web/wwwroot/app.css
+++ b/Elternbeirat.Web/wwwroot/app.css
@@ -23,6 +23,20 @@
--color-accent: #6db863;
--color-accent-dark: #4f9247;
+ /* A whisper of the green for calm background areas (info boxes, highlighted
+ rows). Light enough that body text keeps its full contrast on top. */
+ --color-accent-tint: #eef7ec;
+
+ /* Warm yellow for the rare thing that must catch the eye (a "new" marker, a
+ deadline). Background only, never text -- yellow text is unreadable on white.
+ Used sparingly: if everything is highlighted, nothing is. */
+ --color-highlight: #fdf0c2;
+
+ /* Brand gradient for large surfaces (hero, banner): the header blue flowing into
+ the IGMH green. The green end is a deep shade (#3a7a3e, 5.2:1 with white),
+ not --color-accent, so white text stays readable across the whole sweep. */
+ --gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
+
/* Headings. Its own token (not --color-brand) so headings and links can differ:
in dark mode headings become a soft white while links keep the brand blue. */
--color-heading: #2b6f9c;
@@ -48,14 +62,38 @@
/* Rounded corners and a soft shadow for cards, matching the friendly tone. */
--radius: 12px;
--radius-sm: 6px;
+ --radius-lg: 20px; /* large surfaces (hero, banner) look stiff with
+ the card radius at their size */
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
--shadow-hover: 0 2px 6px rgba(31, 58, 95, 0.12), 0 8px 20px rgba(31, 58, 95, 0.1);
+ --shadow-lg: 0 4px 12px rgba(31, 58, 95, 0.1), 0 16px 40px rgba(31, 58, 95, 0.12);
/* One transition speed for hovers/focus across the site. */
--transition: 0.15s ease;
- /* Reading width, so long text lines never get uncomfortably wide. */
- --content-width: 60rem;
+ /* Width of the page frame (header, content, footer). Wide enough for card grids
+ side by side; running text does not follow it -- paragraphs keep their own
+ reading width (see p below), so lines never get uncomfortably long. */
+ --content-width: 72rem;
+
+ /* Type. The site font is self-hosted (see @font-face below); the system stack
+ stays behind it, so text shows at once and only swaps when Nunito arrives. */
+ --font-family: "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+
+ /* Type scale. h1 and h2 grow fluidly with the viewport (clamp: minimum, preferred
+ in vw, maximum) instead of jumping at a breakpoint, so a long German compound
+ word in a heading still fits a phone screen. Smaller sizes stay fixed: the
+ difference would not be visible. Body text sits a touch above the 1rem default
+ because Nunito's rounded letters read small. */
+ --font-size-h1: clamp(2rem, 1.6rem + 1.6vw, 2.75rem);
+ --font-size-h2: clamp(1.5rem, 1.35rem + 0.6vw, 1.75rem);
+ --font-size-h3: 1.2rem;
+ --font-size-body: 1.0625rem;
+
+ /* Headings: extra-bold for a friendly, confident lead, pulled slightly tighter
+ because heavy letters at large sizes otherwise look spaced out. */
+ --font-weight-heading: 800;
+ --letter-spacing-heading: -0.015em;
}
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
@@ -77,6 +115,13 @@
--color-accent: #86c97d; /* the IGMH green nudged brighter for dark */
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
+ --color-accent-tint: #24332a; /* a dark green-grey: tinted, but the soft
+ body text still reads at 5.5:1 */
+
+ --color-highlight: #3a3220; /* the warm yellow as a deep amber -- a bright
+ yellow would glare on the dark page */
+
+ --gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
body text so headings still lead the page */
@@ -91,12 +136,30 @@
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5), 0 16px 40px rgba(0, 0, 0, 0.45);
}
}
-/* Base styles. Self-hosted system font stack — no external fonts, for privacy. */
+/* Nunito, served from our own wwwroot -- never from Google Fonts, which would hand
+ every visitor's IP to Google (see docs/recht.md). One variable file covers every
+ weight from 400 to 800, so body, bold and headings cost a single ~36 KB request
+ instead of three static files. Subset to Latin incl. German and the common
+ European names (ä ö ü ß é ç ğ ş ł ...), € and German quotes; anything outside falls
+ back to the system font. No italic file: Markdown emphasis is rare, so the browser
+ slanting the upright face is worth the saved request. font-display: swap shows the
+ system font immediately and swaps once the file is there -- text never waits.
+ License: SIL OFL 1.1, see fonts/OFL.txt. */
+@font-face {
+ font-family: "Nunito";
+ src: url("fonts/nunito-latin-wght.woff2") format("woff2");
+ font-weight: 400 800;
+ font-style: normal;
+ font-display: swap;
+}
+
+/* Base styles. */
html {
- font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-family: var(--font-family);
line-height: 1.65;
color: var(--color-text);
/* Tell the browser we support both schemes, so form controls, scrollbars and
@@ -112,6 +175,7 @@ body {
/* A soft tinted page background so white cards/content lift off it. */
background: var(--color-page);
color: var(--color-text);
+ font-size: var(--font-size-body);
}
a {
@@ -129,26 +193,31 @@ a:hover {
h1, h2, h3, h4 {
line-height: 1.25;
color: var(--color-heading);
+ font-weight: var(--font-weight-heading);
+ letter-spacing: var(--letter-spacing-heading);
margin: var(--space-5) 0 var(--space-3);
}
h1 {
- font-size: 1.5rem;
+ font-size: var(--font-size-h1);
+ line-height: 1.15; /* big type needs less leading, or wrapped lines drift apart */
margin-top: 0; /* the first heading on a page needs no gap above */
}
h2 {
- font-size: 1.2rem;
+ font-size: var(--font-size-h2);
}
h3 {
- font-size: 1.05rem;
+ font-size: var(--font-size-h3);
}
/* h4 gets its own size so it stays a step below h3 instead of falling back to the
- browser default, which can render as large as (or larger than) a small h3. */
+ browser default, which can render as large as (or larger than) a small h3. Body
+ size: below that it would read smaller than the text it heads; the extra-bold
+ weight alone sets it apart. */
h4 {
- font-size: 0.95rem;
+ font-size: var(--font-size-body);
}
/* Comfortable paragraph rhythm and a capped line length for readability. */
@@ -161,6 +230,14 @@ h1:focus {
outline: none;
}
+/* Icons (Shared/Icon.razor) sit inline next to text: nudged down to line up with the
+ letters instead of the baseline, and never squeezed when they share a flex row
+ with a long label. */
+.icon {
+ flex-shrink: 0;
+ vertical-align: -0.15em;
+}
+
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
NavLink renders plain elements without a scoped-CSS attribute, so a scoped
".main-nav a" rule would not match them -- the links would fall back to the
diff --git a/Elternbeirat.Web/wwwroot/fonts/OFL.txt b/Elternbeirat.Web/wwwroot/fonts/OFL.txt
new file mode 100644
index 0000000..c8210f0
--- /dev/null
+++ b/Elternbeirat.Web/wwwroot/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/Elternbeirat.Web/wwwroot/fonts/nunito-latin-wght.woff2 b/Elternbeirat.Web/wwwroot/fonts/nunito-latin-wght.woff2
new file mode 100644
index 0000000..65f3728
Binary files /dev/null and b/Elternbeirat.Web/wwwroot/fonts/nunito-latin-wght.woff2 differ
diff --git a/docs/recht.md b/docs/recht.md
index 784f5fe..eb39da3 100644
--- a/docs/recht.md
+++ b/docs/recht.md
@@ -24,6 +24,12 @@ Google Fonts, Maps, YouTube embeds, and CDN scripts transmit visitors' IPs to th
parties. Fonts are served by ourselves. This is the reason behind the "no external
resources" rule in `CLAUDE.md` — data protection, not taste.
+The site font is Nunito (SIL Open Font License 1.1), self-hosted under
+`wwwroot/fonts/` with its license file `OFL.txt` next to it, as the OFL requires.
+The icons are Lucide (ISC, partly MIT via Feather), inlined as SVG in
+`Shared/IconSet.cs` with the license notice in its comment. A smoke test fails if
+the stylesheet or the home page loads anything from an absolute URL.
+
## Photos of children
Only with the consent of the legal guardians — by far the most common mistake on