Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b737f57ef7 | ||
|
|
d539fde09d |
No files matched your search
@@ -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"]);
|
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]
|
[Fact]
|
||||||
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
public void Home_page_maps_to_root_every_other_slug_to_its_path()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,6 +115,30 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
html.ShouldNotContain("href=\"/draft\""); // public=false, filtered out
|
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]
|
[Fact]
|
||||||
public async Task Home_renders_the_embeds_its_page_opts_into()
|
public async Task Home_renders_the_embeds_its_page_opts_into()
|
||||||
{
|
{
|
||||||
@@ -245,4 +269,17 @@ public sealed partial class RouteSmokeTests : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
|
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ForeignResource();
|
private static partial Regex ForeignResource();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches the page's <c><footer></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. *@
|
flex wrapper -- see MainLayout.razor.css. *@
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<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
|
@* CSS-only mobile menu: the hidden checkbox holds the open/closed
|
||||||
state, the label is the hamburger button, and :checked reveals the
|
state, the label is the hamburger button, and :checked reveals the
|
||||||
@@ -37,13 +45,48 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<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-inner">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<Signet />
|
||||||
<span>Elternbeirat der IGMH</span>
|
<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)
|
@foreach (var link in _footer)
|
||||||
{
|
{
|
||||||
<a href="@link.Href">@link.Title</a>
|
<li><a href="@link.Href">@link.Title</a></li>
|
||||||
}
|
}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="footer-copyright">© @_year Elternbeirat der IGMH</p>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -23,6 +23,9 @@ public partial class MainLayout
|
|||||||
private IReadOnlyList<NavLinkItem> _header = [];
|
private IReadOnlyList<NavLinkItem> _header = [];
|
||||||
private IReadOnlyList<NavLinkItem> _footer = [];
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -25,11 +25,12 @@
|
|||||||
z-index keeps it above the content; the opaque background stops content showing
|
z-index keeps it above the content; the opaque background stops content showing
|
||||||
through as it scrolls underneath. */
|
through as it scrolls underneath. */
|
||||||
.site-header {
|
.site-header {
|
||||||
/* Brand-blue bar with a warm accent underline -- the strongest, most visible
|
/* The brand gradient, blue into green -- the strongest, most visible cue that
|
||||||
cue that carries the friendly tone across every page. Uses its own header
|
carries the school's colours across every page. Built on --color-header, so
|
||||||
token so it stays deep blue in dark mode too (see app.css). */
|
it stays deep in dark mode too (see app.css). No accent underline any more:
|
||||||
background: var(--color-header);
|
the gradient already ends in green, and the active menu item carries the
|
||||||
border-bottom: 3px solid var(--color-accent);
|
green stroke. */
|
||||||
|
background: var(--gradient-brand);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -42,22 +43,57 @@
|
|||||||
padding: var(--space-3) 1.25rem;
|
padding: var(--space-3) 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: var(--space-2) var(--space-4);
|
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 {
|
.brand {
|
||||||
font-size: 1.35rem;
|
display: flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
color: #fff;
|
gap: var(--space-2);
|
||||||
|
color: var(--color-on-header);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
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 {
|
.main-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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
|
/* 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. */
|
(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
|
/* 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
|
on short pages. 100dvh - 5rem leaves room for the header with its signet (which
|
||||||
normal flow); dvh follows the mobile browser's shrinking toolbar. On long pages
|
sits above in normal flow); dvh follows the mobile browser's shrinking toolbar.
|
||||||
the content simply grows past this and pushes the footer down naturally. */
|
On long pages the content simply grows past this and pushes the footer down
|
||||||
|
naturally. */
|
||||||
.content {
|
.content {
|
||||||
min-height: calc(100dvh - 4rem);
|
min-height: calc(100dvh - 5rem);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--content-width);
|
max-width: var(--content-width);
|
||||||
@@ -84,39 +121,101 @@
|
|||||||
padding: var(--space-4) 1.25rem var(--space-6);
|
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 {
|
.site-footer {
|
||||||
border-top: 1px solid var(--color-border);
|
position: relative;
|
||||||
background: var(--color-surface);
|
margin-top: var(--space-6);
|
||||||
color: var(--color-text-soft);
|
background: var(--color-footer);
|
||||||
font-size: 0.9rem;
|
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 {
|
.footer-inner {
|
||||||
max-width: var(--content-width);
|
max-width: var(--content-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--space-3) 1.25rem;
|
padding: var(--space-5) 1.25rem var(--space-4);
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1.5fr 1fr 1fr;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-2) var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-4);
|
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 {
|
.footer-nav a {
|
||||||
color: var(--color-text-soft);
|
color: var(--color-on-footer-soft);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-nav a:hover {
|
.footer-nav a:hover {
|
||||||
color: var(--color-brand);
|
color: var(--color-on-footer);
|
||||||
text-decoration: underline;
|
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
|
/* 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
|
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. */
|
:checked state reveals the nav. No JavaScript, so it works under static SSR. */
|
||||||
@@ -144,8 +243,9 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: #fff; /* on the blue header bar */
|
background: var(--color-on-header);
|
||||||
content: "";
|
content: "";
|
||||||
|
transition: transform var(--transition), background-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-toggle-bar::before {
|
.nav-toggle-bar::before {
|
||||||
@@ -156,6 +256,21 @@
|
|||||||
transform: translateY(4px); /* 6px minus the bar's own 2px height */
|
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. */
|
/* The nav takes its own row and is hidden until the checkbox is checked. */
|
||||||
.main-nav {
|
.main-nav {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
@@ -175,6 +290,11 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile per-link spacing/separators for .main-nav a are in the global app.css
|
/* Mobile per-link spacing for .main-nav a is in the global app.css too, for
|
||||||
too, for the same scoped-attribute reason as above. */
|
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);
|
||||||
|
}
|
||||||
@@ -17,6 +17,23 @@
|
|||||||
--color-brand is lightened for text/links. */
|
--color-brand is lightened for text/links. */
|
||||||
--color-header: #328fc6;
|
--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.
|
/* 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
|
The -dark shade is darkened enough to keep small text and the FAQ marker
|
||||||
legible on a white card. */
|
legible on a white card. */
|
||||||
@@ -121,6 +138,12 @@
|
|||||||
|
|
||||||
--color-header: #266f9c; /* the IGMH blue eased down, to sit calmly on dark */
|
--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: #86c97d; /* the IGMH green nudged brighter for dark */
|
||||||
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
|
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
|
||||||
--color-accent-tint: #24332a; /* a dark green-grey: tinted, but the soft
|
--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:
|
/* 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
|
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
|
".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 {
|
.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;
|
text-decoration: none;
|
||||||
padding: var(--space-1) 0;
|
transition: background-color var(--transition);
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: color var(--transition), border-color var(--transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a:hover {
|
.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
|
/* NavLink adds .active to the current route's link: a stronger pill plus a short
|
||||||
where the visitor is on the blue bar. */
|
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 {
|
.main-nav a.active {
|
||||||
color: #fff;
|
background: var(--color-nav-active);
|
||||||
border-bottom-color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: stacked links inside the collapsed menu get row separators. Same global
|
.main-nav a.active::after {
|
||||||
placement, same NavLink reason as above. Scoped to the narrow layout via the
|
content: "";
|
||||||
media query so it does not affect the desktop row. */
|
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) {
|
@media (max-width: 40rem) {
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
padding: 0.6rem 0;
|
padding: 0.6rem var(--space-3);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav a.active {
|
.main-nav a.active::after {
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.15);
|
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
|
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
|
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
|
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 > a[href$=".pdf" i],
|
||||||
.markdown-body .lone-link > p > a[href$=".pdf" i] {
|
.markdown-body .lone-link > p > a[href$=".pdf" i] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
box-sizing: border-box;
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border: 1px solid var(--color-border);
|
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.
|
/* ::: aufruf -- a highlighted call to action: a tinted box with a brand-blue edge.
|
||||||
Its links become buttons (rule above). */
|
Its links become buttons (rule above). */
|
||||||
.markdown-body .aufruf {
|
.markdown-body .aufruf {
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
@@ -705,6 +750,7 @@ h1:focus {
|
|||||||
says what matters. */
|
says what matters. */
|
||||||
.markdown-body .hinweis {
|
.markdown-body .hinweis {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
padding: var(--space-3) var(--space-4) var(--space-3) 3.25rem;
|
padding: var(--space-3) var(--space-4) var(--space-3) 3.25rem;
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
|
|||||||
Reference in new issue
Block a user