/* ==========================================================================
   Site layout
   ==========================================================================
   Page structure, top to bottom, on every page of the site:

     1. .app-header    - banner image + nav bar + page title + tags
     2. .app-titlebar  - thin full-width strip, one label on the left
     3. .app-body      - index | main content | sidebar
     4. .app-footer    - copyright strip

   This file is about POSITION AND SPACING only - what color things are
   comes from assets/css/theme.css instead, so the two concerns never end up
   tangled in the same rule.

   Units: rem everywhere a size should scale with the reader's own font-size
   preference (spacing, component sizing), em for the breakpoints below (the
   same reasoning applied to media queries - see the note there), and ch
   where a size is really "how many characters of text" (e.g. a button's
   max-width). Plain px is kept only for things that are genuinely device
   pixels, not typography - hairline borders/shadows.

   Two breakpoints are used throughout, always the same two:
     - 52.5em (840px)  - when the nav bar's inline links replace the
                          hamburger menu
     - 60em   (960px)  - when the content area becomes a 3-column grid
                          (index and sidebar are otherwise reachable
                          through the nav bar instead, see the "Mobile
                          overlays" section below)
   Expressed in em rather than px so they still correspond to the same
   *relative* point (e.g. "the width of roughly 52.5 default-size
   characters") if the reader has changed their browser's default font
   size, instead of a fixed device-pixel value that ignores it.
   ========================================================================== */

