Merge pull request 'Rework post page (lead, pager), add skip link, unified focus ring, reduced motion and keyboard-accessible menu' (#58) from design/post-page-a11y into main
Build and push image / build (push) Successful in 1m54s

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
Tom committed 2026-10-01 16:50:07 +02:00
commit ed6aa5d5ec
14 files changed
+544 -69

No files matched your search

@@ -0,0 +1,57 @@
using Elternbeirat.Contracts;
using Elternbeirat.Web.Features.Posts;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// 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.
/// </summary>
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");
}
+75 -1
View File
@@ -60,7 +60,16 @@ public sealed partial class RouteSmokeTests : IDisposable
/// Every route that a visitor can reach through the navigation, the FAQ hub /// 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. /// or the news section. The post slugs match the fixture's seed.
/// </summary> /// </summary>
public static TheoryData<string> KnownRoutes => public static TheoryData<string> KnownRoutes => [.. Routes];
/// <summary>
/// The <see cref="KnownRoutes"/> that render an HTML page, i.e. all but the
/// calendar feed.
/// </summary>
public static TheoryData<string> PageRoutes =>
[.. Routes.Where(route => !route.EndsWith(".ics", StringComparison.Ordinal))];
private static readonly string[] Routes =
[ [
"/", "/",
"/board", "/board",
@@ -453,6 +462,71 @@ public sealed partial class RouteSmokeTests : IDisposable
visible.ShouldNotContain("Oktober 2026"); 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, "<h1[ >]").ShouldBe(1);
html.ShouldContain(">Neuer Vorstand</h1>");
}
[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 <base href="/"> 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($"""<a class="skip-link" href="{route}#main" data-enhance-nav="false" """);
html.ShouldContain("Zum Inhalt springen</a>");
html.ShouldContain("""<main id="main" """);
}
[Fact]
public async Task Menu_toggle_is_a_named_checkbox_tied_to_the_menu()
{
// The CSS-only hamburger: the checkbox is what keyboard and screen-reader
// users operate, so it needs a name and must point at the menu it opens.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/", UriKind.Relative));
html.ShouldContain("aria-controls=\"main-nav\"");
html.ShouldContain("Hauptmenü anzeigen</span>");
html.ShouldContain("<nav id=\"main-nav\"");
}
/// <summary> /// <summary>
/// Matches a resource reference that points at an absolute URL, with or without /// Matches a resource reference that points at an absolute URL, with or without
/// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the /// scheme (<c>https://</c>, <c>http://</c> or protocol-relative <c>//</c>): the
@@ -6,6 +6,15 @@
immediately), it pins to the top and stays there while the page scrolls. The 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 <main>, not by a footer is kept at the bottom on short pages by a min-height on <main>, not by a
flex wrapper -- see MainLayout.razor.css. *@ 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 <base href="/"> 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 <main> and keep the focus on the link,
so the next Tab would land in the header menu again. *@
<a class="skip-link" href="@SkipLinkHref" data-enhance-nav="false">Zum Inhalt springen</a>
<header class="site-header"> <header class="site-header">
<div class="header-inner"> <div class="header-inner">
@* Two-line lettering next to the signet: the body's name large, the @* Two-line lettering next to the signet: the body's name large, the
@@ -18,16 +27,23 @@
</span> </span>
</a> </a>
@* CSS-only mobile menu: the hidden checkbox holds the open/closed @* CSS-only mobile menu: the checkbox holds the open/closed state, the
state, the label is the hamburger button, and :checked reveals the label is the hamburger button, and :checked reveals the nav below (see
nav below (see MainLayout.razor.css). No JavaScript, no @@rendermode MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
-- works under static SSR. *@ static SSR. The checkbox is only visually hidden on a phone, not
<input type="checkbox" id="nav-toggle" class="nav-toggle" /> removed, so it is what keyboard and screen-reader users operate: Tab
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen und schließen"> reaches it, Space toggles it, and its checked state is the menu's
open/closed state, announced as such ("Hauptmenü anzeigen,
Kontrollkästchen, aktiviert"). Its name is the label's hidden text;
aria-controls ties it to the menu it opens. aria-expanded would need
JavaScript to keep it in sync, the native checked state does not. *@
<input type="checkbox" id="nav-toggle" class="nav-toggle visually-hidden" aria-controls="main-nav" />
<label for="nav-toggle" class="nav-toggle-label">
<span class="nav-toggle-bar"></span> <span class="nav-toggle-bar"></span>
<span class="visually-hidden">Hauptmenü anzeigen</span>
</label> </label>
<nav class="main-nav" aria-label="Hauptmenü"> <nav id="main-nav" class="main-nav" aria-label="Hauptmenü">
@* The home link comes from PocketBase like every other entry (the @* The home link comes from PocketBase like every other entry (the
"home" page carries location "header"), so it is rendered by the "home" page carries location "header"), so it is rendered by the
loop below -- no separate hard-coded home link, which would show loop below -- no separate hard-coded home link, which would show
@@ -40,7 +56,9 @@
</div> </div>
</header> </header>
<main class="content"> @* 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. *@
<main id="main" class="content" tabindex="-1">
@Body @Body
</main> </main>
@@ -23,6 +23,12 @@ public partial class MainLayout
private IReadOnlyList<NavLinkItem> _header = []; private IReadOnlyList<NavLinkItem> _header = [];
private IReadOnlyList<NavLinkItem> _footer = []; private IReadOnlyList<NavLinkItem> _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. // Berlin's year, not the server's (UTC) -- see FooterYear.
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow()); private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
@@ -62,6 +62,12 @@
color: var(--color-on-header); 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 <svg> carries that /* 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. */ component's scope attribute, not this one's; ::deep reaches it from here. */
.brand ::deep .signet { .brand ::deep .signet {
@@ -97,7 +103,9 @@
} }
/* 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
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,
.nav-toggle-label { .nav-toggle-label {
display: none; display: none;
@@ -205,6 +213,11 @@
text-decoration: underline; 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 /* 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. */ width, not the whole window, so it lines up with the columns above. */
.footer-copyright { .footer-copyright {
@@ -228,6 +241,19 @@
justify-content: space-between; 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. */ /* Show the hamburger button. */
.nav-toggle-label { .nav-toggle-label {
display: block; display: block;
@@ -271,20 +297,29 @@
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */ 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 { .main-nav {
flex-basis: 100%; flex-basis: 100%;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
max-height: 0; max-height: 0;
overflow: hidden; 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 /* 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 to the space below the sticky header; a longer list scrolls inside it
instead of running off the screen. */ instead of running off the screen. */
.nav-toggle:checked ~ .main-nav { .nav-toggle:checked ~ .main-nav {
visibility: visible;
transition: max-height 0.2s ease;
max-height: 70vh; max-height: 70vh;
overflow-y: auto; overflow-y: auto;
margin-top: 0.5rem; margin-top: 0.5rem;
@@ -27,11 +27,6 @@
color: var(--color-text); color: var(--color-text);
} }
.event-card-link:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
.event-details { .event-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -90,7 +90,7 @@
} }
.hero .btn:focus-visible { .hero .btn:focus-visible {
outline-color: var(--color-on-header); outline-color: var(--color-focus-inverse);
} }
/* --- Next event --------------------------------------------------------------- /* --- Next event ---------------------------------------------------------------
@@ -116,7 +116,7 @@
/* The ring sits outside the card, on the gradient, so it is white like the /* The ring sits outside the card, on the gradient, so it is white like the
hero's button rings. */ hero's button rings. */
.next-event:focus-visible { .next-event:focus-visible {
outline: 3px solid var(--color-on-header); outline-color: var(--color-focus-inverse);
outline-offset: 3px; outline-offset: 3px;
} }
@@ -208,11 +208,6 @@
color: var(--color-text); color: var(--color-text);
} }
.tile-link:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* The icon in a soft round tint. */ /* The icon in a soft round tint. */
.tile-icon { .tile-icon {
flex-shrink: 0; flex-shrink: 0;
@@ -23,11 +23,6 @@
color: var(--color-text); 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 /* Icon and date on one line. The icon belongs to Icon's own scope, so it is
reached through ::deep. */ reached through ::deep. */
.post-date { .post-date {
@@ -8,16 +8,39 @@
else if (_post is not null) else if (_post is not null)
{ {
<PageTitle>@_post.Title</PageTitle> <PageTitle>@_post.Title</PageTitle>
<nav> @* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
<a href="/posts">&larr; Alle Beiträge</a> second <nav> next to the main menu would only add noise for screen readers. *@
</nav> <a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
<article>
<div class="post-content"> <article class="post">
<PostDate Value="_post.Date" /> <header class="post-header">
<h2>@_post.Title</h2> <p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
<div class="markdown-body">@Body</div> <h1>@_post.Title</h1>
</div> </header>
<div class="markdown-body post-body">@Body</div>
</article> </article>
@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. *@
<nav class="post-pager" aria-label="Weitere Beiträge">
@if (_neighbors.Previous is { } previous)
{
<a class="post-pager-link post-pager-previous" href="/posts/@previous.Slug" rel="prev">
<span class="post-pager-label"><Icon Name="arrow-left" Size="16" />Vorheriger Beitrag</span>
<span class="post-pager-title">@previous.Title</span>
</a>
}
@if (_neighbors.Next is { } next)
{
<a class="post-pager-link post-pager-next" href="/posts/@next.Slug" rel="next">
<span class="post-pager-label">Nächster Beitrag<Icon Name="arrow-right" Size="16" /></span>
<span class="post-pager-title">@next.Title</span>
</a>
}
</nav>
}
} }
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound() @* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
already ran in OnParametersSetAsync and renders the not-found page. *@ already ran in OnParametersSetAsync and renders the not-found page. *@
@@ -32,6 +32,10 @@ public partial class PostDetail
private Post? _post; 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 // 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 // shows a "temporarily unavailable" note instead of a not-found page, matching
// the 503 this sets on the response. // the 503 this sets on the response.
@@ -48,6 +52,7 @@ public partial class PostDetail
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None); var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_post = posts.FirstOrDefault( _post = posts.FirstOrDefault(
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase)); post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
_neighbors = PostNeighbors.Of(posts, Slug);
} }
catch (PocketBaseUnavailableException exception) catch (PocketBaseUnavailableException exception)
{ {
@@ -1,15 +1,108 @@
/* Push the whole article body down from the "all posts" link above it. Wraps the /* The article: a reading column of about 68 characters with generous leading, so a
date, heading and body, so scoped CSS applies (the date sits in the PostDate long post reads like a page, not like a form. Everything inside follows this
child component and would not be reachable on its own). The date's own look width -- the global 42rem cap on paragraphs, lists and boxes is lifted here, or
comes from PostDate.razor.css. */ the text would stop short of the column it sits in. */
.post-content { .post {
margin-top: 1.5rem; 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 .post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
drop the large default heading gap above it (h1..h4 carry --space-5 top margin max-width: none;
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 { /* Date above the title as a quiet meta line, with the same calendar icon as on the
margin-top: var(--space-1); 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;
}
} }
@@ -0,0 +1,65 @@
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Posts;
/// <summary>
/// The posts next to a given post in time: the one published before it and the
/// one published after it.
/// </summary>
/// <param name="Previous">
/// The older post, or <see langword="null"/> if the post is the oldest.
/// </param>
/// <param name="Next">
/// The newer post, or <see langword="null"/> if the post is the newest.
/// </param>
/// <remarks>
/// "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.
/// </remarks>
/// <seealso cref="PostDetail"/>
public sealed record PostNeighbors(Post? Previous, Post? Next)
{
/// <summary>
/// Finds the neighbours of the post with the given slug.
/// </summary>
/// <param name="posts">
/// All public posts, newest first, as <c>PocketBaseClient.GetPostsAsync</c>
/// returns them.
/// </param>
/// <param name="slug">The slug of the post being shown.</param>
/// <returns>
/// The older and the newer post. Both are <see langword="null"/> if the slug is
/// not in the list, or if it is the only post.
/// </returns>
/// <remarks>
/// The slug is compared case-insensitively, like the route lookup in
/// <see cref="PostDetail"/>, so a page reached as <c>/posts/New-Board</c> still
/// gets its neighbours.
/// </remarks>
/// <example>
/// <code>
/// // posts = [March, February, January] (newest first)
/// var neighbors = PostNeighbors.Of(posts, "february");
/// // neighbors.Previous is January, neighbors.Next is March
/// </code>
/// </example>
public static PostNeighbors Of(IReadOnlyList<Post> 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);
/// <summary>
/// The position of the post with <paramref name="slug"/>, or <c>-1</c>.
/// </summary>
private static int IndexOf(IReadOnlyList<Post> posts, string slug) =>
posts
.Select((post, index) => (post, index))
.FirstOrDefault(
entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
(null!, -1))
.index;
}
+4 -1
View File
@@ -12,7 +12,8 @@ namespace Elternbeirat.Web.Shared;
/// <para> /// <para>
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from /// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
/// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>, /// Feather (<c>calendar</c>, <c>clock</c>, <c>download</c>,
/// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>) /// <c>external-link</c>, <c>help-circle</c>, <c>info</c>, <c>arrow-right</c>,
/// <c>arrow-left</c>)
/// are MIT, Copyright (c) 2013-present Cole Bemis. Both licenses only ask that /// 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 /// the notice travels with the copy, which this comment does. Only the inner
/// elements are stored; <see cref="Icon"/> supplies the shared /// elements are stored; <see cref="Icon"/> supplies the shared
@@ -55,6 +56,8 @@ public static class IconSet
"""<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""", """<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>""",
["newspaper"] = ["newspaper"] =
"""<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""", """<path d="M15 18h-5"/><path d="M18 14h-8"/><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/><rect width="8" height="4" x="10" y="6" rx="1"/>""",
["arrow-left"] =
"""<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>""",
["arrow-right"] = ["arrow-right"] =
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""", """<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
["external-link"] = ["external-link"] =
+129 -18
View File
@@ -11,20 +11,22 @@
--color-brand-dark: #1f5477; --color-brand-dark: #1f5477;
--color-brand-tint: #eaf3f9; --color-brand-tint: #eaf3f9;
/* The header bar's background: the IGMH blue at full strength. Its own token /* The header bar's background: the IGMH blue, eased one step deeper. The
(not --color-brand) both because it only ever carries white text (so it needs original #328fc6 carries white menu text at only 3.6:1, below AA for normal
no darkening) and because it must stay a deep blue in dark mode, where text; #2a77a6 is the same hue at 4.9:1. Its own token (not --color-brand)
--color-brand is lightened for text/links. */ because it only ever carries white text and must stay a deep blue in dark
--color-header: #328fc6; 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 /* Text and menu states on the header. White text, and two translucent dark
washes for the menu pills: they lighten whatever part of the gradient sits 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. The behind a link, so one value works from the blue end to the green end and the
active pill is the stronger one, so "where am I" outranks "where is the white text gains contrast instead of losing it (a white wash dropped the
mouse". */ 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-on-header: #fff;
--color-nav-hover: rgba(255, 255, 255, 0.12); --color-nav-hover: rgba(0, 0, 0, 0.12);
--color-nav-active: rgba(255, 255, 255, 0.2); --color-nav-active: rgba(0, 0, 0, 0.2);
/* Footer: a dark brand blue, so the page visibly ends there. Light text in two /* 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 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. */ one; on the gradient the hero switches it to white. */
--color-focus: #1f5477; --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: /* 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. */ in dark mode headings become a soft white while links keep the brand blue. */
--color-heading: #2b6f9c; --color-heading: #2b6f9c;
@@ -147,6 +154,27 @@
because heavy letters at large sizes otherwise look spaced out. */ because heavy letters at large sizes otherwise look spaced out. */
--font-weight-heading: 800; --font-weight-heading: 800;
--letter-spacing-heading: -0.015em; --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). /* 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-dark: #a3d5f4; /* hover goes lighter, not darker, on dark */
--color-brand-tint: #223038; --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 --color-footer: #10202e; /* a deep night blue: darker than the page, so
the band still reads as the page's end */ the band still reads as the page's end */
@@ -310,6 +339,56 @@ h1:focus {
outline: none; 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 <summary>
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;
}
/* <main> 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 /* 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 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 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); color var(--transition), border-color var(--transition);
} }
.btn:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
.btn-primary { .btn-primary {
background: var(--color-button); background: var(--color-button);
border-color: var(--color-button); border-color: var(--color-button);
@@ -382,6 +456,21 @@ h1:focus {
color: var(--color-brand); 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: /* 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
@@ -403,6 +492,12 @@ h1:focus {
background: var(--color-nav-hover); 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 /* 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 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. */ ::after, not a border, so it follows the text width inside the rounded pill. */
@@ -1029,3 +1124,19 @@ h1:focus {
border: none; border: none;
border-top: 1px solid var(--color-border); 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;
}
}