@charset "UTF-8";

/* Kuiristo main stylesheet.
   Target: latest stable Firefox + Chrome. Plain CSS, no preprocessor --
   native nesting replaces Sass nesting, custom properties replace $variables,
   and cascade layers replace import-order-as-cascade-strategy.

   Layer order is the cascade strategy: position in this list beats
   specificity everywhere below it.

   unlayered  . . . . . . . .  beats every layer, at any specificity
       ▴                       (font-awesome sat here, and both toggle
       │                        icons rendered at once because nothing
       │                        below could hide either one)
   ┌───┴────────┐
   │ utilities  │  winning is its job
   │ components │  one nested block per widget
   │ layout     │  page regions, sticky offsets
   │ base       │  element defaults
   │ tokens     │  custom properties
   │ reset      │  declared, never populated
   │ vendor     │  normalize, font-awesome, tom-select
   └────────────┘
     loses to everything above it

   Two things the picture is for. A rule that must win goes UP a layer,
   never to a heavier selector -- specificity does not cross a layer
   boundary. And anything left outside the stack lands at the top, which
   is a promotion nobody asked for: the keyframes and the last print
   block are currently out there.

   `vendor` comes first, and font-awesome is pulled into it here rather
   than being linked from layout.erb. UNLAYERED CSS BEATS EVERY LAYER, at
   any specificity -- so while font-awesome sat outside the layers, its
   `.fa,.fas,.far { display:inline-block }` won against every rule below,
   and the filter toggles rendered both their on and their off icon at
   once because neither could be hidden. Importing it into the first layer
   puts it back under everything that follows, which is where a vendor
   sheet belongs.

   normalize.css and tom-select.css come in the same way, and for the same
   reason -- normalize had already bitten: it sets `summary{display:list-item}`,
   which beat `.cart-banner{display:flex}` and left the shopping cart banner
   stacked vertically with two disclosure triangles, the native marker that
   flex would have removed plus the one the stylesheet draws. Order inside
   the layer is source order, so normalize stays the base it is meant to be. */
@layer vendor, reset, tokens, base, layout, components, utilities;
@import url("../normalize/normalize.css")        layer(vendor);
@import url("../font-awesome/css/all.min.css")   layer(vendor);
@import url("../tom-select/css/tom-select.css")  layer(vendor);


/* ===== tokens ===== */
@layer tokens {
    :root {
        /* Solarized palette -- http://ethanschoonover.com/solarized
           Kept as the published hex values on purpose: these are exact
           brand colours, and re-expressing them in oklch() would shift
           them by a hair for no benefit. */
        --base03:   #002b36;
        --base02:   #073642;
        --base01:   #586e75;
        --base00:   #657b83;
        --base0:    #839496;
        --base1:    #93a1a1;
        --base2:    #eee8d5;
        --base3:    #fdf6e3;

        --yellow:   #b58900;
        --orange:   #cb4b16;
        --red:      #dc322f;
        --magenta:  #d33682;
        --violet:   #6c71c4;
        --blue:     #268bd2;
        --cyan:     #2aa198;
        --green:    #859900;

        /* Darkened variants, used for borders against the flat colour.
           These are the exact values Sass's darken(c, 10%) produced and the
           old stylesheet shipped. darken() works on HSL lightness, which
           color-mix() with black does NOT reproduce -- mixing in black
           desaturates as it darkens and lands on a visibly different colour --
           so these stay literal rather than computed. */
        --red-dark:    #b9221f;
        --blue-dark:   #1e6ea7;
        --violet-dark: #484fb5;

        /* semantic aliases */
        --content-emphazied:     var(--base01);
        --content-primary:       var(--base00);
        --content-secondary:     var(--base1);
        --background-highlights: var(--base2);
        --background:            var(--base3);

        /* surfaces and interaction */
        --page-bgcolor:   #eeeeee;
        --paper-shadow:   0 2px 6px rgb(0 0 0 / 0.3);
        --paper-border:   1px solid rgb(0 0 0 / 0.3);
        --paper-gap:      1ex;
        --selected-color: rgb(200 0 0);
        --toggled-color:  rgb(0 150 0);
        --hover-color:    rgb(80 0 0);
        --hover-bgcolor:  rgb(242 208 21);

        /* The hover signature the two picture views share. Cartes and
           Vignettes are both built around the photograph, so they answer
           the pointer the same way; naming it once keeps them from
           drifting apart the next time either is touched.

           The ring is the palette's blue -- the colour this page already
           uses for "there is somewhere to go here" -- because a slightly
           darker grey was too quiet to read as an answer at all.

           Neither property moves anything, and that is the point. An
           earlier version lifted the card two pixels, which took its
           title and its tags up with it: a hover that shifts the text
           you are reading is worse than no hover. */
        --picked-ring:       var(--blue);
        --picked-ring-width: 3px;
        --picked-shadow:     0 4px 14px rgb(0 0 0 / 0.28);

        /* Focus ring. Was absent: the old user-interaction mixin set
           `outline: none` with no replacement, which removes the keyboard
           focus indicator outright. */
        --focus-ring:        2px solid var(--blue);
        --focus-ring-offset: 2px;

        color-scheme: light;
    }
}


/* ===== base ===== */
@layer base {
    body {
        padding: 5px;
        margin-block-end: 50px;
        background-color: var(--page-bgcolor);
    }

    /* Root size bump for very narrow screens. Was `font-size: 14px`, which
       overrides the reader's own root size; the percentage is the same
       rendered size at the default 16px while still scaling with it. */
    @media (width <= 410px) {
        html {
            font-size: 87.5%;
        }
    }

    /* Any element that takes focus by keyboard gets a visible ring.
       :focus-visible, so a mouse click does not paint a ring nobody asked
       for. Components may override the colour, never remove the ring. */
    :focus-visible {
        outline: var(--focus-ring);
        outline-offset: var(--focus-ring-offset);
    }

    h1.title {
        position: relative;
        display: flex;
        box-sizing: border-box;
        margin: 0;
        margin-block-end: 1ex;
        padding: 0.75ex 1ex;
        font-size: xx-large;
        text-align: center;
        background: white;

        @media screen {
            border: var(--paper-border);
            box-shadow: var(--paper-shadow);
        }

        & a,
        & span {
            flex-grow: 1;
            color: black;
            text-decoration: none;
        }
    }

    footer {
        padding-block: 5px;
        border-block-start: 1px solid grey;
        font-size: smaller;
        color: grey;

        & * {
            color: grey;
        }

        & a,
        & span[data-url] {
            text-decoration: none;

            &:hover {
                text-decoration: underline;
            }
        }

        & .kv {
            display: inline-block;

            & > span:first-child {
                font-variant: small-caps;
            }
        }

        & .kv + .kv::before {
            content: " / ";
        }

        & .tag {
            padding: 0.25ex 1ex;
            opacity: 0.5;
        }
    }
}


/* ===== layout ===== */
@layer layout {
    /* Sticky stacking: the cart bar sits above the page, so the elements
       that follow it start below its height rather than at the viewport top.

       viewport top
       ────────────────────────────────  0
         .cart-holder.sticky             top: 5px
       ────────────────────────────────
         #full-search.sticky             top: 60px when the cart is
                                         present, 5px when it is not
       ────────────────────────────────
         the list scrolls under both

       The second offset is a sibling selector, not a measurement: it
       applies only while a cart-holder is actually there and not hidden.
       That is why the search bar does not sit 55px too low on a page
       with an empty cart. */
    #full-search.sticky {
        position: sticky;
        top: 5px;
        z-index: 1000;
    }

    .cart-holder.sticky + .recipe > .tagging.sticky,
    .cart-holder.sticky:not(.hidden) + section.recipes > #full-search.sticky {
        top: 60px;
    }
}

