/* ───────────────────────────────────────────────────────────────────────
   about-bento.css — the About section, built as spaced modules.

   Markup is component/about-bento.cpt.php, behaviour script/about-bento.js.
   It was the whole of /about until that page was retired; it is now one
   section of the homepage, under the photo spread.

   Why a module grid and not a scroll: the page it replaced was 2 500px of
   scroll to say "developer in Andorra who also photographs". The length was
   what read as pretentious, not the words. A module grid makes filler
   physically impossible — there is nowhere to put a paragraph nobody asked
   for. That argument only got stronger when it became a section under
   something else.

   IT NO LONGER OWNS A PAGE. Nothing here may style `body`, the header or the
   footer — it is one section on a page that has its own, and the rules that
   used to repaint the nav ink-on-paper went with /about. The palette is
   declared on `.ab` itself for the same reason. That is also what lets it
   sit inside the homepage without a seam: every --ed-* and --border-* token
   it reads is already declared there, in both themes, at the values this
   file used to declare for itself.

   THE ACCENTS ARE MEASURED, NOT CHOSEN. Sampling the saturated pixels of 45
   published photographs gives two registers and only two: sky blue (26 416
   px, mean #2F72AE) and warm stone (9 455 px, mean #7A572F). Both accents
   below are those means, lightened to clear 4.5:1 on paper. So the section is
   coloured by the gallery it links to, and picking a "nicer" hue by hand
   would be the one change that unties it from the photographs.

   COLOUR CARRIES A ROLE, which is the part to preserve when editing:
     terracotta = editorial, to be read   (eyebrows, figures, step numbers)
     sky blue   = interactive, to be used (links, hover, the Contact card)
   Tint something blue that cannot be clicked and the section starts lying.
   ─────────────────────────────────────────────────────────────────────── */

/* ───── Section frame + palette ────────────────────────────────────── */
/* Flush with the spread above it: the homepage's <main> already carries the
   page gutters, so the section takes none of its own and its cards line up
   with "Nino" on the left and the photo grid's right edge on the right. */
.ab {
    /* Light half; the dark half is the block below. Both are --ab-* only —
       the editorial tokens come from the page this sits on. */
    --ab-card: #faf7f1;
    /* The surface *behind* a card, which is still needed even though this no
       longer paints the page: the tooltip's photograph is backed with it, so
       a portrait in a landscape frame letterboxes against something deeper
       than the panel it opens over. */
    --ab-page: #ece5da;

    /* Accents. The -text variants are darkened until small mono type clears
       4.5:1 on --ab-card; the -fill variants are darkened until cream type
       clears it the other way round. One hue, two jobs, two values. */
    --ab-terra-text: #8e4e2b;
    --ab-terra-fill: #9a5732;
    --ab-sky-text: #2f72ae;
    --ab-sky-fill: #37688f;

    --ab-rule: rgba(22, 20, 19, 0.13);
    --ab-edge: rgba(22, 20, 19, 0.10);
    --ab-gap: clamp(0.55rem, 1vw, 0.875rem);
    --ab-radius: 12px;
    --ab-pad: clamp(1rem, 1.6vw, 1.6rem);

    position: relative;
    z-index: 1;
    /* 1 280px is the measure this grid was drawn against and it does not
       follow the page: the homepage's <main> runs to 2 400px on a wide
       desktop, and a four-column bento stretched that far is nine cards of
       560px reading as furniture in an empty room. It stays centred inside
       whatever width it is given, and takes no horizontal padding of its own
       because <main> already carries the page gutters — which is what keeps
       it flush with the spread above on every window narrower than the cap.

       All three of width / max-width / margin are needed, not two: `body` is
       display:flex site-wide and <main> is a column flex container, so the
       auto cross-axis margin cancels the default stretch and `width: 100%` is
       what the max-width then caps (the /portfolio lesson). */
    width: 100%;
    max-width: 1280px;
    margin-inline: auto;
    padding: clamp(1rem, 3vh, 3rem) 0 clamp(1.25rem, 2.5vw, 2.5rem);
    font-family: var(--ed-sans);
    /* Nothing shrinks it either: this section is as tall as it is. */
    flex: 0 0 auto;
    /* The cue above scrolls here; the top padding is what stops the first row
       of cards from arriving flush against the viewport's edge. */
    scroll-margin-top: 0;
}

[data-theme="dark"] .ab {
    --ab-page: #1a1917;
    --ab-card: #272623;
    /* The two filled cards have to weigh the same. On the dark page the blue
       plate was the brighter of the two by a wide margin, which turned it into
       the first thing read on the page instead of the last. */
    --ab-terra-fill: #a25c33;
    /* Lightened for the dark card, same hues: contrast has to be re-earned
       on every background, it is not a property of the colour. */
    --ab-terra-text: #d28a5e;
    --ab-sky-text: #6fa8de;
    --ab-rule: rgba(245, 241, 234, 0.14);
    --ab-edge: rgba(245, 241, 234, 0.09);
}


/* ───── The grid ───────────────────────────────────────────────────── */
.ab__grid {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    grid-template-areas:
        "lede  lede    portrait where"
        "lede  lede    portrait langs"
        "used  used    used     used"
        "photo photo   beyond   links"
        "travels travels resto  resto";
    gap: var(--ab-gap);
}

/* Row floors rather than fractions of the viewport: the page is allowed to
   run a little past the fold (that was the call), and a fraction-sized row
   with a zero minimum is what clipped the bottom band before. */
@media (min-width: 1100px) {
    .ab__grid {
        grid-template-rows:
            minmax(300px, auto)
            minmax(165px, auto)
            minmax(118px, auto)
            minmax(262px, auto)
            /* The globe's row sizes from the card, which is square (see
               .ab__cell--travels): fixing a height here and asking for a square
               there is two rules arguing about the same number. */
            auto;
    }
}

/* ───── Entrance ──────────────────────────────────────────────────────
   A diagonal wave: each cell's delay comes from its own position in the grid,
   set inline by the script, so the sweep follows the reading direction instead
   of DOM order. Total spread is under 300ms — long enough to read as a wave,
   short enough that nobody waits for the page.

   Gated on `.has-js`, which is the site's rule: a reader without JavaScript
   must never be served content at opacity 0. The script also force-settles the
   cells after a timeout, so a frame that never arrives cannot leave the page
   blank. */
.has-js .ab__grid > .ab__cell {
    opacity: 0;
    transform: translateY(14px) scale(0.992);
}
.ab__cell {
    transition: opacity 0.62s cubic-bezier(0.16, 0.84, 0.24, 1),
                transform 0.62s cubic-bezier(0.16, 0.84, 0.24, 1);
}
.has-js .ab__grid > .ab__cell.is-in { opacity: 1; transform: none; }

.ab__cell {
    grid-area: var(--area);
    background: var(--ab-card);
    border: 1px solid var(--ab-edge);
    border-radius: var(--ab-radius);
    padding: var(--ab-pad);
    min-width: 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
}

/* ───── Shared type ───────────────────────────────────────────────── */
.ab__eyebrow {
    margin: 0;
    font-family: var(--ed-mono);
    font-size: 0.62rem;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.16em;
    color: var(--ab-terra-text);
}

.ab__body {
    margin: 0;
    font-size: clamp(0.82rem, 0.95vw, 0.94rem);
    line-height: 1.55;
    color: var(--ed-ink-soft);
    text-wrap: pretty;
}

/* Anything still waiting on Nino's words. Deliberately loud: a placeholder
   that looks like finished copy is how the v2 text got shipped. */
.ab__cell [data-draft] {
    color: var(--ed-ink-mute);
    font-style: italic;
    border-left: 2px dashed var(--ab-rule);
    padding-left: 0.6rem;
}

/* ───── Filled cards ──────────────────────────────────────────────────
   Two of the nine carry a flat colour, to break the grid into something
   with a foreground. They work by *redefining the ink tokens locally*, so
   every child keeps using --ed-ink / --ed-ink-soft and needs no override of
   its own — add a module to a filled card and it inverts for free. */
