/* foodiephiles.com
 *
 * The inked frame is the UI primitive: the masthead is drawn as a panel, and
 * the artwork's gutter-to-panel ratio is the spacing unit. Two exceptions --
 * the strips arrive already framed by the artist, so they wear no CSS border,
 * and the close control is soft and glassy so it floats above the artwork.
 *
 * No webfonts. The wordmark is lettered into the artwork, so the only type set
 * here is chrome: nameplate, strip title, footer.
 */

/* ---------------------------------------------------------------- tokens -- */

:root {
  /* Sampled from the artwork. The reds across all five strips sit at hue 10-18. */
  --paper: #f4f2ed;
  --card: #ffffff;
  --ink: #141414;
  --wine: #c2451e;
  --shade: #5f5b53;

  /* All layout spacing follows an 8px grid (multiples of 0.5rem / 8px). */
  --gutter: 1.5rem;
  --card-gap: 2.5rem;
  --frame: 3px;
  --measure: 70rem;

  /* Press and release are deliberately asymmetric, because that is what reads as
     snappy. Going in wants to be immediate and uncushioned; coming back out can
     afford to overshoot a little and settle. A real spring on the way *in* would
     carry the control past the pressed state, which no button should do.

     --spring is a sampled spring, overshooting about 9%. linear() is Safari
     17.4+ and Chrome 113+; anywhere older the whole transition is dropped and
     the state change is instant, which is a fine floor. */
  --press: cubic-bezier(0.3, 0, 0.2, 1);
  --spring: linear(0, 0.31 3.5%, 0.83 9.5%, 1.03 13.5%, 1.09 17%,
                   1.06 22.5%, 0.99 30%, 0.98 41%, 1);

  color-scheme: light;
}

@media (max-width: 900px) {
  :root {
    --gutter: 1rem;
    --card-gap: 2rem;
  }
}

/* One tint per strip, hand-picked to harmonize with its artwork. The index is
   plain white -- only a comic page, where one strip fills the screen, takes a
   colour. Keep these in sync with each page's theme-color meta tag. */
[data-bg="index"]                { --comic-bg: #ffffff; }
[data-bg="sunday-preservation"]  { --comic-bg: oklch(0.958 0.022  45); }
[data-bg="locavores-fest"]       { --comic-bg: oklch(0.958 0.024  85); }
[data-bg="the-holiday-spirits"]  { --comic-bg: oklch(0.958 0.020 150); }
[data-bg="the-art-of-freshness"] { --comic-bg: oklch(0.958 0.024 128); }
[data-bg="the-offal-date"]       { --comic-bg: oklch(0.958 0.018 205); }

/* ----------------------------------------------------------------- reset -- */

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

* { margin: 0; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scrollbar-gutter: stable;
}

body {
  min-height: 100svh;
  background: var(--comic-bg, var(--paper));
  color: var(--ink);
  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, sans-serif;
  font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  line-height: 1.5;
}

img {
  display: block;
  max-width: 100%;
  height: auto;
}

ol,
ul {
  list-style: none;
  padding: 0;
}

a { color: inherit; }

/* The frame width doubles as the focus ring. The white halo keeps it visible
   over inked frames and red artwork, which the wine alone disappears against. */
:focus-visible {
  outline: var(--frame) solid var(--wine);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px rgb(255 255 255 / 0.95);
}

/* ------------------------------------------------------------ primitives -- */

.eyebrow {
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--shade);
}

