From 6e5a30607e82b2c999fd843c0efed5775eb1bbaa Mon Sep 17 00:00:00 2001 From: tleininger Date: Thu, 1 Oct 2026 14:44:55 +0200 Subject: [PATCH] Polish home page: clamp tile teasers, balance hero heading, fix embed gap --- Elternbeirat.Web.Tests/MarkdownTests.cs | 18 ++++--- Elternbeirat.Web/Features/Home/Home.razor.css | 19 ++++++- Elternbeirat.Web/Shared/Markdown.cs | 51 ++++++------------- docs/redaktion.md | 3 +- 4 files changed, 44 insertions(+), 47 deletions(-) diff --git a/Elternbeirat.Web.Tests/MarkdownTests.cs b/Elternbeirat.Web.Tests/MarkdownTests.cs index 0267aa0..969102a 100644 --- a/Elternbeirat.Web.Tests/MarkdownTests.cs +++ b/Elternbeirat.Web.Tests/MarkdownTests.cs @@ -104,11 +104,17 @@ public class MarkdownTests } [Fact] - public void First_sentence_longer_than_the_limit_is_cut_between_words() - { - var sentence = Markdown.FirstSentence("Eins zwei drei vier fünf.", maxLength: 12); + public void First_sentence_drops_a_colon_that_leads_into_a_list() => + Markdown.FirstSentence("Formulare zum Herunterladen:\n\n- Antrag") + .ShouldBe("Formulare zum Herunterladen"); - sentence.ShouldBe("Eins zwei…"); + [Fact] + public void First_sentence_is_never_cut_short() + { + // Long teasers are clamped by the tile's CSS, not by a character count. + var sentence = string.Join(' ', Enumerable.Repeat("Wort", 60)) + "."; + + Markdown.FirstSentence(sentence).ShouldBe(sentence); } [Theory] @@ -119,8 +125,4 @@ public class MarkdownTests [InlineData("::: hinweis\nText im Baustein.\n:::")] public void First_sentence_is_empty_without_a_top_level_paragraph(string? markdown) => Markdown.FirstSentence(markdown).ShouldBe(""); - - [Fact] - public void First_sentence_rejects_a_limit_below_one() => - Should.Throw(() => Markdown.FirstSentence("Text.", maxLength: 0)); } diff --git a/Elternbeirat.Web/Features/Home/Home.razor.css b/Elternbeirat.Web/Features/Home/Home.razor.css index 5090968..336367d 100644 --- a/Elternbeirat.Web/Features/Home/Home.razor.css +++ b/Elternbeirat.Web/Features/Home/Home.razor.css @@ -38,8 +38,10 @@ /* On the gradient everything is white: the global heading colour is a blue that would vanish here. */ +/* balance evens out the lines, so a short last word ("IGMH") never wraps alone. */ .hero h1 { color: var(--color-on-header); + text-wrap: balance; } /* The intro is editor Markdown, rendered without scope attributes, so it is reached @@ -289,14 +291,23 @@ align-self: start; } + /* The teaser is a whole sentence; the tile cuts it visually after three lines, + so the tiles of a row keep a similar height whatever the editor wrote. The + -webkit- box is the form every current browser supports for line-clamp. */ .tile-text { - display: block; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; } } /* --- Embeds ------------------------------------------------------------------- The blocks the home page opts into (posts, events) sit side by side from desktop - width, so the start page shows both without a long scroll. One column below. */ + width, so the start page shows both without a long scroll. One column below. + In the grid the blocks' own top margin no longer collapses with the margin of + the tiles above, so it is dropped there; otherwise the gap would be doubled. */ @media (min-width: 64rem) { .home-embeds { display: grid; @@ -304,4 +315,8 @@ gap: var(--space-5); align-items: start; } + + .home-embeds ::deep .embed { + margin-top: 0; + } } diff --git a/Elternbeirat.Web/Shared/Markdown.cs b/Elternbeirat.Web/Shared/Markdown.cs index 64d596c..a676f21 100644 --- a/Elternbeirat.Web/Shared/Markdown.cs +++ b/Elternbeirat.Web/Shared/Markdown.cs @@ -84,18 +84,12 @@ public static partial class Markdown /// short description on a home page tile. /// /// The editor's Markdown; may be . - /// - /// The longest sentence kept as is; a longer one is cut at the last word - /// boundary before the limit and ends in …. - /// /// /// The first sentence of the first top-level paragraph, with all Markdown - /// removed and whitespace collapsed, or an empty string when there is no such - /// paragraph (empty body, or only headings, lists and blocks). + /// removed, whitespace collapsed and a trailing colon dropped, or an empty + /// string when there is no such paragraph (empty body, or only headings, lists + /// and blocks). /// - /// - /// is zero or negative. - /// /// /// Only top-level paragraphs count: a heading repeats the title, and the text in /// a list or a ::: block is rarely a sentence that describes the page. @@ -106,6 +100,12 @@ public static partial class Markdown /// longer abbreviation such as bzw. still does; that costs the rest /// of a teaser, never the page. /// + /// + /// A paragraph that leads into a list often ends in : without a + /// period; as a teaser the colon would point at a list that is not there. + /// The sentence is returned whole: a long one is cut by the tile's CSS, + /// which knows the space it has, not by a character count. + /// /// /// /// @@ -113,14 +113,12 @@ public static partial class Markdown /// // "Wir sind sieben Eltern." /// /// - public static string FirstSentence(string? markdown, int maxLength = 140) => - maxLength <= 0 - ? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "Must be positive.") - : string.IsNullOrWhiteSpace(markdown) - ? "" - : Parse(markdown).OfType().FirstOrDefault()?.Inline is { } inline - ? Shorten(UpToSentenceEnd(PlainText(inline)), maxLength) - : ""; + public static string FirstSentence(string? markdown) => + string.IsNullOrWhiteSpace(markdown) + ? "" + : Parse(markdown).OfType().FirstOrDefault()?.Inline is { } inline + ? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ') + : ""; /// /// Parses Markdown with the site's pipeline, for callers that take the document @@ -194,25 +192,6 @@ public static partial class Markdown private static string UpToSentenceEnd(string text) => SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text; - /// - /// Shortens a text to at most characters plus an - /// ellipsis, cutting between words. - /// - /// Plain text. - /// The longest text kept as is. - /// The text, shortened if needed. - /// - /// The search for a space starts at itself, so a - /// word that ends exactly at the limit is kept. A single word longer than the - /// limit is cut hard. - /// - private static string Shorten(string text, int maxLength) => - text.Length <= maxLength - ? text - : text.LastIndexOf(' ', maxLength) is > 0 and var space - ? $"{text[..space].TrimEnd(',', ';', ':', '-', '–')}…" - : $"{text[..maxLength]}…"; - /// /// Matches the end mark of a sentence: ., ! or ? before a /// space or the end, unless it follows a lone letter (z.) or a number diff --git a/docs/redaktion.md b/docs/redaktion.md index be83f70..f28e39f 100644 --- a/docs/redaktion.md +++ b/docs/redaktion.md @@ -131,7 +131,8 @@ Darunter erscheinen automatisch: keinen, entfällt die Karte. - **Kacheln** — eine pro öffentlicher Seite mit `location = header` (außer der Startseite), in Menü-Reihenfolge. Der Kacheltext ist der **erste Satz** des - `body` (ohne Überschrift, ggf. gekürzt); beginnt der Text mit einer Liste oder + `body` (ohne Überschrift, ein Doppelpunkt am Ende fällt weg; ein langer Satz + wird nach drei Zeilen mit „…“ abgeschnitten); beginnt der Text mit einer Liste oder einem Baustein, zeigt die Kachel nur den Titel. Auf dem Handy zeigen die Kacheln immer nur Symbol und Titel (zwei Spalten), ab Tablet-Breite auch den Text (drei Spalten). Das Symbol hängt am Slug und ist