.ab__cell--fill {
    border-color: transparent;
    --ed-ink: #fbf8f2;
    --ed-ink-soft: rgba(251, 248, 242, 0.82);
    --ed-ink-mute: rgba(251, 248, 242, 0.66);
    --ab-terra-text: rgba(251, 248, 242, 0.72);
    --ab-sky-text: #fbf8f2;
    --ab-rule: rgba(251, 248, 242, 0.28);
    color: var(--ed-ink);
}
.ab__cell--fill-terra { background: var(--ab-terra-fill); }
.ab__cell--fill-sky { background: var(--ab-sky-fill); }

/* ───── Lede ──────────────────────────────────────────────────────── */
.ab__cell--lede { justify-content: space-between; gap: 1.1rem; }

.ab__name {
    margin: 0;
    font-family: var(--ed-serif);
    font-weight: 500;
    font-size: clamp(2.6rem, 5.2vw, 4.5rem);
    line-height: 0.9;
    letter-spacing: -0.028em;
    color: var(--ed-ink);
}

.ab__lede {
    margin: 0;
    max-width: 34ch;
    font-family: var(--ed-serif);
    font-size: clamp(1.05rem, 1.5vw, 1.4rem);
    line-height: 1.4;
    color: var(--ed-ink);
    text-wrap: pretty;
}

.ab__lede-sub {
    margin: 0;
    max-width: 42ch;
    font-size: 0.88rem;
    line-height: 1.55;
    color: var(--ed-ink-soft);
}

/* ───── Portrait ──────────────────────────────────────────────────── */
.ab__cell--portrait {
    position: relative;
    padding: 0;
    gap: 0;
    overflow: hidden;
}
.ab__portrait-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center 28%;
    display: block;
    /* No entrance animation and nothing on a timer: the only motion on this
       page answers a pointer. */
    filter: grayscale(0.2);
    transition: filter 0.5s ease;
}
.ab__cell--portrait:hover .ab__portrait-img { filter: grayscale(0); }

/* A solid plate, not a translucent overlay: at 45% black the caption
   vanished into Florence's rooftops. */
.ab__portrait-cap {
    position: absolute;
    left: 0.7rem;
    bottom: 0.7rem;
    padding: 0.35rem 0.6rem;
    border-radius: 6px;
    font-family: var(--ed-mono);
    font-size: 0.6rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--ed-ink);
    background: var(--ab-card);
}

/* ═══════════════════════════════════════════════════════════════════════
   Per-module treatments. Each cell gets its own way of setting text — a
   figure, a swap, a hanging note, a numbered step, an iconed row — so the
   page is read module by module rather than scanned as nine of the same
   card. They share only the accents, the eyebrow and the palette.
   ═══════════════════════════════════════════════════════════════════════ */

/* ───── Where: a fact, set as a figure, on the terracotta plate ────── */
.ab__cell--where { justify-content: center; }
.ab__figure {
    margin: 0;
    font-family: var(--ed-serif);
    font-weight: 500;
    font-size: clamp(2.6rem, 4.4vw, 3.7rem);
    line-height: 0.88;
    letter-spacing: -0.03em;
    color: var(--ed-ink);
    font-variant-numeric: lining-nums tabular-nums;
}
.ab__figure-unit {
    font-family: var(--ed-mono);
    font-size: 0.24em;
    letter-spacing: 0.08em;
    margin-left: 0.2em;
    vertical-align: 0.35em;
    color: var(--ed-ink-mute);
}
.ab__route {
    margin: 0;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-family: var(--ed-mono);
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.11em;
    color: var(--ed-ink);
}
.ab__route-from { color: var(--ed-ink-mute); }
.ab__route i { font-size: 0.85em; color: var(--ed-ink-mute); }
.ab__figure-sub {
    margin: 0;
    font-size: 0.76rem;
    line-height: 1.4;
    color: var(--ed-ink-mute);
    padding-bottom: 0.65rem;
    border-bottom: 1px solid var(--ab-rule);
}

/* ───── Languages: the level is always readable, the phrase is a reward ──
   Hover swaps the name for the same idea in that language. It is layered,
   not toggled by `display`, so the row's height never shifts under the
   pointer — and a touch device, which never hovers, still reads a complete
   row. */
.ab__langs {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-direction: column;
    justify-content: center;
    flex: 1 1 auto;
}
.ab__lang {
    position: relative;
    display: grid;
    grid-template-columns: 1.4rem 1fr auto;
    align-items: baseline;
    gap: 0.5rem;
    padding: 0.34rem 0;
    border-bottom: 1px solid var(--ab-rule);
    font-size: clamp(0.8rem, 0.95vw, 0.92rem);
    color: var(--ed-ink);
}
.ab__lang:last-child { border-bottom: 0; }
.ab__lang-name,
.ab__lang-say {
    grid-area: 1 / 2;
    min-width: 0;
    transition: opacity 0.28s ease, transform 0.28s ease;
}
.ab__lang-say {
    font-family: var(--ed-serif);
    font-style: italic;
    color: var(--ab-terra-text);
    opacity: 0;
    transform: translateY(0.2em);
    pointer-events: none;
}
.ab__lang:not(.ab__lang--nosay):hover .ab__lang-name { opacity: 0; transform: translateY(-0.2em); }
.ab__lang:not(.ab__lang--nosay):hover .ab__lang-say { opacity: 1; transform: none; }
.ab__flag {
    grid-area: 1 / 1;
    align-self: center;
    width: 1.05rem;
    height: auto;
    aspect-ratio: 3 / 2;
    border-radius: 2px;
    /* A hairline instead of a border: at this size a 1px border would eat a
       sixth of the flag's width. */
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.28);
    overflow: hidden;
}
.ab__lang-level {
    font-family: var(--ed-mono);
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--ed-ink-mute);
    white-space: nowrap;
}
.ab__hint {
    margin: 0;
    font-size: 0.7rem;
    line-height: 1.4;
}

/* ───── What I use: a spec strip, read across ─────────────────────────
   The only card that runs horizontally, which is the point: it is the one
   full-width band on the page and it breaks the rhythm of the columns.

   The glyphs are load-bearing, not decoration. Without them three short lines
   of small type across a wide card read as a footnote; the icon gives each
   entry a shape to land on and lets the row be scanned rather than read. */
.ab__cell--used { justify-content: center; }
.ab__used {
    margin: 0;
    padding: 0;
    list-style: none;
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0 clamp(0.75rem, 2.5vw, 2.25rem);
}
/* `start`, not `center`. Centring aligned each entry against its own height, so
   the one with a note pushed its label and value out of line with the other two
   and the row looked accidental. Anchoring to the top lines the labels up and
   the values up, and the note simply hangs below the entry that has one.
   The glyph spans the label and value rows only, never the note, so it sits at
   the same height in all three. */
.ab__used li {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas:
        "glyph label"
        "glyph value"
        ".     note";
    align-content: start;
    column-gap: 0.7rem;
    min-width: 0;
    padding-left: clamp(0.75rem, 2.5vw, 2.25rem);
    border-left: 1px solid var(--ab-rule);
}
.ab__used li:first-child { padding-left: 0; border-left: 0; }

.ab__used-glyph {
    grid-area: glyph;
    align-self: center;
    font-size: 1.5rem;
    line-height: 1;
    color: var(--ab-terra-text);
    transition: transform 0.3s cubic-bezier(0.16, 0.84, 0.24, 1);
}
.ab__used li:hover .ab__used-glyph { transform: translateY(-2px); }

.ab__used-label {
    grid-area: label;
    font-family: var(--ed-mono);
    font-size: 0.6rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--ed-ink-mute);
}
.ab__used-value {
    grid-area: value;
    font-family: var(--ed-serif);
    font-size: clamp(0.95rem, 1.3vw, 1.2rem);
    line-height: 1.15;
    color: var(--ed-ink);
}
.ab__used-note {
    grid-area: note;
    font-size: 0.7rem;
    line-height: 1.35;
    color: var(--ab-terra-text);
}

