Compare commits
2
Commits
3530167cc3
...
ca0b3c311c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca0b3c311c | ||
|
|
33fa1e4e93 |
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
using Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the icon lookup behind the Icon component. Pure logic, no
|
||||||
|
/// PocketBase needed.
|
||||||
|
/// </summary>
|
||||||
|
public class IconSetTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("calendar")]
|
||||||
|
[InlineData("map-pin")]
|
||||||
|
[InlineData("help-circle")] // kept under its old name although Lucide renamed it
|
||||||
|
public void Known_name_returns_svg_markup(string name)
|
||||||
|
{
|
||||||
|
var markup = IconSet.Find(name);
|
||||||
|
|
||||||
|
markup.ShouldNotBeNullOrWhiteSpace();
|
||||||
|
markup.ShouldStartWith("<"); // SVG child elements, ready to inline
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("does-not-exist")]
|
||||||
|
[InlineData("Calendar")] // lookup is case-sensitive, names are code identifiers
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void Unknown_name_returns_null(string? name) =>
|
||||||
|
IconSet.Find(name).ShouldBeNull();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
@@ -15,7 +16,7 @@ namespace Elternbeirat.Web.Tests;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection(PocketBaseTestGroup.Name)]
|
[Collection(PocketBaseTestGroup.Name)]
|
||||||
public sealed class RouteSmokeTests : IDisposable
|
public sealed partial class RouteSmokeTests : IDisposable
|
||||||
{
|
{
|
||||||
// WithWebHostBuilder returns a new factory that wraps the base one; both are
|
// WithWebHostBuilder returns a new factory that wraps the base one; both are
|
||||||
// disposable, so both are held and disposed to avoid leaking either.
|
// disposable, so both are held and disposed to avoid leaking either.
|
||||||
@@ -158,6 +159,39 @@ public sealed class RouteSmokeTests : IDisposable
|
|||||||
events.ShouldContain("Sitzungen und Veranstaltungen auf einen Blick."); // the page body
|
events.ShouldContain("Sitzungen und Veranstaltungen auf einen Blick."); // the page body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/app.css")]
|
||||||
|
[InlineData("/")]
|
||||||
|
public async Task No_resource_is_loaded_from_a_foreign_host(string route)
|
||||||
|
{
|
||||||
|
// Privacy rule (docs/recht.md): nothing may make the visitor's browser contact
|
||||||
|
// a third-party host -- no Google Fonts, no CDN. Check the stylesheet and the
|
||||||
|
// rendered home page for anything that LOADS from an absolute URL (src,
|
||||||
|
// srcset, <link href>, CSS url() and @import). Plain <a href> links are left
|
||||||
|
// alone on purpose: an editor may link to another site, and a link loads
|
||||||
|
// nothing until the visitor clicks it.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var body = await client.GetStringAsync(new Uri(route, UriKind.Relative));
|
||||||
|
|
||||||
|
ForeignResource().Matches(body).Select(match => match.Value).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Site_font_is_self_hosted()
|
||||||
|
{
|
||||||
|
// The @font-face must point at our own wwwroot with a relative URL, and the
|
||||||
|
// file must actually be served -- otherwise every visitor silently falls back
|
||||||
|
// to the system font.
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var css = await client.GetStringAsync(new Uri("/app.css", UriKind.Relative));
|
||||||
|
css.ShouldContain("url(\"fonts/nunito-latin-wght.woff2\")");
|
||||||
|
|
||||||
|
var font = await client.GetAsync(new Uri("/fonts/nunito-latin-wght.woff2", UriKind.Relative));
|
||||||
|
font.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Health_returns_200_when_PocketBase_is_reachable()
|
public async Task Health_returns_200_when_PocketBase_is_reachable()
|
||||||
{
|
{
|
||||||
@@ -187,4 +221,13 @@ public sealed class RouteSmokeTests : IDisposable
|
|||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// <c>src</c>/<c>srcset</c> attributes, a <c><link></c>'s <c>href</c>, CSS
|
||||||
|
/// <c>url()</c> and <c>@import</c>.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex("""(?:\b(?:src|srcset)\s*=\s*|<link\b[^>]*\bhref\s*=\s*|url\(\s*|@import\s+)["']?\s*(?:https?:)?//""", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex ForeignResource();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@*
|
||||||
|
Renders one icon from IconSet as inline SVG. Purely decorative: aria-hidden hides
|
||||||
|
it from screen readers (the text next to it carries the meaning) and
|
||||||
|
focusable="false" keeps old Edge/IE from putting it in the tab order. The stroke
|
||||||
|
is currentColor, so the icon takes the colour of the surrounding text. An unknown
|
||||||
|
name renders nothing at all.
|
||||||
|
*@
|
||||||
|
@if (_markup is not null)
|
||||||
|
{
|
||||||
|
<svg class="icon" width="@Size" height="@Size"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">@_markup</svg>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a decorative line icon from <see cref="IconSet"/> as inline SVG.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Inline SVG, not an <c><img></c> or icon font: no extra request, nothing
|
||||||
|
/// from a third-party host, and the icon follows the text colour (including dark
|
||||||
|
/// mode) through <c>currentColor</c>. The icon is hidden from assistive technology,
|
||||||
|
/// so it must never be the only carrier of meaning.
|
||||||
|
/// </remarks>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// <Icon Name="map-pin" /> Aula
|
||||||
|
/// <Icon Name="calendar" Size="16" />
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <seealso cref="IconSet"/>
|
||||||
|
public partial class Icon
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The inner SVG markup of the current icon, or <see langword="null"/> when the
|
||||||
|
/// name is unknown and nothing should render.
|
||||||
|
/// </summary>
|
||||||
|
private MarkupString? _markup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The icon name, e.g. <c>calendar</c>; see <see cref="IconSet"/> for the set.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
[EditorRequired]
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Width and height in CSS pixels. Defaults to <c>20</c>, which sits well next
|
||||||
|
/// to body text.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public int Size { get; set; } = 20;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override void OnParametersSet() =>
|
||||||
|
_markup = IconSet.Find(Name) is { } markup ? new MarkupString(markup) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fixed set of line icons the site can show, keyed by name.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The icons are inlined as SVG markup instead of being loaded from an icon font or
|
||||||
|
/// a CDN: the site loads nothing from third-party hosts (see <c>docs/recht.md</c>),
|
||||||
|
/// and inline SVG needs no extra request and inherits the text colour.
|
||||||
|
/// <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>)
|
||||||
|
/// 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
|
||||||
|
/// <c><svg></c> wrapper (24x24 view box, 2px round stroke).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Lucide renamed <c>help-circle</c> to <c>circle-question-mark</c>; the old
|
||||||
|
/// name is kept here because it is the one the design work refers to.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <seealso cref="Icon"/>
|
||||||
|
public static class IconSet
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inner SVG markup per icon name. Ordinal keys: names are code identifiers,
|
||||||
|
/// not visitor text, so <c>Calendar</c> is deliberately not <c>calendar</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly FrozenDictionary<string, string> Icons =
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["calendar"] =
|
||||||
|
"""<path d="M8 2v3"/><path d="M16 2v3"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>""",
|
||||||
|
["calendar-plus"] =
|
||||||
|
"""<path d="M16 18h6"/><path d="M16 2v3"/><path d="M19 15v6"/><path d="M21 11.5V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h8.3"/><path d="M3 9h18"/><path d="M8 2v3"/>""",
|
||||||
|
["clock"] =
|
||||||
|
"""<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>""",
|
||||||
|
["map-pin"] =
|
||||||
|
"""<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>""",
|
||||||
|
["file-text"] =
|
||||||
|
"""<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>""",
|
||||||
|
["download"] =
|
||||||
|
"""<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>""",
|
||||||
|
["mail"] =
|
||||||
|
"""<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/>""",
|
||||||
|
["users"] =
|
||||||
|
"""<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>""",
|
||||||
|
["heart-handshake"] =
|
||||||
|
"""<path d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762"/>""",
|
||||||
|
["help-circle"] =
|
||||||
|
"""<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-right"] =
|
||||||
|
"""<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>""",
|
||||||
|
["external-link"] =
|
||||||
|
"""<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>""",
|
||||||
|
["info"] =
|
||||||
|
"""<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>""",
|
||||||
|
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up the inner SVG markup of an icon.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">
|
||||||
|
/// The icon name, e.g. <c>calendar</c> or <c>map-pin</c>. Case-sensitive.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The SVG child elements (paths, circles, rects) to place inside an
|
||||||
|
/// <c><svg></c>, or <see langword="null"/> if <paramref name="name"/> is
|
||||||
|
/// <see langword="null"/> or not part of the set.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// An unknown name yields <see langword="null"/> rather than an exception, so a
|
||||||
|
/// typo costs a missing decoration, never a broken page.
|
||||||
|
/// </remarks>
|
||||||
|
public static string? Find(string? name) =>
|
||||||
|
name is null ? null : Icons.GetValueOrDefault(name);
|
||||||
|
}
|
||||||
@@ -23,6 +23,20 @@
|
|||||||
--color-accent: #6db863;
|
--color-accent: #6db863;
|
||||||
--color-accent-dark: #4f9247;
|
--color-accent-dark: #4f9247;
|
||||||
|
|
||||||
|
/* A whisper of the green for calm background areas (info boxes, highlighted
|
||||||
|
rows). Light enough that body text keeps its full contrast on top. */
|
||||||
|
--color-accent-tint: #eef7ec;
|
||||||
|
|
||||||
|
/* Warm yellow for the rare thing that must catch the eye (a "new" marker, a
|
||||||
|
deadline). Background only, never text -- yellow text is unreadable on white.
|
||||||
|
Used sparingly: if everything is highlighted, nothing is. */
|
||||||
|
--color-highlight: #fdf0c2;
|
||||||
|
|
||||||
|
/* Brand gradient for large surfaces (hero, banner): the header blue flowing into
|
||||||
|
the IGMH green. The green end is a deep shade (#3a7a3e, 5.2:1 with white),
|
||||||
|
not --color-accent, so white text stays readable across the whole sweep. */
|
||||||
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
|
||||||
|
|
||||||
/* 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;
|
||||||
@@ -48,14 +62,38 @@
|
|||||||
/* Rounded corners and a soft shadow for cards, matching the friendly tone. */
|
/* Rounded corners and a soft shadow for cards, matching the friendly tone. */
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
|
--radius-lg: 20px; /* large surfaces (hero, banner) look stiff with
|
||||||
|
the card radius at their size */
|
||||||
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
|
--shadow: 0 1px 3px rgba(31, 58, 95, 0.08), 0 4px 12px rgba(31, 58, 95, 0.06);
|
||||||
--shadow-hover: 0 2px 6px rgba(31, 58, 95, 0.12), 0 8px 20px rgba(31, 58, 95, 0.1);
|
--shadow-hover: 0 2px 6px rgba(31, 58, 95, 0.12), 0 8px 20px rgba(31, 58, 95, 0.1);
|
||||||
|
--shadow-lg: 0 4px 12px rgba(31, 58, 95, 0.1), 0 16px 40px rgba(31, 58, 95, 0.12);
|
||||||
|
|
||||||
/* One transition speed for hovers/focus across the site. */
|
/* One transition speed for hovers/focus across the site. */
|
||||||
--transition: 0.15s ease;
|
--transition: 0.15s ease;
|
||||||
|
|
||||||
/* Reading width, so long text lines never get uncomfortably wide. */
|
/* Width of the page frame (header, content, footer). Wide enough for card grids
|
||||||
--content-width: 60rem;
|
side by side; running text does not follow it -- paragraphs keep their own
|
||||||
|
reading width (see p below), so lines never get uncomfortably long. */
|
||||||
|
--content-width: 72rem;
|
||||||
|
|
||||||
|
/* Type. The site font is self-hosted (see @font-face below); the system stack
|
||||||
|
stays behind it, so text shows at once and only swaps when Nunito arrives. */
|
||||||
|
--font-family: "Nunito", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
|
||||||
|
/* Type scale. h1 and h2 grow fluidly with the viewport (clamp: minimum, preferred
|
||||||
|
in vw, maximum) instead of jumping at a breakpoint, so a long German compound
|
||||||
|
word in a heading still fits a phone screen. Smaller sizes stay fixed: the
|
||||||
|
difference would not be visible. Body text sits a touch above the 1rem default
|
||||||
|
because Nunito's rounded letters read small. */
|
||||||
|
--font-size-h1: clamp(2rem, 1.6rem + 1.6vw, 2.75rem);
|
||||||
|
--font-size-h2: clamp(1.5rem, 1.35rem + 0.6vw, 1.75rem);
|
||||||
|
--font-size-h3: 1.2rem;
|
||||||
|
--font-size-body: 1.0625rem;
|
||||||
|
|
||||||
|
/* Headings: extra-bold for a friendly, confident lead, pulled slightly tighter
|
||||||
|
because heavy letters at large sizes otherwise look spaced out. */
|
||||||
|
--font-weight-heading: 800;
|
||||||
|
--letter-spacing-heading: -0.015em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
|
/* Dark mode: follows the visitor's OS setting automatically (prefers-color-scheme).
|
||||||
@@ -77,6 +115,13 @@
|
|||||||
|
|
||||||
--color-accent: #86c97d; /* the IGMH green nudged brighter for dark */
|
--color-accent: #86c97d; /* the IGMH green nudged brighter for dark */
|
||||||
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
|
--color-accent-dark: #86c97d; /* no darkening needed on a dark surface */
|
||||||
|
--color-accent-tint: #24332a; /* a dark green-grey: tinted, but the soft
|
||||||
|
body text still reads at 5.5:1 */
|
||||||
|
|
||||||
|
--color-highlight: #3a3220; /* the warm yellow as a deep amber -- a bright
|
||||||
|
yellow would glare on the dark page */
|
||||||
|
|
||||||
|
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
|
||||||
|
|
||||||
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
|
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
|
||||||
body text so headings still lead the page */
|
body text so headings still lead the page */
|
||||||
@@ -91,12 +136,30 @@
|
|||||||
|
|
||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.4);
|
--shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5), 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles. Self-hosted system font stack — no external fonts, for privacy. */
|
/* Nunito, served from our own wwwroot -- never from Google Fonts, which would hand
|
||||||
|
every visitor's IP to Google (see docs/recht.md). One variable file covers every
|
||||||
|
weight from 400 to 800, so body, bold and headings cost a single ~36 KB request
|
||||||
|
instead of three static files. Subset to Latin incl. German and the common
|
||||||
|
European names (ä ö ü ß é ç ğ ş ł ...), € and German quotes; anything outside falls
|
||||||
|
back to the system font. No italic file: Markdown emphasis is rare, so the browser
|
||||||
|
slanting the upright face is worth the saved request. font-display: swap shows the
|
||||||
|
system font immediately and swaps once the file is there -- text never waits.
|
||||||
|
License: SIL OFL 1.1, see fonts/OFL.txt. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Nunito";
|
||||||
|
src: url("fonts/nunito-latin-wght.woff2") format("woff2");
|
||||||
|
font-weight: 400 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles. */
|
||||||
html {
|
html {
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: var(--font-family);
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
/* Tell the browser we support both schemes, so form controls, scrollbars and
|
/* Tell the browser we support both schemes, so form controls, scrollbars and
|
||||||
@@ -112,6 +175,7 @@ body {
|
|||||||
/* A soft tinted page background so white cards/content lift off it. */
|
/* A soft tinted page background so white cards/content lift off it. */
|
||||||
background: var(--color-page);
|
background: var(--color-page);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -129,26 +193,31 @@ a:hover {
|
|||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-heading);
|
||||||
margin: var(--space-5) 0 var(--space-3);
|
margin: var(--space-5) 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5rem;
|
font-size: var(--font-size-h1);
|
||||||
|
line-height: 1.15; /* big type needs less leading, or wrapped lines drift apart */
|
||||||
margin-top: 0; /* the first heading on a page needs no gap above */
|
margin-top: 0; /* the first heading on a page needs no gap above */
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.2rem;
|
font-size: var(--font-size-h2);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.05rem;
|
font-size: var(--font-size-h3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* h4 gets its own size so it stays a step below h3 instead of falling back to the
|
/* h4 gets its own size so it stays a step below h3 instead of falling back to the
|
||||||
browser default, which can render as large as (or larger than) a small h3. */
|
browser default, which can render as large as (or larger than) a small h3. Body
|
||||||
|
size: below that it would read smaller than the text it heads; the extra-bold
|
||||||
|
weight alone sets it apart. */
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 0.95rem;
|
font-size: var(--font-size-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comfortable paragraph rhythm and a capped line length for readability. */
|
/* Comfortable paragraph rhythm and a capped line length for readability. */
|
||||||
@@ -161,6 +230,14 @@ h1:focus {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icons (Shared/Icon.razor) sit inline next to text: nudged down to line up with the
|
||||||
|
letters instead of the baseline, and never squeezed when they share a flex row
|
||||||
|
with a long label. */
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
@@ -24,6 +24,12 @@ Google Fonts, Maps, YouTube embeds, and CDN scripts transmit visitors' IPs to th
|
|||||||
parties. Fonts are served by ourselves. This is the reason behind the "no external
|
parties. Fonts are served by ourselves. This is the reason behind the "no external
|
||||||
resources" rule in `CLAUDE.md` — data protection, not taste.
|
resources" rule in `CLAUDE.md` — data protection, not taste.
|
||||||
|
|
||||||
|
The site font is Nunito (SIL Open Font License 1.1), self-hosted under
|
||||||
|
`wwwroot/fonts/` with its license file `OFL.txt` next to it, as the OFL requires.
|
||||||
|
The icons are Lucide (ISC, partly MIT via Feather), inlined as SVG in
|
||||||
|
`Shared/IconSet.cs` with the license notice in its comment. A smoke test fails if
|
||||||
|
the stylesheet or the home page loads anything from an absolute URL.
|
||||||
|
|
||||||
## Photos of children
|
## Photos of children
|
||||||
|
|
||||||
Only with the consent of the legal guardians — by far the most common mistake on
|
Only with the consent of the legal guardians — by far the most common mistake on
|
||||||
|
|||||||
Reference in new issue
Block a user