/* ===== back-to-top ===== */
@layer components {
  #back-to-top {
    position: fixed;
    inset-block-end: 20px;
    inset-inline-end: 20px;
    background: rgb(0 0 0 / 0.7);
    inline-size: 50px;
    block-size: 50px;
    text-decoration: none;
    border-radius: 35px;
    user-select: none;
    z-index: 10;

    /* Hidden until scrolled, and it fades. This was jQuery's fadeIn/fadeOut
       toggling `display`, which cannot transition -- opacity plus
       visibility can, and visibility keeps it unclickable while invisible.
       The `is-visible` class is set by the scroll handler in
       views/_back_to_top.erb. */
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.3s ease, visibility 0.3s ease;

    &.is-visible {
        opacity: 1;
        visibility: visible;
    }

    i {
      color: #fff;
      margin: 0;
      position: relative;
      left: 16px;
      top: 13px;
      font-size: 19px;
      transition: all 0.3s ease;
    }
    &:hover {
      background-color: rgb(0 0 0 / 0.9);
      i { color: #fff; top: 5px; }
    }
  }
}

/* ===== key-accelerator ===== */
@layer components {
  /* Accelerator key */
  #key-accelerator {
    display: grid;
    grid-template-columns: repeat(13, minmax(1em, 1fr));
    gap: 4px;

    margin-block-end: 1rem;

    a {
      box-sizing: border-box;
      border: 1px solid grey;
      border-radius: 5px;
      @media screen {
        border: var(--paper-border);
        box-shadow: var(--paper-shadow);
      }
      display: block;
      padding-block: 0.75ex;
      padding-inline: 0.5ex;
      text-align: center;
      text-decoration: none;
      font-weight: bold;
      color: black;
      user-select: none;
      background-color: rgb(255 245 245);
      &:active { border-color: var(--hover-bgcolor); }
      &:hover  { background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, transparent); }
    }
  }
}

/* ===== index-search ===== */
@layer components {

  #full-search .search-tab {
    .nav-link.category-type::before {
      border-color: var(--blue) transparent;
      display: block;
    }
    .nav-link.category-origin::before {
      border-color: var(--violet) transparent;
      display: block;
    }
    .nav-link.category-preparation::before {
      border-color: var(--green) transparent;
      display: block;
    }
    .nav-link.category-profil::before {
      border-color: var(--magenta) transparent;
      display: block;
    }
    .nav-link.category-season::before {
      border-color: var(--yellow) transparent;
      display: block;
    }
    .nav-link.category-source::before {
      border-color: var(--orange) transparent;
      display: block;
    }
    .nav-link.category-unknown::before {
      border-color: var(--cyan) transparent;
      display: block;
    }

    /* Grid, so that every tab is the same width.

       repeat(auto-fill, minmax(11em, 1fr))  ->  five equal tracks

       ┌───────┐┌───────┐┌───────┐┌───────┐┌───────┐
       │ Temps ││ Ingr. ││ Type  ││ Prep. ││ Profil│
       └───────┘└───────┘└───────┘└───────┘└───────┘
       ┌───────┐┌───────┐┌───────┐┌───────┐. . . . .
       │ Saison││ Orig. ││ Source││ Favor.│  empty  .
       └───────┘└───────┘└───────┘└───────┘. . . . .

       Nine tabs in five tracks leaves the fifth cell of the second row
       empty -- a 283px gap at 1440. This was briefly flex, to close it.
       Rejected: flex fills a short row by growing the items on it, so a
       row of four ends up with visibly wider tabs than a row of five,
       and these tabs are peers. Tabs of matching size read better than
       rows of matching length. The gap is the accepted cost.
       Please do not "fix" it back. */
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
    grid-gap: 4px;
    margin-block-end: 1rem;

    font-size: 140%;
    @media only screen and (max-width: 440px) {
      font-size: 110%;
    }

    .nav-link label {
      cursor: pointer;

      & input + .toggler .fa-toggle-on {
        display: none;
        color: var(--toggled-color);
      }
      & input + .toggler .fa-toggle-off {
        display: inline-block;
      }
      & input:checked + .toggler .fa-toggle-on {
        display: inline-block;
      }
      & input:checked + .toggler .fa-toggle-off {
        display: none;
      }
    }

    .nav-link {
      @media screen {
        border: var(--paper-border);
        box-shadow: var(--paper-shadow);
      }

      display: flex;
      inline-size: 100%;
      box-sizing: border-box;
      user-select: none;
      background-color: rgb(245, 255, 245);

      position: relative;

      &::before {
        content: "";
        position: absolute;
        top: 0;
        right: 0;
        border-width: 16px 0 0 16px;
        border-style: solid;
        display: none;
      }

      &.selected {
        box-shadow: var(--paper-shadow), inset 0 -3px 0 0 var(--selected-color);
      }

      & > *:active {
        text-shadow: 2px 2px 2px rgb(0 0 0 / 0.4);
      }

      &:hover, &:hover::before {
        background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, var(--page-bgcolor));
      }

      & > *:hover {
        color: var(--hover-color);
      }

      & > label {
        padding-block: 1ex;
        padding-inline-end: 0.5em;
        padding-inline-start: 0.25em;
      }

      & > a {
        color: black;
        text-decoration: none;
        padding-block: 1ex;
        padding-inline-start: 0.5em;
        padding-inline-end: 0.25em;
        flex-grow: 1;
      }

      & > a i {
        padding-inline-end: 0.5ex;
      }

      /* a11y fix: source had a bare `outline: none` on `.nav-link > a` with no
         replacement, which removed the keyboard focus indicator entirely.
         Replaced with a visible focus-visible ring instead of reproducing it. */
      & > a:focus-visible {
        outline: 2px solid var(--blue);
        outline-offset: 2px;
      }

      &.disabled, &.disabled * {
        color: grey !important;
        cursor: not-allowed !important;
        text-shadow: none !important;
        box-shadow: none !important;
        transform: none;
      }
    }
  }


  #full-search .search-tab-content {
    @media screen {
      border: var(--paper-border);
      box-shadow: var(--paper-shadow);
    }
    background-color: rgb(245, 255, 245);
    margin-block-end: 1rem;
    margin-block-start: -0.5rem;

    .search-category {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(9em, 1fr));
      font-size: 80%;
      @media only screen and (max-width: 440px) {
        font-size: 65%;
      }

      label {
        /* Lighter than it was. At 2px 2px 2px / 50% this read as a second
           copy of the glyph rather than a shadow: the icons are 51px and the
           small-caps label under them is 80%, so an offset that merely lifts
           the icon smears the lettering. Halved offset and blur, well under
           half the opacity. */
        text-shadow: 1px 1px 2px rgb(0 0 0 / 0.22);
        padding-block: 1.2em;
        padding-inline: 0.8ex;
        margin: 2px;
        text-align: center;
        flex-basis: 0;
        cursor: pointer;
        border: 1px solid transparent;
        white-space: nowrap;
      }
      label:active {
        border: 1px solid var(--hover-bgcolor);
      }
      label:hover {
        background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, transparent);
        color: var(--hover-color);
        border-radius: 1em;
        text-shadow: 1px 1px 2px rgb(100 0 0 / 0.25);
      }

      label input { display: none; }
      label input:checked + div { color: var(--selected-color); }
      label div { font-variant: small-caps; font-weight: bold; }
      label i { font-size: 400%; }
    }


    #group-ingredients {
      .legend span {
        padding-block: 1ex;
        padding-inline-start: 1ex;
        padding-inline-end: 1em;
        white-space: nowrap;
      }
      .legend .optgroup-category { color: var(--red); }
      .legend .optgroup-ingredient { color: var(--blue); }
      .legend .optgroup-ingredient-group { color: var(--violet); }

      .legend {
        display: block;
        margin: 0.5ex;
      }

      label {
        display: flex;
        margin: 1ex;
        align-items: center;
      }
      label > div:first-child { min-inline-size: 4em; max-inline-size: 4em; }
      label input, label .ts-wrapper { flex-grow: 1; }

      /* Customize Selectize plugin */
      .ts-dropdown, .ts-control, .ts-control input {
        font-size: inherit;
        line-height: 125%;
      }

      /* Drop down */
      /* Optgroup */
      .ts-wrapper .ts-dropdown-content .optgroup-header {
        font-weight: bold;
        color: var(--base03);
      }
      .ts-wrapper .ts-dropdown-content .optgroup[data-group=category] .optgroup-header {
        background: var(--red);
      }
      .ts-wrapper .ts-dropdown-content .optgroup[data-group=ingredient] .optgroup-header {
        background: var(--blue);
      }
      .ts-wrapper .ts-dropdown-content .optgroup[data-group=ingredient-group] .optgroup-header {
        background: var(--violet);
      }

      /* Items */
      .ts-wrapper .ts-dropdown-content .group-category {
        border-inline-start: 4px solid currentColor;
        color: var(--red) !important;
      }
      .ts-wrapper .ts-dropdown-content .group-ingredient {
        border-inline-start: 4px solid currentColor;
        color: var(--blue) !important;
      }
      .ts-wrapper .ts-dropdown-content .group-ingredient-group {
        border-inline-start: 4px solid currentColor;
        color: var(--violet) !important;
      }

      /* Tags inside input control */
      .ts-wrapper .ts-control > div {
        color: var(--base3);
      }

      .ts-wrapper .ts-control .group-category {
        background: var(--red);
        border-color: var(--red-dark);
      }
      .ts-wrapper .ts-control .group-category .remove {
        border-inline-start-color: var(--red-dark);
      }
      .ts-wrapper .ts-control .group-ingredient {
        background: var(--blue);
        border-color: var(--blue-dark);
      }
      .ts-wrapper .ts-control .group-ingredient .remove {
        border-inline-start-color: var(--blue-dark);
      }
      .ts-wrapper .ts-control .group-ingredient-group {
        background: var(--violet);
        border-color: var(--violet-dark);
      }
      .ts-wrapper .ts-control .group-ingredient-group .remove {
        border-inline-start-color: var(--violet-dark);
      }
    }


    #group-favorite {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(9em, 1fr));
      font-size: 125%;
      @media only screen and (max-width: 440px) {
        font-size: 100%;
      }

      label {
        text-shadow: 1px 1px 2px rgb(0 0 0 / 0.22);
        padding-block: 1.2em;
        padding-inline: 0.8ex;
        margin: 2px;
        text-align: center;
        flex-basis: 0;
        cursor: pointer;
        border: 1px solid transparent;
        white-space: nowrap;
      }
      label:active {
        border: 1px solid var(--hover-bgcolor);
      }
      label:hover {
        background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, transparent);
        color: var(--hover-color);
        border-radius: 1em;
        text-shadow: 1px 1px 2px rgb(100 0 0 / 0.25);
      }

      label input { display: none; }
      label input:checked + div { color: var(--selected-color); }
      label div { font-variant: small-caps; font-weight: bold; }
    }

    #group-time {
      display: flex;
      flex-wrap: wrap;
      justify-content: space-around;

      label {
        padding: 1ex;
        margin: 2px;
        font-size: 140%;
        text-align: center;
        display: flex;
        align-items: center;
      }
      label > div:first-child {
        margin-inline-end: 10px;
        font-size: 200%;
      }
      label .value {
        font-family: monospace;
        font-size: x-large;
        z-index: -1;
      }
      label .label {
        font-size: 80%;
        z-index: -1;
        color: grey;
        text-shadow: 0px 1px 0px rgb(255 255 255 / 0.3),
                     0px -1px 0px rgb(0 0 0 / 0.7);
        font-style: italic;
      }
    }
  }
}