:root {
    --page-gutter: 1rem;
    --app-bar-height: 4rem;
    /* The only two typefaces used anywhere on the site (see assets/css/fonts.css
       for the actual @font-face rules) - which one applies where is a
       single, simple split: --font-body for actual paragraphs of
       readable prose (markdown.css's .markdown, and the handful of
       prose-like bits of UI text below it - card subtitles, the profile
       description), --font-heading (the site-wide default, set on body
       below) for everything else - headings, menu items, buttons, the
       titlebar, dates. */
    --font-heading: "League Gothic", sans-serif;
    --font-body: "Roboto", sans-serif;
    /* The site's whole type scale, top to bottom, deliberately just three
       steps (plus the titlebar's and the header's own one-off sizes
       below) - every piece of UI text on the site is pinned to one of
       these three variables instead of a typescale token picked ad hoc
       per element, so the number of distinct sizes actually rendered
       anywhere stays small and predictable no matter how many components
       are involved, AND so bumping one of these three, once, always
       moves every element built on top of it together - never just one
       of them by accident. Three tiers, decreasing:
         1. --emphasis-font-size - the nav menu items (desktop + mobile),
            the sidebar name, and a post's own title within the post
            list.
         2. --secondary-font-size - the search placeholder, a month
            heading in the post list ("July 2026"), the pager's
            "Previous/Next page" button labels, and the summary/table-of-
            contents entries on an article page.
         3. --tertiary-font-size - header tag buttons, card/profile
            subtitles, a post's date, the footer. Equal to body-medium's
            own size (0.875rem) - not a new value, just named for this
            scale so every one of these spots stays pinned to the same
            variable instead of independently matching that token's
            number.
       The titlebar (--titlebar-font-size, see .app-titlebar below) and
       the header's own hero title (m3e-heading variant="display" - see
       _layouts/post-list.html/article.html) are each deliberately their
       OWN one-off size, outside this three-tier scale - each is used in
       exactly one place on the page, so neither needs (or should have) a
       shared variable of its own. */
    --emphasis-font-size: 1.375rem;
    --secondary-font-size: 1.125rem;
    --tertiary-font-size: 0.875rem;
    /* The titlebar's own size - was, until now, pinned to the emphasis
       tier above (and WAS that tier's own reference value, 1.375rem);
       swapped with the post list's post-title size (previously a
       dedicated 1.25rem of its own, now pinned to --emphasis-font-size
       instead - see .post-card__title) at explicit request, so the two
       simply traded places rather than either one changing the tier
       scale itself. */
    --titlebar-font-size: 1.25rem;
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

html,
body {
    margin: 0;
    padding: 0;
    height: 100%;
}

body {
    font-family: var(--font-heading);
}

/* Every plain text link on the site (markdown prose, the footer's
   "Jekyll"/"CloudFlare Pages" credits, ...) - not the many M3E buttons
   that also happen to carry an `href`, which already draw their own
   hover/focus feedback and are never plain inline text. A dashed
   underline reads as a deliberately different affordance from the
   permanent solid one under markdown links (see markdown.css), without
   needing a second rule to say so. */
a:hover,
a:focus-visible {
    text-decoration: underline;
    text-decoration-style: dashed;
}

/* ==========================================================================
   1. Header
   ========================================================================== */

/* Each page sets its own background-image directly via an inline `style`
   attribute (see index.html/example-post.html) rather than through a
   CSS custom property consumed here with `var()`: a relative url() held
   in a custom property resolves against the stylesheet where the var()
   is actually used - this file, assets/css/site.css - not against the HTML
   page that declared the property, so a plain `img/...` path here would
   incorrectly request assets/css/img/... instead of the page's own assets/img/...
   Setting `background-image` inline keeps the URL resolved against the
   page itself, where it belongs. */
.app-header {
    position: relative;
    flex: none;
    background-position: center;
    background-size: cover;
    background-repeat: no-repeat;
    color: var(--color-white);
}

/* Nav bar sits on top of the banner image (transparent, see theme.css)
   and stays reachable while scrolling. This is `fixed`, not `sticky`:
   a sticky element can only stay stuck while its own parent (.app-header)
   still intersects the viewport, and disappears the moment you scroll
   past the header entirely - `fixed` pins it to the viewport itself, for
   the whole page, no matter how far down you scroll. Taking it out of
   flow this way means .app-header__content has to reserve its height
   with padding instead (below). */
.app-header__bar {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 20;
}

/* A genuinely FIXED height (not min-height) regardless of page type (a
   list page has no tags row, an article does) or how long the title text
   happens to be - content is vertically centered and clipped inside it
   instead of letting the title/tags change the header's overall height.
   Sized generously (with room to spare for a two-line title) specifically
   so real post titles don't get cropped in normal use. The extra top
   padding (--app-bar-height) is space the now-fixed bar no longer
   reserves for itself by being in normal flow. */
.app-header__content {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 16rem;
    overflow: hidden;
    padding: calc(var(--app-bar-height) + 1.5rem) var(--page-gutter) 1.5rem;
    text-align: center;
}

@media (min-width: 48em) {
    .app-header__content {
        height: 19.5rem;
    }
}

.app-header__title {
    display: block;
    max-width: 100%;
    /* Always white-on-photo regardless of the active color scheme -
       m3e-heading otherwise inherits on-surface/on-background, which
       isn't guaranteed to stay legible over an arbitrary banner image.
       This is the site's one-off, single-use display size (see the tier
       comment in :root) - bumped up from the token's own default
       (2.8125rem) directly, the same way every other m3e-heading
       override on this page works: the component reads its font-size
       exclusively from this custom property, never from a plain CSS
       `font-size`. !important pins this specific value on THIS element
       regardless of anything else that may set the same token elsewhere
       (e.g. the library's own baseline default, injected once as a
       fallback on <html> itself) - a direct declaration on the element
       already wins over an inherited one on its own, but the explicit
       !important removes any doubt and keeps this value from silently
       losing to a future, more specific rule added elsewhere. */
    --md-sys-typescale-display-medium-font-size: 3.7rem !important;
    color: inherit;
}

.app-header__tags {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-top: 1rem;
}

.app-header__tags m3e-button {
    /* Size-agnostic token, not --m3e-button-medium-*: these buttons are
       actually size="extra-small" (see _layouts/article.html) - see the
       long comment on .app-header__nav-links m3e-button above for why
       the bucket-specific token has to match the button's real size (or,
       as here, just skip the bucket entirely). */
    --m3e-button-label-text-font-size: var(--tertiary-font-size);
    /* Every other emphasis/label spot on the site is --font-heading
       (League Gothic); a button's label isn't set to any explicit
       font-family internally (confirmed in its source), so it just
       inherits whatever the page around it uses unless told otherwise
       here - the one deliberate exception, matching the rest of the
       small, prose-adjacent tertiary-tier text (card/profile
       subtitles, the footer). */
    font-family: var(--font-body);
    text-transform: uppercase;
}

/* Visually matches its sibling tag buttons at rest (a text-variant
   m3e-button has no visible fill/border of its own until hovered, just
   label + padding) without actually being one - see the comment in
   _layouts/article.html for why the language indicator is a plain
   <span> instead. color: inherit is the one thing an m3e-button doesn't
   need spelled out here (it already reads white from .app-header's own
   token overrides above) but a plain element does - a <span> has no
   built-in color of its own to override, it simply inherits normally.
   height: 2rem (32px) is size="extra-small"'s own real, fixed
   container-height at the site's default density (confirmed in the
   button's source: --m3e-button-extra-small-container-height defaults to
   32px, adjusted by a density term that's 0 at this site's own default
   density) - matched here exactly, rather than left to emerge from
   padding + line-height the way a plain chip normally would, so the two
   sit at the identical height side by side instead of merely close.
   display: inline-flex + align-items: center centers the label text
   within that fixed height the same way the button centers its own
   label within its container - padding only handles the horizontal
   inset now, not the vertical one. */
.app-header__lang-chip {
    display: inline-flex;
    align-items: center;
    height: 2rem;
    padding: 0 0.75rem;
    color: inherit;
    font-family: var(--font-body);
    font-size: var(--tertiary-font-size);
    text-transform: uppercase;
}

/* ---- Nav bar content ----------------------------------------------- */

/* Just a visibility switch - the m3e-button-group it wraps (index.html)
   handles its own internal layout and spacing. */
.app-header__nav-links {
    display: none;
    align-items: center;
}

/* Tighter left/right padding than the button's own default - two items
   ("Home", "Portfolio") at full padding made for a much wider nav than
   the site needs.
   Deliberately the size-AGNOSTIC custom properties (--m3e-button-
   leading-space, not --m3e-button-medium-leading-space) - m3e-button's
   own default `size` is actually "small" (confirmed in its source), not
   "medium", even though none of these buttons set a `size` attribute at
   all. The "-medium-" scoped tokens tried here in earlier passes were
   therefore silently never read: a button only ever consults its own
   current size bucket's token (here, "-small-"), falling back to this
   plain, bucket-independent one if THAT isn't set either - which is
   exactly why this styling had no visible effect no matter how many
   times the values themselves were adjusted. The size-agnostic token is
   the correct, robust choice regardless: it keeps applying even if a
   future edit adds an explicit `size="..."` attribute here. */
.app-header__nav-links m3e-button {
    --m3e-button-leading-space: 1.25rem;
    --m3e-button-trailing-space: 1.25rem;
    /* Same size as the titlebar and the sidebar name (--emphasis-font-
       size) - League Gothic (see --font-heading) is a narrow, condensed
       face, so menu labels set in it read noticeably smaller than the
       default (label-large, 0.875rem) next to everything else in the
       bar. */
    --m3e-button-label-text-font-size: var(--emphasis-font-size);
    /* Slotted label text inherits text-transform from its flat-tree
       parent (this host element) same as any other inherited CSS
       property - small caps read better than a mixed-case label at a
       condensed, all-caps-tuned display face like League Gothic. */
    text-transform: uppercase;
}

/* Same reasoning as the desktop nav links above, applied to their mobile
   equivalent (see _includes/nav-popups.html). */
.nav-popup m3e-list-action {
    --m3e-list-item-font-size: var(--emphasis-font-size);
    text-transform: uppercase;
}

/* The mobile icon group (toc, search, menu) - visible by
   default (mobile-first), hidden once each target becomes reachable
   another way - see the matching breakpoints below. */
.app-header__menu-toggle,
.app-header__toc-toggle,
.app-header__search-toggle {
    display: inline-flex;
}

@media (min-width: 52.5em) {
    .app-header__nav-links {
        display: flex;
    }

    .app-header__menu-toggle,
    .app-header__search-toggle {
        display: none;
    }
}

@media (min-width: 60em) {
    .app-header__toc-toggle {
        display: none;
    }
}

/* Site title vs. search bar in the bar's title slot: the search bar
   replaces the title once there's room for the inline nav links (52.5em) -
   below that, the compact bar keeps showing the site name instead, and
   search moves into the nav popup (see below). */
.app-header__bar-title {
    display: flex;
    align-items: center;
    width: 100%;
}

/* m3e-app-bar's own title slot only ever expects a short heading-sized
   string (confirmed in its source: it pads the SLOT itself by a mere
   0.25rem, tuned for text sitting directly against the bar's edge, not
   for a slot that also has to double as this same spot's search field
   at wider widths - see .app-header__bar-search above). Left with no
   padding of its own, the site name sat flush against that inherited
   0.25rem with nothing to visually separate it from the bar's edge on
   the narrow widths where it's the only thing shown, and looked
   inconsistent with the search field it swaps places with once scrolled
   (see .app-header__bar--scrolled below) - that field has its own
   pill-shaped padding built in, so switching between the two otherwise
   looked like the content jumped closer to the edge specifically when
   the text was showing. */
.app-header__bar-title-text {
    padding-inline: 0.5rem;
}

/* Deliberately narrow (a search box never needs to hold much text) and
   capped in `ch` rather than a plain length - its size should track how
   many characters it can usefully show, not an arbitrary pixel value. */
.app-header__bar-search {
    display: none;
    width: 100%;
    max-width: 40ch;
}

.app-header__bar-search m3e-search-bar {
    /* One density step down from the default (0) - the search bar reads
       as too large next to the rest of the (already compact, "small")
       app bar otherwise. See
       https://matraic.github.io/m3e/#/styles/density.html. */
    --md-sys-density-scale: -2;
    /* The field's default height (56px, confirmed in its source) is
       still noticeably taller than the rest of the bar even at reduced
       density - set directly, rather than relying on density alone, for
       a predictable, compact result. */
    --m3e-search-bar-container-height: 2.5rem;
}

/* Applies to both search instances (desktop bar + mobile popup, see
   _includes/navbar.html/nav-popups.html) - density (above, desktop only)
   also scales down the input's own font-size, which left "Search the
   site" visibly smaller than every other label on the page; pinned to
   the secondary tier, matching the pager's "Previous/Next page" label
   text, everywhere the search box appears (see the tier comment in
   :root). */
.search-box m3e-search-bar {
    --m3e-search-bar-input-text-font-size: var(--secondary-font-size);
}

/* m3e-search-bar's own stylesheet sets the slotted input's font-size/
   weight/line-height/tracking (confirmed in its source), but never its
   font-family - a plain <input> doesn't inherit that from the page by
   default (form controls get their own browser UA font unless told
   otherwise), which is why it was rendering in a different typeface than
   the rest of the site. */
.search-box input {
    font-family: inherit;
}

@media (min-width: 52.5em) {
    .app-header__bar-title-text {
        display: none;
    }

    .app-header__bar-search {
        display: block;
    }

    /* Swapped back once the page has scrolled: .app-header__bar--scrolled
       is added by assets/js/app.js the moment window.scrollY > 0 (the same class
       that switches the bar to its frosted-glass background, see
       theme.css) - reusing it here instead of a second scroll listener
       means both effects always change in lockstep, at the exact same
       scroll position, with a single source of truth for "has this page
       been scrolled". The site name reads better than an active search
       field once the banner (and the context it gives the search box) has
       scrolled out of view. */
    .app-header__bar--scrolled .app-header__bar-title-text {
        display: block;
    }

    .app-header__bar--scrolled .app-header__bar-search {
        display: none;
    }
}

/* ==========================================================================
   2. Titlebar
   ========================================================================== */

/* tertiary-container (not the more vivid tertiary itself, see the custom
   color anchors in theme.css) - the same background as the sidebar's
   profile panel (.app-body__sidebar, theme.css), so the two read as one
   deliberately-colored "block" bookending the page's content area. */
.app-titlebar {
    display: flex;
    align-items: center;
    flex: none;
    padding: 0.5rem var(--page-gutter);
    background-color: var(--md-sys-color-tertiary-container);
    color: var(--md-sys-color-on-tertiary-container);
    font: var(--md-sys-typescale-title-large-font-weight, 500)
        var(--titlebar-font-size) / 1.4 var(--font-heading);
    text-transform: uppercase;
}

/* The label sits inline-block so its own box is exactly as wide as its
   text - the accent bar below (an ::after, not a plain border) is
   positioned relative to THIS box, so it always matches the text's
   length instead of stretching across the whole titlebar. Its bottom
   edge is offset by exactly the titlebar's own bottom padding, so it
   reaches all the way down to the titlebar's bottom edge. `align-items:
   center` on .app-titlebar (above) centers this label vertically within
   the strip. */
.app-titlebar__label {
    position: relative;
    display: inline-block;
}

.app-titlebar__label::after {
    content: "";
    position: absolute;
    /* Slightly wider than the label's own text box on both sides,
       rather than matching it exactly (left/right: 0) - reads as a
       deliberate underline mark drawn under the word, not a border that
       happens to be exactly as wide as its content. */
    left: -0.375rem;
    right: -0.375rem;
    bottom: -0.5rem;
    height: 0.1875rem;
    /* Square corners on purpose - a deliberately sharp accent mark, not
       a soft pill shape. A literal white, not a role token (secondary-
       container and primary were both tried first) - meant to stay
       white regardless of scheme, not track whatever a role resolves
       to in light vs dark. */
    background-color: var(--color-white);
}

/* ==========================================================================
   3. Body: index / main / sidebar
   ==========================================================================
   Single column, main content only, on mobile (index and sidebar move to
   the overlays below); a 3-column grid from 60em up, spanning the full
   viewport width edge to edge - the index and sidebar rails are already
   capped to a fixed range by their own minmax() (below), so they never
   grow past a normal rail width no matter how wide the screen is, and
   stay flush against the left/right edges. The middle track is the only
   flexible (1fr) one, so on a very large/ultra-wide monitor it's the only
   part that would otherwise keep growing well past a comfortable reading
   width - .app-body__main caps and centers its OWN content for that
   reason (see below), rather than capping this whole grid, which would
   pull the rails away from the edges along with it.
   grid-template-rows: 1fr (desktop only) makes that single row - and so
   every column in it, including the sidebar's background - stretch to
   fill the full height of .app-body, all the way down to .app-footer,
   even when the main content itself is short.
   ========================================================================== */

.app-body {
    flex: 1 1 auto;
    display: grid;
    grid-template-areas: "main";
    grid-template-columns: minmax(0, 1fr);
}

@media (min-width: 60em) {
    .app-body {
        grid-template-areas: "index main sidebar";
        grid-template-columns: minmax(13.75rem, 16.25rem) minmax(0, 1fr) minmax(
                17.5rem,
                21.25rem
            );
        grid-template-rows: 1fr;
    }
}

/* ---- Index column / mobile panel -----------------------------------
   On a single article this holds the table of contents, on a list page a
   category note instead - but the RESPONSIVE BEHAVIOR is identical in
   both cases, and identical to the nav popup right below: a full-screen
   panel below the sticky bar on mobile (opened by
   .app-header__toc-toggle, closed via the [hidden] attribute), replaced
   by a normal sticky grid column from 60em up. Keeping both panels on
   the same [hidden]-attribute mechanism (rather than e.g. a CSS class)
   means assets/js/app.js only needs one, tiny toggle helper for both. */
.app-body__index {
    grid-area: index;
    position: fixed;
    inset: var(--app-bar-height) 0 0 0;
    z-index: 15;
    padding: 1rem;
    overflow-y: auto;
    background-color: var(--md-sys-color-surface-container);
}

.app-body__index[hidden] {
    display: none;
}

@media (min-width: 60em) {
    .app-body__index {
        /* Overrides every mobile-panel property above back to a normal,
           always-visible grid column - including display, so it stays
           visible regardless of the [hidden] attribute's state. */
        display: block;
        position: sticky;
        inset: auto;
        top: calc(var(--app-bar-height) + 1.5rem);
        align-self: start;
        z-index: auto;
        overflow-y: visible;
        background-color: transparent;
        /* Left gutter only, no right gutter of its own - keeps it close
           to the viewport's left edge instead of centered with a
           matching gutter on both sides. */
        padding: 2rem 1rem 2rem 1.5rem;
    }
}

.app-body__main {
    grid-area: main;
    min-width: 0;
    /* Tighter side gutters on mobile than the desktop 1.5rem - at narrow
       widths the full 1.5rem reads as an oversized margin eating into
       the already-limited reading width. */
    padding: 1.5rem var(--page-gutter) 3rem;
}

@media (min-width: 37.5em) {
    .app-body__main {
        padding-left: 1.5rem;
        padding-right: 1.5rem;
    }
}

/* Caps and centers the actual reading content (the post list, an
   article, the pager) within the main column - not the column itself,
   which stays exactly as wide as the space between the index/sidebar
   rails. Without this, the flexible middle column on a very large/
   ultra-wide monitor would stretch post cards and article text well past
   a comfortable reading width. */
.app-body__main > * {
    max-width: 60rem;
    margin-inline: auto;
}

/* Mobile-only home-page copy of the profile block (see the comment in
   _layouts/article.html) - a plain block in normal document flow, so its
   height is automatic (as tall as its own content needs), unlike the
   desktop .app-body__sidebar rail below, which is stretched to the grid
   row's full height as a matter of course. Same background treatment as
   that rail (see the shared selector in theme.css) so it still reads as
   the same colored panel, just relocated. */
.app-body__mobile-profile {
    display: block;
}

@media (min-width: 60em) {
    .app-body__mobile-profile {
        /* The real, sticky sidebar column takes over from here - see
           .app-body__sidebar below. */
        display: none;
    }
}

.app-body__sidebar {
    grid-area: sidebar;
    min-width: 0;
    /* No padding here: the background (theme.css) must reach every edge
       of this column. The profile panel's own content gets its padding
       from .profile-panel instead, see below. Hidden entirely on mobile
       - unlike the index panel above, there's no toggle for it. */
    display: none;
}

@media (min-width: 60em) {
    .app-body__sidebar {
        /* A flex column, top-aligned (flex's own default) - the profile
           panel starts near the top of this rail, exactly like the index
           column's table of contents opposite it, rather than floating
           in the middle of a tall, short-content page. */
        display: flex;
        flex-direction: column;
    }
}

/* Nav popup (hamburger menu) and search popup: small menus anchored
   under the bar, top-right - NOT full-screen panels, unlike the index
   panel above. There's no room for the inline nav links/search bar below
   52.5em, so each moves into its own popup instead (see index.html). A
   fixed, comfortable width (rather than shrinking to content) with real
   padding around it, so it reads as a proper menu instead of a cramped
   sliver next to its longest label. */
.nav-popup {
    position: fixed;
    top: calc(var(--app-bar-height) + 0.5rem);
    right: 1rem;
    left: auto;
    width: min(12rem, calc(100vw - 2rem));
    max-height: calc(100vh - var(--app-bar-height) - 1.5rem);
    z-index: 15;
    padding: 0.5rem;
    overflow-y: auto;
    border-radius: var(--md-sys-shape-corner-large, 1rem);
    background-color: var(--md-sys-color-surface-container);
    box-shadow: 0 0.25rem 1rem
        color-mix(in srgb, var(--color-black) 32%, transparent);
}

/* The search popup specifically needs more room than the nav menu one -
   it holds a real text input (which needs enough width to actually read
   back what's typed into it) and, once there's a query, a list of result
   rows with a title and a date on each - both would wrap awkwardly at
   the nav popup's own, deliberately narrow width. */
#search-popup {
    width: min(20rem, calc(100vw - 2rem));
}