/* One per line below the three-column grid: at 390px, three entries across put
   "Galaxy S25 Ultra" on four lines. */
@media (max-width: 900px) {
    .ab__used {
        grid-template-columns: minmax(0, 1fr);
        gap: 0.85rem;
    }
    .ab__used li {
        padding-left: 0;
        border-left: 0;
        padding-top: 0.85rem;
        border-top: 1px solid var(--ab-rule);
    }
    .ab__used li:first-child { padding-top: 0; border-top: 0; }
}

/* ───── Latest photograph ─────────────────────────────────────────── */
.ab__cell--photo {
    position: relative;
    padding: 0;
    gap: 0;
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    justify-content: flex-end;
}
/* Ten stacked thumbnails, one visible. Cross-faded rather than swapped, so the
   change does not read as a jump cut, and only the visible one is composited. */
.ab__photo-img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    opacity: 0;
    transition: opacity 0.55s ease;
}
.ab__photo-img.is-on { opacity: 1; }

/* The bar sits on the card's own bottom edge, full width, inside the radius
   because the cell clips. transform-origin left with scaleX keeps it on the
   compositor: animating `width` would lay out the card sixty times a second. */
.ab__photo-bar {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 2;
    height: 3px;
    background: rgba(251, 248, 242, 0.22);
}
.ab__photo-bar-fill {
    display: block;
    height: 100%;
    background: #fbf8f2;
    transform: scaleX(0);
    transform-origin: left center;
}
.ab__cell--photo[data-paused] .ab__photo-bar { opacity: 0.45; }

.ab__photo-meta,
.ab__photo-cta {
    position: relative;
    z-index: 1;
    color: #fbf8f2;
}
.ab__photo-meta {
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    padding: 2.75rem var(--ab-pad) 0;
    background: linear-gradient(to top, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0.35) 55%, transparent);
}
.ab__photo-meta .ab__eyebrow { color: rgba(251, 248, 242, 0.72); }
.ab__photo-place {
    font-family: var(--ed-serif);
    font-size: clamp(1.05rem, 1.5vw, 1.35rem);
    line-height: 1.15;
    /* Two lines' worth, reserved. Titles vary in length and without this the
       caption block grew and shrank under the reader every five seconds. */
    min-height: 2.3em;
    display: flex;
    align-items: flex-end;
}
.ab__photo-cta {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.35rem var(--ab-pad) calc(var(--ab-pad) + 3px);
    background: rgba(0, 0, 0, 0.78);
    font-family: var(--ed-mono);
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: rgba(251, 248, 242, 0.85);
    transition: color 0.25s ease;
}
.ab__cell--photo:hover .ab__photo-cta { color: #fbf8f2; }
.ab__cell--photo:focus-visible { outline: 2px solid var(--ab-sky-text); outline-offset: 2px; }

.ab__cell--photo-empty { justify-content: center; background: var(--ed-paper-deep); }

/* ───── Off screen: iconed rows that call up their own photograph ─────
   Hovering a row raises that subject's picture behind the card and drops the
   other rows out of the way.

   LEGIBILITY IS STRUCTURAL, NOT TUNED. Two earlier attempts put a veil over
   the photograph (a flat scrim, then an eased gradient plus text shadows) to
   rescue text sitting on it. Both fought the point of showing the picture.
   The page already had the answer in the portrait's caption: a solid plate,
   ink on paper, no shadow anywhere. --ab-card and --ed-ink invert together
   between themes, so the plate needs no dark-mode rule of its own.

   The image sits inside its own row, so nothing here names a row index: add a
   sixth entry to $offscreen and it works with no change to this file. */
.ab__cell--beyond { position: relative; overflow: hidden; }

.ab__off-bg {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    z-index: 0;
    /* Load-bearing. The image is inside its <li> but positioned against the
       card, so it overflows its row and covers the whole cell. Left hit-testable,
       hovering any margin of the card lands on the last row's image, and that
       row's <li> matches :hover while its visible line does not, which showed
       the Travel photograph with no plate anywhere. */
    pointer-events: none;
    opacity: 0;
    transform: scale(1.09);
    transition: opacity 0.45s ease, transform 0.9s cubic-bezier(0.16, 0.84, 0.24, 1);
}
.ab__off li:hover .ab__off-bg { opacity: 1; transform: none; }

/* A move from one row to another is a move along the list, so the photographs
   travel with it: down the list and the incoming picture rises through the
   bottom edge while the outgoing one leaves through the top. Same distance,
   same timing, opposite edges — the two stay edge to edge for the whole travel,
   so they never overlap and never open a gap between them.

   The first reveal is not a move, and neither is leaving the list: with nothing
   to displace, both keep the fade above.

   Direction is the one thing the stylesheet cannot work out — :hover names the
   live row, never the row it took over from. The script writes it down as
   --off-y, the edge a given picture travels through, and the lengths and the
   timing stay here. The 0 default only keeps these rules valid on their own. */
.ab__off-bg { --off-y: 0px; }
.ab__off li:hover .ab__off-bg.is-slide {
    /* Opacity is settled at both ends of a slide, so only the travel is
       animated: a picture crossing the card must not dip through transparent. */
    opacity: 1;
    transition: transform 0.55s cubic-bezier(0.16, 0.84, 0.24, 1);
}
.ab__off li:hover .ab__off-bg.is-slide.is-from { transform: translateY(var(--off-y)); }
/* The row that has just lost the pointer. It no longer matches :hover, so this
   has to hold its own opacity up until the picture is off the card. */
.ab__off-bg.is-out {
    opacity: 1;
    transform: translateY(var(--off-y));
    transition: transform 0.55s cubic-bezier(0.16, 0.84, 0.24, 1);
}

.ab__off {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-direction: column;
    justify-content: center;
    flex: 1 1 auto;
}

/* The row carries the grid and the plate; the <li> is only a positioning
   parent for the photograph, which is why it has no padding of its own. */
.ab__off-row {
    position: relative;
    z-index: 1;
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: baseline;
    gap: 0.3rem;
    padding: 0.55rem 0;
    border-bottom: 1px solid var(--ab-rule);
    font-size: clamp(0.8rem, 0.95vw, 0.92rem);
    color: var(--ed-ink);
    transition: opacity 0.35s ease, color 0.3s ease, text-shadow 0.3s ease,
                border-color 0.3s ease;
}
.ab__off li:last-child .ab__off-row { border-bottom: 0; }
/* Fixed width so the names line up down the list whatever the glyph is. */
.ab__off-row i {
    width: 1.05rem;
    color: var(--ab-terra-text);
    font-size: 0.95em;
    transition: color 0.3s ease;
}

/* The background that appears on the live row wraps the glyph and the name and
   stops there. The horizontal padding is cancelled by an equal negative margin,
   so the words sit in exactly the same place whether the background is there or
   not: the rectangle appears, nothing moves. */
.ab__off-tag {
    display: flex;
    align-items: baseline;
    gap: 0.3rem;
    min-width: 0;
    padding: 0.16rem 0.5rem;
    margin-left: -0.5rem;
    border-radius: 7px;
    background: transparent;
    transition: background-color 0.3s ease;
}
.ab__off-row:hover .ab__off-tag { background: rgba(0, 0, 0, 0.44); }

/* The note is the default state and the hover takes it away: on the live row
   the photograph is the detail, so the words step aside and the name moves
   into the space they leave. */
.ab__off-row em {
    font-family: var(--ed-mono);
    font-style: normal;
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--ed-ink-mute);
    white-space: nowrap;
    justify-self: end;
    transition: opacity 0.3s ease;
}
.ab__off-row:hover em { opacity: 0; }

