Add ::: team block rendering board members as person cards with initials avatars

This commit is contained in:
tleininger committed 2026-10-01 15:55:28 +02:00
1 parent efb6fa9109
commit a5a68ed122
8 files changed
+815 -10

No files matched your search

+46 -8
View File
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.RegularExpressions;
using Markdig;
using Markdig.Extensions.CustomContainers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -20,8 +21,8 @@ namespace Elternbeirat.Web.Shared;
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
/// into <c>&lt;div class="name"&gt;</c>. That is how editors use the
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
/// <c>hinweis</c>) without writing HTML. An unknown name just yields a
/// div without styling, so a typo never breaks a page.
/// <c>hinweis</c>, <c>team</c>) without writing HTML. An unknown name
/// just yields a div without styling, so a typo never breaks a page.
/// </description>
/// </item>
/// <item>
@@ -45,8 +46,14 @@ namespace Elternbeirat.Web.Shared;
/// the text around an element), but the stylesheet needs exactly that to show a
/// lone <c>mailto:</c> link as a button and a lone PDF link as a file card.
/// </para>
/// <para>
/// The list items of a <c>::: team</c> block become person cards
/// (<see cref="TeamMember"/>): the initials avatar and the split into name,
/// role and duties need the text itself, which CSS cannot read.
/// </para>
/// </remarks>
/// <seealso cref="IconSet"/>
/// <seealso cref="TeamMember"/>
public static partial class Markdown
{
/// <summary>
@@ -183,22 +190,24 @@ public static partial class Markdown
Markdig.Markdown.Parse(markdown, Pipeline);
/// <summary>
/// Renders a document from <see cref="Parse"/> to HTML, marking lone links on
/// the way, exactly as <see cref="ToHtml"/> does.
/// Renders a document from <see cref="Parse"/> to HTML, building the team cards
/// and marking lone links on the way, exactly as <see cref="ToHtml"/> does.
/// </summary>
/// <param name="document">The parsed document; changed in place.</param>
/// <returns>The rendered HTML.</returns>
internal static string Render(MarkdownDocument document) =>
Markdig.Markdown.ToHtml(MarkLoneLinks(document), Pipeline);
Markdig.Markdown.ToHtml(MarkLoneLinks(BuildTeamCards(document)), Pipeline);
/// <summary>
/// Flattens inline Markdown to the text a reader sees: emphasis and link
/// markup dropped, link text kept, images left out, whitespace collapsed.
/// </summary>
/// <param name="inline">The inline content, e.g. of a paragraph or heading.</param>
/// <param name="inlines">
/// The inline content, e.g. of a paragraph or heading, or a part of it.
/// </param>
/// <returns>The visible text, trimmed.</returns>
internal static string PlainText(ContainerInline inline) =>
Whitespace().Replace(AppendText(new StringBuilder(), inline).ToString(), " ").Trim();
internal static string PlainText(IEnumerable<Inline> inlines) =>
Whitespace().Replace(inlines.Aggregate(new StringBuilder(), AppendText).ToString(), " ").Trim();
/// <summary>
/// Whether an inline carries no visible content (whitespace or a line break).
@@ -279,6 +288,35 @@ public static partial class Markdown
[GeneratedRegex(@"\s+")]
private static partial Regex Whitespace();
/// <summary>
/// Turns the list items of every <c>::: team</c> block into person cards.
/// </summary>
/// <param name="document">The parsed document; changed in place.</param>
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
/// <remarks>
/// Only lists directly in the block count; a nested list inside a card stays a
/// list. <c>::: team gross</c> adds the class
/// <see cref="TeamMember.LargeModifier"/> to the block, because Markdig only
/// turns the block name into a class, not the words after it.
/// </remarks>
private static MarkdownDocument BuildTeamCards(MarkdownDocument document)
{
foreach (var team in document.Descendants<CustomContainer>().Where(TeamMember.IsTeam).ToList())
{
if (TeamMember.IsLarge(team))
{
team.GetAttributes().AddClass(TeamMember.LargeModifier);
}
foreach (var item in team.OfType<ListBlock>().SelectMany(list => list.OfType<ListItemBlock>()))
{
TeamMember.ToCard(item);
}
}
return document;
}
/// <summary>
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
/// one link.
+372
View File
@@ -0,0 +1,372 @@
using System.Globalization;
using Markdig.Extensions.CustomContainers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace Elternbeirat.Web.Shared;
/// <summary>
/// One person in a <c>::: team</c> block, read from a list item that follows the
/// convention the board page has always used.
/// </summary>
/// <param name="Name">The person's name: the bold start of the item.</param>
/// <param name="Role">
/// The role after the name, without its parentheses, or <see langword="null"/>
/// when the item has none (most of the "Beisitz" members).
/// </param>
/// <param name="Duties">
/// The responsibilities from the italic second line, split at commas; empty when
/// there is no such line.
/// </param>
/// <remarks>
/// The convention, one list item per person:
/// <code>
/// - **Maik Palm** (Vorsitzender)
/// *Schulkonferenz, Mensarat, Homepage*
/// Mitglied im Gesamtelternbeirat.
/// </code>
/// The bold name becomes the card's heading, the role a chip, every duty a small
/// tag and any further line the note. Line breaks may be hard (two spaces) or soft:
/// editors forget the two spaces, and the card should not depend on them.
/// <para>
/// The board stays Markdown in the page body on purpose: a dedicated
/// collection would be cleaner, but there is no way yet to bring a schema
/// change to production (Gitea #39). Reading the convention costs the editors
/// nothing they do not already write.
/// </para>
/// <para>
/// An item that does not start with a bold name is not an error: it becomes a
/// plain card showing its text as written (<see cref="PlainCardClass"/>).
/// </para>
/// </remarks>
/// <seealso cref="Markdown"/>
public sealed record TeamMember(string Name, string? Role, IReadOnlyList<string> Duties)
{
/// <summary>
/// The block name editors type: <c>::: team</c>.
/// </summary>
public const string ContainerName = "team";
/// <summary>
/// The modifier for larger cards, <c>::: team gross</c>; also the class it adds
/// to the block.
/// </summary>
/// <remarks>
/// A word after the block name rather than a block of its own
/// (<c>::: team-gross</c>): the block stays <c>team</c>, so a typo in the
/// modifier only costs the larger size, never the whole grid. <c>groß</c> is
/// accepted too; <c>gross</c> is the documented form because it types the same
/// on every keyboard.
/// </remarks>
public const string LargeModifier = "gross";
/// <summary>
/// The class on every card, i.e. on every list item in a <c>::: team</c> block.
/// </summary>
public const string CardClass = "team-card";
/// <summary>
/// The extra class on a card whose item does not follow the convention.
/// </summary>
public const string PlainCardClass = "team-card-plain";
/// <summary>
/// How many avatar colours there are; <c>app.css</c> defines
/// <c>--color-avatar-1</c> to <c>--color-avatar-5</c> to match.
/// </summary>
public const int ColorCount = 5;
/// <summary>
/// FNV-1a offset basis and prime (32 bit), see <see cref="Fnv"/>.
/// </summary>
private const uint FnvOffset = 2166136261;
private const uint FnvPrime = 16777619;
/// <summary>
/// The letters on the avatar, see <see cref="InitialsOf"/>.
/// </summary>
public string Initials => InitialsOf(Name);
/// <summary>
/// The avatar colour, from <c>1</c> to <see cref="ColorCount"/>, see
/// <see cref="ColorOf"/>.
/// </summary>
public int Color => ColorOf(Name);
/// <summary>
/// Builds the initials shown on a person's avatar.
/// </summary>
/// <param name="name">The person's name.</param>
/// <returns>
/// The first letter of the first and of the last word, upper case; one letter
/// for a single word; an empty string when the name holds no letter at all.
/// </returns>
/// <remarks>
/// A double name counts as one word, so it adds one letter, not two: two
/// letters are what fits the circle, and they are what people expect from a
/// name like <c>Reger-Stilgenbauer</c>. Letters outside A–Z (<c>Ö</c>,
/// <c>Ş</c>) are kept as they are, and leading punctuation is skipped.
/// </remarks>
/// <example>
/// <code>
/// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR"
/// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ"
/// TeamMember.InitialsOf("Isabell"); // "I"
/// </code>
/// </example>
public static string InitialsOf(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) switch
{
[] => "",
[var only] => Initial(only),
[var first, .., var last] => Initial(first) + Initial(last),
};
/// <summary>
/// Picks a person's avatar colour from the name.
/// </summary>
/// <param name="name">The person's name.</param>
/// <returns>A number from <c>1</c> to <see cref="ColorCount"/>.</returns>
/// <remarks>
/// The same name always gets the same colour, on every request and after every
/// restart, so a person does not change colour between visits. That is why this
/// is a fixed hash (FNV-1a) and not <see cref="string.GetHashCode()"/>, which
/// .NET randomises per process. Surrounding whitespace does not count.
/// <para>
/// FNV-1a alone spreads short names badly over its low bits, which the
/// remainder by <see cref="ColorCount"/> reads: four of the six "Beisitz"
/// members shared one colour. The MurmurHash3 finaliser (<see cref="Mix"/>)
/// stirs all bits into the low ones first.
/// </para>
/// </remarks>
public static int ColorOf(string name) =>
(int)(Mix(Fnv(name.Trim())) % ColorCount) + 1;
/// <summary>
/// Reads a person from the first paragraph of a list item.
/// </summary>
/// <param name="paragraph">The paragraph to read; not changed.</param>
/// <returns>
/// The person and the inlines that form the note (everything after the name
/// line and, if present, the duties line; possibly empty), or
/// <see langword="null"/> when the paragraph does not start with a bold name.
/// </returns>
/// <remarks>
/// The duties line is only recognised when it is nothing but one italic span;
/// any other second line is part of the note, so no text is lost.
/// </remarks>
internal static (TeamMember Member, Inline[] Note)? Read(ParagraphBlock paragraph) =>
Lines(paragraph).ToArray() is [var head, .. var rest]
&& Content(head) is [EmphasisInline { DelimiterCount: 2 } name, .. var role]
&& Markdown.PlainText(name) is { Length: > 0 } nameText
? rest is [var second, .. var after] && Content(second) is [EmphasisInline { DelimiterCount: 1 } duties]
? (new TeamMember(nameText, RoleOf(role), DutiesOf(duties)), [.. after.SelectMany(line => line)])
: (new TeamMember(nameText, RoleOf(role), []), [.. rest.SelectMany(line => line)])
: null;
/// <summary>
/// Turns one list item of a <c>::: team</c> block into a person card.
/// </summary>
/// <param name="item">The list item; changed in place.</param>
/// <remarks>
/// The first paragraph is rebuilt from new inlines: raw HTML inlines for the
/// tags and literal inlines for the text, so Markdig escapes everything the
/// editor wrote. The note keeps its original inlines, so links and emphasis in it
/// still render. Any further blocks in the item stay untouched below.
/// </remarks>
internal static void ToCard(ListItemBlock item)
{
var attributes = item.GetAttributes();
attributes.AddClass(CardClass);
if (item.FirstOrDefault() is ParagraphBlock { Inline: not null } paragraph && Read(paragraph) is { } card)
{
foreach (var inline in card.Note)
{
inline.Remove();
}
paragraph.Inline = card.Member.Head()
.Concat(card.Note.Length > 0 ? [Open("span", "team-note"), .. card.Note, Close("span")] : [])
.Aggregate(new ContainerInline(), (container, inline) => container.AppendChild(inline));
}
else
{
attributes.AddClass(PlainCardClass);
}
}
/// <summary>
/// Whether a <c>:::</c> block is a team block.
/// </summary>
/// <param name="container">The block to inspect.</param>
/// <returns><see langword="true"/> for <c>::: team</c>, with or without modifier.</returns>
internal static bool IsTeam(CustomContainer container) =>
string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a team block asks for larger cards (<c>::: team gross</c>).
/// </summary>
/// <param name="container">The team block.</param>
/// <returns>
/// <see langword="true"/> when one of the words after the name is
/// <c>gross</c> or <c>groß</c>, in any case.
/// </returns>
internal static bool IsLarge(CustomContainer container) =>
(container.Arguments ?? "")
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Any(word => word.Equals(LargeModifier, StringComparison.OrdinalIgnoreCase)
|| word.Equals("groß", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// The card's head: avatar, name, role chip and duty tags, as inlines.
/// </summary>
/// <returns>The inlines, in reading order; missing parts are left out.</returns>
/// <remarks>
/// The avatar is hidden from screen readers: it only repeats the name next to
/// it.
/// </remarks>
private Inline[] Head() =>
[
.. Initials is { Length: > 0 } initials
? [Open("span", $"team-avatar team-avatar-{Color.ToString(CultureInfo.InvariantCulture)}", " aria-hidden=\"true\""), new LiteralInline(initials), Close("span")]
: Array.Empty<Inline>(),
.. Element("team-name", Name, "strong"),
.. Role is { } role ? Element("team-role", role) : [],
.. Duties.Count > 0
? [Open("span", "team-duties"), .. Duties.SelectMany(Duty), Close("span")]
: Array.Empty<Inline>(),
];
/// <summary>
/// The 32-bit FNV-1a hash of a text's UTF-16 code units.
/// </summary>
/// <param name="text">The text.</param>
/// <returns>The hash.</returns>
private static uint Fnv(string text) =>
unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime));
/// <summary>
/// The MurmurHash3 finaliser (<c>fmix32</c>): spreads every input bit over all
/// output bits.
/// </summary>
/// <param name="hash">The hash to mix.</param>
/// <returns>The mixed hash.</returns>
private static uint Mix(uint hash) =>
unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16));
/// <summary>
/// Folds the high bits of a value into its low bits.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="shift">How far to shift right before the XOR.</param>
/// <returns><paramref name="value"/> XOR <paramref name="value"/> shifted right.</returns>
private static uint XorShift(uint value, int shift) => value ^ (value >> shift);
/// <summary>
/// One duty tag, led by a space so screen readers keep the tags apart.
/// </summary>
/// <param name="duty">The duty.</param>
/// <returns>The space and the tag.</returns>
private static Inline[] Duty(string duty) =>
[new LiteralInline(" "), .. Element("team-duty", duty)];
/// <summary>
/// An element with escaped text inside.
/// </summary>
/// <param name="classes">The value of the class attribute; written as is.</param>
/// <param name="text">The text; escaped by Markdig on rendering.</param>
/// <param name="tag">The element name.</param>
/// <returns>Opening tag, text and closing tag.</returns>
private static Inline[] Element(string classes, string text, string tag = "span") =>
[Open(tag, classes), new LiteralInline(text), Close(tag)];
/// <summary>
/// An opening tag with a class, as a raw HTML inline.
/// </summary>
/// <param name="tag">The element name.</param>
/// <param name="classes">The value of the class attribute; written as is.</param>
/// <param name="attributes">
/// Further attributes, written as is after the class, with a leading space.
/// </param>
/// <returns>The tag.</returns>
/// <remarks>
/// Only ever called with constants from this class, never with editor text:
/// nothing here is escaped.
/// </remarks>
private static HtmlInline Open(string tag, string classes, string attributes = "") =>
new($"<{tag} class=\"{classes}\"{attributes}>");
/// <summary>
/// A closing tag, as a raw HTML inline.
/// </summary>
/// <param name="tag">The element name.</param>
/// <returns>The tag.</returns>
private static HtmlInline Close(string tag) => new($"</{tag}>");
/// <summary>
/// The first letter of a word, upper case.
/// </summary>
/// <param name="word">One word of a name.</param>
/// <returns>The letter, or an empty string if the word has none.</returns>
private static string Initial(string word) =>
word.FirstOrDefault(char.IsLetter) is var letter and not '\0'
? letter.ToString().ToUpper(Cultures.German)
: "";
/// <summary>
/// The text after the name, without separators and parentheses.
/// </summary>
/// <param name="inlines">The inlines after the bold name on the first line.</param>
/// <returns>The role, or <see langword="null"/> if nothing is left.</returns>
private static string? RoleOf(IEnumerable<Inline> inlines) =>
Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text
&& (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role
? role
: null;
/// <summary>
/// Splits the italic duties line at its commas.
/// </summary>
/// <param name="duties">The italic span.</param>
/// <returns>The duties, trimmed, without empty entries.</returns>
private static string[] DutiesOf(EmphasisInline duties) =>
Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
/// <summary>
/// The inlines of a line that carry content: no whitespace, no line break.
/// </summary>
/// <param name="line">One line from <see cref="Lines"/>.</param>
/// <returns>The content inlines, in order.</returns>
private static Inline[] Content(IEnumerable<Inline> line) =>
[.. line.Where(inline => !Markdown.IsBlank(inline))];
/// <summary>
/// Splits a paragraph's inlines into lines.
/// </summary>
/// <param name="paragraph">The paragraph.</param>
/// <returns>
/// The lines; each keeps the line break that ends it, so the note can be moved
/// with its breaks intact.
/// </returns>
private static IEnumerable<List<Inline>> Lines(ParagraphBlock paragraph)
{
var line = new List<Inline>();
foreach (var inline in paragraph.Inline?.ToList() ?? [])
{
line.Add(inline);
if (inline is LineBreakInline)
{
yield return line;
line = [];
}
}
if (line.Count > 0)
{
yield return line;
}
}
}
+163
View File
@@ -74,6 +74,21 @@
--color-button-light-hover: #eaf3f9;
--color-on-button-light: #1f5477;
/* Initials avatars on the person cards (::: team, see TeamMember.cs). Five
soft tints, each with a deep text shade of the same hue, so a board of ten
people looks lively but calm. The colour is picked from the name, so it means
nothing -- it only helps tell the cards apart. Every pair >= 5.3:1. */
--color-avatar-1: #dbeaf5; /* blue */
--color-on-avatar-1: #1f5477;
--color-avatar-2: #dcefd8; /* green */
--color-on-avatar-2: #2f6b33;
--color-avatar-3: #fbeec5; /* warm yellow */
--color-on-avatar-3: #7a5a00;
--color-avatar-4: #f8dfe0; /* rose */
--color-on-avatar-4: #8e2f3a;
--color-avatar-5: #e7e0f3; /* violet */
--color-on-avatar-5: #553a8a;
/* Keyboard focus ring (:focus-visible). Its own token so it can stay strong on
the light page (dark blue, 8:1 on white) and flip to a light blue on the dark
one; on the gradient the hero switches it to white. */
@@ -181,6 +196,19 @@
--color-button-light-hover: #d3dee6;
--color-on-button-light: #1a3d57; /* 9:1 on the eased white */
/* Avatars: the tints turn into deep shades, the text into light ones; each
pair >= 7:1, so the circles do not glare on the dark cards. */
--color-avatar-1: #1f3a4f;
--color-on-avatar-1: #a3d5f4;
--color-avatar-2: #24402a;
--color-on-avatar-2: #a6dc9e;
--color-avatar-3: #4a3d14;
--color-on-avatar-3: #f3d98a;
--color-avatar-4: #4d2a2f;
--color-on-avatar-4: #f4b8bf;
--color-avatar-5: #3a3150;
--color-on-avatar-5: #cdbdf0;
--color-focus: #a3d5f4; /* light blue: a dark ring would vanish here */
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
@@ -853,6 +881,141 @@ h1:focus {
line-height: 1.15;
}
/* ::: team -- one person card per list item (Markdown.cs/TeamMember.cs build the
avatar, name, role chip, duty tags and note). A grid like the kacheln, a little
wider so a long double name still fits its line. Each "## Vorsitz" etc. above
stays a normal heading; every group is a block of its own. */
.markdown-body .team > ul {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: var(--space-3);
max-width: none;
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
}
.markdown-body .team li + li {
margin-top: 0;
}
/* The card: avatar in a column of its own, everything else stacked beside it.
align-content keeps a short card's text at the top when a long neighbour in the
same row stretches it. Hover only deepens the shadow -- nothing moves. */
.markdown-body .team-card {
display: grid;
grid-template-columns: auto 1fr;
column-gap: var(--space-3);
align-content: start;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow);
transition: box-shadow var(--transition);
}
.markdown-body .team-card:hover {
box-shadow: var(--shadow-hover);
}
/* In a loose list (blank lines between items) Markdig wraps the card's text in a
<p>; display: contents lets its parts join the card's grid all the same. */
.markdown-body .team-card > p:first-child {
display: contents;
}
/* Everything but the avatar goes into the text column -- including whatever else
an editor puts into the item (a second paragraph, a sub-list). */
.markdown-body .team-card > :not(.team-avatar),
.markdown-body .team-card > p:first-child > :not(.team-avatar) {
grid-column: 2;
}
/* A card without the convention (no bold name) is just its text. */
.markdown-body .team-card-plain {
display: block;
}
.markdown-body .team-avatar {
grid-row: 1 / span 4;
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
font-weight: var(--font-weight-heading);
letter-spacing: 0.02em;
}
.markdown-body .team-avatar-1 { background: var(--color-avatar-1); color: var(--color-on-avatar-1); }
.markdown-body .team-avatar-2 { background: var(--color-avatar-2); color: var(--color-on-avatar-2); }
.markdown-body .team-avatar-3 { background: var(--color-avatar-3); color: var(--color-on-avatar-3); }
.markdown-body .team-avatar-4 { background: var(--color-avatar-4); color: var(--color-on-avatar-4); }
.markdown-body .team-avatar-5 { background: var(--color-avatar-5); color: var(--color-on-avatar-5); }
.markdown-body .team-name {
color: var(--color-heading);
font-size: 1.15rem;
line-height: 1.3;
}
/* The role as a chip under the name; justify-self keeps it as wide as its text,
not the column. */
.markdown-body .team-role {
justify-self: start;
margin-top: var(--space-1);
padding: 0.1rem var(--space-2);
border-radius: 999px;
background: var(--color-brand-tint);
color: var(--color-brand-dark);
font-size: 0.85rem;
font-weight: 700;
}
/* Duties as small outlined tags: quieter than the role chip, because the role
says who someone is and the duties only what they look after. */
.markdown-body .team-duties {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-2);
}
.markdown-body .team-duty {
padding: 0.05rem var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-soft);
font-size: 0.8rem;
}
.markdown-body .team-note {
margin-top: var(--space-2);
color: var(--color-text-soft);
font-size: 0.95rem;
}
/* ::: team gross -- the chair: wider cards, a bigger avatar and name, and a green
edge so the first group reads as the lead of the page. */
.markdown-body .team.gross > ul {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}
.markdown-body .team.gross .team-card {
padding: var(--space-4);
border-top: 4px solid var(--color-accent);
}
.markdown-body .team.gross .team-avatar {
width: 4rem;
height: 4rem;
font-size: 1.3rem;
}
.markdown-body .team.gross .team-name {
font-size: 1.35rem;
}
/* Tables (pipe tables): not in the content yet, styled ahead of time. As a block
with overflow they scroll sideways on a phone instead of stretching the page. */
.markdown-body table {