.nav-popup[hidden] {
    display: none;
}

@media (min-width: 52.5em) {
    .nav-popup {
        display: none;
    }
}

/* ---- Search box --------------------------------------------------------
   One instance in the desktop nav bar (.app-header__bar-search), one in
   the mobile nav popup.
   ------------------------------------------------------------------- */

.search-box {
    position: relative;
    width: 100%;
}

.search-box__results {
    position: absolute;
    inset-inline: 0;
    top: calc(100% + 0.25rem);
    z-index: 16;
    max-height: 60vh;
    overflow-y: auto;
    border-radius: var(--md-sys-shape-corner-medium, 0.75rem);
    background-color: var(--md-sys-color-surface-container);
    box-shadow: 0 0.125rem 0.5rem
        color-mix(in srgb, var(--color-black) 24%, transparent);
}

.search-box__results[hidden] {
    display: none;
}

.search-box__no-results {
    padding: 0.75rem 1rem;
    color: var(--md-sys-color-on-surface-variant);
}

/* The date on each result (the supporting-text slot, see renderSearchResults
   in assets/js/app.js) matches .post-card__meta exactly - same tier, same
   typeface - rather than the list item's own defaults, since it's the
   same kind of text (a post's date) shown in the same kind of list row
   as a post-list card, just in a different list. The font-size has to go
   through the component's own token (m3e-list-action sets an explicit
   font-size on this slot internally, which a plain inherited font-size
   can't override - same reasoning as every other m3e-* typescale
   override in this file); font-family isn't one of its tokens at all, so
   that one just targets the actual slotted element directly. */
