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