Compare commits

..
2 Commits
10 changed files with 402 additions and 54 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
using Elternbeirat.Web.Features.Layout;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the footer's copyright year. Pure logic, no PocketBase needed.
/// </summary>
public class FooterYearTests
{
[Fact]
public void New_year_in_Berlin_wins_over_the_UTC_year()
{
// 23:30 UTC on New Year's Eve is already 00:30 in Berlin (winter time, +1),
// so the footer must show the new year although the server clock says the old.
var now = new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero);
FooterYear.For(now).ShouldBe(2027);
}
[Fact]
public void Ordinary_day_keeps_its_year()
{
var now = new DateTimeOffset(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
FooterYear.For(now).ShouldBe(2026);
}
}
@@ -43,6 +43,15 @@ public class NavBuilderTests
footer.Select(link => link.Title).ShouldBe(["Impressum", "Datenschutz"]);
}
[Fact]
public void No_pages_give_empty_menus()
{
// PocketBase down or nothing published yet: both menus are empty lists, not
// null, so the layout can simply skip a column instead of crashing.
NavBuilder.Header([]).ShouldBeEmpty();
NavBuilder.Footer([]).ShouldBeEmpty();
}
[Fact]
public void Home_page_maps_to_root_every_other_slug_to_its_path()
{
+37
View File
@@ -115,6 +115,30 @@ public sealed partial class RouteSmokeTests : IDisposable
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
}
[Fact]
public async Task Footer_links_every_public_header_and_footer_page_in_order()
{
// The footer repeats the header pages as "Schnellzugriff" and then lists the
// footer pages, each column in its order field. Only the <footer> is checked,
// so the header menu cannot make the test pass on its own. The expected list
// mirrors the fixture's pages; the draft (public=false) must be missing.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
var footer = FooterElement().Match(html);
footer.Success.ShouldBeTrue();
var hrefs = Href().Matches(footer.Value).Select(match => match.Groups[1].Value);
hrefs.ShouldBe(
[
"/", "/board", "/patrons", "/faqs", "/downloads", "/posts", "/events",
"/contact", "/imprint", "/privacy", "/faq-lunch",
]);
footer.Value.ShouldContain("Schnellzugriff");
footer.Value.ShouldContain("Weiteres");
}
[Fact]
public async Task Home_renders_the_embeds_its_page_opts_into()
{
@@ -245,4 +269,17 @@ public sealed partial class RouteSmokeTests : IDisposable
/// </summary>
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
private static partial Regex ForeignResource();
/// <summary>
/// Matches the page's <c>&lt;footer&gt;</c> element, from its opening tag to the
/// closing one. The layout renders exactly one footer and nests no other.
/// </summary>
[GeneratedRegex("""<footer\b.*?</footer>""", RegexOptions.Singleline)]
private static partial Regex FooterElement();
/// <summary>
/// Matches an <c>href</c> attribute and captures its value.
/// </summary>
[GeneratedRegex(@"\bhref=""([^""]*)""")]
private static partial Regex Href();
}
@@ -8,7 +8,15 @@
flex wrapper -- see MainLayout.razor.css. *@
<header class="site-header">
<div class="header-inner">
<a class="brand" href="/">Elternbeirat IGMH</a>
@* Two-line lettering next to the signet: the body's name large, the
school small below it. One link, so the whole mark leads home. *@
<a class="brand" href="/">
<Signet />
<span class="brand-text">
<span class="brand-name">Elternbeirat</span>
<span class="brand-school">IGMH Mannheim</span>
</span>
</a>
@* CSS-only mobile menu: the hidden checkbox holds the open/closed
state, the label is the hamburger button, and :checked reveals the
@@ -37,13 +45,48 @@
</main>
<footer class="site-footer">
@* A soft wave as the transition from the page into the dark footer. Pure
decoration, so hidden from screen readers; it is filled with the footer
colour and stretched to the full width (preserveAspectRatio="none"). *@
<svg class="footer-wave" viewBox="0 0 1440 48" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path d="M0 24C240 48 480 48 720 28S1200 0 1440 20V48H0z" />
</svg>
<div class="footer-inner">
<div class="footer-brand">
<Signet />
<span>Elternbeirat der IGMH</span>
<nav class="footer-nav">
</div>
@* Both link columns come from PocketBase like the header. An empty list
(no such pages, or PocketBase down) renders nothing -- no lone
heading above an empty column. *@
@if (_header.Count > 0)
{
<nav class="footer-nav" aria-label="Schnellzugriff">
<h2 class="footer-heading">Schnellzugriff</h2>
<ul>
@foreach (var link in _header)
{
<li><a href="@link.Href">@link.Title</a></li>
}
</ul>
</nav>
}
@if (_footer.Count > 0)
{
<nav class="footer-nav" aria-label="Weiteres">
<h2 class="footer-heading">Weiteres</h2>
<ul>
@foreach (var link in _footer)
{
<a href="@link.Href">@link.Title</a>
<li><a href="@link.Href">@link.Title</a></li>
}
</ul>
</nav>
}
</div>
<p class="footer-copyright">© @_year Elternbeirat der IGMH</p>
</footer>
@@ -23,6 +23,9 @@ public partial class MainLayout
private IReadOnlyList<NavLinkItem> _header = [];
private IReadOnlyList<NavLinkItem> _footer = [];
// Berlin's year, not the server's (UTC) -- see FooterYear.
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
protected override async Task OnInitializedAsync()
{
try
@@ -25,11 +25,12 @@
z-index keeps it above the content; the opaque background stops content showing
through as it scrolls underneath. */
.site-header {
/* Brand-blue bar with a warm accent underline -- the strongest, most visible
cue that carries the friendly tone across every page. Uses its own header
token so it stays deep blue in dark mode too (see app.css). */
background: var(--color-header);
border-bottom: 3px solid var(--color-accent);
/* The brand gradient, blue into green -- the strongest, most visible cue that
carries the school's colours across every page. Built on --color-header, so
it stays deep in dark mode too (see app.css). No accent underline any more:
the gradient already ends in green, and the active menu item carries the
green stroke. */
background: var(--gradient-brand);
box-shadow: var(--shadow);
position: sticky;
top: 0;
@@ -42,22 +43,57 @@
padding: var(--space-3) 1.25rem;
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: var(--space-2) var(--space-4);
}
/* Signet and two-line lettering side by side. The lettering is a column of its
own, so the name and the school line up on the left edge. */
.brand {
font-size: 1.35rem;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-on-header);
text-decoration: none;
white-space: nowrap;
}
.brand:hover {
color: var(--color-on-header);
}
/* The signet lives in its own component (Signet.razor), so its <svg> carries that
component's scope attribute, not this one's; ::deep reaches it from here. */
.brand ::deep .signet {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-name {
font-size: 1.35rem;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
}
.brand-school {
font-size: 0.85rem;
font-weight: 600;
}
/* The links are pills with their own padding (see app.css), so the gap between
them can stay small. */
.main-nav {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
align-items: center;
gap: var(--space-1) var(--space-2);
}
/* The hamburger button and its state checkbox are hidden on wide screens; the
@@ -72,11 +108,12 @@
(b-xxxxx), so a scoped rule ".main-nav a" would not match them. See app.css. */
/* Content: at least tall enough that the footer sits at the bottom of the viewport
on short pages. 100dvh - 4rem leaves room for the header (which sits above in
normal flow); dvh follows the mobile browser's shrinking toolbar. On long pages
the content simply grows past this and pushes the footer down naturally. */
on short pages. 100dvh - 5rem leaves room for the header with its signet (which
sits above in normal flow); dvh follows the mobile browser's shrinking toolbar.
On long pages the content simply grows past this and pushes the footer down
naturally. */
.content {
min-height: calc(100dvh - 4rem);
min-height: calc(100dvh - 5rem);
box-sizing: border-box;
width: 100%;
max-width: var(--content-width);
@@ -84,39 +121,101 @@
padding: var(--space-4) 1.25rem var(--space-6);
}
/* Footer */
/* Footer: a dark brand-blue band with light text, so the page visibly ends here.
Its own tokens (--color-footer, --color-on-footer...) keep it dark in both
colour schemes, like the header. position:relative anchors the wave. */
.site-footer {
border-top: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-soft);
font-size: 0.9rem;
position: relative;
margin-top: var(--space-6);
background: var(--color-footer);
color: var(--color-on-footer-soft);
font-size: 0.95rem;
}
/* The wave sits just above the footer's top edge (bottom: 100%), drawn over the
page background in the footer colour, so the band seems to rise softly instead
of starting with a hard line. Fixed height, full width: on a phone it simply
flattens out. Overlapped by 1px so no hairline gap shows between wave and band
when the browser rounds. */
.footer-wave {
position: absolute;
bottom: calc(100% - 1px);
left: 0;
display: block;
width: 100%;
height: 2.5rem;
fill: var(--color-footer);
}
/* Three columns: brand, quick links, further pages. The brand gets a bit more room
because it holds the signet. A missing link column simply leaves its cell empty,
so the others keep their place. */
.footer-inner {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-3) 1.25rem;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-2) var(--space-4);
}
.footer-nav {
display: flex;
padding: var(--space-5) 1.25rem var(--space-4);
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
gap: var(--space-4);
}
.footer-brand {
display: flex;
align-items: center;
align-self: start;
gap: var(--space-2);
color: var(--color-on-footer);
font-weight: 700;
}
.footer-brand ::deep .signet {
flex-shrink: 0;
width: 3rem;
height: 3rem;
}
/* Column headings label the lists; they are not a second page heading, so they
stay small and quiet. */
.footer-heading {
margin: 0 0 var(--space-2);
color: var(--color-on-footer);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.footer-nav ul {
list-style: none;
margin: 0;
padding: 0;
}
.footer-nav li + li {
margin-top: var(--space-1);
}
.footer-nav a {
color: var(--color-text-soft);
color: var(--color-on-footer-soft);
text-decoration: none;
}
.footer-nav a:hover {
color: var(--color-brand);
color: var(--color-on-footer);
text-decoration: underline;
}
/* Copyright: a thin rule separates it from the columns. The rule spans the content
width, not the whole window, so it lines up with the columns above. */
.footer-copyright {
box-sizing: border-box;
max-width: var(--content-width);
margin: 0 auto;
padding: var(--space-3) 1.25rem;
border-top: 1px solid var(--color-footer-border);
font-size: 0.85rem;
}
/* Mobile: on narrow screens the nav collapses behind a hamburger button. This is
pure CSS -- the #nav-toggle checkbox holds the open/closed state and its
:checked state reveals the nav. No JavaScript, so it works under static SSR. */
@@ -144,8 +243,9 @@
display: block;
width: 1.5rem;
height: 2px;
background: #fff; /* on the blue header bar */
background: var(--color-on-header);
content: "";
transition: transform var(--transition), background-color var(--transition);
}
.nav-toggle-bar::before {
@@ -156,6 +256,21 @@
transform: translateY(4px); /* 6px minus the bar's own 2px height */
}
/* Open: the hamburger turns into an X. The middle bar fades out and the outer
two meet in the middle and cross. Only the icon changes shape; nothing around
it moves. The checkbox sits right before the label, hence the + combinator. */
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar {
background-color: transparent;
}
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar::before {
transform: rotate(45deg);
}
.nav-toggle:checked + .nav-toggle-label .nav-toggle-bar::after {
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */
}
/* The nav takes its own row and is hidden until the checkbox is checked. */
.main-nav {
flex-basis: 100%;
@@ -175,6 +290,11 @@
margin-top: 0.5rem;
}
/* Mobile per-link spacing/separators for .main-nav a are in the global app.css
too, for the same scoped-attribute reason as above. */
/* Mobile per-link spacing for .main-nav a is in the global app.css too, for
the same scoped-attribute reason as above. */
/* The footer columns stack: one column, brand on top. */
.footer-inner {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,35 @@
namespace Elternbeirat.Web.Features.Layout;
/// <summary>
/// Works out the year shown in the footer's copyright line.
/// </summary>
/// <remarks>
/// The container runs on UTC, but visitors live in Germany. Taking the year
/// straight from the server clock would show the old year for the first hour
/// (two in summer time) of every new year, so the instant is converted to
/// Europe/Berlin first.
/// </remarks>
public static class FooterYear
{
/// <summary>
/// IANA id; resolves on every platform .NET supports, Windows included.
/// </summary>
private static readonly TimeZoneInfo Berlin =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>
/// Returns the calendar year in Berlin at the given instant.
/// </summary>
/// <param name="now">
/// The current instant, in any offset; usually
/// <see cref="TimeProvider.GetUtcNow"/>.
/// </param>
/// <returns>The year a visitor in Germany would see on their calendar.</returns>
/// <example>
/// <code>
/// FooterYear.For(new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.Zero)); // 2027
/// </code>
/// </example>
public static int For(DateTimeOffset now) =>
TimeZoneInfo.ConvertTime(now, Berlin).Year;
}
@@ -0,0 +1,17 @@
@*
The site's own small mark: a speech bubble (the board speaks for the parents)
holding a grown-up and a child. Inline SVG, so there is no extra request and
nothing from a third-party host. Decorative only -- the name next to it carries
the meaning, so it is hidden from screen readers. Colours come from tokens in
Signet.razor.css; the size is set by whoever places it (MainLayout.razor.css).
*@
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
<path class="signet-bubble"
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z" />
<g class="signet-figures">
<circle cx="15" cy="12" r="3.5" />
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z" />
<circle cx="26" cy="16" r="2.6" />
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z" />
</g>
</svg>
@@ -0,0 +1,11 @@
/* A white bubble with green figures. It sits on the header gradient and on the dark
footer, both of which always carry white text, so --color-on-header is right in
both places. The figures use the darker green so they stay distinct on the white
bubble (the lighter --color-accent would wash out). */
.signet-bubble {
fill: var(--color-on-header);
}
.signet-figures {
fill: var(--color-accent-dark);
}
+64 -18
View File
@@ -17,6 +17,23 @@
--color-brand is lightened for text/links. */
--color-header: #328fc6;
/* Text and menu states on the header. White text, and two translucent white
washes for the menu pills: they lighten whatever part of the gradient sits
behind a link, so one value works from the blue end to the green end. The
active pill is the stronger one, so "where am I" outranks "where is the
mouse". */
--color-on-header: #fff;
--color-nav-hover: rgba(255, 255, 255, 0.12);
--color-nav-active: rgba(255, 255, 255, 0.2);
/* Footer: a dark brand blue, so the page visibly ends there. Light text in two
steps (headings and the name brighter, links softer); both stay >= 7:1 on the
band. The border token is a faint white line for the copyright rule. */
--color-footer: #1a3d57;
--color-on-footer: #f1f5f9;
--color-on-footer-soft: #c5d3df;
--color-footer-border: rgba(255, 255, 255, 0.15);
/* Accent for highlights (active nav, event dates, call-outs): the IGMH green.
The -dark shade is darkened enough to keep small text and the FAQ marker
legible on a white card. */
@@ -121,6 +138,12 @@
--color-header: #266f9c; /* the IGMH blue eased down, to sit calmly on dark */
--color-footer: #10202e; /* a deep night blue: darker than the page, so
the band still reads as the page's end */
--color-on-footer: #c9ced6; /* the heading tone, 10:1 on the band */
--color-on-footer-soft: #a2a8b2; /* the body text tone, 6.9:1 on the band */
--color-footer-border: rgba(255, 255, 255, 0.1);
--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
@@ -253,37 +276,55 @@ h1:focus {
/* Header navigation links. These live here (global), not in MainLayout.razor.css:
NavLink renders plain <a> elements without a scoped-CSS attribute, so a scoped
".main-nav a" rule would not match them -- the links would fall back to the
global link colour (dark blue) and become unreadable on the blue header bar. */
global link colour (dark blue) and become unreadable on the blue header bar.
Every link is a pill with the same padding whether active or not, so switching
pages never shifts the row. Full white, not a softened white: the blue end of
the gradient already leaves little contrast headroom. */
.main-nav a {
color: rgba(255, 255, 255, 0.85);
position: relative;
padding: var(--space-1) var(--space-3);
border-radius: 999px;
color: var(--color-on-header);
text-decoration: none;
padding: var(--space-1) 0;
border-bottom: 2px solid transparent;
transition: color var(--transition), border-color var(--transition);
transition: background-color var(--transition);
}
.main-nav a:hover {
color: #fff;
color: var(--color-on-header);
background: var(--color-nav-hover);
}
/* NavLink adds .active to the current route's link; a warm accent underline marks
where the visitor is on the blue bar. */
/* NavLink adds .active to the current route's link: a stronger pill plus a short
green stroke under the text mark where the visitor is. The stroke is drawn by
::after, not a border, so it follows the text width inside the rounded pill. */
.main-nav a.active {
color: #fff;
border-bottom-color: var(--color-accent);
background: var(--color-nav-active);
}
/* Mobile: stacked links inside the collapsed menu get row separators. Same global
placement, same NavLink reason as above. Scoped to the narrow layout via the
media query so it does not affect the desktop row. */
.main-nav a.active::after {
content: "";
position: absolute;
left: var(--space-3);
right: var(--space-3);
bottom: 0.2rem;
height: 2px;
border-radius: 1px;
background: var(--color-accent);
}
/* Mobile: the stacked links in the open menu become full-width rows with a
rounded highlight instead of pills, and the green stroke shrinks to a short
mark at the start, so a long row does not get a long line. Same global
placement, same NavLink reason as above. */
@media (max-width: 40rem) {
.main-nav a {
padding: 0.6rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
padding: 0.6rem var(--space-3);
border-radius: var(--radius-sm);
}
.main-nav a.active {
border-bottom-color: rgba(255, 255, 255, 0.15);
.main-nav a.active::after {
right: auto;
width: 1.5rem;
}
}
@@ -600,12 +641,15 @@ h1:focus {
this is what turns a plain list of documents on /downloads into a download
area. Markdown.cs marks such paragraphs/items .lone-link, because CSS cannot see
the text around a link. Two child paths: in a tight list the link sits directly
in the <li>, in a loose list inside a <p>. */
in the <li>, in a loose list inside a <p>. border-box: the 42rem include the
padding, so the card ends flush with the running text instead of overhanging
it (same for the aufruf and hinweis boxes below). */
.markdown-body .lone-link > a[href$=".pdf" i],
.markdown-body .lone-link > p > a[href$=".pdf" i] {
display: flex;
align-items: center;
gap: var(--space-3);
box-sizing: border-box;
max-width: 42rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
@@ -692,6 +736,7 @@ h1:focus {
/* ::: aufruf -- a highlighted call to action: a tinted box with a brand-blue edge.
Its links become buttons (rule above). */
.markdown-body .aufruf {
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
margin: 0 0 var(--space-4);
padding: var(--space-4);
max-width: 42rem;
@@ -705,6 +750,7 @@ h1:focus {
says what matters. */
.markdown-body .hinweis {
position: relative;
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
margin: 0 0 var(--space-4);
padding: var(--space-3) var(--space-4) var(--space-3) 3.25rem;
max-width: 42rem;