.search-box__results {
    --m3e-list-item-supporting-text-font-size: var(--tertiary-font-size);
}

.search-box__results [slot="supporting-text"] {
    font-family: var(--font-body);
}

.nav-popup .search-box__results {
    position: static;
    margin-top: 0.5rem;
    box-shadow: none;
}

/* m3e-app-bar wraps its title slot (where .app-header__bar-search lives,
   see navbar.html) in an internal m3e-text-overflow element whose own
   shadow CSS sets `overflow: hidden` on it (confirmed in its source) -
   there to truncate a long plain-text title with an ellipsis instead of
   overflowing the bar, but it clips ANYTHING slotted there that extends
   past the bar's own compact height, including this dropdown - entirely
   invisibly, which is why typing into the desktop search field never
   visibly showed a single result even though the underlying fetch/filter
   logic (assets/js/app.js) ran correctly the whole time. The mobile popup's own
   copy (.nav-popup .search-box__results, above) never hit this: it lives
   in the nav popup's own markup, not inside the app bar at all.
   `position: fixed` escapes that clipping ancestor - a fixed-position
   element's containing block is the viewport itself, not any ancestor's
   overflow box, REGARDLESS of how deep the ancestor chain is - as long
   as nothing in between sets a transform/filter/will-change/perspective
   (none of the app bar's own internals do). Its on-screen position is
   then computed in JS (see positionFixedResultsList in assets/js/app.js) rather
   than derived from being absolutely positioned under a relative
   ancestor, since escaping to `fixed` also means giving up that
   automatic alignment. */