.page {
  max-width: var(--measure);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ----------------------------------------------------------------- index -- */

/* Banner and nameplate are one framed object, and the only outlined box on the
   index -- the strips below carry the artist's own frames. */
.masthead {
  border: var(--frame) solid var(--ink);
  background: var(--card);
  margin-block-start: var(--card-gap);
  margin-block-end: var(--card-gap);
}

.masthead img { width: 100%; }

.masthead-caption {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 1.5rem;
  border-block-start: var(--frame) solid var(--ink);
  padding: 1rem 1.5rem;
}

.masthead-caption h1 {
  font-size: clamp(1.5rem, 0.9rem + 3vw, 2.5rem);
  font-weight: 800;
  letter-spacing: 0.02em;
  line-height: 1;
  text-transform: uppercase;
}

/* One strip per row. */
.archive {
  display: grid;
  gap: var(--card-gap);
  padding-block-end: var(--card-gap);
}

.archive a {
  display: block;
  text-decoration: none;
  touch-action: manipulation;
  /* Would otherwise land as a grey slab across the whole strip. */
  -webkit-tap-highlight-color: transparent;
}

.archive img { width: 100%; }

/* No hover state -- nothing should compete with the artwork. Focus keeps the
   ring, since a shadow alone would not clear WCAG's 3:1 for an indicator, but
   against white it can be a soft lift rather than the halo used over artwork. */
.archive a:focus-visible {
  outline-offset: 6px;
  box-shadow: 0 6px 20px rgb(20 20 20 / 0.18);
}

/* A press does get an answer, though, and it is load-bearing: a tap here is
   followed by a network round trip before anything else moves, and a
   cross-document view transition makes the outgoing page hold its last frame
   while that happens. Without this the page reads as dead rather than busy.

   Depression rather than a highlight, matching the close control. 2% on
   something this large reads about the same as the close control's 8% does at
   3rem -- the perceived push is proportional to the object. */
/* Touch only. This exists because a tap has no hover to answer it, and on a
   pointer the click is instant and the morph is the feedback -- so on desktop it
   is pure cost, and an expensive one: scaling a 1072px-wide image makes Safari
   re-rasterize it at the new scale. */
@media (hover: none) {
  /* Release. Scoped here with the press it answers -- the only transform this
     link ever carries is the one below, so on a pointer the transition stood
     against a property that never changed. */
  .archive a { transition: transform 260ms var(--spring); }

  .archive a:active {
    transform: scale(0.98);
    transition: transform 90ms var(--press);
  }
}

/* ------------------------------------------------------------ comic page -- */

.comic-page {
  --measure: 92rem;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-block: var(--gutter);
  padding-inline: calc(var(--gutter) * 0.5);
}

.comic-header {
  display: flex;
  padding-block-end: calc(var(--gutter) * 1.5);
}

.comic-title {
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* One uncut strip, paged by four invisible snap targets laid over it, so the
   morph target stays a single <img>. Quarters are honest snap points: measured
   off the artwork, every drawn panel centre lands within 0.26% of the image
   width of its exact quarter -- about 10px on a 3900px strip. A sixth strip
   drawn to different proportions would need that re-checked. */
.strip-track { position: relative; }
.strip-track img { width: 100%; }
.strip-snaps { display: none; }

/* Clicking off the artwork exits, so the page is the click target and says so.
   Pointer only, because that is the only place the behaviour exists -- close.js
   binds that handler under (hover: hover) alone, and a cursor nobody has was
   claiming otherwise. cursor inherits, so this reaches the scroller too and the
   artwork has to opt back out. */
@media (hover: hover) {
  .comic-page { cursor: pointer; }
  .strip-track img { cursor: auto; }
}

/* A caption box -- the device a comic already uses to address the reader from
   outside the story, which is exactly what this control is. It was a glassy
   circle carrying an X, which meant borrowing dialog chrome for something that
   is not a dialog, and quarantining it from the design system as "the one soft
   thing" precisely because it did not belong to it. Drawn as an inked frame it
   belongs, and the glass, the shadows and the exception all go.

   The label is "Back", not "Close": nothing is being dismissed, you are going
   to an archive. It also stops the visible label and the accessible name
   disagreeing -- the aria-label reads "Back to all strips", which now contains
   the visible text rather than contradicting it. */
.close {
  position: fixed;
  z-index: 10;
  /* Wide only -- the narrow override below is the one that needs a floor. This
     was written as a max() of --gutter-derived and fixed arms, but --gutter is
     1.5rem at every width this rule survives to, which made both arms
     env + 1.75rem exactly. */
  bottom: calc(env(safe-area-inset-bottom, 0px) + 1.75rem);
  inset-inline: 0;
  margin-inline: auto;
  width: fit-content;
  padding: 0.8125rem 1.5rem;
  border: var(--frame) solid var(--ink);
  background: var(--card);
  color: var(--ink);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  line-height: 1;
  text-transform: uppercase;
  text-decoration: none;
  touch-action: manipulation;
  /* Would otherwise land as a grey slab over the frame. */
  -webkit-tap-highlight-color: transparent;
  /* Release. Transform only: the old rule transitioned background-color on an
     element carrying two box-shadows and a 50% radius, which repaints the whole
     layer every frame and takes the transform down with it -- Safari is far less
     forgiving of that than Chromium, and it is what made this feel clunky. */
  transition: transform 260ms var(--spring);
}

/* Instant, uncushioned, and pointer-only. A colour swap needs no easing to read
   as immediate, and not transitioning it keeps the press on the compositor. */
@media (hover: hover) {
  .close:hover {
    background: var(--ink);
    color: var(--card);
  }
}

.close:active {
  transform: scale(0.94);
  transition: transform 90ms var(--press);
}

/* --------------------------------------------------- comic page, narrow -- */

@media (max-width: 900px) {
  .close {
    bottom: max(calc(env(safe-area-inset-bottom, 0px) + 1.5rem), 2rem);
  }

  /* One panel per screen, flick to advance. scroll-snap-stop: always is what
     makes a hard flick settle on the next panel instead of coasting past. */
  .strip {
    height: 100svh;
    overflow-x: auto;
    overscroll-behavior: contain;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    display: grid;
    align-content: safe center;
  }

  .strip::-webkit-scrollbar { display: none; }

  /* The scroller is full-bleed, so an outset ring would draw off-screen. */
  .strip:focus-visible { outline-offset: -6px; }

  /* Four panel-widths plus the two end gutters, so one quarter fills the screen
     bar --edge on each side. The artist draws no gutter outside panels one and
     four, so --edge stands in for it; measured off the artwork, half a drawn
     gutter is ~7px on a 393px screen, which is what 0.5rem is matching.

     Sized in one calc rather than 400% + padding so that scroll offset zero is
     already panel one's snap position and the far end is panel four's. Both
     ends then land right without leaning on the initial snap. */
  .strip-track {
    --edge: calc(var(--gutter) * 0.5);
    width: calc(400% - 6 * var(--edge));
    padding-inline: var(--edge);

    /* The stand-in that fills the gap before the strip decodes -- see --cover
       below for which picture and why. content-box on both, so it occupies
       exactly the <img>'s box: left at the default padding-box it would paint
       under --edge as well, and an 8px strip of blurred cover would survive at
       each end after the artwork landed on top of it. */
    background-image: var(--cover);
    background-origin: content-box;
    background-clip: content-box;
    background-size: 100% 100%;
    background-repeat: no-repeat;
  }

  .strip-snaps {
    position: absolute;
    inset-block: 0;
    /* Absolute insets resolve against the track's padding box, but the <img>
       fills its content box. `inset: 0` therefore describes a different grid
       than the artwork, out of phase by the padding -- three quarters of it at
       panel one, pushing it outwards, and the same the other way at panel four,
       which is why only the end panels looked off centre. Inset by --edge so
       the snap grid and the artwork are one box. */
    inset-inline: var(--edge);
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 25%;
  }

  /* What fills the gap before the strip decodes. The cover is the same picture
     at lower resolution -- 3.216 against the strip's 3.212, identical framing --
     and the index has already fetched it, so the artwork appears immediately
     and at no cost, then sharpens. Nothing swaps; there is no spinner to design.

     The x descriptors are pure device-pixel-ratio, and the index picks its cover
     off 95vw, so every phone lands on exactly the rung already in its cache:
     393 CSS px x 1/2/3 resolves to 640/960/1280 on both sides.

     Narrow only, deliberately. Wide the strip is 67 KB rather than 144 KB, and a
     background image is fetched at lower priority than the fetchpriority=high
     <img> it would be standing in for -- it would arrive too late to stand in
     for anything, having cost a request.

     Applied by .strip-track above. */
  [data-bg="sunday-preservation"]  { --cover: image-set(
    url("/images/covers/sunday-preservation-640.avif")   1x,
    url("/images/covers/sunday-preservation-960.avif")   2x,
    url("/images/covers/sunday-preservation-1280.avif")  3x); }
  [data-bg="the-holiday-spirits"]  { --cover: image-set(
    url("/images/covers/the-holiday-spirits-640.avif")   1x,
    url("/images/covers/the-holiday-spirits-960.avif")   2x,
    url("/images/covers/the-holiday-spirits-1280.avif")  3x); }
  [data-bg="locavores-fest"]       { --cover: image-set(
    url("/images/covers/locavores-fest-640.avif")        1x,
    url("/images/covers/locavores-fest-960.avif")        2x,
    url("/images/covers/locavores-fest-1280.avif")       3x); }
  [data-bg="the-art-of-freshness"] { --cover: image-set(
    url("/images/covers/the-art-of-freshness-640.avif")  1x,
    url("/images/covers/the-art-of-freshness-960.avif")  2x,
    url("/images/covers/the-art-of-freshness-1280.avif") 3x); }
  [data-bg="the-offal-date"]       { --cover: image-set(
    url("/images/covers/the-offal-date-640.avif")        1x,
    url("/images/covers/the-offal-date-960.avif")        2x,
    url("/images/covers/the-offal-date-1280.avif")       3x); }

  .strip-snaps > li {
    scroll-snap-align: center;
    scroll-snap-stop: always;
  }

  /* The header overlays the artwork so the panel gets the full screen. The fade
     must use the page's own colour -- the per-strip tint, not paper. */
  .comic-header {
    position: fixed;
    z-index: 9;
    inset-block-start: 0;
    inset-inline: 0;
    display: flex;
    justify-content: center;
    align-items: center;
    padding-inline: var(--gutter);
    padding-block: calc(env(safe-area-inset-top, 0px) + 0.6rem) 0.6rem;
    background: linear-gradient(
      var(--comic-bg, var(--paper)) 70%,
      rgb(from var(--comic-bg, var(--paper)) r g b / 0));
    pointer-events: none;
  }

  .comic-title {
    font-size: 0.85rem;
    max-width: 85vw;
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* The scroller is already exactly one viewport tall; any padding here is
     added on top of it and makes the page scroll vertically. */
  .comic-page { padding: 0; }
}

/* ------------------------------------------------------- scroll markers -- */

/* Progressive enhancement, and the only thing telling you there are four
   panels. Chromium 135+ only; elsewhere you get plain snapping, which stands on
   its own. Markers are bars rather than dots so they read as panel edges. */
@supports (scroll-marker-group: after) {
  @media (max-width: 900px) {
    .strip { scroll-marker-group: after; }

    .strip::scroll-marker-group {
      position: fixed;
      z-index: 9;
      inset-block-end: max(6.5rem, calc(50svh - 60vw - 2rem));
      inset-inline: 0;
      display: flex;
      justify-content: center;
      align-items: center;
      gap: 0.5rem;
    }

    .strip-snaps > li::scroll-marker {
      content: "";
      width: 1.75rem;
      height: 3px;
      border: none;
      border-radius: 2px;
      background: rgb(20 20 20 / 0.22);
      transition: background-color 0.15s, height 0.15s;
    }

    .strip-snaps > li::scroll-marker:target-current {
      background: var(--ink);
      height: 5px;
      border-radius: 3px;
    }
  }
}

/* ---------------------------------------------------- morph transitions -- */

/* Wide only, deliberately. Wide, the index thumbnail and the comic page are the
   same object at the same proportions -- a 3.2:1 strip becoming a 3.2:1 strip --
   which is the case this API exists for. Narrow they are not: the destination is
   one panel filling a roughly 1:2 screen, so the "morph" was a 3.2:1 picture
   being cropped into a portrait one, and needed object-fit: cover plus a
   left anchor to look like anything at all. Two different pictures with a tween
   between them is not a morph.

   It is also where the cost lands hardest. A cross-document transition holds the
   outgoing page's last frame until the incoming document can render, and a phone
   on a cold radio is exactly where that reads as a dead tap rather than a slow
   one. Paying that for a crop was the wrong trade. */
@view-transition { navigation: auto; }

/* A view-transition-name must be unique per document but match across the two
   documents to morph, so each comic's index thumbnail and its strip share one.
   Spelled out rather than derived with attr(): that is Chromium-only, and
   elsewhere the invalid declaration would drop the morph in Safari too. */
[data-comic="sunday-preservation"]  { view-transition-name: strip-sunday-preservation; }
[data-comic="the-holiday-spirits"]  { view-transition-name: strip-the-holiday-spirits; }
[data-comic="locavores-fest"]       { view-transition-name: strip-locavores-fest; }
[data-comic="the-art-of-freshness"] { view-transition-name: strip-the-art-of-freshness; }
[data-comic="the-offal-date"]       { view-transition-name: strip-the-offal-date; }

@media (max-width: 900px) {
  @view-transition { navigation: none; }

  /* No transition runs down here, so this is not about pairing any more -- it
     keeps a name off an element laid out four viewports wide, and keeps narrow
     rendering identical to what was measured with it in place. The rest of the
     old narrow pairing -- the name moved onto the scroller, and the
     object-fit: cover that made a 3.2:1 picture cropped into a portrait screen
     bearable -- is gone with the transition it existed for. */
  .strip-track img[data-comic] { view-transition-name: none; }
}

/* ---------------------------------------------------------------- footer -- */

.site-footer {
  padding-block: 2rem;
  text-align: center;
  font-size: 0.75rem;
  color: var(--shade);
}

/* -------------------------------------------------------- reduced motion -- */

@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }

  html { scroll-behavior: auto; }

  *,
  *::before,
  *::after {
    transition-duration: 1ms !important;
    animation-duration: 1ms !important;
  }

  /* The two press states deliberately keep their transforms. They are the only
     signal that a tap landed, and at 1ms each is a state change rather than
     motion -- which is what this query is about. */
}

/* ------------------------------------------------------------ 404 notice -- */

/* Sits where the strips would be. */
.page-notice {
  padding-block: 2rem 4rem;
}

.page-notice a {
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration: underline;
  text-underline-offset: 0.25em;
}