/* ===== index-view ===== */
@layer components {
  #view-selector {
    display: grid;
    /* Was repeat(2, 1fr), which put the third view on a row of its own
       covering half the width. auto-fit takes the count from the space
       instead, so adding or removing a view needs no change here, and a
       phone still stacks them rather than squeezing three into 390px. */
    grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
    gap: 4px;
    margin-block-end: 1rem;

    a {
      @media screen {
        border: var(--paper-border);
        box-shadow: var(--paper-shadow);
      }
      background-color: rgb(245 245 255);
      color: black;
      text-decoration: none;

      font-size: 140%;
      @media only screen and (max-width: 440px) {
        font-size: 110%;
      }

      padding-block: 0.5ex;
      padding-inline: 1ex;

      &.selected {
        box-shadow: var(--paper-shadow), inset 0 -3px 0 0 var(--selected-color);
      }
      &:active {
        text-shadow: 2px 2px 2px rgb(0 0 0 / 0.4);
      }
      &:hover, &:hover::before {
        background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, var(--page-bgcolor));
      }
      &:hover {
        color: var(--hover-color);
      }
      &:focus-visible {
        outline: 2px solid var(--blue);
        outline-offset: 2px;
      }
    }
  }
}

/* ===== recipe-list ===== */
@layer components {
  /* What this layer is styling, since the markup is only half server-made.

     div.recipe-list-start          sentinel: grows the window upwards
     dl.recipe-list
      ├─ dt.accel-key   "A"         one heading per letter group
      ├─ dd.recipe-item             a row: server-rendered, or injected
      ├─ dd.recipe-item              from the deferred markup by javascript
      ├─ dd.dummy-expander          closes the group
      ├─ dt.accel-key   "B"
      └─ ...
     div.recipe-list-end            sentinel: grows the window downwards

     Only the first screenful of rows arrives as markup; the rest travel
     as inert text and index-search.js injects them as the reader scrolls.
     Two consequences for anything written here. A rule may not assume a
     row was present at parse time, and a rule may not assume the row it
     matches will still be there after the next search -- the list is
     rebuilt wholesale, not toggled. The sentinels carry no style: they
     exist for an IntersectionObserver.

     Three views share this markup, switched by a class on the dl:

     Liste                 Vignettes            Cartes
     (no class)            .river-flow          .cards
     ┌────────────────┐    ┌────┐┌─────┐┌───┐   ┌─────┐ ┌─────┐
     │ name  tags   t │    │ im ││ img ││im │   │ img │ │ img │
     ├────────────────┤    └────┘└─────┘└───┘   ├─────┤ ├─────┤
     │ name  tags   t │    ┌─────┐┌───┐┌────┐   │ name│ │ name│
     ├────────────────┤    │ img ││im ││ img│   │ tags│ │ tags│
     │ name  tags   t │    └─────┘└───┘└────┘   └─────┘ └─────┘
     └────────────────┘    justified rows,      fixed grid,
     text first, dense     picture only         picture and row  */
  /* Full width, like the three views selector and the search tabs above it.

     This was capped at 104ch and centred, on the argument that at 1920 the
     eye crosses most of the screen to get from a recipe name to its cooking
     time. True, but it left the list visibly narrower than every control
     above it, and a column of content inset from its own page chrome reads
     as a mistake -- which is how it was reported. The eye travel is the
     accepted cost; don't cap this back without also narrowing the chrome.

     The name takes the slack rather than the tags:

     │◂──────────────── one row ─────────────────▸│
     ┌──────────────────┐┌────────┐┌──────┐┌──────┐
     │ .recipe-name     ││ .tags  ││ time ││ port │
     └──────────────────┘└────────┘└──────┘└──────┘
       flex-grow: 1        flex-shrink: 0
       min-inline-size: 0
            │
            └─ without this a flex item refuses to shrink below its
               own content, and the name clips instead of ellipsising  */
  .recipe-list:not(.river-flow):not(.cards) {
    dd.recipe-item .recipe-name { flex-grow: 1; min-inline-size: 0; }
    dd.recipe-item .tags        { flex-shrink: 0; }

    /* The thumbnail here is a hover preview, not a target: it magnifies to
       scale(6) under the pointer, and clicking what behaves as a
       magnifying glass would surprise. The link stays in the markup
       because one template serves all three views and the view is a class
       switched in the browser -- so it is switched off here rather than
       left out. The item still takes the hover, since a link that ignores
       the pointer does not swallow it. */
    dd.recipe-item .picture-preview .picture-link { pointer-events: none; }

    /* The magnify scales the img and lifts it with z-index, which used to
       bite because the img was itself the flex item of .picture-preview.
       It is not any more -- the link is -- and z-index does nothing to a
       static, non-flex box, so without this the enlarged thumbnail paints
       underneath the rows below it. The lift moves to the link. */
    dd.recipe-item:hover .picture-preview .picture-link {
      position: relative;
      z-index: 150;
    }
  }

  /* On a phone the date costs a fifth of the width, reads the same on
     nearly every row, and is paid for by truncating the recipe name --
     which is the one thing on the row worth reading. */
  @media (width < 40em) {
    .recipe-list:not(.river-flow):not(.cards) .recipe-item .updated-time {
      display: none;
    }
  }

  .recipe-list {
    padding-inline-start: 0;

    dt {
      @media screen {
        border: var(--paper-border);
        box-shadow: var(--paper-shadow);
      }
      user-select: none;
      background-color: white;
      padding-block: 0.5ex;
      padding-inline: 1ex 0;
      font-size: x-large;
      margin-block-start: 0.5em;
      margin-block-end: 0.75ex;
    }

    dd.dummy-expander {
      display: none;
    }

    dd.recipe-item + dd.recipe-item {
      border-block-start: 1px solid silver;
    }
    dd.recipe-item {
      display: flex;
      margin: 0;
      align-items: center;

      & > * + * { margin-inline-start: 0.5ex; }

      img {
        max-block-size: 1em;
        border-radius: 2px;
      }

      .recipe-name {
        padding-block: 0.75ex;
        padding-inline: 1ex;
        flex-grow: 1;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        text-decoration: none;
      }

      .picture-preview {
        min-inline-size: 2.5em;
        display: flex;
        justify-content: center;
        img { max-inline-size: 2em; }
      }

      .portion, .total-time, .preparation-time {
        font-family: monospace;
      }

      .tags             { white-space: nowrap; }
      .portion          { min-inline-size: 8ex;  }
      .total-time       { min-inline-size: 11ex; }
      .preparation-time { min-inline-size: 8ex;  }

      .tag.robot-name    { display: none; }
      .tag.recipe-source { display: none; }

      &:hover {
        background-color: white;
        img {
          transform: scale(6);
          transform-origin: right 50%;
          border-radius: 4px;
          transition: 0.5s;
          transition-timing-function: ease;
          z-index: 150;
        }
      }
    }

    @media (max-width: 800px) { /* Galaxy Note 10.1 */
      .tags { font-size: 75%; }
    }

    @media (max-width: 600px) {
      .tags .tag.robot-name { display: none; }
      .portion { display: none; }
    }

    @media (max-width: 410px) {
      .preparation-time { display: none; }
      .tags { display: none; }
    }
  }


  .recipe-list.river-flow {
    /* Justified rows, the flexbox way. Each picture keeps its own aspect
       ratio and grows to help fill the row it lands on, so every row ends
       flush on both edges and no image is ever cropped.

       ┌──────┐┌────────────┐┌───────┐
       │ img  ││    img     ││  img  │   a full row: the items share
       └──────┘└────────────┘└───────┘   the slack, both edges align
       ┌─────────┐┌──────┐
       │   img   ││ img  │ ░░░░░░░░░░░   a short last row: ::after
       └─────────┘└──────┘                takes the remainder

       The absurd flex-grow is the whole trick, and it is why it must stay
       absurd. A final row of two would otherwise share all the leftover
       space between those two pictures and blow them up to several times
       the size of every other row. An ::after that grows by 999999999
       claims essentially all of it, leaving the real items at their
       natural width. Any value merely "large" competes with the items;
       this one does not. dd.dummy-expander does the same job at the end
       of each letter group, which is why it is display:block here and
       invisible everywhere else. */
    display: flex;
    flex-wrap: wrap;

    &::after {
      content: '';
      flex-grow: 999999999;
    }

    dt {
      inline-size: 100%;
    }

    dd.dummy-expander {
      display: block;
      flex-grow: 999999999;
    }

    dd.recipe-item {
      position: relative;

      /* Was a flat 150px, which is small on a wide screen for a view whose
         entire point is the photograph. Scales with the viewport between a
         phone-sized floor and a ceiling that keeps a row from becoming two
         enormous pictures. */
      block-size: clamp(150px, 13vw, 230px);

      /* outline, not border: a border occupies layout, so each item was 2px
         wider than the width the justified row arithmetic gave it, and the
         error accumulated across a row. An outline draws in the same place
         and costs nothing. */
      outline: 1px solid rgb(0 0 0 / 0.25);
      outline-offset: -1px;
      margin: 5px;

      /* Hover. Not the list's scale(6) magnify -- the picture already
         fills the tile, so that rule throws it six times past its own
         frame, which is why this view used to switch it off outright and
         offer nothing in its place. What is wanted is only enough to say
         "this answers to you": the picture pushes in a little behind the
         tile's own clip, and the outline darkens so the tile reads as
         picked out of the row.

         The ring and the shadow are the same pair Cartes uses, deliberately:
         these two views are the two built around the photograph, and there
         is no reason for them to answer the pointer differently. The tile
         rings rather than borders because a border would occupy layout and
         throw off the justified row arithmetic above.

         The push is motion and is asked for only where motion is welcome.
         The ring and the shadow are not motion, so they apply either way --
         a reader who turns animation off should still get the feedback. */
      transition: box-shadow 160ms ease;

      /* The ring is an overlay above the picture, not an outline on the
         tile, and that is a correctness matter rather than a taste one.

         An outline belongs to the tile, and the picture is a child that
         gains a stacking context the moment it takes a transform -- so on
         hover the enlarged photograph painted straight over the ring.
         Sampling the tile's own edge pixels showed it plainly: at rest the
         left edge read #A8B1BD, hovered it read the photograph. The ring
         vanished exactly when it was meant to appear, which is what makes
         it look like a flicker rather than a bug.

         An absolutely positioned overlay with a z-index cannot be covered
         by a sibling that has none. It costs no layout, so the justified
         rows are untouched, and it lets both views carry the same ring at
         the same width without either one touching a property that
         occupies space. Same reason the title above needs its z-index. */
      &::after {
        content: '';
        position: absolute;
        inset: 0;
        z-index: 2;
        pointer-events: none;
        border: var(--picked-ring-width) solid transparent;
        transition: border-color 160ms ease;
      }

      &:hover {
        box-shadow: var(--picked-shadow);
        img { transform: none; }

        &::after { border-color: var(--picked-ring); }
      }

      @media (prefers-reduced-motion: no-preference) {
        .picture-preview img       { transition: transform 300ms ease; }
        &:hover .picture-preview img { transform: scale(1.06); }
      }

      overflow: hidden;

      flex-grow: 1;

      * { margin: 0; }

      .starred {
        position: absolute;
        inset-block-end: 5px;
        inset-inline-end: 5px;
      }

      .tags             { display: none; }
      .portion          { display: none; }
      .total-time       { display: none; }
      .preparation-time { display: none; }
      .updated-time     { display: none; }

      .recipe-name {
        position: absolute;
        /* Moved to the foot of the tile: the subject of a photograph is
           usually near its middle, and a caption across the top covered it.
           At the bottom the title sits over the least interesting part of
           most pictures. */
        inset-block-end: 0;
        inset-inline: 0;
        text-overflow: ellipsis;
        inline-size: 100%;
        box-sizing: border-box;

        /* White on a scrim rather than black on 40% white. The old pair was
           legible or not depending entirely on the photograph underneath --
           readable over the pale jam, nearly gone over the dark bowl -- and
           a caption that works on some tiles and not others is a caption
           that cannot be trusted. A gradient darkens only what it needs to
           and fades to nothing over the picture itself. */
        color: white;
        background: linear-gradient(to top,
                                    rgb(0 0 0 / 0.78),
                                    rgb(0 0 0 / 0.45) 55%,
                                    transparent);
        padding-block: 1.4em 0.4ex;
        text-shadow: 0 1px 2px rgb(0 0 0 / 0.55);

        /* Above the picture, explicitly. The caption is absolutely
           positioned but comes EARLIER in the markup than the picture
           does, and the moment the picture takes a transform it gains a
           stacking context and paints as a positioned box at z-index 0 --
           later in the markup, therefore on top. The title vanished under
           its own photograph for exactly as long as the pointer rested on
           it. Nothing here changes at rest; this only decides who wins
           once the push below is running. */
        z-index: 1;
      }

      .picture-preview {
        block-size: 100%;
        inline-size: 100%;

        /* The link is the picture's parent now, so it has to carry the
           whole tile itself: the percentage sizes below resolve against
           their parent, and an auto-height anchor would collapse them. */
        .picture-link {
          display: block;
          block-size: 100%;
          inline-size: 100%;
        }

        img {
          block-size: 100%;
          object-fit: cover;
          max-block-size: 100%;
          max-inline-size: 100%;
          min-inline-size: 100%;
          z-index: inherit;
        }
      }
    }
  }
  .recipe-list.river-flow.rigid {
    dd {
      inline-size: 225px;
    }
  }


  /* Cards: a third view, not a replacement for either of the other two.
     The list is dense and text-first; river-flow is a justified photo
     river that hides the metadata on purpose. This shows the photograph
     AND what the row says about it.

     Column count comes from the available space rather than from
     breakpoints -- auto-fit with a min() guard gives one column on a phone
     and four at 1440 from a single declaration, and never forces an
     overflow below its minimum. */
  .recipe-list.cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
    gap: 1.2rem;
    /* stretch, not start: items must match their row's height or every
       card ends at a different place and the meta lines go ragged. */
    align-items: stretch;

    dt { grid-column: 1 / -1; }
    dd.dummy-expander { display: none; }

    /* No magnify on hover. The list shows a 2em thumbnail and blows it up
       to scale(6) so it can be seen at all; here the picture already fills
       the card, so the same rule throws it six times past its own frame.
       river-flow switches it off for the same reason.

       What stands in its place is deliberately small: the card lifts a
       couple of pixels, its shadow deepens and its border firms up, and
       the picture pushes in behind the card's own clip. The lift and the
       push are motion, and are asked for only where motion is welcome;
       the shadow and the border are not, and apply either way. */
    dd.recipe-item:hover img {
      transform: none;
      border-radius: 0;
    }

    dd.recipe-item, dd.recipe-item + dd.recipe-item {
      display: grid;
      grid-template-areas: "pic" "date" "name" "tags" "meta";
      /* 1fr on the tag row is what pins meta to the bottom of the card */
      grid-template-rows: auto auto auto 1fr auto;
      gap: 0.35rem;
      margin: 0;
      padding: 0;
      border: 1px solid rgb(0 0 0 / 0.12);
      border-block-start: 1px solid rgb(0 0 0 / 0.12);
      border-radius: 6px;
      background: white;
      overflow: clip;

      /* The card itself never moves and never resizes. It used to lift two
         pixels on hover, which read as a defect rather than an effect:
         the whole card is one box, so the title and the tag row went up
         with it and the text under the pointer shifted while being read.
         The ring and the shadow say the same thing and cost no geometry. */
      position: relative;                   /* for the ring below */
      transition: box-shadow 160ms ease, border-color 160ms ease;

      /* The same overlay ring Vignettes uses, at the same width -- see the
         note there for why it is an overlay and not a border. Here it is
         also what lets the ring be three pixels at all: the card's border
         occupies layout, so thickening THAT on hover would shift the card
         and bring back the moving text the note above is about. The border
         only changes colour, and the ring carries the weight. */
      &::after {
        content: '';
        position: absolute;
        inset: 0;
        z-index: 2;
        pointer-events: none;
        border: var(--picked-ring-width) solid transparent;
        border-radius: inherit;
        transition: border-color 160ms ease;
      }

      &:hover {
        border-color: var(--picked-ring);
        box-shadow: var(--picked-shadow);

        &::after { border-color: var(--picked-ring); }
      }

      /* Only the picture moves, and only inside the card's own clip. */
      @media (prefers-reduced-motion: no-preference) {
        .picture-preview img         { transition: transform 300ms ease; }
        &:hover .picture-preview img { transform: scale(1.05); }
      }

      & > * + * { margin-inline-start: 0; }

      .picture-preview {
        grid-area: pic;
        min-inline-size: 0;
        inline-size: 100%;
        aspect-ratio: 16 / 10;
        display: block;

        /* The push is clipped by this plain rectangle rather than by the
           card's rounded, white-backed one. A transformed layer clipped by
           a rounded box is where hairline seams along an edge come from,
           and the card's white background is what such a seam would show.
           Not reproduced in capture at either device pixel ratio -- this
           removes the mechanism rather than a measured defect. */
        overflow: clip;

        /* As in river-flow: the link now sits between the frame and the
           picture, and has to carry the frame's box or the sizes below
           resolve against nothing. */
        .picture-link {
          display: block;
          inline-size: 100%;
          block-size: 100%;
        }

        /* The list caps thumbnails at 2em; a card wants the picture the
           page is already downloading at full size. */
        img {
          max-inline-size: none;
          max-block-size: none;
          inline-size: 100%;
          block-size: 100%;
          object-fit: cover;
          border-radius: 0;
          display: block;
        }
      }

      .updated-time {
        grid-area: date;
        padding-inline: 0.8rem;
        font-size: 0.72em;
        color: rgb(0 0 0 / 0.5);
        font-variant-numeric: tabular-nums;
      }

      .recipe-name {
        grid-area: name;
        padding-block: 0;
        padding-inline: 0.8rem;
        font-size: 1.02em;
        line-height: 1.25;

        /* One line, clipped with an ellipsis, as the list does. Wrapping
           made the name row taller on the cards that needed two lines, and
           since cards stretch to match their row, one long title grew every
           card beside it.

           min-inline-size:0 is what makes the ellipsis appear at all: a
           grid item's automatic minimum is its content, so without this the
           name refuses to shrink, widens its column and pushes the card out
           of the track the outer grid gave it. */
        min-inline-size: 0;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      /* One line, scrolling rather than clipped: a card with five tags
         keeps all five reachable, and cannot grow taller than its
         neighbours to show them. Wrapping was tried and rejected -- even
         clamped to two lines it left rows of unequal height, which is the
         thing this is here to prevent. */
      .tags {
        grid-area: tags;
        padding-inline: 0.8rem;
        display: flex;
        flex-wrap: nowrap;
        gap: 0.3ex;
        align-items: start;
        overflow-x: auto;
        overflow-y: hidden;
        scrollbar-width: none;
        /* a sideways flick scrolls the tags, not the page behind them */
        overscroll-behavior-inline: contain;

        --tag-fade: 1.8rem;

        .tag { flex: 0 0 auto; }

        /* One line, scrolled, never wrapped -- wrapping made cards taller
           than their neighbours and left the bottom of every row ragged.

             is-cut-start          is-cut-end
                  │                     │
                  ▾                     ▾
             ┌ ─ ┬────────────────────┬ ─ ┐
             ░░░ │ tag  tag  tag  tag │ ░░░   both, mid-scroll
             └ ─ ┴────────────────────┴ ─ ┘
              fade                   fade

           Fades only on an edge that is actually cut, which is why the
           classes come from card-tags.js: a mask cannot ask whether its
           element overflows, and the css that can -- scroll-driven
           animation -- is chrome-only. Applied unconditionally it would
           dim the last tag of every row that fits perfectly well.

           is-draggable is published whenever the row overflows at all,
           and is what turns the cursor into a grab handle. */
        &.is-cut-end {
          mask-image: linear-gradient(to right,
                                      #000 calc(100% - var(--tag-fade)),
                                      transparent);
        }
        &.is-cut-start {
          mask-image: linear-gradient(to left,
                                      #000 calc(100% - var(--tag-fade)),
                                      transparent);
        }
        &.is-cut-start.is-cut-end {
          mask-image: linear-gradient(to right,
                                      transparent,
                                      #000 var(--tag-fade),
                                      #000 calc(100% - var(--tag-fade)),
                                      transparent);
        }

        /* Only offer the grab where there is something to drag to. */
        &.is-draggable { cursor: grab; }
        &.is-dragging  { cursor: grabbing; user-select: none; }
      }
      .tags::-webkit-scrollbar { display: none; }

      .total-time, .preparation-time, .portion {
        grid-area: meta;
        padding-block-end: 0.7rem;
        font-size: 0.85em;
      }
      .total-time       { justify-self: start;  padding-inline-start: 0.8rem; }
      .preparation-time { justify-self: center; }
      .portion          { justify-self: end;    padding-inline-end: 0.8rem; }
    }
  }
}