.app-header__bar-search .search-box__results {
    position: fixed;
}

/* ---- Index column content ---------------------------------------------
   On a single article, this holds the article's table of contents; on a
   list page (home/category), a short category-specific note instead.
   ------------------------------------------------------------------- */

.article-toc {
    display: block;
    width: 100%;
    max-width: 100%;
    /* Secondary tier (see the comment in :root) - the toc's own default
       (confirmed in its source) reads noticeably smaller than everything
       else in the index column next to it. Both the resting and the
       active/selected entry are pinned to the same size, so scrolling
       past a heading doesn't visibly grow or shrink its own toc entry as
       it becomes the active one. */
    --m3e-toc-item-font-size: var(--secondary-font-size);
    --m3e-toc-item-selected-font-size: var(--secondary-font-size);
}

/* One or more info cards, stacked with a consistent gap between them -
   see _includes/categories-info/info-*.html, each of which is a single
   include file holding as many (or as few) ".info-card" blocks as that
   page needs. Adding or removing a card is just adding or removing one
   such block from that page's include file - nothing else on the page
   needs to change. */
.info-cards {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

/* Plain "filled" m3e-card, no re-skinning at all - every background
   experiment tried on this card (corner patches, rails, border-image,
   full-bleed photo + scrim - see git history) has been removed, so it's
   back to the library's own stock container color (surfaceContainer
   Highest, confirmed in m3e-web's own source) with nothing overridden. */
.info-card {
    display: block;
    width: 100%;
    transition: filter var(--md-sys-motion-duration-short-4, 200ms)
        var(--md-sys-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1));
}

/* A hover tint, to match the feedback every other card on the site gives
   (.post-card is `actionable`, which draws its own state-layer/ripple
   internally - see its source). These cards aren't links, so making them
   `actionable` too would add a misleading cursor/focus-ring/ripple for a
   click that does nothing. */