/* The live row has no plate: the name and its glyph sit straight on the
   photograph, held by a halo rather than a drop shadow. Three tight layers, the
   largest offset a single pixel, so it reads as air around the letters instead
   of an object behind them.

   White on both themes, and not a token: the ground here is a photograph, which
   can be a yellow wall or a night sky, so the type cannot follow the page. The
   glyph turns white with it, since terracotta at this size disappears into a
   warm picture. The separator goes too, or it would draw a hairline across the
   photograph. */
.ab__off-row:hover,
.ab__off-row:hover i {
    color: #fff;
    text-shadow:
        0 1px 1px rgba(0, 0, 0, 0.55),
        0 0 8px rgba(0, 0, 0, 0.5),
        0 0 22px rgba(0, 0, 0, 0.34);
}

/* The other rows stay on the picture instead of leaving it: they turn light too,
   held back to a little over half strength with a single shadow layer, which is
   enough to read them and not enough to compete. The notes all go, live row
   included, since mono capitals at 0.62rem over a photograph are noise. The
   separators go with them, or they would rule hairlines across the image. */
.ab__cell--beyond:has(.ab__off-row:hover) .ab__off-row:not(:hover),
.ab__cell--beyond:has(.ab__off-row:hover) .ab__off-row:not(:hover) i {
    color: rgba(255, 255, 255, 0.62);
    /* Two layers, because half-strength white over sunlit snow was the case
       that nearly did not read. */
    text-shadow:
        0 1px 2px rgba(0, 0, 0, 0.66),
        0 0 10px rgba(0, 0, 0, 0.5);
}
.ab__cell--beyond:has(.ab__off-row:hover) em { opacity: 0; }
.ab__cell--beyond:has(.ab__off-row:hover) .ab__off-row { border-bottom-color: transparent; }
.ab__cell--beyond:has(.ab__off-row:hover) > .ab__eyebrow { opacity: 0; }

.ab__cell--beyond > .ab__eyebrow { position: relative; z-index: 1; transition: opacity 0.3s ease; }

/* ───── Contact: one tinted row per channel ───────────────────────────
   Each row carries its platform's colour as `--brand`, set inline in the PHP,
   so a single rule below dresses all four and adding a fifth needs no CSS.
   The glyph takes --brand at full strength and the row takes a 13% mix of the
   same value: the icon has to read as the stronger of the two, or the row just
   looks like a coloured button.

   NOTE ON THE PAGE'S COLOUR ROLES: the terracotta-reads / blue-clicks rule in
   this file's header still governs the rest of the page, but not this card.
   Here the colour identifies the destination instead, which is why the card no
   longer carries a flat blue fill of its own. */
/* Hovering a row washes the whole card in that row's own colour, while the
   other rows keep theirs. Two things make that work without naming a single
   colour twice: the wash is painted by the hovered row itself, so it reads
   `--brand` off the element that already carries it, and it sits on the
   negative layer, so it goes under every row's background and text instead of
   over them. `isolation` is what keeps "under the rows" from becoming "under
   the card": without a stacking context here, a z-index of -1 would put the
   wash behind the card's own background and nothing would show. */
.ab__cell--links { position: relative; isolation: isolate; overflow: hidden; }
.ab__link::before {
    content: "";
    position: absolute;
    /* Far enough to reach every corner of the card from any row; the card's
       own rounded overflow does the clipping. */
    inset: -50rem;
    z-index: -1;
    pointer-events: none;
    /* The same expression as the hovered row's own background, so the card
       takes exactly that colour and the row melts into it: one channel owns
       the card, and the other rows stay as their own swatches on top. */
    background: color-mix(in srgb, var(--brand-tint, var(--brand)) 34%, var(--ab-card));
    opacity: 0;
    transition: opacity 0.35s ease;
}
.ab__link:hover::before { opacity: 1; }

.ab__links {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 0.3rem;
    flex: 1 1 auto;
}
.ab__link {
    display: grid;
    grid-template-columns: 1.5rem 1fr auto;
    align-items: center;
    gap: 0.4rem;
    padding: 0.5rem 0.6rem;
    border-radius: 7px;
    font-size: clamp(0.8rem, 0.95vw, 0.92rem);
    color: var(--ed-ink);
    text-decoration: none;
    /* Mixed into the card colour rather than left transparent: identical at
       rest, but it means the wash under the card cannot tint the rows that are
       not being hovered. They were coming out muddy, the green one especially. */
    background: color-mix(in srgb, var(--brand-tint, var(--brand)) 22%, var(--ab-card));
    transition: background-color 0.25s ease;
}
.ab__link:hover { background: color-mix(in srgb, var(--brand-tint, var(--brand)) 34%, var(--ab-card)); }
.ab__link:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.ab__links-glyph { color: var(--brand); font-size: 1.05em; }
.ab__links-arrow {
    color: var(--ed-ink-mute);
    font-size: 0.85em;
    transition: color 0.25s ease;
}
/* Nothing here moves on hover any more: the card taking the row's colour is the
   whole effect, and the arrow only darkens. */
.ab__link:hover .ab__links-arrow { color: var(--ed-ink); }

/* X's mark is black on light and white on dark. Here it is the fault state, so
   it is --brand-alt that flips; the resting Twitter blue holds on both grounds. */