/* ===== tag ===== */
@layer components {
  .tag {
    display: inline-block;
    padding-block: 0.5ex;
    padding-inline: 1ex;
    font-weight: bold;
    color: var(--base3);
    background-color: var(--base00);
    border-radius: 5px;
    font-size: smaller;

    &.on {
      color: var(--base03);
      box-shadow: inset 0px -3px 0px 0px var(--base02);
    }

    /* Group of categories for recipes */
    &.recipe-type        { background-color: var(--blue);    }
    &.recipe-origin      { background-color: var(--violet);  }
    &.recipe-preparation { background-color: var(--green);   }
    &.recipe-profil      { background-color: var(--magenta); }
    &.recipe-season      { background-color: var(--yellow);  }
    &.recipe-source      { background-color: var(--orange);  }
    &.recipe-unknown     { background-color: var(--cyan);    }

    /* Group of categories for ingredients */
    &.ingredient-animal   { background-color: var(--red);     }
    &.ingredient-vegetal  { background-color: var(--green);   }
    &.ingredient-liquid   { background-color: var(--blue);    }
    &.ingredient-product  { background-color: var(--yellow);  }
    &.ingredient-feculent { background-color: var(--violet);  }
    &.ingredient-unknown  { background-color: var(--cyan);    }

    /* Robot name */
    &.robot-name { background-color: var(--cyan); }
  }

  .tagging {
    padding: 1ex;
    text-align: justify;

    .tag {
      cursor: pointer;
      margin: 0.5ex;
    }
    .tag:hover .close {
      color: red;
    }

    &.sticky {
      position: sticky;
      inset-block-start: 1ex;
      z-index: 100;
      border-radius: 5px;
      background-color: var(--page-bgcolor);
      margin: 1ex;
    }
  }
}

