diff --git a/Elternbeirat.Web.Tests/PostNeighborsTests.cs b/Elternbeirat.Web.Tests/PostNeighborsTests.cs
new file mode 100644
index 0000000..636c0b9
--- /dev/null
+++ b/Elternbeirat.Web.Tests/PostNeighborsTests.cs
@@ -0,0 +1,57 @@
+using Elternbeirat.Contracts;
+using Elternbeirat.Web.Features.Posts;
+
+namespace Elternbeirat.Web.Tests;
+
+///
+/// Unit tests for the older/newer post next to a post. The list comes newest
+/// first, as from PocketBase; "previous" is the older post. Pure logic, no
+/// PocketBase needed.
+///
+public class PostNeighborsTests
+{
+ // Newest first, like PocketBaseClient.GetPostsAsync.
+ private static readonly Post[] Posts = [Post("march"), Post("february"), Post("january")];
+
+ private static Post Post(string slug) =>
+ new() { Id = slug, Title = slug, Slug = slug };
+
+ [Fact]
+ public void Middle_post_has_the_older_as_previous_and_the_newer_as_next()
+ {
+ var neighbors = PostNeighbors.Of(Posts, "february");
+
+ neighbors.Previous?.Slug.ShouldBe("january");
+ neighbors.Next?.Slug.ShouldBe("march");
+ }
+
+ [Fact]
+ public void Newest_post_has_no_next()
+ {
+ var neighbors = PostNeighbors.Of(Posts, "march");
+
+ neighbors.Previous?.Slug.ShouldBe("february");
+ neighbors.Next.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Oldest_post_has_no_previous()
+ {
+ var neighbors = PostNeighbors.Of(Posts, "january");
+
+ neighbors.Previous.ShouldBeNull();
+ neighbors.Next?.Slug.ShouldBe("february");
+ }
+
+ [Fact]
+ public void Only_post_has_no_neighbors() =>
+ PostNeighbors.Of([Post("only")], "only").ShouldBe(new PostNeighbors(null, null));
+
+ [Fact]
+ public void Unknown_slug_has_no_neighbors() =>
+ PostNeighbors.Of(Posts, "december").ShouldBe(new PostNeighbors(null, null));
+
+ [Fact]
+ public void Slug_is_compared_case_insensitively() =>
+ PostNeighbors.Of(Posts, "February").Next?.Slug.ShouldBe("march");
+}
diff --git a/Elternbeirat.Web.Tests/RouteSmokeTests.cs b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
index 33999fb..6d62cfe 100644
--- a/Elternbeirat.Web.Tests/RouteSmokeTests.cs
+++ b/Elternbeirat.Web.Tests/RouteSmokeTests.cs
@@ -60,7 +60,16 @@ public sealed partial class RouteSmokeTests : IDisposable
/// Every route that a visitor can reach through the navigation, the FAQ hub
/// or the news section. The post slugs match the fixture's seed.
///
- public static TheoryData KnownRoutes =>
+ public static TheoryData KnownRoutes => [.. Routes];
+
+ ///
+ /// The that render an HTML page, i.e. all but the
+ /// calendar feed.
+ ///
+ public static TheoryData PageRoutes =>
+ [.. Routes.Where(route => !route.EndsWith(".ics", StringComparison.Ordinal))];
+
+ private static readonly string[] Routes =
[
"/",
"/board",
@@ -453,6 +462,71 @@ public sealed partial class RouteSmokeTests : IDisposable
visible.ShouldNotContain("Oktober 2026");
}
+ [Fact]
+ public async Task Post_page_has_the_title_as_its_only_h1()
+ {
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
+
+ Regex.Count(html, "]").ShouldBe(1);
+ html.ShouldContain(">Neuer Vorstand ");
+ }
+
+ [Fact]
+ public async Task Newest_post_links_only_to_the_older_one()
+ {
+ // The fixture has two posts: new-board (newer) and new-hall (older).
+ // "Vorheriger Beitrag" is the older post, so the newest post has no "next".
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri("/posts/new-board", UriKind.Relative));
+
+ html.ShouldContain("href=\"/posts/new-hall\" rel=\"prev\"");
+ html.ShouldNotContain("rel=\"next\"");
+ }
+
+ [Fact]
+ public async Task Oldest_post_links_only_to_the_newer_one()
+ {
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri("/posts/new-hall", UriKind.Relative));
+
+ html.ShouldContain("href=\"/posts/new-board\" rel=\"next\"");
+ html.ShouldNotContain("rel=\"prev\"");
+ }
+
+ [Theory]
+ [MemberData(nameof(PageRoutes))]
+ public async Task Every_page_starts_with_a_skip_link_to_its_own_content(string route)
+ {
+ // The skip link must carry the page's own path: with a bare
+ // "#main" would lead back to the home page. It must also bypass Blazor's
+ // enhanced navigation, which scrolls but does not move the focus.
+ var client = _factory.CreateClient();
+
+ var html = await client.GetStringAsync(new Uri(route, UriKind.Relative));
+
+ html.ShouldContain($"""");
+ html.ShouldContain("""");
+ html.ShouldContain("
/// Matches a resource reference that points at an absolute URL, with or without
/// scheme (https:// , http:// or protocol-relative // ): the
diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor b/Elternbeirat.Web/Components/Layout/MainLayout.razor
index b78e43f..918e404 100644
--- a/Elternbeirat.Web/Components/Layout/MainLayout.razor
+++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor
@@ -6,6 +6,15 @@
immediately), it pins to the top and stays there while the page scrolls. The
footer is kept at the bottom on short pages by a min-height on , not by a
flex wrapper -- see MainLayout.razor.css. *@
+
+@* Skip link: the first stop for the Tab key, straight past the menu to the content.
+ The href carries the current path because of in App.razor: a
+ bare "#main" would resolve to "/#main" and lead every subpage back home.
+ data-enhance-nav="false" leaves the jump to the browser: blazor.web.js would
+ otherwise take the click, only scroll to and keep the focus on the link,
+ so the next Tab would land in the header menu again. *@
+Zum Inhalt springen
+
-
+ @* The skip link's target. tabindex="-1" makes it focusable by the link alone
+ (not by Tab), so after the jump the next Tab continues inside the content. *@
+
@Body
diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs
index 32ea7c0..2d23812 100644
--- a/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs
+++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor.cs
@@ -23,6 +23,12 @@ public partial class MainLayout
private IReadOnlyList _header = [];
private IReadOnlyList _footer = [];
+ // The skip link's target on this very page: path and query of the request plus
+ // the fragment. See the comment on the skip link in the markup for why it cannot
+ // be a bare "#main".
+ private string SkipLinkHref =>
+ $"{HttpContext?.Request.PathBase}{HttpContext?.Request.Path}{HttpContext?.Request.QueryString}#main";
+
// Berlin's year, not the server's (UTC) -- see FooterYear.
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
diff --git a/Elternbeirat.Web/Components/Layout/MainLayout.razor.css b/Elternbeirat.Web/Components/Layout/MainLayout.razor.css
index 1a7b9b5..2b9a7f5 100644
--- a/Elternbeirat.Web/Components/Layout/MainLayout.razor.css
+++ b/Elternbeirat.Web/Components/Layout/MainLayout.razor.css
@@ -62,6 +62,12 @@
color: var(--color-on-header);
}
+/* On the blue gradient the global brand-blue focus ring would vanish; white
+ stays visible on both ends of it. */
+.brand:focus-visible {
+ outline-color: var(--color-focus-inverse);
+}
+
/* The signet lives in its own component (Signet.razor), so its carries that
component's scope attribute, not this one's; ::deep reaches it from here. */
.brand ::deep .signet {
@@ -97,7 +103,9 @@
}
/* The hamburger button and its state checkbox are hidden on wide screens; the
- media query below turns them on for narrow ones. */
+ media query below turns them on for narrow ones. display:none (beating the
+ global .visually-hidden the checkbox also carries) takes the checkbox out of the
+ tab order here, where there is no menu to open. */
.nav-toggle,
.nav-toggle-label {
display: none;
@@ -205,6 +213,11 @@
text-decoration: underline;
}
+/* Same reason as the brand link in the header: a light ring on the dark band. */
+.footer-nav a:focus-visible {
+ outline-color: var(--color-focus-inverse);
+}
+
/* 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 {
@@ -228,6 +241,19 @@
justify-content: space-between;
}
+ /* The checkbox comes back as a real, focusable control -- only visually hidden
+ (global .visually-hidden), so Tab stops on it and Space toggles the menu. It
+ lives under the label, so the focus ring is drawn there instead. */
+ .nav-toggle {
+ display: block;
+ }
+
+ .nav-toggle:focus-visible + .nav-toggle-label {
+ outline: 3px solid var(--color-focus-inverse);
+ outline-offset: 2px;
+ border-radius: var(--radius);
+ }
+
/* Show the hamburger button. */
.nav-toggle-label {
display: block;
@@ -271,20 +297,29 @@
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.
+ max-height:0 alone would only hide it from the eye: its links would still
+ be Tab stops and still be read out. visibility:hidden takes them out of
+ both. Transitioning visibility delays the switch to hidden until the fold
+ has finished, so closing still animates. Opening uses the transition of the
+ :checked rule below, which leaves visibility out: the links must be Tab
+ stops the moment Space is pressed, not 0.2s later. */
.main-nav {
flex-basis: 100%;
flex-direction: column;
gap: 0;
max-height: 0;
overflow: hidden;
- transition: max-height 0.2s ease;
+ visibility: hidden;
+ transition: max-height 0.2s ease, visibility 0.2s;
}
/* Checked state: reveal the stacked links. The max-height caps the open menu
to the space below the sticky header; a longer list scrolls inside it
instead of running off the screen. */
.nav-toggle:checked ~ .main-nav {
+ visibility: visible;
+ transition: max-height 0.2s ease;
max-height: 70vh;
overflow-y: auto;
margin-top: 0.5rem;
diff --git a/Elternbeirat.Web/Features/Events/EventCard.razor.css b/Elternbeirat.Web/Features/Events/EventCard.razor.css
index e45c830..1f3fd3e 100644
--- a/Elternbeirat.Web/Features/Events/EventCard.razor.css
+++ b/Elternbeirat.Web/Features/Events/EventCard.razor.css
@@ -27,11 +27,6 @@
color: var(--color-text);
}
-.event-card-link:focus-visible {
- outline: 3px solid var(--color-focus);
- outline-offset: 2px;
-}
-
.event-details {
display: flex;
flex-direction: column;
diff --git a/Elternbeirat.Web/Features/Home/Home.razor.css b/Elternbeirat.Web/Features/Home/Home.razor.css
index 143f199..d922e29 100644
--- a/Elternbeirat.Web/Features/Home/Home.razor.css
+++ b/Elternbeirat.Web/Features/Home/Home.razor.css
@@ -90,7 +90,7 @@
}
.hero .btn:focus-visible {
- outline-color: var(--color-on-header);
+ outline-color: var(--color-focus-inverse);
}
/* --- Next event ---------------------------------------------------------------
@@ -116,7 +116,7 @@
/* The ring sits outside the card, on the gradient, so it is white like the
hero's button rings. */
.next-event:focus-visible {
- outline: 3px solid var(--color-on-header);
+ outline-color: var(--color-focus-inverse);
outline-offset: 3px;
}
@@ -208,11 +208,6 @@
color: var(--color-text);
}
-.tile-link:focus-visible {
- outline: 3px solid var(--color-focus);
- outline-offset: 2px;
-}
-
/* The icon in a soft round tint. */
.tile-icon {
flex-shrink: 0;
diff --git a/Elternbeirat.Web/Features/Posts/PostCard.razor.css b/Elternbeirat.Web/Features/Posts/PostCard.razor.css
index 7ef89d0..f0947a1 100644
--- a/Elternbeirat.Web/Features/Posts/PostCard.razor.css
+++ b/Elternbeirat.Web/Features/Posts/PostCard.razor.css
@@ -23,11 +23,6 @@
color: var(--color-text);
}
-.post-card:focus-visible {
- outline: 3px solid var(--color-focus);
- outline-offset: 2px;
-}
-
/* Icon and date on one line. The icon belongs to Icon's own scope, so it is
reached through ::deep. */
.post-date {
diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor b/Elternbeirat.Web/Features/Posts/PostDetail.razor
index ec7cc4c..3023e66 100644
--- a/Elternbeirat.Web/Features/Posts/PostDetail.razor
+++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor
@@ -8,16 +8,39 @@
else if (_post is not null)
{
@_post.Title
-
- ← Alle Beiträge
-
-
-
-
-
@_post.Title
-
@Body
-
+ @* A plain link, not a : one link is no navigation block, and an unlabelled
+ second next to the main menu would only add noise for screen readers. *@
+ Alle Beiträge
+
+
+
+ @Body
+
+ @if (_neighbors.Previous is not null || _neighbors.Next is not null)
+ {
+ @* Older post on the left, newer on the right, like a timeline. A missing side
+ leaves its cell empty, so the remaining link keeps its place. *@
+
+ }
}
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
already ran in OnParametersSetAsync and renders the not-found page. *@
diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs
index 7eb1aef..b61fe08 100644
--- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs
+++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.cs
@@ -32,6 +32,10 @@ public partial class PostDetail
private Post? _post;
+ // The older and newer post for the "Vorheriger / Nächster Beitrag" links at the
+ // end; both empty until loaded or when the post stands alone.
+ private PostNeighbors _neighbors = new(null, null);
+
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of a not-found page, matching
// the 503 this sets on the response.
@@ -48,6 +52,7 @@ public partial class PostDetail
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_post = posts.FirstOrDefault(
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
+ _neighbors = PostNeighbors.Of(posts, Slug);
}
catch (PocketBaseUnavailableException exception)
{
diff --git a/Elternbeirat.Web/Features/Posts/PostDetail.razor.css b/Elternbeirat.Web/Features/Posts/PostDetail.razor.css
index f7dd27b..cbf12b6 100644
--- a/Elternbeirat.Web/Features/Posts/PostDetail.razor.css
+++ b/Elternbeirat.Web/Features/Posts/PostDetail.razor.css
@@ -1,15 +1,108 @@
-/* Push the whole article body down from the "all posts" link above it. Wraps the
- date, heading and body, so scoped CSS applies (the date sits in the PostDate
- child component and would not be reachable on its own). The date's own look
- comes from PostDate.razor.css. */
-.post-content {
- margin-top: 1.5rem;
+/* The article: a reading column of about 68 characters with generous leading, so a
+ long post reads like a page, not like a form. Everything inside follows this
+ width -- the global 42rem cap on paragraphs, lists and boxes is lifted here, or
+ the text would stop short of the column it sits in. */
+.post {
+ max-width: 68ch;
+ margin-top: var(--space-4);
+ line-height: 1.75;
}
-/* The post title is the first h2 in the content, sitting right under its date, so
- drop the large default heading gap above it (h1..h4 carry --space-5 top margin
- globally); date and title belong together. Scoped to :first-of-type so h2
- subheadings inside the Markdown body keep their normal spacing. */
-.post-content h2:first-of-type {
- margin-top: var(--space-1);
+.post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
+ max-width: none;
+}
+
+/* Date above the title as a quiet meta line, with the same calendar icon as on the
+ post cards. The title follows directly, so the gap between them stays small
+ (h1 carries no top margin globally). */
+.post-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ margin: 0 0 var(--space-2);
+ color: var(--color-text-soft);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.post-meta ::deep .icon {
+ color: var(--color-accent-dark);
+}
+
+/* The first paragraph as the lead: larger and in the soft tone, so it reads as an
+ introduction. Pure CSS on the editor's Markdown -- if a post starts with a
+ heading or a list instead, there simply is no lead. Same text colour token as
+ the rest, so it keeps AA contrast in both schemes. */
+.post-body ::deep > p:first-child {
+ color: var(--color-text-soft);
+ font-size: 1.2rem;
+ line-height: 1.65;
+}
+
+/* Older/newer post at the end: two cards side by side, older on the left, newer on
+ the right. The grid keeps each in its column even if the other is missing. Hover
+ = border and shadow, nothing moves. */
+.post-pager {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-3);
+ max-width: 68ch;
+ margin-top: var(--space-6);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--color-border);
+}
+
+.post-pager-link {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ background: var(--color-bg);
+ color: var(--color-text);
+ text-decoration: none;
+ transition: box-shadow var(--transition), border-color var(--transition);
+}
+
+.post-pager-link:hover {
+ border-color: var(--color-brand);
+ box-shadow: var(--shadow-hover);
+ color: var(--color-text);
+}
+
+.post-pager-next {
+ grid-column: 2;
+ text-align: right;
+}
+
+.post-pager-label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ color: var(--color-text-soft);
+ font-size: 0.85rem;
+ font-weight: 700;
+}
+
+.post-pager-next .post-pager-label {
+ align-self: flex-end;
+}
+
+.post-pager-title {
+ color: var(--color-brand);
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+/* On a phone the two cards stack: a half-width card would squeeze a long title
+ into a narrow column. Older first, as in the wide layout. */
+@media (max-width: 40rem) {
+ .post-pager {
+ grid-template-columns: 1fr;
+ }
+
+ .post-pager-next {
+ grid-column: 1;
+ }
}
diff --git a/Elternbeirat.Web/Features/Posts/PostNeighbors.cs b/Elternbeirat.Web/Features/Posts/PostNeighbors.cs
new file mode 100644
index 0000000..998d996
--- /dev/null
+++ b/Elternbeirat.Web/Features/Posts/PostNeighbors.cs
@@ -0,0 +1,65 @@
+using Elternbeirat.Contracts;
+
+namespace Elternbeirat.Web.Features.Posts;
+
+///
+/// The posts next to a given post in time: the one published before it and the
+/// one published after it.
+///
+///
+/// The older post, or if the post is the oldest.
+///
+///
+/// The newer post, or if the post is the newest.
+///
+///
+/// "Previous" and "next" follow time, as a reader moving through the news would:
+/// "Vorheriger Beitrag" is the older one, "Nächster Beitrag" the newer one. The
+/// list order of the posts page (newest first) is the other way round, which is
+/// why the direction is pinned down here instead of being left to the markup.
+///
+///
+public sealed record PostNeighbors(Post? Previous, Post? Next)
+{
+ ///
+ /// Finds the neighbours of the post with the given slug.
+ ///
+ ///
+ /// All public posts, newest first, as PocketBaseClient.GetPostsAsync
+ /// returns them.
+ ///
+ /// The slug of the post being shown.
+ ///
+ /// The older and the newer post. Both are if the slug is
+ /// not in the list, or if it is the only post.
+ ///
+ ///
+ /// The slug is compared case-insensitively, like the route lookup in
+ /// , so a page reached as /posts/New-Board still
+ /// gets its neighbours.
+ ///
+ ///
+ ///
+ /// // posts = [March, February, January] (newest first)
+ /// var neighbors = PostNeighbors.Of(posts, "february");
+ /// // neighbors.Previous is January, neighbors.Next is March
+ ///
+ ///
+ public static PostNeighbors Of(IReadOnlyList posts, string slug) =>
+ IndexOf(posts, slug) is var index and >= 0
+ ? new PostNeighbors(
+ Previous: index + 1 < posts.Count ? posts[index + 1] : null,
+ Next: index > 0 ? posts[index - 1] : null)
+ : new PostNeighbors(null, null);
+
+ ///
+ /// The position of the post with , or -1 .
+ ///
+ private static int IndexOf(IReadOnlyList posts, string slug) =>
+ posts
+ .Select((post, index) => (post, index))
+ .FirstOrDefault(
+ entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
+ (null!, -1))
+ .index;
+}
diff --git a/Elternbeirat.Web/Shared/IconSet.cs b/Elternbeirat.Web/Shared/IconSet.cs
index 1bb863b..ccde8b9 100644
--- a/Elternbeirat.Web/Shared/IconSet.cs
+++ b/Elternbeirat.Web/Shared/IconSet.cs
@@ -12,7 +12,8 @@ namespace Elternbeirat.Web.Shared;
///
/// Source: Lucide (lucide.dev ), ISC license; the icons derived from
/// Feather (calendar , clock , download ,
-/// external-link , help-circle , info , arrow-right )
+/// external-link , help-circle , info , arrow-right ,
+/// arrow-left )
/// 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
@@ -55,6 +56,8 @@ public static class IconSet
""" """,
["newspaper"] =
""" """,
+ ["arrow-left"] =
+ """ """,
["arrow-right"] =
""" """,
["external-link"] =
diff --git a/Elternbeirat.Web/wwwroot/app.css b/Elternbeirat.Web/wwwroot/app.css
index eb658f2..540f44b 100644
--- a/Elternbeirat.Web/wwwroot/app.css
+++ b/Elternbeirat.Web/wwwroot/app.css
@@ -11,20 +11,22 @@
--color-brand-dark: #1f5477;
--color-brand-tint: #eaf3f9;
- /* The header bar's background: the IGMH blue at full strength. Its own token
- (not --color-brand) both because it only ever carries white text (so it needs
- no darkening) and because it must stay a deep blue in dark mode, where
- --color-brand is lightened for text/links. */
- --color-header: #328fc6;
+ /* The header bar's background: the IGMH blue, eased one step deeper. The
+ original #328fc6 carries white menu text at only 3.6:1, below AA for normal
+ text; #2a77a6 is the same hue at 4.9:1. Its own token (not --color-brand)
+ because it only ever carries white text and must stay a deep blue in dark
+ mode, where --color-brand is lightened for text/links. */
+ --color-header: #2a77a6;
- /* 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". */
+ /* Text and menu states on the header. White text, and two translucent dark
+ washes for the menu pills: they deepen whatever part of the gradient sits
+ behind a link, so one value works from the blue end to the green end and the
+ white text gains contrast instead of losing it (a white wash dropped the
+ active pill to 2.7:1). 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);
+ --color-nav-hover: rgba(0, 0, 0, 0.12);
+ --color-nav-active: rgba(0, 0, 0, 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
@@ -90,6 +92,11 @@
one; on the gradient the hero switches it to white. */
--color-focus: #1f5477;
+ /* The focus ring on the dark brand surfaces (header, hero gradient, footer),
+ where the dark ring above would vanish: white in both schemes, >= 4.9:1 on
+ every one of them. */
+ --color-focus-inverse: #fff;
+
/* 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;
@@ -147,6 +154,27 @@
because heavy letters at large sizes otherwise look spaced out. */
--font-weight-heading: 800;
--letter-spacing-heading: -0.015em;
+
+ /* WCAG AA check of the colour pairs, light / dark (computed, October 2026).
+ Text needs 4.5:1, large text and focus rings/graphics 3:1.
+ text on card / page ............... 15.9 / 6.2 14.6 / 7.2
+ soft text on card / page .......... 7.5 / 6.2 6.9 / 7.2
+ soft text on green / blue tint .... 6.8 / 5.6 6.6 / 5.7
+ link (brand) on card / page ....... 5.4 / 7.5 5.0 / 8.7
+ link on green / blue tint ......... 5.0 / 6.7 4.8 / 6.9
+ heading on card / page ............ 5.4 / 9.3 5.0 / 10.9
+ white on header / gradient green .. 4.9 / 5.5 5.2 / 6.4
+ white on menu pill hover / active . 6.0 / 6.6 6.9 / 7.6
+ footer heading / link on footer ... 10.4 / 10.5 7.4 / 6.9
+ white on button / button hover .... 5.4 / 5.5 8.1 / 8.1
+ light button text ................. 8.1 / 9.7 (hover 7.2 / 8.3)
+ white on calendar band ............ 5.2 / 6.4
+ avatar blue / green ............... 7.2 / 8.6 5.9 / 6.7
+ focus ring on page / card ......... 7.5 / 11.0 8.1 / 9.4
+ white ring on header / footer ..... 4.9 / 5.5 11.4 / 16.6
+ green markers on card (graphic) ... 3.8 / 7.5
+ All pass. Exempt as pure decoration: the light green --color-accent (card
+ stripes, heading bars, the active-menu stroke), which never carries text. */
}
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
@@ -164,7 +192,8 @@
--color-brand-dark: #a3d5f4; /* hover goes lighter, not darker, on dark */
--color-brand-tint: #223038;
- --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; white on it 5.5:1 */
--color-footer: #10202e; /* a deep night blue: darker than the page, so
the band still reads as the page's end */
@@ -310,6 +339,56 @@ h1:focus {
outline: none;
}
+/* Keyboard focus, one look for the whole site: a thick ring in --color-focus with a
+ little air between ring and element, so it never melts into a card's border or a
+ button's fill. :focus-visible, not :focus -- the ring shows for keyboard users
+ and stays away after a mouse click. Covers every focusable thing a page has:
+ links (and with them every card, which is one big link), buttons, the
+ of each disclosure, form fields and anything made focusable with tabindex. Dark
+ brand surfaces (header, hero, footer) switch the colour to
+ --color-focus-inverse. */
+:is(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
+ outline: 3px solid var(--color-focus);
+ outline-offset: 2px;
+}
+
+/* takes focus from the skip link (tabindex="-1") only so the next Tab
+ continues inside the content; a ring around the whole page would just be noise. */
+main:focus {
+ outline: none;
+}
+
+/* Skip link: the first thing on every page, so keyboard and screen-reader users
+ can jump past the header menu straight to the content. Off screen until it gets
+ focus, then it drops in at the top left over the sticky header -- a link that
+ is only hidden visually stays in the tab order, display:none would remove it. */
+.skip-link {
+ position: absolute;
+ top: var(--space-2);
+ left: var(--space-2);
+ z-index: 100;
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-sm);
+ background: var(--color-button-light);
+ box-shadow: var(--shadow-lg);
+ color: var(--color-on-button-light);
+ font-weight: 700;
+ text-decoration: none;
+ transform: translateY(-200%);
+}
+
+/* It lands on the header, so it takes the light button look and the white ring
+ of the dark brand surfaces. Shown instantly, no transition: it is a jump aid,
+ not an effect. */
+.skip-link:focus {
+ transform: none;
+ outline-color: var(--color-focus-inverse);
+}
+
+.skip-link:hover {
+ color: var(--color-on-button-light);
+}
+
/* Text for screen readers only: off screen visually, still read aloud. For places
where the visible form is decoration (the calendar sheet) and the spoken form
must carry the full information. Not display:none -- that hides it from screen
@@ -353,11 +432,6 @@ h1:focus {
color var(--transition), border-color var(--transition);
}
-.btn:focus-visible {
- outline: 3px solid var(--color-focus);
- outline-offset: 2px;
-}
-
.btn-primary {
background: var(--color-button);
border-color: var(--color-button);
@@ -382,6 +456,21 @@ h1:focus {
color: var(--color-brand);
}
+/* Quiet = no frame at all, just the brand-blue label; hover lays the blue tint
+ behind it. For a way back ("Alle Beiträge") that should be easy to find but not
+ compete with the page's content. The negative inline margin lines the label up
+ with the text below while the hover tint still gets its padding. */
+.btn-quiet {
+ margin-inline: calc(-1 * var(--space-3));
+ padding-inline: var(--space-3);
+ color: var(--color-brand);
+}
+
+.btn-quiet:hover {
+ background: var(--color-brand-tint);
+ color: var(--color-brand);
+}
+
/* 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
@@ -403,6 +492,12 @@ h1:focus {
background: var(--color-nav-hover);
}
+/* The dark default ring would vanish on the blue header (2.3:1); white stands out
+ on the whole gradient. */
+.main-nav a:focus-visible {
+ outline-color: var(--color-focus-inverse);
+}
+
/* 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. */
@@ -1029,3 +1124,19 @@ h1:focus {
border: none;
border-top: 1px solid var(--color-border);
}
+
+/* Reduced motion: visitors who asked their system for less movement get none.
+ The site has no animations, only short transitions (hover colours, the
+ hamburger turning into an X, the mobile menu folding open); these are cut to
+ an instant here. Near zero rather than none, so anything that waits for a
+ transition to end still gets its event. */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ ::before,
+ ::after {
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ scroll-behavior: auto !important;
+ }
+}