.info-card:hover {
    filter: brightness(1.06);
}

/* The actual layout: mascot above, text below - both left-aligned
   (align-items: flex-start keeps the fixed-size mascot pinned to the
   start edge instead of centered or stretched), with the text given its
   own explicit full width below (.info-card__text) rather than being
   squeezed into whatever space is left next to a fixed-width image in a
   row. Lives on its own element (slotted via slot="content" in the
   markup) rather than on .info-card itself - see the HTML comment for
   why that's required for the flex layout to actually apply.
   No padding of its own: m3e-card already reserves --m3e-card-padding
   (1rem by default, confirmed in its source) around whatever it slots
   in, the exact same inset .post-card's own content relies on without
   adding any padding of its own either - adding a second, independent
   1rem here on top of that would have doubled the gap around the card's
   edges to 2rem, visibly more generous than every other card on the
   site. */
.info-card__content {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.75rem;
    text-align: left;
}

/* A plain circle, not the organic shape used before (m3e-shape, name=
   "9-sided-cookie") - a faint, barely-there disc (black mixed only 10%
   into transparent, so it reads as "slightly darker than nothing" rather
   than a bold, fully-opaque tone) with the photo itself sized a little
   smaller than the disc (calc(100% - 0.5rem)) and centered on it, so a
   thin ring of that same soft shade shows all the way around the image
   rather than the photo filling the disc edge-to-edge. Simple enough to
   need no frame/photo stacking trick: unlike an arbitrary M3 shape, a
   circle can take a real border, but a same-color ring built from
   background + a slightly smaller centered image reads identically
   without needing a border property at all.
   display: inline-flex, not flex - as a flex item of .info-card__content
   (below) a flex item's own display is always "blockified" back to a
   block-level flex box regardless of which one is specified here, so
   nothing changes for this, its original context. Used bare (not as a
   flex item) - e.g. reused as-is by .app-footer's own back-to-top link,
   footer.html - inline-flex is what lets it sit inline with surrounding
   text instead of dropping to its own line the way plain flex would. */
.info-card__mascot {
    flex: none;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: 50%;
    background-color: color-mix(in srgb, var(--color-black) 10%, transparent);
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

.info-card__mascot img {
    display: block;
    width: calc(100% - 0.5rem);
    height: calc(100% - 0.5rem);
    border-radius: 50%;
    object-fit: cover;
}

/* Full width of the flex column (net of .info-card__content's own
   padding) rather than shrink-to-fit - `align-items: flex-start` above
   sizes flex children to their content by default, which for a <p> would
   otherwise mean wrapping only as late as its longest word forces it to,
   not using all the space actually available next to the card's edges. */
.info-card__text {
    width: 100%;
    margin: 0;
    /* on-surface-variant, paired with .info-card's own background
       (surface-container-highest, see above) - both roles come from
       the same neutral surface family, not a colored container. */
    color: var(--md-sys-color-on-surface-variant);
    font-family: var(--font-body);
    font-size: var(--tertiary-font-size);
}

/* ---- Profile panel (sidebar content) ------------------------------- */

.profile-panel {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: 0.5rem;
    padding: 2rem 1.5rem;
    /* Lifts this panel's own content above the decorative shape pattern
       painted on its parent's ::after (see .app-body__sidebar::after,
       theme.css), which would otherwise sit on top of it wherever the
       two overlap. Set here, on the base (mobile) rule, rather than as
       its own separate, unconditional rule in theme.css: an unconditional
       `.profile-panel { position: relative }` there, loaded AFTER this
       file, would have equal specificity to (and so silently override)
       the @media (min-width: 60em) `position: sticky` immediately below
       - which is exactly the bug that broke the sticky sidebar earlier.
       Declaring it only here means the desktop override below still only
       ever has to change `position` itself; z-index simply carries
       through unchanged at every width. */
    position: relative;
    z-index: 1;
}

@media (min-width: 60em) {
    .profile-panel {
        /* Starts at the top of .app-body__sidebar (its flex parent's
           default top alignment, above), but settles lower once
           scrolling makes it stick - roughly a third of the way down the
           viewport, closer to visually centered than pinned right under
           the bar. A plain `top` offset like this (no accompanying
           negative transform) is what keeps this safe: `position: sticky`
           can only ever push an element DOWN from its normal flow
           position to reach that offset, never past where it already
           was, so it can't escape .app-body__sidebar's own box above -
           unlike a `top: 50%` + `translateY(-50%)` trick (tried
           previously), which has no awareness of the column's own height
           and could push the panel above a short column's top edge. */
        position: sticky;
        top: max(calc(var(--app-bar-height) + 1.5rem), 30vh);
    }
}

/* The outer box both shape layers are absolutely positioned within -
   rotating IT on hover (rather than either shape individually) turns the
   frame and photo together as one rigid unit, so they always stay
   perfectly aligned with each other mid-animation.
   No `prefers-reduced-motion` override here on purpose: a contained,
   12deg rotation on a small element isn't the kind of large-scale or
   parallax-style motion that guideline is meant to suppress, and a
   reduced-motion visitor with the OS-level "show animations" preference
   off is exactly what turned this transition off entirely in testing -
   the animation this rule defines never ran, which is why it looked like
   an instant, "drastic" jump instead of the intended smooth turn. */
.profile-panel__avatar {
    position: relative;
    width: 7.5rem;
    height: 7.5rem;
    margin-bottom: 0.5rem;
    /* Promotes this element - AND the two shape layers inside it, same
       property, see below - to their own compositor layers ahead of
       time, so the rotation is a pure, GPU-composited transform instead
       of one that also has to repaint the clip-path shapes on every
       frame. Applying `will-change` to all three (not just this
       outermost one) matters here specifically because each shape layer
       is a custom element with its own shadow root and its own internal
       `will-change: clip-path` - without also promoting them directly,
       they don't reliably inherit the parent's compositing the same way
       a plain <div> would. */
    will-change: transform;
    transition: transform var(--md-sys-motion-duration-medium-2, 300ms)
        var(--md-sys-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1));
}