/* ===== loading ===== */
@layer components {
  @keyframes loading-spin {
    0%   { transform: rotate(0deg);   }
    100% { transform: rotate(360deg); }
  }

  @keyframes loading-pulse {
    50% { background: var(--spinner-color); }
  }

  .loading-spinner-circle {
    border-radius: 50%;
    inline-size: var(--spinner-base-size);
    block-size: var(--spinner-base-size);
    border: .25rem solid var(--spinner-track-color);
    border-top-color: var(--spinner-color);
    animation: loading-spin var(--spin-duration) infinite linear;
  }
  .loading-spinner-circle--double {
    border-style: double;
    border-width: .5rem;
  }

  .loading-spinner-bar {
    display: inline-block;
    margin-inline: var(--spinner-base-size);
    position: relative;
    inline-size: calc(var(--spinner-base-size) / 4);
    block-size: var(--spinner-base-size);
    background: var(--spinner-track-color);
    animation: loading-pulse var(--pulse-duration) infinite;
    animation-delay: calc(var(--pulse-duration) / 3);
    &::before, &::after {
      content: '';
      position: absolute;
      display: block;
      block-size: calc(var(--spinner-base-size) / 1.5);
      inline-size: calc(var(--spinner-base-size) / 4);
      background: var(--spinner-track-color);
      inset-block-start: 50%;
      transform: translateY(-50%);
      animation: loading-pulse var(--pulse-duration) infinite;
    }
    &::before {
      inset-inline-start: calc(var(--spinner-base-size) / -2);
    }
    &::after {
      inset-inline-start: calc(var(--spinner-base-size) / 2);
      animation-delay: calc(var(--pulse-duration) / 1.5);
    }
  }

  .loading {
    /* Local to this component (not shared tokens): the spinner's base
       size and animation timing, plus the spinner's own two-tone color
       ($white / $off-white in the Sass — $white literally held red,
       preserved verbatim; see FINDINGS). */
    --spinner-base-size: 1em;
    --spinner-color: rgb(255 0 0);
    --spinner-track-color: rgb(255 0 0 / 0.2);
    --spin-duration: 1s;
    --pulse-duration: 750ms;

    .loading-message {
      padding: 1ex;
      text-align: center;
    }
  }
}

