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