.profile-panel__avatar:hover {
    transform: rotate(12deg);
}

/* The "border": the full-size copy of the shape, filled solid. */
.profile-panel__avatar-frame {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    will-change: transform;
    /* A literal white, not a role token (background and secondary-
       container were both tried first) - the frame is meant to stay
       white regardless of scheme, not track whatever a role resolves
       to in light vs dark. */
    --m3e-shape-container-color: var(--color-white);
}

/* The photo: the same shape again, inset by the border's thickness so a
   ring of .profile-panel__avatar-frame shows all the way around it. */
.profile-panel__avatar-photo {
    position: absolute;
    inset: 0.5rem;
    width: calc(100% - 1rem);
    height: calc(100% - 1rem);
    will-change: transform;
}

.profile-panel__avatar-photo img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* A small "scroll for more" nudge, the last thing in the panel (after
   the social links row) - mobile only. The desktop sidebar is a normal,
   fully-visible rail with nothing further to reveal by scrolling, but
   the mobile home copy of this same block (.app-body__mobile-profile,
   see the comment in _layouts/article.html) sits right above the actual
   page content, where a first-time visitor could plausibly not realize
   there's more below. Just a plain child of .profile-panel's own flex
   column here - centered by that column's own align-items: center, same
   as every other child in it, with no positioning of its own to speak
   of. */
.profile-panel__scroll-hint {
    --m3e-icon-size: 1.75rem;
    color: var(--md-sys-color-on-tertiary-container);
    animation: profile-panel-scroll-hint 1.6s
        var(--md-sys-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1))
        infinite;
}

@media (min-width: 60em) {
    .profile-panel__scroll-hint {
        display: none;
    }
}

/* Drifts down a little from its own resting spot and back, rather than a
   full in/out fade or a larger bounce - a subtle, continuous cue to
   scroll down without becoming a distraction at the bottom of the panel.
   Purely translateY(0) to a positive offset (down) - this icon is a
   plain flex child of .profile-panel's own column, not absolutely
   positioned, so its resting transform is just "none" rather than an
   offset needed to center it some other way. */
@keyframes profile-panel-scroll-hint {
    0%,
    100% {
        transform: translateY(0);
    }

    50% {
        transform: translateY(25%);
    }
}

/* An infinite, ambient animation (unlike the avatar's own hover
   rotation, a short one-shot transition) is exactly the kind of motion
   prefers-reduced-motion: reduce is meant to suppress - a continuous
   bobbing icon is a much closer match to what that preference is
   actually about than a brief, user-triggered turn. */
@media (prefers-reduced-motion: reduce) {
    .profile-panel__scroll-hint {
        animation: none;
    }
}

/* Narrower than the full sidebar width the icon-button row below is
   free to use - a slightly shorter measure for the name/description/
   divider reads less like a stretched-out label and more like a
   deliberately composed little card. */
.profile-panel__name {
    max-width: 85%;
    /* m3e-heading reads font-size straight from the shared
       --md-sys-typescale-title-large-font-size token (confirmed in its
       source - it has no component-scoped size token of its own),
       overridden here, locally, so this one heading matches the
       titlebar/menu items (--emphasis-font-size) without changing every
       other title-large heading on the site. */
    --md-sys-typescale-title-large-font-size: var(--emphasis-font-size);
    /* m3e-heading otherwise reads its own text color from on-surface/on-
       background (confirmed in its source, same reasoning as
       .app-header__title above) - wrong now that its actual background
       is the sidebar's tertiary-container (theme.css), not the page's
       plain surface. `inherit` picks up .app-body__sidebar's own `color`
       instead, which IS already the correct on-tertiary-container. */
    color: inherit;
    text-transform: uppercase;
}

.profile-panel__description {
    max-width: 85%;
    margin: 0;
    /* Same on-tertiary-container as the rest of this panel (see
       .app-body__sidebar, theme.css) rather than on-surface-variant -
       right role, now that the panel's actual background is a colored
       container instead of a plain surface. */
    color: var(--md-sys-color-on-tertiary-container);
    font-family: var(--font-body);
    font-size: var(--tertiary-font-size);
}

.profile-panel__links {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 0.5rem;
    /* .profile-panel's own gap (0.5rem) already puts this same distance
       both above this row (from the description) and below it (to
       .profile-panel__scroll-hint) - equal on paper, but these tonal
       icon-buttons are a visually heavier, larger shape than that small
       plain icon, so the identical gap read as noticeably tighter above
       than below. This margin adds on top of that shared gap, on this
       side only, to compensate - .profile-panel__scroll-hint keeps the
       plain 0.5rem below the row untouched. */
    margin-top: 0.5rem;
}

.brand-icon {
    width: 1.25rem;
    height: 1.25rem;
}

/* ---- Post list (.app-body__main, on a list/category page) ---------- */

/* A side inset on top of .app-body__main's own gutter, matching
   .markdown's (markdown.css) exact values at the exact same breakpoint -
   an article's prose and this list of post-cards are the two other
   things that can occupy .app-body__main, and without this, a card's
   own edge landed flush against the column's edge while the article's
   text sat noticeably further in from it, the same column reading with
   two different insets depending on which page you were on. Horizontal
   only (no vertical padding added) - .app-body__main's own top/bottom
   padding is already page-appropriate for a list, unlike .markdown's,
   which is tuned specifically for where prose starts under a titlebar. */
.post-list {
    padding: 0 0.5rem;
}

@media (min-width: 50em) {
    .post-list {
        padding: 0 2rem;
    }
}