/* ===== starred ===== */
@layer components {
  .starred {
    font-size: larger;

    .fas.fa-star {
      display: none;
      color: var(--red);
    }
    .far.fa-star {
      display: inline-block;
      color: var(--base03);
    }

    &.selected {
      .fas.fa-star { display: inline-block; }
      .far.fa-star { display: none; }
    }
  }
}

/* ===== shopping-cart ===== */
@layer components {
  .cart-holder {
    @media screen {
      border: var(--paper-border);
      box-shadow: var(--paper-shadow);
    }
    background: white;
    margin-block-end: 1ex;
    z-index: 2000;

    &.sticky {
      position: sticky;
      inset-block-start: 0.5ex;
    }
  }

  details.cart-holder {
    summary {
      cursor: pointer;
      padding: 1ex;
    }
    /* Chrome-only pseudo-element with no standard equivalent: hides the
       native <details> marker so it doesn't double up with the "\25b6"
       glyph below. Kept despite the "no vendor prefixes" rule, since
       removing it changes rendered output in Chrome. See FINDINGS. */
    summary::-webkit-details-marker {
      display: none;
    }
    summary::before {
      content: "\25b6  ";
      user-select: none;
      inline-size: 1.5em;
    }

    &[open] summary::before {
      content: "\25bc  ";
      user-select: none;
    }

    summary:hover {
      background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, transparent);
      color: var(--hover-color);
    }
  }



  .cart-banner {
    display: flex;
    font-size: 150%;
    line-height: 1.15;
    white-space: nowrap;

    .cart-summary {
      flex-grow: 1;
    }

    .cart-checkout {
      flex-shrink: 0;
      cursor: pointer;
      margin-block: 0;
      margin-inline: 1ex;
      &:hover {
        transition: 0.5s;
        transform: scale(1.5);
      }
    }
  }



  .cart-list {
    min-inline-size: 20em;
    max-inline-size: 40em;
    @media screen {
      border: var(--paper-border);
      box-shadow: var(--paper-shadow);
    }
    background-color: #eeffff;
    padding: 1ex;
    overflow-y: scroll;
    position: fixed;
    inset-inline-end: calc( 1ex / 1.5 );
    inset-block-start: calc( (1em + 2ex) * 1.5 * 1.15 + 2px + 0.5ex );
  }
  .cart-item + .cart-item {
    margin-block-start: 1ex;
  }

  .cart-item {
    padding: 0.5ex;
    display: flex;
    user-select: none;

    .item-entry {
      display: flex;
      flex-grow: 1;
      align-items: center;
      justify-content: center;
      white-space: nowrap;
      text-decoration: none;
      color: inherit;
      &:hover .item-title {
        color: var(--blue);
      }

      .item-picture {
        flex-shrink: 0;
        inline-size: 2em;
        display: flex;
        align-items: center;
        justify-content: center;

        img {
          max-block-size: 1em;
          max-inline-size: 2em;
          border-radius: 4px;
        }

        margin-inline-end: 1ex;
      }


      .item-spacer {
        margin-block: 0;
        margin-inline: 1ex;
        flex-grow: 1;
        border-block-end: 1px dotted currentColor;
        min-inline-size: 2em;
      }
    }
    .item-count {
      inline-size: 2ex;
      margin-block: 0;
      margin-inline: 1ex;
      text-align: right;
      flex-shrink: 0;
      flex-grow: 0;
      font-family: monospace;
      font-size: 120%;
    }

    i.fas, i.far {
      inline-size: 1.5em;
      cursor: pointer;
      text-align: center;
    }
    i.fa-plus  { color: var(--green); }
    i.fa-minus { color: var(--red);   }
  }

  .cart-empty {
    align-items: center;
    justify-items: center;
    margin-block-start: 1em;
    text-align: right;
    inline-size: 100%;
    background-color: #ffeeee;
    padding: 1ex;
    box-sizing: border-box;
    border-radius: 3px;
    cursor: pointer;
    border: 1px solid #eedddd;
    i.fas, i.far {
      margin-inline-start: 1em;
    }

    &:hover {
      color: var(--hover-color);
      border-color: var(--hover-bgcolor);
      background-color: color-mix(in srgb, var(--hover-bgcolor) 20%, transparent);
    }
  }


  .cart-item-add {
    cursor: pointer;
    white-space: nowrap;

    &::after {
      color: color-mix(in srgb, var(--red) 80%, transparent);
      content: attr(data-count);
      font-size: smaller;
      vertical-align: sub;
    }
  }
}

