Compare commits

...
10 Commits
Author SHA1 Message Date
Tom 53cee04396 Merge pull request 'Design/blocks layout' (#60) from design/blocks-layout into main
Build and push image / build (push) Successful in 1m58s
Reviewed-on: #60
2026-10-01 19:28:04 +02:00
tleininger ee69892058 fix buttons 2026-10-01 19:06:54 +02:00
tleininger a847507b46 Centre one 42rem reading column on content, post, event and FAQ pages 2026-10-01 18:48:55 +02:00
tleininger 7d09233ca2 Keep mobile menu links as pills with a full-width active stroke 2026-10-01 18:28:06 +02:00
tleiningerandClaude Opus 5.5 8e4333b3ac Keep design blocks in the reading column, rework key figures and tiles, drop focus ring on tabindex=-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:24:34 +02:00
Tom ebb3b387c5 Merge pull request 'Serve static maintenance page with 503 when PocketBase is unreachable' (#59) from feature/maintenance-page into main
Build and push image / build (push) Successful in 1m55s
Reviewed-on: #59
2026-10-01 17:12:25 +02:00
tleininger 7e56e83953 Serve static maintenance page with 503 when PocketBase is unreachable 2026-10-01 17:12:00 +02:00
Tom ed6aa5d5ec 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
2026-10-01 16:50:07 +02:00
tleininger ba65b18d29 Rework post page (lead, pager), add skip link, unified focus ring, reduced motion and keyboard-accessible menu 2026-10-01 16:48:52 +02:00
Tom ae362639e3 Merge pull request 'Render board members as person cards via ::: team block' (#56) from design/board-team into main
Build and push image / build (push) Successful in 2m1s
Reviewed-on: #56
2026-10-01 16:15:33 +02:00
24 changed files with 1238 additions and 93 deletions

No files matched your search

@@ -0,0 +1,73 @@
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the health cache behind the maintenance gate. The probe is a fake
/// that counts its calls, so no PocketBase is needed.
/// </summary>
public class PocketBaseHealthCacheTests
{
private readonly SteppingClock _clock = new(new DateTimeOffset(2026, 10, 1, 10, 0, 0, TimeSpan.Zero));
private int _probes;
private Func<CancellationToken, Task<bool>> Probe(bool healthy) =>
_ =>
{
_probes++;
return Task.FromResult(healthy);
};
[Fact]
public async Task Result_is_reused_within_the_lifetime()
{
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
_clock.Now += PocketBaseHealthCache.Lifetime - TimeSpan.FromSeconds(1);
var healthy = await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
healthy.ShouldBeTrue();
_probes.ShouldBe(1);
}
[Fact]
public async Task Stale_result_is_probed_again()
{
// The way back after an outage: once the lifetime is over, the new answer
// replaces the old one.
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
_clock.Now += PocketBaseHealthCache.Lifetime;
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
healthy.ShouldBeTrue();
_probes.ShouldBe(2);
}
[Fact]
public async Task Unhealthy_result_is_cached_too()
{
// While PocketBase is down, not every request should wait for a failing probe.
var cache = new PocketBaseHealthCache(_clock);
await cache.IsHealthyAsync(Probe(healthy: false), CancellationToken.None);
var healthy = await cache.IsHealthyAsync(Probe(healthy: true), CancellationToken.None);
healthy.ShouldBeFalse();
_probes.ShouldBe(1);
}
/// <summary>
/// A clock the test moves forward by hand, to step past the cache lifetime without
/// waiting.
/// </summary>
/// <param name="start">The moment the clock shows at first.</param>
private sealed class SteppingClock(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
}
}
@@ -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");
}
+131 -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
/// 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",
@@ -299,8 +308,64 @@ public sealed partial class RouteSmokeTests : IDisposable
var response = await client.GetAsync(new Uri("/health", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
(await response.Content.ReadAsStringAsync()).ShouldBe("PocketBase unreachable");
}
[Theory]
[InlineData("/")]
[InlineData("/board")]
[InlineData("/posts/new-board")]
public async Task Page_shows_maintenance_page_when_PocketBase_is_unreachable(string route)
{
// The maintenance gate: with PocketBase down, a page is not rendered half-empty
// but answered with the static maintenance.html, a 503 and a hint when to retry.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri(route, UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
response.Headers.RetryAfter.ShouldNotBeNull();
(await response.Content.ReadAsStringAsync()).ShouldContain("Die Seite ist gerade nicht erreichbar");
}
[Fact]
public async Task Calendar_feed_stays_an_empty_calendar_when_PocketBase_is_unreachable()
{
// Not a page, so the gate lets it through: subscribed calendar apps keep
// getting a valid (empty) calendar instead of an HTML page.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var response = await client.GetAsync(new Uri("/events.ics", UriKind.Relative));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
(await response.Content.ReadAsStringAsync()).ShouldContain("BEGIN:VCALENDAR");
}
[Fact]
public async Task Maintenance_page_and_its_font_stay_reachable_when_PocketBase_is_unreachable()
{
// Static files are not gated: the maintenance page loads its self-hosted font
// from /fonts while PocketBase is down.
await using var factory = UnreachablePocketBase();
var client = factory.CreateClient();
var page = await client.GetAsync(new Uri("/maintenance.html", UriKind.Relative));
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
page.StatusCode.ShouldBe(HttpStatusCode.OK);
font.StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// A throwaway app pointed at a dead address: nothing listens on port 1, so every
/// PocketBase call is refused at once, without a timeout wait.
/// </summary>
private WebApplicationFactory<Program> UnreachablePocketBase() =>
_baseFactory.WithWebHostBuilder(builder =>
builder.UseSetting("PocketBase:BaseUrl", "http://localhost:1"));
[Fact]
public async Task Home_hero_takes_title_and_buttons_from_the_body()
{
@@ -453,6 +518,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;
@@ -1,14 +1,29 @@
/* The card grid (.event-list) is shared with the home-page embed and lives in the
global app.css; the card itself is EventCard. Only the page's own parts are here. */
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
every page of the site shares one column under the header. */
h1,
h2,
article,
p,
.event-list,
.past-events {
max-width: 42rem;
margin-inline: auto;
}
/* auto, not 0: this rule outweighs the column rule above and would otherwise pull
the button back to the left edge of the page. */
.events-subscribe {
margin: var(--space-4) 0;
margin: var(--space-4) auto;
}
/* Past events: one folded-up card. Opened, a compact list grouped by year,
deliberately quieter than the upcoming cards above. The open/close triangle is
the shared .disclosure rule in app.css, the same as on the FAQ topics. */
.past-events {
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
margin-top: var(--space-5);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
@@ -0,0 +1,17 @@
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
every page of the site shares one column under the header. The topic cards and
the questions inside them live in the global app.css. */
h1,
article,
p,
.faq-topic {
box-sizing: border-box; /* padding inside the 42rem, flush with the text */
max-width: 42rem;
margin-inline: auto;
}
/* The intro's last paragraph drops its margin (app.css), so the first card would
sit right under the text; this gives it the same air as between sections. */
article {
margin-bottom: var(--space-5);
}
+8 -4
View File
@@ -35,8 +35,8 @@ public partial class Home
private HttpContext? HttpContext { get; set; }
// Heading, intro and buttons come from the "home" page's body. Until it is loaded
// -- and for good if PocketBase is down -- the static fallback heading stands, so
// the start page always has a title.
// -- and for good if PocketBase fails during this request -- the static fallback
// heading stands, so the start page always has a title.
private HomeIntro _intro = HomeIntro.Fallback;
// The hero, the tiles and the next-event card are fixed structure of the start
@@ -53,8 +53,12 @@ public partial class Home
}
// Pages and events load side by side and fail on their own: without events the
// page loses only the card, without pages only text and tiles. Either way the
// start page stays a 200 rather than a 503 -- it is degraded, not broken.
// page loses only the card, without pages only text and tiles. A PocketBase that
// is plainly down never gets this far -- the maintenance gate in Program.cs
// answers with maintenance.html first. These catches are the second line: the
// gate's health result is cached for a few seconds, so PocketBase can fail between
// that check and these reads. Without them that would be a 500; with them the
// start page stays a 200 rather than a 503 -- degraded, not broken, and rare.
private async Task LoadPagesAsync(CancellationToken token)
{
try
+22 -7
View File
@@ -90,7 +90,27 @@
}
.hero .btn:focus-visible {
outline-color: var(--color-on-header);
outline-color: var(--color-focus-inverse);
}
/* On a phone the buttons used to wrap onto a row each: two wide blocks stacked,
far too heavy for the top of the page. Instead they share one row in equal
columns -- the same buttons as on the desktop, just side by side and a little
tighter. Short labels ("Termine", "Kontakt") fit on one line; a longer one
wraps inside its button (centred) rather than pushing the other off the row. */
@media (max-width: 40rem) {
.hero-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: var(--space-2);
}
.hero .btn {
justify-content: center;
padding: var(--space-2) var(--space-3);
text-align: center;
}
}
/* --- Next event ---------------------------------------------------------------
@@ -116,7 +136,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 +228,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;
@@ -0,0 +1,11 @@
/* A content page is one reading column: its text, boxes and card grids all stop at
42rem (app.css). Left-aligned in the 72rem layout that column left a wide empty
strip on the right of a desktop screen; centred, the empty space splits evenly and
the page sits balanced under the header. Title and body share the column, so they
keep one left edge. The children inside keep margin 0 and simply fill it. */
h1,
article,
p {
max-width: 42rem;
margin-inline: auto;
}
@@ -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">&larr; 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,116 @@
/* 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: the site's centred 42rem reading column (as on a content page,
ContentPage.razor.css) 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, so nothing stops short of the column
(the post's own leading makes the text a touch wider than elsewhere). */
.post {
max-width: 42rem;
margin: var(--space-4) auto 0;
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);
/* The back link is an inline button, which margin: auto cannot centre. A left
margin of half the free space puts it on the column's left edge instead, and
max() keeps it at 0 once the screen is narrower than the column. */
.btn-quiet {
margin-left: max(0px, (100% - 42rem) / 2);
}
.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: 42rem;
margin: var(--space-6) auto 0;
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;
}
}
@@ -10,3 +10,20 @@
grid-column: 1 / -1;
}
}
/* Same centred 42rem reading column as a content page (ContentPage.razor.css), so
every page of the site shares one column under the header. Two cards still fit
side by side in it. */
h1,
article,
p,
.post-list {
max-width: 42rem;
margin-inline: auto;
}
/* The intro's last paragraph drops its margin (app.css), so the first card would
sit right under the text; this gives it the same air as between sections. */
article {
margin-bottom: var(--space-5);
}
@@ -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;
}
+37
View File
@@ -2,6 +2,8 @@ using Elternbeirat.Contracts;
using Elternbeirat.PocketBase;
using Elternbeirat.Web.Components;
using Elternbeirat.Web.Features.Events;
using Elternbeirat.Web.Shared;
using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
@@ -25,6 +27,10 @@ builder.Services.AddHttpClient<PocketBaseClient>(client =>
client.Timeout = TimeSpan.FromSeconds(5);
});
// Shared by all requests, so the maintenance gate below asks PocketBase at most once
// every few seconds rather than on every page view.
builder.Services.AddSingleton<PocketBaseHealthCache>();
var app = builder.Build();
// NPM sits in front of the app and handles HTTPS. Without this line the app would
@@ -48,6 +54,37 @@ if (app.Environment.IsDevelopment() is false)
// is down (a 503) it would wrongly show "not found" instead of our own message.
app.UseAntiforgery();
// Maintenance gate: while PocketBase is down, every page gets the static
// maintenance.html with 503 instead of a half-empty page (the menu comes from
// PocketBase too). Routing has already picked the endpoint at this point, so the gate
// covers exactly the Razor component pages: /health, the calendar feeds, the static
// assets (incl. maintenance.html and its font) are not components and answer for
// themselves. A new endpoint is thus left alone unless it is a page. The health
// result is cached (PocketBaseHealthCache), so the page may come back a few seconds
// after PocketBase does. Retry-After tells well-behaved clients and crawlers when to
// come back; no-store keeps the 503 page out of any cache.
app.Use(async (context, next) =>
{
var isPage = context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>() is not null;
var services = context.RequestServices;
if (!isPage
|| await services.GetRequiredService<PocketBaseHealthCache>().IsHealthyAsync(
services.GetRequiredService<PocketBaseClient>().IsHealthyAsync,
context.RequestAborted))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.Headers.RetryAfter = "60";
context.Response.Headers.CacheControl = "no-store";
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.SendFileAsync(
app.Environment.WebRootFileProvider.GetFileInfo("maintenance.html"),
context.RequestAborted);
});
app.MapStaticAssets();
app.MapRazorComponents<App>();
+4 -1
View File
@@ -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"] =
@@ -0,0 +1,73 @@
namespace Elternbeirat.Web.Shared;
/// <summary>
/// Remembers for a few seconds whether PocketBase answered its health check, so the
/// maintenance gate does not probe PocketBase on every page request.
/// </summary>
/// <param name="time">
/// The clock that decides when a result is stale; injected so tests can step past
/// <see cref="Lifetime"/> without waiting.
/// </param>
/// <remarks>
/// The gate in <c>Program.cs</c> asks this cache before rendering any page and serves
/// the static <c>maintenance.html</c> with 503 while the answer is "unhealthy". A
/// failed check is kept just as long as a passed one: while PocketBase is down every
/// probe would otherwise wait for the connection to fail again.
/// <para>
/// The price of caching is a short blind spot in both directions: after
/// PocketBase fails, pages may still render for up to <see cref="Lifetime"/> (the
/// components' own fallbacks cover that), and after it recovers the maintenance
/// page may stay up for as long.
/// </para>
/// <para>
/// Registered as a singleton. No lock: two requests that find the result stale at
/// the same moment both probe, and the later answer wins -- harmless, and cheaper
/// than making every request wait on one another.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// var healthy = await cache.IsHealthyAsync(pocketBase.IsHealthyAsync, context.RequestAborted);
/// </code>
/// </example>
/// <seealso cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>
public sealed class PocketBaseHealthCache(TimeProvider time)
{
/// <summary>
/// How long a health check result is reused before PocketBase is asked again.
/// </summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(10);
// One reference, swapped as a whole, so a reader never sees the result of one
// check paired with the time of another.
private volatile Check? _last;
/// <summary>
/// Returns the last known health of PocketBase, probing again once it is older
/// than <see cref="Lifetime"/>.
/// </summary>
/// <param name="probe">
/// The actual check, usually <see cref="PocketBase.PocketBaseClient.IsHealthyAsync"/>.
/// Passed in rather than injected because the typed client is transient and this
/// cache is a singleton.
/// </param>
/// <param name="token">Cancels the probe, e.g. when the visitor goes away.</param>
/// <returns>
/// <see langword="true"/> if PocketBase answered its last check (or answers this
/// one), otherwise <see langword="false"/>.
/// </returns>
/// <exception cref="OperationCanceledException">
/// <paramref name="token"/> was cancelled during a probe; nothing is cached then.
/// </exception>
public async Task<bool> IsHealthyAsync(Func<CancellationToken, Task<bool>> probe, CancellationToken token) =>
_last is { } last && time.GetUtcNow() - last.At < Lifetime
? last.Healthy
: (_last = new Check(await probe(token), time.GetUtcNow())).Healthy;
/// <summary>
/// One health check result and when it was taken.
/// </summary>
/// <param name="Healthy">Whether PocketBase answered.</param>
/// <param name="At">When the answer came in.</param>
private sealed record Check(bool Healthy, DateTimeOffset At);
}
+234 -37
View File
@@ -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,28 @@
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
key figure / caption on card ...... 5.4 / 7.5 5.0 / 6.9
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 +193,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 +340,60 @@ 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.
tabindex="-1" is left out: such an element is never reached with Tab, only
focused by code -- the h1 by FocusOnNavigate on every page load, <main> by the
skip link. Chromium counts that as :focus-visible, and since this rule outweighs
h1:focus / main:focus below, the page title would otherwise get a ring. */
:is(a, button, summary, input, select, textarea, [tabindex]:not([tabindex="-1"])):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 +437,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 +461,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 +497,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. */
@@ -421,19 +521,18 @@ h1:focus {
background: var(--color-accent);
}
/* Mobile: the stacked links in the open menu become full-width rows with a
rounded highlight instead of pills, and the green stroke shrinks to a short
mark at the start, so a long row does not get a long line. Same global
placement, same NavLink reason as above. */
/* Mobile: the open menu stacks the links centred, each as wide as its text -- so
they stay the same pills as on the desktop, with the stroke under the whole word.
Only taller, for a comfortable tap target; the stroke moves up with the extra
padding so it still sits right under the text, not at the pill's edge. Same
global placement, same NavLink reason as above. */
@media (max-width: 40rem) {
.main-nav a {
padding: 0.6rem var(--space-3);
border-radius: var(--radius-sm);
}
.main-nav a.active::after {
right: auto;
width: 1.5rem;
bottom: 0.55rem;
}
}
@@ -807,24 +906,39 @@ h1:focus {
mask: var(--icon-info) center / contain no-repeat;
}
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. auto-fill
with a minimum width gives as many columns as fit (one on a phone) without any
breakpoint. */
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. They keep
the 42rem reading column like the text and the other boxes: one calm left and
right edge down the page instead of blocks of three different widths. */
.markdown-body :is(.kacheln, .kennzahlen) > ul {
display: grid;
gap: var(--space-3);
max-width: none;
box-sizing: border-box;
max-width: 42rem;
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
}
/* Kacheln: auto-fit with a 15rem minimum gives exactly two columns in the 42rem
(three would need 46rem), so four tiles sit as 2 x 2. Below ~31rem it drops to
one column -- two would squeeze a long word like "Chancengerechtigkeit" out of
its card. min() keeps a single card from overflowing a very narrow screen. */
.markdown-body .kacheln > ul {
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
}
/* Kennzahlen: one row of equal columns where there is room. auto-fill used to
leave empty tracks on the right on a wide screen and one figure alone in a second
row on a phone. A narrow column switches to stacked rows instead (below). The
block is its own container, so the switch follows the width the figures actually
get, not the screen. */
.markdown-body .kennzahlen {
container-type: inline-size;
}
.markdown-body .kennzahlen > ul {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-auto-flow: column;
grid-auto-columns: 1fr;
}
/* The grid gap does the spacing; the list's own item spacing would only push the
@@ -848,24 +962,87 @@ h1:focus {
color: var(--color-heading);
}
/* On a phone the tiles keep their card but tighten up, so four of them stacked
do not turn into a long scroll. */
@media (max-width: 40rem) {
.markdown-body .kacheln > ul {
gap: var(--space-2);
}
.markdown-body .kacheln li {
padding: var(--space-2) var(--space-3);
}
.markdown-body .kacheln li > strong:first-child {
margin-bottom: 0;
}
}
/* Kennzahlen: "- **seit 1975** Gründung" -- the bold part becomes a big figure,
the rest a small caption beneath. Brand blue for the figure, not the green: the
green does not reach 3:1 on its own tint, the blue does. */
the rest a small caption beneath. A white card with a green stroke on top, not
a green wash: a ::: hinweis often follows right below, and two green tints in a
row ran together into one green patch. Brand blue for the figure: 5.4:1 on the
card (5.0 dark), where the green would not reach 3:1. */
.markdown-body .kennzahlen li {
padding: var(--space-4) var(--space-3);
padding: var(--space-4) var(--space-2);
border-top: 4px solid var(--color-accent);
border-radius: var(--radius);
background: var(--color-accent-tint);
background: var(--color-bg);
box-shadow: var(--shadow);
color: var(--color-text-soft);
text-align: center;
}
/* The figure scales with the block (cqi, not vw): 2rem in the full 42rem, a little
less just above the switch to rows. nowrap: "seit 1975" split over two lines
reads as two figures. */
.markdown-body .kennzahlen li > strong:first-child {
display: block;
color: var(--color-brand);
font-size: 2rem;
font-size: clamp(1.5rem, 4.75cqi, 2rem);
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
line-height: 1.15;
white-space: nowrap;
}
/* Below 30rem three columns get under 10rem each: the figure no longer fits on one
line and a caption like "im Einsatz für die IGMH" stacks up word by word. So the
figures become rows of one card -- figure left, caption beside it, a thin line
between rows. Subgrid gives every row the same figure column, so the captions
line up whatever the figure's length. */
@container (max-width: 30rem) {
.markdown-body .kennzahlen > ul {
grid-auto-flow: row;
grid-template-columns: max-content 1fr;
gap: 0;
border-top: 4px solid var(--color-accent);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow);
}
.markdown-body .kennzahlen li {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
column-gap: var(--space-3);
align-items: baseline;
padding: var(--space-2) var(--space-3);
border-top: none;
border-radius: 0;
background: none;
box-shadow: none;
text-align: left;
}
.markdown-body .kennzahlen li + li {
border-top: 1px solid var(--color-border);
}
.markdown-body .kennzahlen li > strong:first-child {
font-size: 1.35rem;
}
}
/* ::: team -- one person card per list item (Markdown.cs/TeamMember.cs build the
@@ -873,13 +1050,17 @@ h1:focus {
a normal heading; every group is a block of its own. One raster for all of them,
so the cards line up across groups: a group of one person gets the same card
width as a group of six, not a stretched one. 17rem fits a long double name.
Like every other block it stays in the 42rem reading column, so the cards share
the edges of the text above and below them; that leaves room for two columns
(three would need 52.5rem), one on a phone.
The cards of a row share one height (the grid's default stretch), so a row reads
as a calm band; the card itself keeps its content at the top (align-content). */
.markdown-body .team > ul {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
gap: var(--space-3);
max-width: none;
box-sizing: border-box;
max-width: 42rem;
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
@@ -1029,3 +1210,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;
}
}
+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<!--
Static maintenance page shown when PocketBase is unreachable. The gate in Program.cs
serves this file with status 503 before any component renders, so it must stand on
its own: no menu, no PocketBase, no script, and its styles are inlined rather than
linked to the fingerprinted app.css (whose hashed name this static file cannot know).
The tokens below are a copy of the ones this page needs from app.css, light and dark,
under the same names -- when a colour changes there, change it here too. The header
band and the signet mirror MainLayout and Signet.razor, so the page still looks like
the site. The font is our own self-hosted Nunito; static files stay reachable while
PocketBase is down, the gate only covers pages.
-->
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="robots" content="noindex"/>
<title>Elternbeirat der IGMH – gerade nicht erreichbar</title>
<style>
:root {
--color-brand: #2b6f9c;
--color-brand-dark: #1f5477;
--color-header: #2a77a6;
--color-on-header: #fff;
--color-accent-dark: #4f9247;
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
--color-focus: #1f5477;
--color-heading: #2b6f9c;
--color-text: #222;
--color-text-soft: #555;
--color-bg: #fff;
--color-page: #f4f6f4;
--color-border: #e0e0e0;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;
--radius: 12px;
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
--font-family: "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-size-body: 1.0625rem;
--font-weight-heading: 800;
--letter-spacing-heading: -0.015em;
}
@media (prefers-color-scheme: dark) {
:root {
--color-brand: #7cc0ec;
--color-brand-dark: #a3d5f4;
--color-header: #266f9c;
--color-accent-dark: #86c97d;
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
--color-focus: #a3d5f4;
--color-heading: #c9ced6;
--color-text: #a2a8b2;
--color-text-soft: #a2a8b2;
--color-bg: #222834;
--color-page: #171b22;
--color-border: #333b47;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
/* Absolute path: this file is served for any page URL, e.g. /posts/new-board,
where a relative "fonts/..." would point into /posts/. */
@font-face {
font-family: "Nunito";
src: url("/fonts/nunito-latin-wght.woff2") format("woff2");
font-weight: 400 800;
font-style: normal;
font-display: swap;
}
html {
font-family: var(--font-family);
line-height: 1.65;
color-scheme: light dark;
}
body {
margin: 0;
min-height: 100dvh;
background: var(--color-page);
color: var(--color-text);
font-size: var(--font-size-body);
}
/* The header band of MainLayout without the menu: there is nothing to
navigate to while the pages are down. */
header {
background: var(--gradient-brand);
box-shadow: var(--shadow);
}
.header-inner {
max-width: 72rem;
margin: 0 auto;
padding: var(--space-3) 1.25rem;
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-on-header);
}
.signet {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
}
.signet-bubble {
fill: var(--color-on-header);
}
.signet-figures {
fill: var(--color-accent-dark);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand-name {
font-size: 1.35rem;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
}
.brand-school {
font-size: 0.85rem;
font-weight: 600;
}
main {
box-sizing: border-box;
max-width: 36rem;
margin: var(--space-5) auto;
padding: 0 1.25rem;
}
.card {
background: var(--color-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--space-5);
}
h1 {
margin: 0 0 var(--space-3);
color: var(--color-heading);
font-size: 1.5rem;
line-height: 1.25;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: 600;
text-underline-offset: 2px;
}
a:hover {
color: var(--color-brand-dark);
}
a:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
border-radius: 2px;
}
.contact {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
color: var(--color-text-soft);
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<!-- Not a link: the start page is down as well, it would only lead here. -->
<svg class="signet" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true" focusable="false">
<path class="signet-bubble"
d="M8 4h24a6 6 0 0 1 6 6v15a6 6 0 0 1-6 6H18l-8 6v-6H8a6 6 0 0 1-6-6V10a6 6 0 0 1 6-6z"/>
<g class="signet-figures">
<circle cx="15" cy="12" r="3.5"/>
<path d="M9 26c0-5 2.7-8 6-8s6 3 6 8z"/>
<circle cx="26" cy="16" r="2.6"/>
<path d="M21.5 26c0-3.8 2-6 4.5-6s4.5 2.2 4.5 6z"/>
</g>
</svg>
<span class="brand-text">
<span class="brand-name">Elternbeirat</span>
<span class="brand-school">IGMH Mannheim</span>
</span>
</div>
</header>
<main>
<div class="card">
<h1>Die Seite ist gerade nicht erreichbar</h1>
<p>
Wir bitten um Entschuldigung. Die Website des Elternbeirats der IGMH ist im
Moment nicht verfügbar. Wir arbeiten daran, bitte versuchen Sie es in Kürze
noch einmal.
</p>
<p class="contact">
In dringenden Fällen erreichen Sie den Elternbeirat per E-Mail:<br/>
<a href="mailto:kontakt@elternbeirat-igmh.info">kontakt@elternbeirat-igmh.info</a>
</p>
</div>
</main>
</body>
</html>
+25
View File
@@ -128,6 +128,31 @@ development and the tests and are **not** used on Unraid — see `entwicklung.md
---
## Outage: PocketBase unreachable
If `eb-pocketbase` is down while `eb-blazor` runs, the app does not render
half-empty pages (the menu comes from PocketBase too). A gate in `Program.cs`
answers every **page** request with the static `wwwroot/maintenance.html`:
| Request | Answer while PocketBase is down |
|---|---|
| Any page (`/`, `/board`, `/posts/…`, unknown slugs) | **503**, `Retry-After: 60`, maintenance page |
| `/health` | **503** `PocketBase unreachable` (for Uptime Kuma) |
| `/events.ics` | 200 with an empty calendar, so subscriptions keep working |
| `/events/{id}.ics` | 503 |
| Static files (CSS, fonts, PDFs, `/maintenance.html`) | served as usual |
The gate caches the health check for **10 seconds** (`PocketBaseHealthCache`). So
after PocketBase stops, pages can render for up to 10 s more (the components
degrade on their own in that window: empty menu, a short note, 503), and after it
starts again the maintenance page can stay up for up to 10 s. No restart of the
web app is needed — the site comes back by itself.
`maintenance.html` carries its styles inline, as a copy of the tokens from
`app.css`. When colours change there, copy them over.
---
## Rollback
`scripts/release.sh` additionally tags each release with the short commit SHA,