[data-theme="dark"] .ab__link--x { --brand-alt: #E7E9EA; }

/* LinkedIn's darker brand navy, tint only. Diluted, #0A66C2 and Twitter's
   #1D9BF0 land close enough to read as one repeated swatch on adjacent rows. */
.ab__link--linkedin { --brand-tint: #004182; }

/* ───── The Twitter row's failing bulb ────────────────────────────────
   The row rests as the old Twitter bird in Twitter blue and stutters to X on
   irregular bursts. That way round the current name is the fault, which is the
   joke.

   The state is a class toggled from the page's script rather than a keyframe
   animation: CSS cannot draw a random number, and a keyframe loop
   dense enough to feel alive is also short enough for the loop to become
   visible. See the script at the foot of about/v3.php.

   Nothing here transitions, on purpose. Interpolated, the swap becomes a
   crossfade and reads as a dissolve; instant, it reads as contact failing.
   Hovering the row settles it on Twitter, so the reader can stop it. */
.ab__link--x {
    /* Resting: Twitter blue, glyph and row alike. --brand-tint is left equal to
       --brand here rather than dropped, so the fallback in .ab__link stays the
       only place that decides what an unset tint means. */
    --brand: #1D9BF0;
    --brand-tint: #1D9BF0;
    /* The fault. Glyph flips per theme, the wash does not: a near-white wash on
       the dark card read as a camera flash rather than a dying contact. */
    --brand-alt: #1B2733;
    --ab-fault-wash: #5B7083;
    position: relative;
}

/* Two stacked pairs share the swap: the glyph and the label. The classes carry
   opacity only, so the label can change word without inheriting the glyph's
   blue — blue text on the blue wash would not have cleared contrast. */
.ab__lamp,
.ab__lamp-label { display: grid; line-height: 1; }
.ab__lamp i,
.ab__lamp-label > span { grid-area: 1 / 1; justify-self: start; }
.ab__lamp-then { opacity: 0; }
.ab__lamp-now { opacity: 1; }

/* The fault glyph takes the fault colour; no halo either way. */
.ab__lamp i.ab__lamp-then { color: var(--brand-alt); }

/* The slate wash on the row, switched with the glyph. It rides a pseudo-element
   rather than re-declaring --brand-tint, so the row's resting blue and the
   fault's slate never have to be the same variable. */
/* Opaque, not a translucent overlay: layered over the resting blue it produced
   a muddy blue-grey instead of a clean cut to slate. Mixing against --ab-card
   reproduces exactly what a 24% tint on the bare card would composite to. */
.ab__link--x::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    background: color-mix(in srgb, var(--ab-fault-wash) 24%, var(--ab-card));
    opacity: 0;
}

/* The wash is a positioned pseudo-element, so it paints above normal-flow
   content, not below it. Translucent that was invisible; opaque it hid the
   glyph, the label and the arrow outright. Raising the row's children is the
   fix, and it has to cover all of them, arrow included. */
.ab__link--x > * { position: relative; z-index: 1; }

/* Hovering this row locks it into the fault, so the card has to take the fault's
   colour and not Twitter blue: the same mix the row itself is showing, so the
   two are one surface like every other row. The row also used to clip its own
   overflow, which cut the card wash off at its edges — that is why this one row
   left the card untouched. */
.ab__link--x:hover::before {
    background: color-mix(in srgb, var(--ab-fault-wash) 24%, var(--ab-card));
}

.ab__link--x.is-lit .ab__lamp-then,
.ab__link--x:hover .ab__lamp-then { opacity: 1; }
.ab__link--x.is-lit .ab__lamp-now,
.ab__link--x:hover .ab__lamp-now { opacity: 0; }
.ab__link--x.is-lit::after,
.ab__link--x:hover::after { opacity: 1; }

/* ───── Travels: a globe that answers the pointer ──────────────────────
   The canvas carries no information. Every fact the card states — the count,
   the country names, the visit numbers, the title line — is text in the DOM,
   so the card is complete before a single pixel of globe is drawn and stays
   complete if the outlines never arrive.

   Rest and hover are two different readings of the same data, not two
   contents: at rest the names alone, on hover the same names with their counts
   and the title. Both are laid out at once and only their opacity moves, which
   is why nothing in the card can shift under the pointer.

   Hover is `:hover, .is-hot` throughout. `.is-hot` is the script's doing and is
   what a touch screen gets — there is no hover on a phone, and without it the
   card would be stuck in its resting state on half the devices that see it. */
.ab__cell--travels {
    /* Square, and sized from itself rather than from the row: `align-self:
       start` lets the aspect ratio set the height instead of inheriting a
       stretched one. The restaurant deck shares this row and is square for
       the same reason and at the same width, so the two line up top and
       bottom without either one being measured against the other. */
    aspect-ratio: 1 / 1;
    align-self: start;

    /* The globe's palette, declared here rather than in the script so the two
       themes sit with every other colour on the page. about-globe.js reads
       these six off the element. */
    --globe-sea: #e7dfd2;
    --globe-land: rgba(22, 20, 19, 0.20);
    --globe-edge: rgba(22, 20, 19, 0.10);
    --globe-grid: rgba(22, 20, 19, 0.055);
    --globe-rim: rgba(22, 20, 19, 0.13);
    --globe-on: var(--ab-terra-fill);
    /* The outline on a visited country. Without one they are flat terracotta
       shapes that merge wherever they touch: France, Spain, Italy and Morocco
       read as a single blob at rest, and Andorra — which sits between two of
       them and is the size of a pixel — cannot be seen at all once its marker
       dot has faded out at zoom. Darkened rather than lightened here so it reads
       as an edge and not as a highlight. */
    --globe-on-edge: color-mix(in srgb, var(--ab-terra-fill) 68%, #2a1a10);
    /* City names, painted on the canvas. Full ink rather than the terracotta of
       the pin beside them: a place name is something to read, and the accent is
       for the shape it belongs to. */
    --globe-label: var(--ed-ink);

    position: relative;
    overflow: hidden;
    /* The children are all absolutely positioned over the canvas, so the flex
       column the other cells use has nothing to lay out here. */
    display: block;
    padding: 0;
}

[data-theme="dark"] .ab__cell--travels {
    --globe-sea: #201f1c;
    --globe-land: rgba(245, 241, 234, 0.21);
    /* Lightened on the dark theme: contrast has to be re-earned on every
       background, it is not a property of the colour. */
    --globe-on-edge: color-mix(in srgb, var(--ab-terra-fill) 62%, #f2e8dc);
    --globe-edge: rgba(245, 241, 234, 0.10);
    --globe-grid: rgba(245, 241, 234, 0.05);
    --globe-rim: rgba(245, 241, 234, 0.13);
}

/* ── The head: eyebrow, and a title that only hover asks for ────────── */
/* The two text overlays. Both span the full width so their scrim can: mono and
   serif type laid straight over coastlines is legible on the sea and not on the
   land, and a wash that stops at the padding edge is a visible rectangle. Both
   are pointer-events:none so that every hover, drag and wheel goes to the
   canvas underneath — the sphere fills the card now, and the card is the globe. */
.ab__travels-head,
.ab__travels-foot {
    position: absolute;
    left: 0;
    right: 0;
    z-index: 2;
    padding: var(--ab-pad);
    pointer-events: none;
}

.ab__travels-head {
    top: 0;
    background: linear-gradient(to bottom,
        color-mix(in srgb, var(--ab-card) 88%, transparent) 0%,
        color-mix(in srgb, var(--ab-card) 50%, transparent) 60%,
        transparent 100%);
    padding-bottom: calc(var(--ab-pad) * 1.5);
}

/* ── The globe ──────────────────────────────────────────────────────────
   The whole card, edge to edge, with the text laid over it. It used to sit in a
   band between the head and the foot, where the sphere could only ever be as
   wide as the shortest side of what was left — about 60% of the card. Given the
   card, it is the card.

   Absolute rather than `height: 100%`: a percentage height resolves against the
   parent's height *property*, and once the card stops being a fixed square
   (narrow screens, where its height is auto) that property is auto too. The
   percentage then falls back to the canvas's intrinsic size, which is whatever
   the script last wrote — a measurement feeding itself. */
.ab__globe {
    position: absolute;
    inset: 0;
    z-index: 0;
}
.ab__globe-canvas {
    position: absolute;
    inset: 0;
    display: block;
    /* Both, and neither is redundant: inset alone leaves a replaced element at
       its intrinsic width, which for a canvas is its width attribute — the very
       number the script derives from this measurement. */
    width: 100%;
    height: 100%;
    touch-action: none;   /* or a drag on the sphere scrolls the page instead */
    cursor: grab;
}
.ab__cell--travels.is-grabbed .ab__globe-canvas { cursor: grabbing; }

/* Nothing to grab until the outlines are in, and nothing to grab at all for a
   reader who never gets them. */
.ab__cell--travels:not(.has-globe) .ab__globe-canvas { cursor: default; }

/* ── The foot: the count, then the names ────────────────────────────── */
.ab__travels-foot {
    bottom: 0;
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    background: linear-gradient(to top,
        color-mix(in srgb, var(--ab-card) 90%, transparent) 0%,
        color-mix(in srgb, var(--ab-card) 62%, transparent) 58%,
        transparent 100%);
    padding-top: calc(var(--ab-pad) * 2.1);
}

.ab__travels-count {
    /* Line-height 0.88 on .ab__figure clips descenders in a flex column. */
    line-height: 1;
}

.ab__travels-list {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    gap: 0.12rem 0.62rem;
    font-family: var(--ed-mono);
    font-size: 0.68rem;
    letter-spacing: 0.01em;
    color: var(--ed-ink-soft);
    /* Two rows of names held open from the start. The list is going to change,
       and the globe should not resize every time a country is added. */
    min-height: 2.6em;
    align-content: flex-start;
}
.ab__travels-list li { display: inline-flex; }

/* The names are controls. Hovering or focusing one turns the globe to that
   country and opens its panel, which is the only route to a country facing away
   from you.

   Terracotta on hover, not the page's sky blue. The stylesheet's rule is that
   blue means interactive — but it means interactive *because it navigates*, and
   the point of that rule is that nothing wears blue unless it can be clicked
   through to somewhere. These go nowhere: they light a country and open a panel,
   both terracotta, and the colour is the thread from the badge to the thing it
   does. Blue here would promise a link that does not exist. */
.ab__travels-list { pointer-events: auto; }   /* the foot around it is `none` */

.ab__travels-badge {
    appearance: none;
    -webkit-appearance: none;
    display: inline-flex;
    align-items: baseline;
    gap: 0.24em;
    margin: 0;
    padding: 0.2em 0.44em;
    font: inherit;
    color: inherit;
    letter-spacing: inherit;
    cursor: pointer;
    border: 1px solid var(--ab-rule);
    border-radius: 4px;
    background: color-mix(in srgb, var(--ab-card) 66%, transparent);
    transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.ab__travels-badge:hover,
.ab__travels-badge:focus-visible {
    color: var(--ab-terra-text);
    background: color-mix(in srgb, var(--ab-terra-fill) 15%, var(--ab-card));
    border-color: color-mix(in srgb, var(--ab-terra-fill) 42%, transparent);
}
.ab__travels-badge:focus-visible {
    outline: 2px solid var(--ab-terra-text);
    outline-offset: 1px;
}
.ab__travels-home .ab__travels-badge { color: var(--ab-terra-text); }
/* The count sits in the flow at rest as well as on hover — reserved, not
   inserted — so revealing it cannot rewrap the names underneath it. */
.ab__travels-n {
    font-size: 0.86em;
    color: var(--ed-ink-mute);
    font-variant-numeric: tabular-nums;
    opacity: 0;
    transition: opacity 0.3s ease;
}
.ab__cell--travels:hover .ab__travels-n,
.ab__cell--travels.is-hot .ab__travels-n { opacity: 1; }
/* "home" is not a visit count, so it is not behind the reveal: it is a standing
   fact about one of the names and reads at rest with the rest of the list. */
.ab__travels-n--home { opacity: 1; }

/* ── The country tooltip ────────────────────────────────────────────────
   One panel per country, all of them present, positioned and revealed by the
   script as the pointer crosses the sphere. Hidden with opacity only: `hidden`
   and `visibility` both remove text from the accessibility tree, and this is
   the card's actual subject — a screen reader should get all seven countries
   and what he says about each, not silence.

   `left`/`top` are written by the script and deliberately left out here, so
   there is no default position competing with the measured one. The entrance
   uses transform, which is why positioning uses left/top and not translate. */
.ab__tip {
    position: absolute;
    z-index: 3;
    width: min(14.75rem, 56%);
    margin: 0;
    opacity: 0;
    pointer-events: none;
    background: var(--ab-card);
    border: 1px solid var(--ab-edge);
    border-radius: 10px;
    overflow: hidden;
    box-shadow: 0 14px 34px -14px rgba(22, 20, 19, 0.42),
                0 2px 6px -2px rgba(22, 20, 19, 0.18);
    transform: translateY(5px) scale(0.985);
    transform-origin: 20% 0;
    transition: opacity 0.2s ease, transform 0.24s cubic-bezier(0.16, 0.84, 0.24, 1);
}
.ab__tip.is-on {
    opacity: 1;
    transform: none;
}

.ab__tip-img {
    display: block;
    width: 100%;
    aspect-ratio: 16 / 10;
    object-fit: cover;
    /* Explicit, and it is the whole reason a pinned photograph can be trusted:
       the frame is landscape and several of his photographs are portrait, so
       what survives is the middle band. The default happens to be 50% 50%, but
       "the subject is in the centre" is now a stated requirement rather than a
       browser default nobody wrote down. */
    object-position: 50% 50%;
    /* The photograph sits against the panel, not against the page, so it takes
       the panel's own deeper tone behind any transparency or letterboxing. */
    background: var(--ab-page);
}

.ab__tip-body {
    padding: 0.7rem 0.8rem 0.78rem;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
}

.ab__tip-name {
    margin: 0;
    display: flex;
    align-items: baseline;
    gap: 0.45rem;
    font-family: var(--ed-serif);
    font-weight: 500;
    font-size: 1.06rem;
    letter-spacing: -0.015em;
    color: var(--ed-ink);
}
.ab__tip-home {
    font-family: var(--ed-mono);
    font-size: 0.6rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--ab-terra-text);
    font-variant-numeric: tabular-nums;
}

.ab__tip-text {
    margin: 0;
    font-size: 0.78rem;
    line-height: 1.5;
    color: var(--ed-ink-soft);
    text-wrap: pretty;
}

.ab__tip-cities {
    margin: 0.05rem 0 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    gap: 0.28rem;
}
/* `--ed-ink-soft`, not `--ed-ink-mute`. Mute is #706b60 on the dark panel and
   #8a847a on the light one: 2.8:1 and 3.5:1, both under 4.5:1, and on the dark
   theme the city names were barely there. Soft clears it in both. */
.ab__tip-cities li {
    font-family: var(--ed-mono);
    font-size: 0.6rem;
    letter-spacing: 0.02em;
    color: var(--ed-ink-soft);
    padding: 0.16em 0.42em;
    border: 1px solid var(--ab-rule);
    border-radius: 3px;
    background: color-mix(in srgb, var(--ed-ink) 3%, transparent);
}

/* Touch: there is no pointer to follow, so the panel is not chased around the
   sphere. It parks at the bottom of the card, above the name list, and the
   script stops writing left/top — hence the two !importants, which override the
   inline left/top from a session that started with a mouse attached.

   It also turns on its side. Stacked, at the full width of a phone card, the
   photograph alone was 200px and the panel 332 of the card's 406: tapping a
   country hid the globe you tapped it on. Landscape, the picture is a square
   thumbnail and the panel is about a third of the height. */
@media (hover: none) {
    .ab__tip {
        width: auto;
        left: var(--ab-pad) !important;
        right: var(--ab-pad);
        top: auto !important;
        bottom: calc(var(--ab-pad) * 3.6);
        transform-origin: 50% 100%;
        display: flex;
        align-items: flex-start;
    }
    .ab__tip-img {
        flex: 0 0 auto;
        width: min(7.5rem, 34%);
        aspect-ratio: 1 / 1;
    }
    .ab__tip-body {
        flex: 1 1 auto;
        min-width: 0;
        padding: 0.6rem 0.72rem 0.66rem;
    }
    .ab__tip-text {
        font-size: 0.75rem;
        line-height: 1.45;
    }
}

/* Full width once the grid drops to two columns, where a square would be
   600px of card on a tablet. The floor moves to the globe rather than staying
   on the card: a card-height floor is spent by the head and the foot first, and
   on a 390px phone that left the sphere 154px across — the one element the card
   exists for, smaller than the paragraph above it. Sizing the band directly
   means the text adds to the card's height instead of eating into the globe. */
@media (max-width: 900px) {
    .ab__cell--travels { aspect-ratio: auto; min-height: min(104vw, 560px); }
}

@media (prefers-reduced-motion: reduce) {
    /* No wave, and no delay to sit through: the cells are simply there. */
    .ab__cell { transition: none; }
    .has-js .ab__grid > .ab__cell { opacity: 1; transform: none; }
    /* The bulb never starts: the script checks the same query. A flicker is
       the one effect on this page that cannot be softened into something
       acceptable for a reader who asked not to be shown motion. */
    .ab__portrait-img,
    .ab__photo-img,
    .ab__photo-bar-fill,
    .ab__link,
    .ab__off-row,
    .ab__used-glyph,
    .ab__links-glyph,
    .ab__links-arrow,
    .ab__lang-name,
    .ab__lang-say,
    /* The reveal still happens, it just stops moving. */
    .ab__off-bg { transform: none; transition: opacity 0.2s ease; }
    /* No rotation under reduced motion: the script never starts it, and the
       bar is hidden because there is nothing left for it to count down. */
    .ab__photo-bar { display: none; }
    .ab__used li:hover .ab__used-glyph { transform: none; }
    /* The reveal is the information; the slide up into place is not. */
    .ab__tip { transform: none; transition: opacity 0.15s ease; }
}

/* ───── Restaurants ────────────────────────────────────────────────────
   A deck of visit cards you scroll through, seen from its left: every card is
   turned a little to the right, and the turn eases off as a card reaches the
   middle of the frame, so the one you are reading is the one closest to
   square-on. Nothing moves on its own — the reader's scroll is the whole of
   the animation, which is the rule the photo strip above already follows.

   THE PERSPECTIVE LIVES ON THE SCROLLER, not on a wrapper. `overflow` other
   than visible forces `transform-style` back to flat, so a preserve-3d box
   between the scroller and the cards would silently flatten the deck. Putting
   `perspective` on the scrolling element itself projects each direct child
   independently, which is exactly what a deck is.

   The vanishing point sits at 22% from the left, off the cards rather than
   behind them: a centred one puts the viewer square in front of the deck and
   the right-hand turn stops reading as a turn at all.

   Cards carry TWO custom properties, both written per frame by
   about-bento.js: --d is where the card sits relative to the middle of the
   frame, -1 above to +1 below, and --a is its size. They have resting values
   here, so the deck is already angled with the script absent or asleep.
   ─────────────────────────────────────────────────────────────────────── */
.ab__cell--resto {
    /* THE DECK IS THE CARD, and the head and the foot are laid over it — the
       same move the globe next door makes, for the same reason. With the first
       card centred at rest (which is what the spacers below are for), a deck in
       a band between two lines of text opens with half a frame of nothing above
       it: the empty half is not optional, it is what "centred" means when there
       is nothing before the first card. Given the whole card instead, that half
       is where the eyebrow lives, and the deck runs behind it to both edges. */
    position: relative;
    padding: 0;
    gap: 0;
    overflow: hidden;

    /* THE HEIGHT HAS TO BE DEFINITE, and this is the whole reason for the
       aspect ratio. The deck is a scroller inside a flex column: it only
       clips if the box around it already knows how tall it is. Left on
       `auto` — which is what the grid's last row is — the row asks this cell
       for its content height, gets all nine cards, and the module renders as
       a 2 400px column with no scrolling in it at all.
       Square, two columns wide: the same shape and the same height as the
       globe beside it, arrived at independently. */
    aspect-ratio: 1 / 1;
    align-self: start;

    /* One number the whole deck is built from: the card's width in px, never a
       percentage. The frame's top and bottom spacers are half a card tall, and
       a card's height is its width — a percentage would resolve against the
       wrong box in one of the two places and the deck would sit off-centre. */
    --resto-w: clamp(210px, 22vw, 315px);
    /* 55/85 halved: half the height of a card of width --resto-w. */
    --resto-half: calc(var(--resto-w) * 0.3235);
    /* Named, because the spacers have to subtract it — see below. */
    --resto-gap: 0.7rem;
}

/* Over the deck, and taking no pointer: a wheel or a drag that starts on the
   eyebrow still reaches the cards underneath. Both bands wash out into the
   card's own colour rather than sitting on a hard edge, so the deck reads as
   passing behind them. */
.ab__resto-head,
.ab__resto-foot {
    position: absolute;
    left: 0;
    right: 0;
    z-index: 2;
    padding: var(--ab-pad);
    pointer-events: none;
}

.ab__resto-head {
    top: 0;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    padding-bottom: calc(var(--ab-pad) * 1.5);
    background: linear-gradient(to bottom,
        color-mix(in srgb, var(--ab-card) 88%, transparent) 0%,
        color-mix(in srgb, var(--ab-card) 50%, transparent) 60%,
        transparent 100%);
}

.ab__resto-warn {
    margin: 0;
    font-size: 0.72rem;
    line-height: 1.4;
}

.ab__resto-track {
    /* The frame the deck is seen through: the whole card, edge to edge, with
       the two text bands over it. Absolute rather than `height: 100%`, which
       would resolve against the parent's height *property* — and the cell's
       height comes from an aspect ratio, not from a length. */
    position: absolute;
    inset: 0;

    /* Being positioned also makes it the offsetParent of the cards, which
       about-bento.js depends on: it works in `offsetTop`, and every one of
       those numbers has to be measured from the box that scrolls them. */

    perspective: 1100px;
    perspective-origin: 24% 50%;

    /* There is nothing about a deck of cards that says "scroller", so the
       cursor has to. Dragging it with the left button is the phone gesture
       brought to a mouse; see about-bento.js. */
    cursor: grab;

    overflow-y: auto;
    overscroll-behavior-y: contain;
    /* NO `scroll-snap-type` HERE, and it is not an oversight. A scroll snap
       area is the element's *transformed* border box, and every card in this
       deck is turned and pushed back in Z — so the browser would snap the
       projection rather than the card and land a dozen pixels short of the
       middle every time. about-bento.js settles the deck itself, off the
       layout positions, which are the ones that are actually true. */

    margin: 0;
    /* Side padding only, and it is the card's gutter now that the deck runs to
       the cell's edges. The room above and below is two spacers, below — padding
       could not do that job, because a percentage padding resolves against the
       *width* of the box even when it is the top one. */
    padding: 0 calc(var(--ab-pad) + 0.8rem) 0 var(--ab-pad);
    list-style: none;

    display: flex;
    flex-direction: column;
    gap: var(--resto-gap);

    /* Top and bottom of the frame dissolve rather than cut, so the deck reads
       as continuing past the card instead of ending at its spacers. The fade is
       shallower than a spacer is deep, so it never takes a bite out of the card
       sitting in the middle. */
    -webkit-mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent);
            mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent);

    scrollbar-width: none;
}
.ab__resto-track::-webkit-scrollbar { display: none; }

/* Half a frame less half a card at each end, which is exactly what the first
   and the last card need to reach the middle. The middle of the frame is where
   a card is square-on, where a click sends it and where a drag settles, so a
   card that cannot get there is a card the deck cannot show — and the first one
   has to be there before anything is touched at all.
   Less the gap, which is the part that is easy to miss: a spacer is a flex item
   like any other, so the column's `gap` lands between it and the first card as
   well, and a formula that ignores it opens the deck eleven pixels high. `max`
   guards a frame too short to hold one card. */
.ab__resto-track::before,
.ab__resto-track::after {
    content: '';
    flex: 0 0 auto;
    height: max(0px, calc(50% - var(--resto-half) - var(--resto-gap)));
}

.ab__resto-track.is-grabbed {
    cursor: grabbing;
    /* A drag across type selects it otherwise, and the deck comes away with a
       blue band across three cards. */
    user-select: none;
}

/* Grabbing wins over everything under the hand: mid-drag the pointer is
   captured by the scroller, so the cursor a card would otherwise ask for is no
   longer about anything the reader can do. */
.ab__resto-track.is-grabbed .ab__resto-card { cursor: grabbing; }

/* The scroller is focusable, because a keyboard has to be able to reach the
   deck; it must therefore also show that it has been reached. */
.ab__resto-track:focus-visible {
    outline: 2px solid var(--ab-sky-text);
    outline-offset: 3px;
    border-radius: 8px;
}

.ab__resto {
    --d: 0;      /* signed distance from the middle of the frame, -1 … +1 */
    --a: 0.34;   /* its size; the resting value is what a no-JS deck shows */

    flex: 0 0 auto;
    /* Centred rather than flush left: the turn is what fills the width, and
       the slack it needs looks deliberate when it is on both sides. */
    align-self: center;
    width: var(--resto-w);
    /* 85 × 55mm. The cards are the shape of the things being scanned, and a
       scan that does not fit that ratio is letterboxed rather than cropped —
       a visit card cropped is a visit card with the address cut off. */
    aspect-ratio: 85 / 55;

    /* The turn, the depth and the tilt, all read off --d and --a. Written as
       one transform because they are one movement: a card leaning back into
       the deck as it leaves the middle of the frame. */
    transform:
        translateX(calc(var(--a) * 16px))
        translateZ(calc(var(--a) * -105px))
        rotateY(calc(12deg + var(--a) * 14deg))
        rotateX(calc(var(--d) * -4deg));
    opacity: calc(1 - var(--a) * 0.4);

    /* No transition on the transform: it is rewritten every frame while the
       reader scrolls, and a transition on top of that smears instead of
       smoothing. The dimming is the same value, so it is left alone too. */
    will-change: transform;
}

.ab__resto-card {
    position: relative;
    display: block;
    width: 100%;
    height: 100%;
    border-radius: 9px;
    overflow: hidden;
    background: var(--ab-page);
    border: 1px solid var(--ab-edge);
    /* Cast to the left, which is the side the deck is turned away from — the
       shadow is what says the card is standing off the page rather than
       printed on it. */
    box-shadow: -10px 14px 26px rgba(22, 20, 19, 0.22);
    text-decoration: none;
    color: inherit;
    transition: transform 0.34s var(--ease-out, cubic-bezier(0.16, 0.84, 0.24, 1)),
                filter 0.34s ease,
                box-shadow 0.34s ease;
}

[data-theme="dark"] .ab__resto-card {
    box-shadow: -10px 14px 30px rgba(0, 0, 0, 0.45);
}

/* Two different clicks, so two different cursors. A card away from the middle
   is a scroll target — clicking it brings it there. The one at the middle is
   the card itself, and follows its link if it has one. `is-centred` is written
   by about-bento.js, which is also what reads it back on the way in.
   Everything still works without the class: no JS means no card is marked, and
   every card offers to be scrolled to by a handler that is not there — which
   is why `grab` on the scroller underneath is the honest resting state. */
.ab__resto:not(.is-centred) .ab__resto-card { cursor: pointer; }
.ab__resto.is-centred .ab__resto-card { cursor: inherit; }

/* Hover lifts the inner card, not the <li>: the <li>'s transform belongs to
   the scroll handler and anything this rule put there would be overwritten on
   the next frame. Brightening here also undoes the deck's dimming, so the card
   under the pointer comes back to full strength wherever it is in the frame. */
.ab__resto-card:hover,
.ab__resto-card:focus-visible {
    transform: translateX(-7px) scale(1.028);
    filter: brightness(1.14);
    box-shadow: -14px 18px 34px rgba(22, 20, 19, 0.3);
}
.ab__resto-card:focus-visible {
    outline: 2px solid var(--ab-sky-text);
    outline-offset: 3px;
}

.ab__resto-scan {
    /* Grabbing a card must move the deck, not peel the picture off it. */
    -webkit-user-drag: none;
    user-select: none;

    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* `contain`, not `cover`: see the aspect-ratio note above. A scan that
       does not come in at 85 × 55 letterboxes against the card's own paper,
       which is a scan sitting on a card — cropping it to fill would be an
       address cut off. */
    object-fit: contain;
    display: block;
}

/* The name band. Over a scan it is a scrim at the foot of the card; on a
   typeset card the same block is the card, centred, with no scrim under it. */
.ab__resto-face {
    position: absolute;
    inset: auto 0 0 0;
    padding: 0.85rem 0.9rem 0.7rem;
    display: flex;
    flex-direction: column;
    gap: 0.14rem;
    background: linear-gradient(to top,
                rgba(18, 16, 15, 0.88), rgba(18, 16, 15, 0.55) 55%, transparent);
    color: #fbf8f2;
}

.ab__resto-name {
    margin: 0;
    font-family: var(--ed-serif);
    font-size: clamp(0.95rem, 1.25vw, 1.12rem);
    font-weight: 500;
    letter-spacing: -0.012em;
    line-height: 1.15;
}

.ab__resto-meta {
    margin: 0;
    font-family: var(--ed-mono);
    font-size: 0.575rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: rgba(251, 248, 242, 0.78);
}

/* ── The card with no scan ──────────────────────────────────────────────
   Not a placeholder: a restaurant that never handed him a card gets this
   permanently, so it has to look like a card someone chose to print, not like
   a picture that failed to load. Paper, a hairline, and the name set in the
   serif — the same face as a real visit card, minus the ink someone else
   paid for. */
.ab__resto-card--typeset {
    background: var(--ab-card);
    border-color: var(--ab-rule);
}
.ab__resto-card--typeset .ab__resto-face {
    inset: 0;
    justify-content: center;
    align-items: center;
    gap: 0.5rem;
    padding: 1rem;
    text-align: center;
    background: none;
    color: var(--ed-ink);
}
.ab__resto-card--typeset .ab__resto-name { font-size: clamp(1.05rem, 1.5vw, 1.3rem); }
.ab__resto-card--typeset .ab__resto-meta {
    color: var(--ab-terra-text);
    padding-top: 0.5rem;
    border-top: 1px solid var(--ab-rule);
}

/* Corner stamp. Mono and small, in the opposite corner to the name band, so
   the two never collide however long the name is. */
.ab__resto-year {
    position: absolute;
    top: 0.6rem;
    right: 0.7rem;
    font-family: var(--ed-mono);
    font-size: 0.58rem;
    letter-spacing: 0.1em;
    padding: 0.17rem 0.4rem;
    border-radius: 999px;
    background: rgba(18, 16, 15, 0.5);
    color: rgba(251, 248, 242, 0.9);
    backdrop-filter: blur(3px);
}
.ab__resto-card--typeset .ab__resto-year {
    background: var(--ab-page);
    color: var(--ab-terra-text);
    backdrop-filter: none;
}

/* Loud on purpose, and gone the moment $restaurants_template is false. */
.ab__resto-chip {
    position: absolute;
    top: 0.6rem;
    left: 0.7rem;
    font-family: var(--ed-mono);
    font-size: 0.55rem;
    text-transform: uppercase;
    letter-spacing: 0.13em;
    padding: 0.17rem 0.42rem;
    border-radius: 4px;
    background: var(--ab-terra-fill);
    color: #fbf8f2;
}

.ab__resto-foot {
    bottom: 0;
    margin: 0;
    display: flex;
    align-items: baseline;
    gap: 0.45rem;
    font-size: 0.78rem;
    color: var(--ed-ink-mute);
    padding-top: calc(var(--ab-pad) * 1.5);
    background: linear-gradient(to top,
        color-mix(in srgb, var(--ab-card) 88%, transparent) 0%,
        color-mix(in srgb, var(--ab-card) 50%, transparent) 60%,
        transparent 100%);
}
.ab__resto-count {
    font-family: var(--ed-serif);
    font-size: 1.5rem;
    line-height: 1;
    color: var(--ab-terra-text);
}

@media (max-width: 900px) {
    /* Full width here, so square would be a deck as tall as the page is wide.
       An explicit height instead — still definite, which is the only thing the
       scroller actually requires — capped against the viewport so the deck can
       never be taller than the screen it is scrolled in. */
    .ab__cell--resto {
        aspect-ratio: auto;
        height: clamp(400px, 58vh, 500px);
        --resto-w: clamp(235px, 52vw, 320px);
    }
}

/* A deck that leans is still a deck; a deck that re-leans on every scroll tick
   is motion, and this is where motion is declined. The cards keep one fixed
   angle, the script never writes to them (see about-bento.js), and scrolling
   the list goes on working exactly as the browser does it. */
@media (prefers-reduced-motion: reduce) {
    .ab__resto {
        --d: 0;
        --a: 0.3;
        will-change: auto;
    }
    .ab__resto-card { transition: none; }
}

/* Nothing hovers on a touch screen, so the deck's own dimming would be the
   only thing separating the card in the middle from the rest — which is
   enough, and the lift is dropped rather than left to fire on tap. */
@media (hover: none) {
    .ab__resto-card:hover { transform: none; filter: none; }
}

/* ───── Narrow ────────────────────────────────────────────────────── */
@media (max-width: 900px) {
    .ab__grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        grid-template-areas:
            "lede    lede"
            "portrait portrait"
            "where   langs"
            "used    used"
            "photo   photo"
            "beyond  links"
            "travels travels"
            "resto   resto";
    }
    .ab__cell--portrait { aspect-ratio: 4 / 3; }
    .ab__cell--photo { aspect-ratio: 4 / 3; }
}

@media (max-width: 560px) {
    .ab__grid {
        grid-template-columns: minmax(0, 1fr);
        grid-template-areas:
            "lede" "portrait" "where" "langs" "used" "photo" "beyond" "links" "travels" "resto";
    }
}
