Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6aa5d5ec | ||
|
|
ba65b18d29 |
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");
|
||||
}
|
||||
@@ -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.
|
||||
/// </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",
|
||||
@@ -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, "<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>
|
||||
/// 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
|
||||
|
||||
@@ -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 <main>, 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 <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">
|
||||
<div class="header-inner">
|
||||
@* Two-line lettering next to the signet: the body's name large, the
|
||||
@@ -18,16 +27,23 @@
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@* CSS-only mobile menu: the hidden checkbox holds the open/closed
|
||||
state, the label is the hamburger button, and :checked reveals the
|
||||
nav below (see MainLayout.razor.css). No JavaScript, no @@rendermode
|
||||
-- works under static SSR. *@
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
|
||||
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen und schließen">
|
||||
@* CSS-only mobile menu: the checkbox holds the open/closed state, the
|
||||
label is the hamburger button, and :checked reveals the nav below (see
|
||||
MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
|
||||
static SSR. The checkbox is only visually hidden on a phone, not
|
||||
removed, so it is what keyboard and screen-reader users operate: Tab
|
||||
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="visually-hidden">Hauptmenü anzeigen</span>
|
||||
</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
|
||||
"home" page carries location "header"), so it is rendered by the
|
||||
loop below -- no separate hard-coded home link, which would show
|
||||
@@ -40,7 +56,9 @@
|
||||
</div>
|
||||
</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
|
||||
</main>
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ public partial class MainLayout
|
||||
private IReadOnlyList<NavLinkItem> _header = [];
|
||||
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.
|
||||
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
|
||||
|
||||
|
||||
@@ -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 <svg> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,16 +8,39 @@
|
||||
else if (_post is not null)
|
||||
{
|
||||
<PageTitle>@_post.Title</PageTitle>
|
||||
<nav>
|
||||
<a href="/posts">← Alle Beiträge</a>
|
||||
</nav>
|
||||
<article>
|
||||
<div class="post-content">
|
||||
<PostDate Value="_post.Date" />
|
||||
<h2>@_post.Title</h2>
|
||||
<div class="markdown-body">@Body</div>
|
||||
</div>
|
||||
@* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
|
||||
second <nav> next to the main menu would only add noise for screen readers. *@
|
||||
<a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
|
||||
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
<p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
|
||||
<h1>@_post.Title</h1>
|
||||
</header>
|
||||
<div class="markdown-body post-body">@Body</div>
|
||||
</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()
|
||||
already ran in OnParametersSetAsync and renders the not-found page. *@
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,7 +12,8 @@ namespace Elternbeirat.Web.Shared;
|
||||
/// <para>
|
||||
/// Source: Lucide (<c>lucide.dev</c>), ISC license; the icons derived from
|
||||
/// 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
|
||||
/// the notice travels with the copy, which this comment does. Only the inner
|
||||
/// 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"/>""",
|
||||
["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"/>""",
|
||||
["arrow-left"] =
|
||||
"""<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>""",
|
||||
["arrow-right"] =
|
||||
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
||||
["external-link"] =
|
||||
|
||||
@@ -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 <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
|
||||
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 <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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user