/* ===== recipe ===== */
@layer components {

.kuiristo-settings {
    font-weight: bold;
    white-space: nowrap;

    .icon-speed {
        transform: rotate(20deg);
    }
}

.recipe-preview {
    display: flex;
    justify-content: center;
    margin-block-end: 1em;

    & > .holder {
        position: relative;
        display: inline-block;
        max-width: 100%;
        @media screen {
            border: var(--paper-border);
            box-shadow: var(--paper-shadow);
        }
        box-sizing: border-box;
    }

    .recipe-annotation {
        position: absolute;
        right: 0.5ex;
        bottom: 0.5ex;

        .starred {
            text-shadow: 0 0 0.3em rgba(200,200,200, 0.75);

            &:hover {
                transform-origin: right bottom;
                transition: 0.5s;
                transform: scale(1.5);
            }
        }
    }

    .recipe-tags {
        position: absolute;
        right: 0.5ex;
        top: 0.5ex;
    }

    .recipe-times {
        display: flex;
        flex-direction: column;
        position: absolute;
        top: 0.5ex;
        left: 0.5ex;

        & > div {
            text-align: center;
            width: 4rem;
        }

        .icon {
            font-size: 2.5rem;
            color: silver;
            display: block;
            text-shadow: 0px 1px 0px rgba(0,0,0,.5);
        }
        span {
            font-weight: bold;
            text-shadow: 0 0 0.3em rgba(200,200,200, 0.75);
        }
    }

    .recipe-ratings {
        position: absolute;
        bottom: 0.5ex;
        left: 0.5ex;
        font-size: 1.4rem;
        color: #444444;
    }

    .recipe-picture {
        max-width: 100%;
        max-height: 80vh;
        margin-block: 0;
        margin-inline: auto;
        display: block;
    }
}

.recipe-cooking {
    h2 { margin-block: 0 1ex; margin-inline: 0; }
    dl { margin: 0; }

    dt {
        margin-block: 1em 0.5ex;
        margin-inline: 0;
        font-size: larger;
        font-weight: bold;
    }

    display: flex;
    & > section {
        margin: 0.5ex;
    }

    .recipe-steps { flex-grow: 1; }
    .recipe-ingredients { min-width: 30ex; }
}

.recipe-steps {
    dd {
        margin: 0;
    }

    ol {
        list-style: none;
        padding-inline-start: 0;

        li {
            padding-block: 0.75ex;
            padding-inline: 1ex;
            background-color: white;
            @media screen {
                border: var(--paper-border);
                box-shadow: var(--paper-shadow);
            }
            padding-inline-start: calc(3em + 10px);
            position: relative;
            min-height: calc(2em + 1.5ex);
            text-align: justify;
        }

        li:before {
            content: attr(data-step-index);
            font-size: 2em;
            color: silver;
            position: absolute;
            left: 10px;
            top: 0;
            width: 1em;
            text-align: right;
        }

        li:hover {
            border-color: gold;
        }
        li:hover::before {
            color: gold;
        }

        li + li {
            margin-block-start: 1ex;
        }
    }
}

.recipe-ingredients {
    dd {
        margin: 0;
    }

    ul {
        list-style: none;
        padding-inline-start: 0;

        li {
            padding-block: 0.5ex;
            padding-inline: 0.75em;
            padding-inline-start: 1em;
            position: relative;
        }
        li + li {
            border-block-start: 1px solid silver;
        }
        .preparation {
            display: block;
            font-size: smaller;
            color: grey;
            margin-inline-start: 1em;
        }
        .optional {
            font-size: smaller;
            position: absolute;
            left: 0;
        }
    }
}

.recipe-information {
    & > ul {
        list-style: none;
        padding: 0;
    }
    & > ul > li {
        margin-block-end: 1ex;

        .text {
            @media screen {
                border: var(--paper-border);
                box-shadow: var(--paper-shadow);
            }
            padding-block: 0.5ex;
            padding-inline: 1ex;
            background-color: white;
            text-align: justify;
            p { margin-block: 0.5ex; margin-inline: 0; }
            ul { padding-inline-start: 1.2em; }
            li { margin-block: 0.5ex; margin-inline: 0; list-style: square; }
        }
        .label {
            display: inline-block;
            font-weight: bold;
            color: white;
            padding-block: 0.5ex;
            padding-inline: 1em;
            border-radius: 5px 5px 0 0;
            border: var(--paper-border);
            border-block-end: none;
            min-width: 16ex;
        }
    }

    .info-tip          .label { background-color: #5cb85c; }
    .info-variation    .label { background-color: #5bc0de; }
    .info-beverage     .label { background-color: #f0ad4e; }
    .info-background   .label { background-color: #428bca; }
    .info-serving_size .label { background-color: #c881bd; }
}

.recipe-nutrition {
    & > div {
        display: flex;
        flex-wrap: wrap;
    }

    table {
        border-collapse: collapse;
        border: 1px solid black;
        @media screen {
            border: var(--paper-border);
            box-shadow: var(--paper-shadow);
        }
        margin-inline-end: 1em;
        margin-block-end: 1em;
    }
    table:last-child {
        margin-inline-end: 0;
    }

    th, td {
        border-block-start: 1px solid grey;
        padding: 1ex;
    }
    th {
        text-align: left;
        vertical-align: top;
    }

    tbody td:nth-of-type(1) {
        text-align: right;
        padding-inline-end: 0;
        min-width: 5ex;
    }

    thead th {
        color: white;
        background-color: grey;
    }
}

@media (max-width: 410px) {
    .recipe-cooking {
        flex-direction: column;
    }

    .recipe-preview {
        .recipe-ratings {
            left: inherit;
            right: 0.5ex;
        }
        .recipe-annotation {
            top: 2.5em;
            bottom: inherit;
        }
    }
    .nutrition {
        table { width: 100%; }
    }
}

@media print {
    .recipe {
        h2 > span:first-child {
            display: none !important;
        }
    }

    .recipe-steps {
        h2, dt, ol li {
            padding-inline-start: 1.3cm;
        }
        ol li {
            background: inherit;
            min-height: inherit;
        }
        ol li + li {
            margin-block-start: 0.5ex;
        }
    }

    .recipe-information {
        .label, .text {
            color: inherit !important;
            background: inherit !important;
            border: none !important;
            box-shadow: none !important;
            padding: 0 !important;
        }
        .label > span:first-child {
            display: none !important;
        }
    }

    .recipe-nutrition {
        h2 { display: none; }
        & > div {
            table + table { display: none; }

            tr { display: inline-flex; }
            tr:after { content: ' / '; }
            tr:last-child:after { content: ''; }
            tr.nutrition-energy.calory:after { content: ' ('; }
            tr.nutrition-energy.joule:after { content: ') / '; }

            td, th { display: inline-block; font-weight: normal; }
        }
    }
}

}

/* ===== components ===== */
@layer components {
    .paper-box {
        background: white;

        @media screen {
            border: var(--paper-border);
            box-shadow: var(--paper-shadow);
        }
    }

    .flash-notice {
        padding: 1ex;
        background: #ffeeee;

        @media screen {
            border: var(--paper-border);
            box-shadow: var(--paper-shadow);
        }
    }

    /* A term annotated with a source URL, revealed on hover. */
    span[data-url] {
        text-decoration: underline;
        cursor: help;

        &:hover::before {
            content: "URL: " attr(data-url);
            position: absolute;
            left: 2em;
            right: 2em;
            z-index: 1000;
            display: inline-block;
            padding: 0.5ex 1ex;
            font-family: monospace;
            white-space: nowrap;
            text-decoration: none;
            cursor: default;
            background: white;
            border-radius: 5px;

            @media screen {
                border: var(--paper-border);
                box-shadow: var(--paper-shadow);
            }
        }
    }
}


/* ===== utilities ===== */
@layer utilities {
    /* Winning is the job of this layer, which is why it is last in the
       layer order and why !important is tolerable only here. */
    .hidden {
        display: none !important;
        visibility: hidden;
    }

    .updating {
        color: var(--red);
        animation: pulse 1s infinite;
    }

    .hover-scale:hover {
        transition: 0.5s;
        transform: scale(1.5);
    }
}


/* Attention pulse, used by .updating and by the cart. */
@keyframes pulse {
      0% { box-shadow: 0 0 0  0   rgb(204 44 44 / 0.6); }
     70% { box-shadow: 0 0 0 10px rgb(204 44 44 / 0);   }
    100% { box-shadow: 0 0 0  0   rgb(204 44 44 / 0);   }
}

/* Motion is opt-out: the pulse is decorative, and the colour change on
   .updating still communicates the state without it. */
@media (prefers-reduced-motion: reduce) {
    .updating {
        animation: none;
    }

    .hover-scale:hover {
        transition: none;
        transform: none;
    }
}


/* ===== print ===== */
@page {
    size: A4;
    margin: 10mm;

    @top-center {
        content: string(doctitle);
    }
}

@page :left  { @bottom-left  { content: counter(page); } }
@page :right { @bottom-right { content: counter(page); } }
@page :blank { @top-center   { content: "This page is intentionally left blank."; } }

@media print {
    html {
        background: inherit;
        font-size: 10pt;
    }

    body {
        background: inherit;
        color: inherit;
    }

    ul {
        list-style: square;
    }

    h1.title {
        string-set: doctitle content();
        border: none;
        box-shadow: none;
        background: inherit;
        color: inherit;
    }

    .web-only {
        display: none;
    }
}