.post-list__month {
    display: block;
    margin: 1.5rem 0 0.5rem;
    /* m3e-heading (variant="title" size="small" - see _layouts/post-
       list.html) reads its font-size from the shared --md-sys-typescale-
       title-small-font-size token; overridden here, scoped to just this
       host, so this one heading follows --secondary-font-size instead of
       every other title-small heading on the site (see the tier comment
       in :root above). */
    --md-sys-typescale-title-small-font-size: var(--secondary-font-size);
    color: var(--md-sys-color-on-surface-variant);
    text-transform: uppercase;
}

.post-list__month:first-child {
    margin-top: 0;
}

.post-card {
    display: block;
    width: 100%;
    margin-bottom: 0.5rem;
    text-align: left;
}

.post-card__thumb {
    width: 100%;
    max-width: 5.75rem;
    aspect-ratio: 16 / 9;
    object-fit: cover;
    /* Keeps the photo from touching the title/subtitle text next to it -
       m3e-card lays the horizontal orientation's header and content
       slots edge to edge, with no gap of its own to rely on. */
    margin-right: 0.75rem;
}

.post-card__body {
    display: flex;
    flex-direction: column;
    gap: 0.125rem;
}

/* Emphasis tier (see the comment in :root) - the same size as the nav
   menu items, the sidebar name and the summary/toc entries, now that
   this and the titlebar's own size (--titlebar-font-size) have traded
   places at explicit request. Uppercase with no extra tracking (0,
   overriding title-large's own default) - flush, blocky caps read
   better tight than spaced out at a condensed display face like League
   Gothic. */
.post-card__title {
    --md-sys-typescale-title-large-font-size: var(--emphasis-font-size);
    --md-sys-typescale-title-large-tracking: 0;
    text-transform: uppercase;
}

.post-card__subtitle {
    margin: 0;
    color: var(--md-sys-color-on-surface-variant);
    font-family: var(--font-body);
    font-size: var(--tertiary-font-size);
}

.post-card__meta {
    margin: 0;
    font-family: var(--font-body);
    font-size: var(--tertiary-font-size);
    color: var(--md-sys-color-on-surface-variant);
}

/* ---- Pager (bottom of .app-body__main) ------------------------------ */

.pager {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
    /* The two buttons must always stay side by side, never wrap onto
       separate lines. */
    flex-wrap: nowrap;
}

.pager m3e-button {
    /* Capped in `ch` - a button's label is text, so its maximum size
       should be expressed as "about this many characters", not a fixed
       length - generous enough that neither label ("Previous page"/
       "Next page", the longest used) ever gets clipped. Raised from
       20ch to fit that same label comfortably at the bigger secondary
       tier font-size below (1.125rem, up from the button's own
       label-large default of 0.875rem) - the same character count needs
       more physical width once each character itself is bigger. */
    max-width: 28ch;
    /* Without this, `.pager`'s `flex-wrap: nowrap` was free to shrink
       these buttons narrower than their own label actually needs -
       flex items default to a min-width equal to their content's
       min-content size only while `overflow: visible`; once the
       button's internal label clips overflow (to support its own
       ellipsis-on-truncation behavior), that automatic minimum drops to
       0, and the flex algorithm happily compressed the label below its
       full text width to keep both buttons on one line, truncating it.
       flex-shrink: 0 removes these buttons from that shrinking
       distribution entirely, so a button is always exactly as wide as
       its label needs and never clips it. */
    flex-shrink: 0;
    /* A touch more compact than the default density, so both buttons
       comfortably fit side by side without wrapping even on narrow
       phones. */
    --md-sys-density-scale: -1;
    /* Middle tier, same as a post's title and a month heading in the
       list right above this pager (see the tier comment in :root) -
       bigger than the button's own label-large default (0.875rem).
       Size-agnostic token (not --m3e-button-medium-*): this button never
       sets a `size` attribute, so it defaults to "small" - see the
       comment on .app-header__nav-links m3e-button above. */
    --m3e-button-label-text-font-size: var(--secondary-font-size);
}

/* ==========================================================================
   4. Footer
   ========================================================================== */

/* White regardless of theme, not a role token - the footer's actual
   background is --md-sys-color-tertiary-container (theme.css), which
   reads as the same fixed --color-amethyst in both schemes (the custom
   color anchors there set it to that literal color either way), not a
   role token that would get lighter or darker switching between light/
   dark the way --md-sys-color-on-surface-variant would expect to
   contrast against. Hardcoded to match the same "always white,
   deliberately not theme-reactive" treatment already used for
   .app-header above, over its own banner photo for the exact same
   reason. Background color itself lives in theme.css (this file is
   position/spacing only, per the top of this file) - not set here. */
.app-footer {
    flex: none;
    padding: 0.25rem var(--page-gutter);
    text-align: center;
    color: var(--color-white);
    font-family: var(--font-body);
    font-size: var(--tertiary-font-size);
}

.app-footer a {
    color: inherit;
}

/* Sits inline with the copyright text above it (footer.html) via
   .info-card__mascot's own inline-flex (site.css) - vertical-align is
   what actually lines it up against that surrounding text baseline;
   inline-flex alone only controls that it FLOWS inline, not where it
   sits within the line. */
.app-footer .info-card__mascot {
    vertical-align: middle;
}

/* Below 60em the mascot drops to its own line via a plain <br>
   (footer.html) - no separator dot needed since there's nothing after it
   on that line. From 60em up the opposite: the <br> is hidden and the
   separator appears instead, so the mascot reads as one more
   &middot;-separated item inline with everything before it, exactly like
   "Jekyll"/"CloudFlare Pages"/"M3E" already do. */
.app-footer__backtotop-separator {
    display: none;
}

@media (min-width: 60em) {
    .app-footer__backtotop-separator {
        display: inline;
    }

    .app-footer__backtotop-break {
        display: none;
    }
}
