Merge pull request 'Refresh header and footer with signet, gradient, nav pills and three-column footer; fix box-sizing of design boxes' (#53) from design/header-footer into main
Build and push image / build (push) Successful in 1m57s
Build and push image / build (push) Successful in 1m57s
Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
commit
b737f57ef7
10 files changed
+407
-59
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"]);
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
||||
@@ -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><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. *@
|
||||
<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">
|
||||
<span>Elternbeirat der IGMH</span>
|
||||
<nav class="footer-nav">
|
||||
@foreach (var link in _footer)
|
||||
{
|
||||
<a href="@link.Href">@link.Title</a>
|
||||
}
|
||||
</nav>
|
||||
<div class="footer-brand">
|
||||
<Signet />
|
||||
<span>Elternbeirat der IGMH</span>
|
||||
</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)
|
||||
{
|
||||
<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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user