/* zenmeals-os — the shop floor, not the ledger.
 *
 * This file carries the ZenMeals corporate design across from the customer-
 * facing store at zenmeals.ch, so the tool a sourcer opens in a Migros aisle is
 * recognisably the same business the customer bought from. The values below are
 * not invented: they were read out of the live theme's CSS custom properties,
 * and the three font files next to this stylesheet are the store's own woff2
 * files, byte for byte.
 *
 *   headings  Figtree 700          (--font-heading-family, weight 700)
 *   prose     Assistant 400 / 700  (--font-body-family, weight 400 / bold 700)
 *
 * The fonts are EMBEDDED IN THE BINARY, not fetched from Google or Shopify. A
 * sourcing list that needs a third party to be up before it can be read is a
 * list that fails in the one place it is used — a supermarket basement on one
 * bar of mobile data. This is the same rule the books UI keeps for Chart.js and
 * Alpine, kept here for the same reason.
 *
 * FIGURES STAY MONOSPACE, a deliberate departure from the store. zenmeals.ch
 * sells a subscription; this page is read by someone comparing "1250 g" against
 * "2 x 500g" while holding a basket. Columns of digits that do not line up are
 * columns that get misread, so every quantity, weight and franc amount is set
 * in a monospace face with tabular figures. Prose is the brand's Assistant;
 * numbers are a tool.
 *
 * COLOUR NEVER CARRIES A FACT ALONE. The availability cell is green when an
 * item is in stock at the configured branch — and it also says "Rigi". Strip
 * every colour out of this file and the page must still be correct, because
 * roughly one man in twelve cannot rely on the green, and because a screenshot
 * in bright sun and a phone in battery-saver mode are both real.
 *
 * Every foreground/background pair below was measured, not eyeballed. The
 * store's raw pastels are decorative and fail as text colours — #8cb99c on
 * #dbe9e0 is 1.76:1 — so the state colours are darkened derivatives of the
 * brand hues that clear 4.5:1, and the pastels are kept for backgrounds only.
 */

/* ---------------------------------------------------------------- fonts -- */

@font-face {
  font-family: "Assistant";
  src: url("fonts/assistant-400.8f7d1f17c2ac3abc.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Assistant";
  src: url("fonts/assistant-700.8544167430ed9aae.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Figtree";
  src: url("fonts/figtree-700.34f6b374fc35ab0c.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

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

:root {
  /* The store's own palette. The RGB triplets in the comments are the values as
   * they appear in the live theme, so a future reader can diff this file
   * against zenmeals.ch without having to trust it. */
  --zm-ink: #020912;      /*   2,  9, 18  — foreground, and the dark scheme's ground */
  --zm-paper: #fcfcfc;    /* 252,252,252  — the default scheme's ground */
  --zm-yellow: #ffcc03;   /* 255,204,  3  — the primary button. Ink on yellow, never yellow on paper */
  --zm-mint: #dbe9e0;     /* 219,233,224  — scheme background */
  --zm-sky: #e4f6ff;      /* 228,246,255  — scheme background */
  --zm-peach: #ffdfb8;    /* 255,223,184  — scheme background */
  --zm-link: #356abc;     /*  53,106,188  — link */
  --zm-blue: #2b7ee8;     /*  43,126,232  — contrast */
  --zm-green: #8cb99c;    /* 140,185,156  — contrast (decorative only: 1.76:1 on mint) */
  --zm-orange: #ffa639;   /* 255,166, 57  — contrast (decorative only) */
  --zm-cyan: #64cbff;     /* 100,203,255  — contrast */
  --zm-grey: #bcbcbc;     /* 188,188,188  — contrast */

  /* Surfaces, derived from the palette rather than invented. */
  --ground: var(--zm-paper);
  --panel: #ffffff;
  --panel-2: #f3f5f2;
  --ink: var(--zm-ink);
  --ink-2: #4b5560;
  --ink-3: #79838e;
  --rule: #e3e7ea;
  --rule-strong: #cdd3d9;

  /* State. Each is only ever an ACCOMPANIMENT to a word, and each is measured
   * against the background it actually sits on. */
  --ok: #276840;          /* 5.33:1 on --ok-bg. The raw --zm-green is 1.76:1 and unusable as text */
  --ok-bg: var(--zm-mint);
  --warn: #8a5200;        /* 5.02:1 on --warn-bg */
  --warn-bg: var(--zm-peach);
  --unknown: #5c646c;     /* 5.48:1 on --unknown-bg. "We did not look" is not a warning */
  --unknown-bg: var(--panel-2);
  --info-bg: var(--zm-sky);

  --heading: Figtree, ui-sans-serif, system-ui, -apple-system, sans-serif;
  --sans: Assistant, ui-sans-serif, system-ui, -apple-system, sans-serif;
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;

  /* The store sets body 1.05 and headings 1.1904761904761905 as multipliers. */
  --body-size: 1.05rem;
  --heading-scale: 1.19;

  /* A thumb is about 9 mm across. Nothing tappable is smaller than this, and
   * the list's checkboxes are bigger still — see .check. */
  --tap: 44px;

  --radius: 10px;
  --shadow: 0 1px 2px rgb(2 9 18 / 5%), 0 1px 1px rgb(2 9 18 / 4%);
}

/* Dark is not a second design. It is the store's own dark scheme — ink becomes
 * the ground, paper the foreground — with the pastels replaced by deep tints,
 * because #dbe9e0 behind pale text is unreadable in both directions. */
@media (prefers-color-scheme: dark) {
  :root {
    --ground: var(--zm-ink);
    --panel: #0b1220;
    --panel-2: #131c2b;
    --ink: var(--zm-paper);
    --ink-2: #a7b0bb;
    --ink-3: #79838e;
    --rule: #1e2836;
    --rule-strong: #2d3949;

    --ok: #7fc79b;        /* 7.83:1 on --ok-bg */
    --ok-bg: #12281c;
    --warn: #e8a44e;      /* 7.53:1 on --warn-bg */
    --warn-bg: #2c1f0d;
    --unknown: #98a1ab;
    --unknown-bg: #131c2b;
    --info-bg: #0e2233;

    --shadow: none;
  }
}

/* The explicit toggle beats the media query, in both directions. */
:root[data-theme="light"] {
  --ground: var(--zm-paper);
  --panel: #ffffff;
  --panel-2: #f3f5f2;
  --ink: var(--zm-ink);
  --ink-2: #4b5560;
  --ink-3: #79838e;
  --rule: #e3e7ea;
  --rule-strong: #cdd3d9;
  --ok: #276840;
  --ok-bg: var(--zm-mint);
  --warn: #8a5200;
  --warn-bg: var(--zm-peach);
  --unknown: #5c646c;
  --unknown-bg: var(--panel-2);
  --info-bg: var(--zm-sky);
  --shadow: 0 1px 2px rgb(2 9 18 / 5%), 0 1px 1px rgb(2 9 18 / 4%);
}

:root[data-theme="dark"] {
  --ground: var(--zm-ink);
  --panel: #0b1220;
  --panel-2: #131c2b;
  --ink: var(--zm-paper);
  --ink-2: #a7b0bb;
  --ink-3: #79838e;
  --rule: #1e2836;
  --rule-strong: #2d3949;
  --ok: #7fc79b;
  --ok-bg: #12281c;
  --warn: #e8a44e;
  --warn-bg: #2c1f0d;
  --unknown: #98a1ab;
  --unknown-bg: #131c2b;
  --info-bg: #0e2233;
  --shadow: none;
}

/* ------------------------------------------------------------- elements -- */

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

html {
  /* The phone must not zoom when a field is focused, and must not resize the
   * text because a captive portal said so. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--body-size);
  font-weight: 400;
  line-height: 1.5;
  /* The list is read one-handed; the last row must clear the home indicator. */
  padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

h1,
h2,
h3 {
  font-family: var(--heading);
  font-weight: 700;
  line-height: 1.2;
  margin: 0 0 0.5em;
  letter-spacing: -0.01em;
}

h1 { font-size: calc(var(--body-size) * var(--heading-scale) * 1.6); }
h2 { font-size: calc(var(--body-size) * var(--heading-scale) * 1.25); }
h3 { font-size: calc(var(--body-size) * var(--heading-scale)); }

a {
  color: var(--zm-link);
  text-underline-offset: 2px;
}

/* Every figure on the page. Tabular so a column of grams is a column and not a
 * ragged edge; monospace so 1250 and 1.250 cannot be confused in bad light. */
.num,
td.num,
.qty,
.chf {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

/* ------------------------------------------------------------- controls -- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap);
  padding: 0 1.1em;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: var(--zm-yellow);
  color: var(--zm-ink);   /* 13.22:1. Yellow on paper would be 1.44:1 and is never used */
  font-family: var(--sans);
  font-size: 1rem;
  font-weight: 700;
  text-decoration: none;
  cursor: pointer;
}

.btn--quiet {
  background: transparent;
  border-color: var(--rule-strong);
  color: var(--ink);
}

.btn:focus-visible,
.check input:focus-visible + .check__box {
  outline: 3px solid var(--zm-blue);
  outline-offset: 2px;
}

/* ------------------------------------------------------ the sourcing row -- */

/* One ingredient. A list of blocks rather than a <table> on purpose: a
 * five-column table on a 360 px screen either scrolls sideways or shrinks the
 * numbers, and both are wrong for someone reading at arm's length. Columns
 * come back at 40rem — see the media query at the end. */
.row {
  display: grid;
  /* Same trap as .portions: the middle track carries ingredient names, and
   * German ones ("Macadamianüsse (gemahlen)") are long enough that an auto
   * floor would push the availability cell off the right edge. */
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: 0.25rem 0.9rem;
  align-items: start;
  padding: 0.85rem 1rem;
  border-bottom: 1px solid var(--rule);
}

/* Ticked items stay on the page — a list that hides what you did cannot be
 * checked against the basket at the till. They recede; they do not vanish.
 *
 * Lighter grey and unbolded, rather than the blanket `opacity: 0.55` this was
 * until now. The difference is not only taste: opacity composites the WHOLE
 * subtree, and a child cannot opt back out of an ancestor's. It therefore took
 * the green tick down with it — the one mark on the row that says this item is
 * in the trolley, dimmed along with the text it is meant to answer for.
 * Recolouring the words leaves the tick alone at full strength.
 *
 * The weight is the other half. Bold is what makes an unticked name findable
 * while walking; a list where the done rows are still bold has thirty items
 * shouting equally and no way to see what is left. */
.row--done { color: var(--ink-3); }
.row--done .row__name,
.row--done .row__buy,
.row--done .row__have,
.row--done .row__short,
.row--done .row__need,
.row--done .promo { font-weight: 400; color: var(--ink-3); }
.row--done .row__name { text-decoration: line-through; }

/* The picture and the availability pill carry colour of their own, so greying
 * the text does not reach them. They fade instead — and only they, which is
 * what keeps the tick out of it. */
.row--done .row__pic,
.row--done .avail { opacity: 0.6; }

.row__name { grid-column: 2; font-weight: 700; }
.row__need { grid-column: 2; color: var(--ink-2); font-size: 0.92em; }
.row__buy  { grid-column: 2; font-weight: 700; }

/* The picture of the good. See internal/os/web/icon.go for why it is here at
 * all; this is only how big it is and where it sits.
 *
 * It runs INSIDE the name, at the size of the word it precedes, the way an
 * emoji does — not in a column of its own. A column was tried and measured:
 * a 40px picture track on a 360px screen leaves the name track 63px, which
 * breaks "Blumenkohl" across two lines and makes every row taller. The list
 * that fits fewer goods on a screen is the worse list to hold in a shop, and
 * a picture is not worth a row of scrolling.
 *
 * There is no tile behind it, and that was checked rather than assumed: all
 * twenty-one were rendered at about this size on both scheme grounds before
 * the tile was dropped. The ink outline carries the pale goods (cauliflower, garlic,
 * macadamia) against paper, and the flat colour carries the dark ones (black
 * lentils, chia, cocoa) against near-black. A tile would have been twenty-one
 * bright chips down a dark page, bought for nothing. */
.row__pic {
  /* FLOATED, not inline. Inline, a name too long for its track breaks at the
   * picture — leaving the picture alone on the first line and "Blumenkohl"
   * under it, which reads as two rows. Floated, the name starts beside the
   * picture and its second line runs under it. */
  float: left;
  /* The size of the word it precedes, near enough — an emoji renders at about
   * this against the same text. Measured, not guessed: at 1.5em "Shiitake /
   * Maitake" takes a third line on a 360px screen and at 1.25em it does not,
   * and a picture is not worth a line of a list read in an aisle. */
  width: 1.25em;
  height: 1.25em;
  margin: 0.1em 0.3em 0 0;
}

/* The offer mark: Migros is running a promotion on this row's article.
 *
 * --ok, the same measured green the availability words use (5.33:1 light,
 * 7.83:1 dark) rather than the raw brand green, which is 1.76:1 and unreadable
 * as text. The glyph carries the meaning on its own — stripped of colour the
 * ❋ is still there — and the words are in the title and the aria-label, so
 * nothing here is signalled by hue alone.
 *
 * Not bold and not larger. There is one of these on a list of thirty rows and
 * it should be findable, not loud: the rows still to pick up are what the page
 * is for, and an offer is a footnote to one of them. */
.promo {
  color: var(--ok);
  margin-left: 0.35em;
  /* cursor: help says the title is worth a hover on a desktop; on the phone
   * this page is written for, the long press finds it anyway. */
  cursor: help;
}

/* Deliberately larger than --tap: hit with a thumb while the other hand holds
 * a basket, and a missed tap costs a re-read of the row. */
.check {
  grid-column: 1;
  grid-row: 1 / span 3;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.check input {
  position: absolute;
  opacity: 0;
  width: 52px;
  height: 52px;
  margin: 0;
}

.check__box {
  width: 30px;
  height: 30px;
  border: 2px solid var(--rule-strong);
  border-radius: 8px;
  background: var(--panel);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* The tick. Drawn as two borders of a rotated box rather than a glyph, because
 * a font that has not loaded yet would leave the one control on the page that
 * says "this is in the trolley" blank.
 *
 * It is --ok, the muted green already used for "in stock" — not the brand
 * yellow, which fills the whole box and shouts across a list of thirty rows.
 * A done row is the quiet state: the tick is there to be found when you look
 * for it, not to compete with the rows still to pick up. The tick is never
 * the only signal — .row--done also fades the row and strikes the name — so
 * the list still reads with the colour stripped out. */
.check__box::after {
  content: "";
  width: 8px;
  height: 15px;
  border: solid var(--ok);
  border-width: 0 3px 3px 0;
  border-radius: 1px;
  transform: translateY(-2px) rotate(45deg);
  opacity: 0;
}

.check input:checked + .check__box {
  border-color: var(--ok);
}

.check input:checked + .check__box::after { opacity: 1; }

/* --------------------------------------------------------- availability -- */

/* Four states, four words. Colour is the second signal, never the first, and
 * "unknown" is visually distinct from "not stocked" because they are different
 * facts: one is a gap in what we looked at, the other is an answer. */
.avail {
  grid-column: 3;
  grid-row: 1 / span 2;
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  padding: 0.2em 0.55em;
  border-radius: 999px;
  font-size: 0.85em;
  font-weight: 700;
  white-space: nowrap;
}

.avail--ok      { background: var(--ok-bg);      color: var(--ok); }
.avail--else    { background: var(--info-bg);    color: var(--zm-link); }
.avail--none    { background: var(--warn-bg);    color: var(--warn); }
.avail--unknown { background: var(--unknown-bg); color: var(--unknown); }

/* The age of an availability answer is part of the answer. A check from last
 * Tuesday is not a check. */
.avail__age {
  font-weight: 400;
  color: var(--ink-3);
  font-size: 0.9em;
}

/* ---------------------------------------------------------------- flags -- */

/* What the tool could not work out. Never hidden, never styled to be
 * ignorable: a row we could not map is a row that will be missing from the
 * basket, and that is worse than an ugly page. */
.flag {
  display: block;
  grid-column: 1 / -1;
  margin-top: 0.5rem;
  padding: 0.5rem 0.7rem;
  border-left: 3px solid var(--warn);
  background: var(--warn-bg);
  color: var(--warn);
  border-radius: 0 var(--radius) var(--radius) 0;
  font-size: 0.9em;
}

.stale {
  display: block;
  padding: 0.6rem 1rem;
  background: var(--warn-bg);
  color: var(--warn);
  font-weight: 700;
  font-size: 0.92em;
}

/* Whether the portion counts above could have come from Shopify.
 *
 * Three states, and the third is not a shade of the second: no credentials on
 * the server is UNKNOWN territory — nobody has tried — while credentials and
 * no install is a warning, because the counts on the page are hand-typed and
 * the page has to keep saying so. The colour only ever accompanies the
 * sentence; every state reads correctly in greyscale. */
.conn {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
  padding: 0.6rem 1rem;
  font-size: 0.92em;
  font-weight: 700;
}

.conn--connected    { background: var(--ok-bg);      color: var(--ok); }

/* The mascot praising a prepared or delivered stop. Sized to the text beside
 * it rather than the other way round — this is still a status line, and a
 * mascot that took it over would stop being one. */
.conn__mascot { flex: none; width: 40px; height: auto; display: block; }
.conn--disconnected { background: var(--warn-bg);    color: var(--warn); }
.conn--unconfigured { background: var(--unknown-bg); color: var(--unknown); }

/* ---------------------------------------------------------------- wider -- */

@media (min-width: 40rem) {
  /* ONE grid for the whole list, not one per row.
   *
   * Each <li> used to carry its own `grid-template-columns`, and a grid only
   * ever sizes its own tracks — so every row measured its columns against its
   * OWN contents. The availability pill is the widest thing on the right and
   * it is not the same width twice ("Rigiplatz ✓ vor 2 Std." against "✗
   * nirgends", or nothing at all on a row with no article), and because the
   * name track is 1fr it swallowed whatever the pill did not use. The result
   * was a page of columns that lined up with nothing: the quantity for
   * Broccoli sat a centimetre right of the quantity for Ingwer.
   *
   * The list owns the track definition now and each row maps onto it with
   * `subgrid`, so all five columns are measured once, across every row, and
   * every row draws them in the same place. Subgrid rather than
   * `display: contents` on the row because the row is a BOX — it has the
   * padding and the rule under it — and display:contents would throw both
   * away.
   *
   * Columns only. The rows stay each row's own business, which is what lets a
   * name wrap to a second line without dragging the whole list with it. */
  .rows--sourcing {
    display: grid;
    grid-template-columns: auto minmax(12rem, 1fr) 8rem 8rem auto;
  }

  .rows--sourcing .row {
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
    align-items: center;
  }

  .row__name { grid-column: 2; }
  .row__need { grid-column: 3; grid-row: 1; }
  .row__buy  { grid-column: 4; grid-row: 1; }
  .check     { grid-row: 1; }

  /* The pill hugs its words; it does not fill the column.
   *
   * Sharing one grid made the last track as wide as the WIDEST pill in the
   * list, and a grid item stretches into its track by default — so "✗
   * nirgends" drew as a wide amber bar reaching to the page edge, which reads
   * as a banner about the row rather than a label on it. Only the text is
   * ever the coloured shape. */
  .avail {
    grid-column: 5;
    grid-row: 1;
    justify-self: start;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* ------------------------------------------------------------ furniture -- */

/* The bar carries exactly two things: the menu, and one line of title. Every
 * other control moved into the menu, because two rows of chrome above a
 * shopping list is two rows of a phone screen not showing the shopping list. */
.topbar {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.55rem 0.9rem;
  border-bottom: 1px solid var(--rule);
  background: var(--panel);
  padding-top: calc(0.55rem + env(safe-area-inset-top));
}

/* One line, and it stays one line. min-width:0 lets the flex child shrink so
 * the ellipsis can happen at all — without it a long page name pushes the
 * whole bar wider than the screen instead of truncating. */
.topbar__title {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  min-width: 0;
  margin: 0;
  font-family: var(--heading);
  font-size: 1.02rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  white-space: nowrap;
  overflow: hidden;
}

.topbar__brand {
  display: inline-flex;
  align-items: baseline;
  gap: 0.28rem;
  flex: none;
  color: var(--ink);
  text-decoration: none;
}

/* Sized in em so the wordmark tracks the title's own size rather than a
 * pixel guess, and aligned on its baseline rather than its box — the logo has
 * transparent padding above and below the letters. */
.topbar__logo {
  height: 0.82em;
  width: auto;
  align-self: center;
  display: block;
}

.topbar__os { font-size: 0.95em; color: var(--ink-2); }

.topbar__sep { flex: none; color: var(--ink-3); }

/* The page name is what gives way when the screen is narrow: the brand is two
 * short words and the sourcer already knows where they are. */
.topbar__page {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--ink);
}

.topbar form { margin: 0; }

.btn--small {
  min-height: 34px;
  padding: 0 0.7em;
  font-size: 0.85rem;
}

main { display: block; }

.page, .card {
  max-width: 44rem;
  margin: 0 auto;
  padding: 1.25rem 1rem 3rem;
}

/* The login card is the one screen a stranger can reach, so it is the one
 * screen that should look composed rather than administrative. */
.card { max-width: 26rem; padding-top: 3rem; }

.muted { color: var(--ink-2); }

.notice {
  padding: 0.7rem 0.9rem;
  background: var(--info-bg);
  border-radius: var(--radius);
}

.stack { display: grid; gap: 0.6rem; margin-top: 1.25rem; }

.stack label { font-weight: 700; font-size: 0.9rem; }

.stack input {
  min-height: var(--tap);
  padding: 0 0.8em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  /* 16px minimum: iOS Safari zooms the whole page when a focused field is
   * smaller than this, and a page that zooms on focus is a page the sourcer
   * has to pinch back every time. */
  font-size: 16px;
  font-family: var(--sans);
}

.rows {
  list-style: none;
  margin: 1.25rem 0 0;
  padding: 0;
  border-top: 1px solid var(--rule);
}

/* The portion counts. Two numbers the sourcer edits before shopping, so they
 * sit at the top and are big enough to hit without zooming. */
.portions {
  display: grid;
  /* minmax(0, 1fr) and NOT 1fr. A bare 1fr means minmax(AUTO, 1fr), and the
   * auto floor is the content's intrinsic width — for <input type="number">
   * that is a UA default of roughly 170px plus a spinner, which two of cannot
   * fit beside each other on a 360px phone. The track then refuses to shrink
   * and the whole page scrolls sideways, in a shop, one-handed. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 0.4rem 0.8rem;
  align-items: center;
  margin: 1rem 0;
  padding: 0.9rem 1rem;
  background: var(--panel-2);
  border-radius: var(--radius);
}

.portions label { font-size: 0.85rem; font-weight: 700; }
.portions label[for="sv"] { grid-column: 1; grid-row: 1; }
.portions label[for="np"] { grid-column: 2; grid-row: 1; }
.portions input[name="sv"] { grid-column: 1; grid-row: 2; }
.portions input[name="np"] { grid-column: 2; grid-row: 2; }

.portions input {
  /* Fill the track rather than assert an intrinsic width against it. Both are
   * needed: width:100% alone still leaves the auto minimum in place. */
  width: 100%;
  min-width: 0;
  min-height: var(--tap);
  padding: 0 0.7em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  /* 16px keeps iOS Safari from zooming the page on focus. */
  font-size: 16px;
}

.portions .btn { grid-column: 1 / -1; grid-row: 3; margin-top: 0.3rem; }

/* Placed explicitly. Auto-placement would put it in the next free cell, which
 * after a full-width button means inventing a fourth row half a column wide. */
.portions .chip { grid-column: 1 / -1; grid-row: 4; justify-self: start; }
/* The chip and the sync button share row 4 and wrap inside it, so the button
 * sits beside the time it would change — and is not caught by the full-width
 * .btn rule above, which would stack it onto Berechnen. */
.portions__asof { grid-column: 1 / -1; grid-row: 4; display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.portions .portions__asof .btn { grid-column: auto; grid-row: auto; margin-top: 0; }

/* The week, directly above the form. One short line; the address it is signed
 * in as moved into the menu, where it is looked at once a month. */
/* Nothing on this page may scroll sideways. A wide cell should wrap or
 * ellipsize inside its own track; if one ever escapes, this is the backstop
 * that keeps the LIST readable rather than the whole page shifted. */
.page { max-width: 100%; }
/* hyphens first, `anywhere` as the backstop. The picture takes about 25px off
 * the first line of every name, so names wrap more often than they used to,
 * and "Blumenk / ohl" is what `anywhere` does on its own. The page carries a
 * lang attribute, so the browser can hyphenate properly — "Blumen-kohl" — and
 * `anywhere` stays behind it for the words no dictionary will break. */
.row__name { min-width: 0; hyphens: auto; overflow-wrap: anywhere; }

.week {
  margin: 0 0 0.6rem;
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--ink-2);
}

/* The aisle heading. Sticky, because the sourcer looks up from the trolley and
 * needs to know which part of the shop the rows under their thumb belong to. */
.section {
  position: sticky;
  top: 0;
  z-index: 1;
  margin: 1.5rem 0 0;
  padding: 0.45rem 1rem;
  background: var(--panel-2);
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
  font-family: var(--heading);
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-2);
}

.rows + .section { margin-top: 0; }

/* The quantity is what the eye goes to, so it is set large and the word under
 * it is deliberately quiet. A label in FRONT of the figure — "Need 9250 g" —
 * pushes the number right and makes a column of them impossible to scan. */
.row__need {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: 1.05rem;
  font-weight: 700;
  line-height: 1.15;
  color: var(--ink);
}

.row__need small {
  display: block;
  font-family: var(--sans);
  font-size: 0.72rem;
  font-weight: 400;
  color: var(--ink-3);
}

/* ---------------------------------------------------------------- menu -- */

/* A <details> disclosure. The three tools live in here rather than across the
 * top, because the top of this page is the only place a phone has left once the
 * aisle heading is sticky — and the tools are used once a session while the
 * list is read for twenty minutes.
 *
 * No JavaScript: <details> opens natively, is keyboard reachable, and survives
 * a page whose script never arrived. */
.menu { position: relative; }

.menu > summary {
  /* The default triangle would sit next to the bars and read as a second,
   * unrelated control. */
  list-style: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap);
  height: var(--tap);
  margin-left: -0.5rem;
  border-radius: var(--radius);
  cursor: pointer;
}

.menu > summary::-webkit-details-marker { display: none; }
.menu > summary:focus-visible { outline: 3px solid var(--zm-blue); outline-offset: 2px; }

/* Three bars, drawn rather than fetched: one span, two pseudo-elements, no
 * icon font and no request. */
.menu__bars,
.menu__bars::before,
.menu__bars::after {
  display: block;
  width: 20px;
  height: 2px;
  border-radius: 2px;
  background: var(--ink);
  content: "";
}

.menu__bars { position: relative; }
.menu__bars::before { position: absolute; top: -6px; }
.menu__bars::after { position: absolute; top: 6px; }

/* Open: the bars become a cross, so the control says what tapping it will do. */
.menu[open] .menu__bars { background: transparent; }
.menu[open] .menu__bars::before { top: 0; transform: rotate(45deg); }
.menu[open] .menu__bars::after { top: 0; transform: rotate(-45deg); }

.menu__panel {
  position: absolute;
  top: calc(var(--tap) + 0.3rem);
  left: -0.5rem;
  z-index: 10;
  display: flex;
  flex-direction: column;
  min-width: 13rem;
  padding: 0.4rem;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--panel);
  box-shadow: 0 8px 24px rgb(2 9 18 / 12%);
}

.menu__panel a,
.menu__soon {
  display: block;
  min-height: var(--tap);
  padding: 0.6em 0.7em;
  border-radius: 8px;
  font-size: 0.95rem;
  font-weight: 700;
  text-decoration: none;
  white-space: nowrap;
}

.menu__panel a { color: var(--ink); }
.menu__panel a[aria-current="page"] { background: var(--zm-yellow); color: var(--zm-ink); }

/* Visibly not a destination, and it says why. */
.menu__soon { color: var(--ink-3); }
.menu__soon small { font-weight: 400; }

.menu__rule {
  height: 0;
  margin: 0.35rem 0.2rem;
  border: 0;
  border-top: 1px solid var(--rule);
}

/* Two links, small, side by side. Used once and then remembered in a cookie,
 * so it earns a corner of the menu and not a corner of the screen. */
.menu__langs { display: flex; gap: 0.3rem; padding: 0.15rem 0.2rem; }

.menu__langs a {
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  padding: 0 0.75em;
  border-radius: 8px;
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--ink-3);
  text-decoration: none;
  white-space: nowrap;
}

/* aria-current marks the active language for a screen reader; the styling
 * follows the same attribute so the two can never disagree. */
.menu__langs a[aria-current="true"] {
  color: var(--ink);
  background: var(--panel-2);
}

/* Who this phone is signed in as. The kitchen's phone is shared. */
.menu__who {
  margin: 0.2rem 0.2rem 0.5rem;
  padding: 0 0.7em;
  font-size: 0.8rem;
  line-height: 1.35;
  color: var(--ink-2);
}

.menu__who strong { color: var(--ink); word-break: break-all; }

.menu__panel form { margin: 0 0.2rem 0.2rem; }
.menu__panel form .btn { width: 100%; }

/* A two-word marker, not a sentence. It says the number beside it was typed
 * by hand rather than measured — which the page may never leave unsaid — in
 * the space a sentence would have taken above the list. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  padding: 0.15em 0.55em;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 700;
  white-space: nowrap;
}

.chip--manual {
  background: var(--warn-bg);
  color: var(--warn);
  cursor: help;
}

.chip--manual::before { content: "✎"; }

/* The measured counterpart. It uses the ok pair rather than a new colour,
 * because "these numbers came from the orders" is the same kind of fact as
 * every other ok state on the site — and both members of the pair are already
 * measured at ≥4.5:1 in both themes, which a fresh green would not be. */
.chip--measured {
  background: var(--ok-bg);
  color: var(--ok);
  cursor: help;
}

.chip--measured::before { content: "❋"; }

/* A phase that is on record — prepared, delivered. The ok pair again, for the
 * same reason as above, and a tick: done, and written down. The tick is
 * pseudo-content, so a screen reader hears the word and not "check mark". */
.chip--done {
  background: var(--ok-bg);
  color: var(--ok);
}

.chip--done::before { content: "✔︎"; }

/* The forecast arrow. A TAP TARGET, not a hover: this page is read on a phone
 * in a shop, where there is no cursor to hover with, so the bubble has to be
 * reachable by touch and by a screen reader — hence a button with a label
 * rather than a styled span. */
.trend {
  appearance: none;
  border: 0;
  background: transparent;
  cursor: help;
  font-size: 0.95rem;
  line-height: 1;
  padding: 0.15em 0.35em;
  border-radius: 999px;
}

/* Up is more food and down is less — and both use the measured state pair
 * rather than green-good/red-bad. More orders is not "good news" to somebody
 * deciding what to carry home; it is a bigger shopping trip. The colours say
 * "this is a reading", and the arrow says which way. */
.trend--up   { color: var(--ok);   background: var(--ok-bg); }
.trend--down { color: var(--warn); background: var(--warn-bg); }
.trend--flat { color: var(--ink-soft, #555); background: transparent; }

/* The two disclosures under the list: rows held back, rows not needed. Closed
 * they are one quiet line; open they name every row they are counting, which
 * is what makes the count worth believing. */
.aside {
  margin-top: 0.8rem;
  font-size: 0.9em;
  color: var(--ink-2);
}

.aside > summary {
  min-height: var(--tap);
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.2rem 0;
  cursor: pointer;
  list-style: none;
}

.aside > summary::-webkit-details-marker { display: none; }

/* The affordance: a caret that turns. Without it a summary reads as a
 * sentence nobody thinks to tap. */
.aside > summary::before {
  content: "";
  flex: none;
  width: 0;
  height: 0;
  border-left: 5px solid currentColor;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: transform 120ms ease;
}

.aside[open] > summary::before { transform: rotate(90deg); }
.aside > summary:focus-visible { outline: 3px solid var(--zm-blue); outline-offset: 2px; }

.aside__list {
  margin: 0.1rem 0 0;
  padding: 0 0 0 1.1rem;
  list-style: disc;
}

.aside__list li { margin: 0.25em 0; }
.aside__list small { color: var(--ink-3); }

/* -------------------------------------------------------------- budget -- */

/* The budgeting page. Not one new colour: every pair below is one of the state
 * tokens measured at the top of this file, because a page whose subject is what
 * it does not know is the last place to introduce an unmeasured foreground.
 *
 * Three states, three renderings, and the rendering never depends on colour
 * alone — an unknown figure says "unbekannt" in words, an estimate says
 * "geschätzt", and stripping every colour out of this file leaves the page
 * exactly as correct as it was. */

.figs {
  list-style: none;
  margin: 1rem 0 0;
  padding: 0;
}

/* One labelled amount. On a phone the label sits above its figure rather than
 * beside it: a franc amount squeezed into the right-hand third of a 360 px
 * screen wraps, and a wrapped franc amount is a franc amount misread. */
.fig {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.15rem 0.8rem;
  align-items: baseline;
  padding: 0.85rem 1rem;
  border-bottom: 1px solid var(--rule);
}

.fig__label {
  grid-column: 1;
  font-weight: 700;
}

/* What a figure does NOT include. Small, but never grey enough to skip: a
 * margin that quietly leaves out three costs is read as a margin. */
.fig__label small {
  display: block;
  font-weight: 400;
  font-size: 0.8rem;
  color: var(--ink-2);
}

.fig__amount {
  grid-column: 2;
  grid-row: 1;
  font-size: 1.15rem;
  font-weight: 700;
  white-space: nowrap;
}

/* The gap, as a thing you can see from across the room. Not a dash, not an
 * empty cell, not CHF 0.00 — a word, on its own ground. */
.fig__unknown {
  padding: 0.15em 0.6em;
  border-radius: 999px;
  background: var(--unknown-bg);
  color: var(--unknown);       /* 5.48:1 — measured at the top of this file */
  font-family: var(--sans);
  font-size: 0.9rem;
  font-weight: 700;
}

/* The label that can never come off an amount. */
.fig__basis {
  grid-column: 2;
  grid-row: 2;
  justify-self: end;
  padding: 0.1em 0.5em;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 700;
  white-space: nowrap;
}

.fig--measured .fig__basis  { background: var(--ok-bg);   color: var(--ok); }
.fig--estimated .fig__basis { background: var(--warn-bg); color: var(--warn); }

/* The en dash between the two ends of an interval. Spaced, and never the thing
 * that stands in for a missing number — that is .fig__unknown's job, in words. */
.fig__dash { font-weight: 400; color: var(--ink-2); }

/* Why an estimate is an estimate, or why a gap is a gap. It is the whole
 * content of an unknown, so it is set as prose at full reading contrast rather
 * than as a footnote. */
.fig__why {
  grid-column: 1 / -1;
  margin: 0.45rem 0 0;
  font-size: 0.88rem;
  line-height: 1.45;
  color: var(--ink-2);
}

/* ------------------------------------------------------------ the gaps -- */

/* Today two of twenty-five ingredients have a price anybody paid, so this is
 * not an appendix — it is the page. It reads as content, not as an error. */
.gaps { padding: 0.25rem 1rem 0; }

.gaps__count {
  margin: 0.6rem 0;
  font-weight: 700;
}

.gaps__list {
  list-style: none;
  margin: 0.9rem 0 0;
  padding: 0;
}

.gap {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.2rem 0.8rem;
  padding: 0.7rem 0;
  border-top: 1px solid var(--rule);
}

.gap__name { font-weight: 700; }

.gap__state {
  justify-self: end;
  padding: 0.1em 0.55em;
  border-radius: 999px;
  background: var(--unknown-bg);
  color: var(--unknown);
  font-size: 0.8rem;
  font-weight: 700;
}

.gap__why {
  grid-column: 1 / -1;
  margin: 0.2rem 0 0;
  font-size: 0.88rem;
  line-height: 1.45;
  color: var(--ink-2);
}

.gaps__unpriced {
  margin-top: 1rem;
  padding: 0.7rem 0.9rem;
  border-radius: var(--radius);
  background: var(--panel-2);
  font-size: 0.92rem;
  line-height: 1.5;
}

/* ------------------------------------------------------------ the bill -- */

/* A bill row has no checkbox — nothing here is picked up off a shelf — so it
 * drops the first column the sourcing row reserves for one. Name and cost on
 * the top line, quantity and price beneath: the two numbers a reader compares
 * are the two on the right, in one column, in tabular figures. */
.row--bill { grid-template-columns: 1fr auto; }

.row--bill .row__name { grid-column: 1; grid-row: 1; }
.row--bill .row__need { grid-column: 1; grid-row: 2; }

.row__price,
.row__cost {
  grid-column: 2;
  justify-self: end;
  text-align: right;
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  line-height: 1.2;
}

.row__cost { grid-row: 1; font-size: 1.05rem; font-weight: 700; }
.row__price { grid-row: 2; color: var(--ink-2); font-size: 0.92rem; }

.row__price small,
.row__cost small {
  display: block;
  font-family: var(--sans);
  font-size: 0.72rem;
  font-weight: 400;
  color: var(--ink-3);
}

/* An unpriced row says so where its price would be, in words. */
.row__price--none {
  font-family: var(--sans);
  font-weight: 700;
  color: var(--unknown);
}

/* Why a row could not be costed. Every unknown carries its reason — but this
 * one is set as quiet prose rather than as a .flag, because twenty-three amber
 * warning boxes down one page is a wall nobody reads, and "Was fehlt" states
 * the same gaps above where the eye lands. */
.row__why {
  grid-column: 1 / -1;
  margin: 0.4rem 0 0;
  font-size: 0.85rem;
  line-height: 1.45;
  color: var(--ink-2);
}

/* --------------------------------------------------------- provenance -- */

/* A figure nobody can source is worse than a blank, so this block is the last
 * thing on the page and the first thing to check. */
.source { padding: 0.25rem 1rem 0; }

.source h3 {
  margin: 1.4rem 0 0.4rem;
  font-size: 0.95rem;
}

.source__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.source__list li {
  padding: 0.6rem 0;
  border-top: 1px solid var(--rule);
}

.source__name { font-weight: 700; margin-right: 0.5rem; }

.source__list small { color: var(--ink-2); margin-left: 0.4rem; }

.source__why {
  margin: 0.25rem 0 0;
  font-size: 0.88rem;
  line-height: 1.45;
  color: var(--ink-2);
}

/* The portion counts, when Shopify supplied them and there is no form. */
.portions__measured,
.portion-count {
  margin: 0.6rem 0;
  font-weight: 700;
  color: var(--ink-2);
}

@media (min-width: 40rem) {
  .fig {
    grid-template-columns: minmax(14rem, 1fr) auto 7rem;
    align-items: center;
  }

  .fig__amount { grid-column: 2; grid-row: 1; }
  .fig__basis  { grid-column: 3; grid-row: 1; justify-self: start; }
  .fig__why    { grid-column: 1 / -1; }

  .row--bill {
    grid-template-columns: minmax(12rem, 1fr) 8rem 11rem 8rem;
    align-items: center;
  }

  .row--bill .row__name  { grid-column: 1; grid-row: 1; }
  .row--bill .row__need  { grid-column: 2; grid-row: 1; }
  .row--bill .row__price { grid-column: 3; grid-row: 1; }
  .row--bill .row__cost  { grid-column: 4; grid-row: 1; }
}
/* --------------------------------------------------------------- lager -- */

/* The shelf. One block per (ingredient, unit), because two units for one
 * ingredient are two shelves and their sum is a number about nothing.
 *
 * A block list rather than a table, for the same reason the sourcing row is
 * one: four columns on a 360 px screen either scroll sideways or shrink the
 * figure, and the figure is what the page is for. */
.shelf {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--rule);
}

/* One column, not two. The name and the level used to be two grid cells side
 * by side; they are now the two ends of the row's summary line, and the row
 * stacks that line, the count box it opens, the target and the age. */
.shelf__row {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0.15rem;
  /* No top padding: the summary line's own --tap min-height centres the name
   * and gives the row its air. Adding the old 0.7rem on top of that would make
   * every one of twenty-five rows a third taller for nothing. */
  padding: 0 1rem 0.5rem;
  border-bottom: 1px solid var(--rule);
}

/* The line a thumb lands on. Tapping it opens a box that counts THIS shelf —
 * so the whole line is the target, not the eight monospace characters of the
 * figure, which is not a thing anybody hits while holding a jar.
 *
 * min-height is --tap and the row's own vertical padding came down to match,
 * so the list is the height it always was and the target is a proper one. */
.shelf__head {
  /* A grid and not a flex row, and the difference is visible on twenty rows.
   * Flex shrinks the level in proportion to how wide it is, so the long
   * "unbekannt — nie gezählt" folded onto two lines the moment the NAME needed
   * two — on half the shelf. A grid gives the level an auto track, which keeps
   * its max-content width wherever there is room and gives way only where
   * there is not: one row in twenty-five, and the right one. */
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: 0.9rem;
  min-height: var(--tap);
  cursor: pointer;
  list-style: none;
}

.shelf__head::-webkit-details-marker { display: none; }
.shelf__head:focus-visible { outline: 3px solid var(--zm-blue); outline-offset: -3px; }

/* The affordance: the same caret the aside uses, for the same reason — without
 * it a level reads as a printed figure and nobody thinks to tap it. Drawn from
 * borders rather than set as a glyph, so a font that has not arrived yet does
 * not leave the row looking inert. */
.shelf__head::after {
  content: "";
  width: 0;
  height: 0;
  border-left: 5px solid var(--ink-3);
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: transform 120ms ease;
}

.shelf__edit[open] .shelf__head::after { transform: rotate(90deg); }

.shelf__name { font-weight: 700; }

/* Shrinkable, but never breakable: a figure and its unit stay on one line,
 * because "1250" on one line and "g" on the next is two facts pretending to be
 * one. The word "unbekannt" has no such claim on a line and is allowed to wrap
 * — see below. */
.shelf__level {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  text-align: right;
  white-space: nowrap;
}

/* The count box on the row. Wraps rather than scrolls: on a 360 px screen with
 * a unit picker beside it the button drops to its own line, which is fine, and
 * a control that scrolled out of sight is a control nobody presses. */
.shelf__count {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin: 0.15rem 0 0.55rem;
}

/* Monospace and tabular, like the figure it replaces — what you type lines up
 * under what was there. 16px for the same reason every other field on this
 * site is: below it, iOS Safari zooms the page on focus. */
.shelf__count-qty {
  flex: 1 1 6rem;
  min-width: 0;
  min-height: var(--tap);
  padding: 0 0.8em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: 16px;
}

/* The row's own unit, printed beside the field rather than offered in a
 * picker. It is not a control: this shelf is measured in grams and typing a
 * number here does not get to change that. */
.shelf__count-unit {
  flex: none;
  font-family: var(--mono);
  font-weight: 700;
  color: var(--ink-2);
}

/* And where nothing states the unit, the picker that asks. */
.shelf__count select {
  flex: none;
  min-height: var(--tap);
  padding: 0 0.5em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-size: 16px;
  font-family: var(--sans);
}

/* "unknown" is a WORD, and the pill is only its second signal. It is
 * deliberately not the warning colour: nobody has done anything wrong, the
 * shelf has simply never been counted — and a page that shouts at 20 rows on a
 * fresh install teaches the reader to ignore all of them. */
.shelf__level--unknown {
  /* It wraps. It is a sentence, not a measurement, and the longest ingredient
   * name in this kitchen — Sonnenblumenlecithin — leaves it no room to stay on
   * one line beside the caret on a 360 px screen. */
  white-space: normal;
  font-family: var(--sans);
  font-size: 0.85em;
  padding: 0.2em 0.6em;
  border-radius: 999px;
  background: var(--unknown-bg);
  color: var(--unknown);
}

/* A figure with no age is a guess: when it was counted, when it last moved,
 * and who established it. Quiet, but never absent — and --ink-2 rather than
 * --ink-3 because this line carries facts a person has to be able to read.
 * Measured: 7.40:1 on the paper, 9.11:1 in the dark scheme; --ink-3 would be
 * 3.76:1, which is under AA and is for decoration, not for evidence. */
.shelf__meta {
  grid-column: 1 / -1;
  font-size: 0.78rem;
  line-height: 1.35;
  color: var(--ink-2);
}

.shelf__row--unknown .shelf__name { color: var(--ink-2); font-weight: 400; }

/* The forms under the shelf. A native <details> each, closed by default: the
 * shelf is what the page is read for, and the forms are what a person opens
 * once. No script, for the same reason the menu has none. */
.tool { border-bottom: 1px solid var(--rule); }

.tool > summary {
  display: flex;
  align-items: center;
  min-height: var(--tap);
  padding: 0.6rem 1rem;
  font-weight: 700;
  cursor: pointer;
  list-style: none;
}

.tool > summary::-webkit-details-marker { display: none; }
.tool > summary:focus-visible { outline: 3px solid var(--zm-blue); outline-offset: -3px; }

/* The affordance is a character, not an icon font and not a request. */
.tool > summary::after {
  content: "+";
  margin-left: auto;
  font-family: var(--mono);
  font-size: 1.2em;
  color: var(--ink-3);
}

.tool[open] > summary::after { content: "\2212"; }

.tool .stack { margin: 0; padding: 0 1rem 1.3rem; }

/* Selects have to look and hit like the inputs beside them, including the 16px
 * that keeps iOS Safari from zooming the page on focus. */
.stack select {
  min-height: var(--tap);
  padding: 0 0.6em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-size: 16px;
  font-family: var(--sans);
}

/* A quantity and its unit are one field in two boxes: they are never entered
 * apart, and a unit that scrolled out of sight is how millilitres get typed
 * into a gram shelf. */
.pair {
  display: grid;
  grid-template-columns: 1fr 7rem;
  gap: 0.5rem;
}

.pair input {
  min-height: var(--tap);
  padding: 0 0.8em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: 16px;
}

.pair select {
  min-height: var(--tap);
  padding: 0 0.5em;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-size: 16px;
}

/* What just happened. Green, and also a sentence — the colour is the second
 * signal here as everywhere. */
.done {
  margin-top: 1rem;
  background: var(--ok-bg);
  color: var(--ok);
  font-weight: 700;
}

/* A note under a field, not a warning: it explains a rule the field keeps
 * ("0 is an answer") rather than reporting that something went wrong. */
.hint {
  margin: -0.2rem 0 0;
  font-size: 0.8rem;
  line-height: 1.35;
  color: var(--ink-2);
}

/* The standing decision, under the level it is a decision about. Quieter than
 * the level: the level is what is true now, the target is what somebody once
 * decided should be true. */
.shelf__target {
  grid-column: 1 / -1;
  font-size: 0.82rem;
  color: var(--ink-2);
}

/* The gap between the two is the reason the target was written down, so it is
 * the one part of the line that is allowed to be loud — and it is a WORD
 * ("750 g fehlen"), never a bare colour. */
.shelf__short {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--warn);
}

/* The audit trail. The delta leads, because "what changed and which way" is
 * the question; the rest of the line answers "to what, when, by whom, why". */
.moves {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--rule);
}

.move {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.1rem 0.8rem;
  padding: 0.65rem 1rem;
  border-bottom: 1px solid var(--rule);
}

.move__delta {
  grid-row: 1 / span 2;
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  white-space: nowrap;
}

/* Direction is carried by the SIGN, which is right there in the text. The
 * colour is a second reading of the same fact, never the only one. */
.move__delta--up { color: var(--ok); }
.move__delta--down { color: var(--warn); }

.move__what { font-weight: 700; }

/* Who, when, why, and against which document. Small, but --ink-2 and not
 * --ink-3 (7.40:1 rather than 3.76:1): this is the audit trail, and an audit
 * trail nobody can read in a kitchen is decoration. */
.move__who,
.move__why,
.move__ref {
  grid-column: 2;
  font-size: 0.78rem;
  line-height: 1.35;
  color: var(--ink-2);
}

/* The document behind an imported movement — a receipt key, a batch id. It is
 * a reference rather than prose, so it is set as one. */
.move__ref {
  font-family: var(--mono);
  font-size: 0.72rem;
}

/* What the shelf already covers, under the amount to buy. Quieter than the
 * number itself: the sourcer acts on what is missing, and looks at this only
 * to understand why the figure above is smaller than the recipe. */
.row__have { color: var(--ink-2); font-weight: 700; }

/* The availability label as a link to the article's own page. It has to stay
 * legible against four different pill backgrounds, so it inherits the pill's
 * colour rather than taking the link blue, and earns its underline on the
 * offset so the glyph beside it is not crossed through. */
.avail__link {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}

/* Present for a screen reader, absent for everybody else. clip-path rather
 * than display:none, which would remove it from the accessibility tree too. */
.u-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* A branch that has some but not enough. Amber, like --none, and NOT green:
 * the colour is the claim, and "3 of 62" has not earned a green one. It
 * overrides only the pill's own colours, never its size — the shortfall text
 * lives in .row__buy for exactly that reason. */
.avail--short {
  background: var(--warn-bg);
  color: var(--warn);
}

/* The trolley instruction under the row. Quiet, because the sourcer acts on
 * the pill and reads this to know what to pick up. */
.row__short { color: var(--warn); }

/* The folded gap reasons. Same disclosure shape as the sourcing asides, so a
 * reader who has learned one has learned the other. */
.fig__more { margin: 0.2rem 0 0.6rem; font-size: 0.9em; color: var(--ink-2); }
.fig__more > summary { min-height: var(--tap); display: flex; align-items: center; cursor: pointer; }
.fig__reasons { margin: 0.2rem 0 0; padding-left: 1.1rem; }
.fig__reasons li { margin: 0.2em 0; }

/* ---------------------------------------------- the narrow layout, measured --
 *
 * An availability pill in the UNKNOWN state carries a whole reason sentence —
 * "? nicht alle Filialen geprüft" — and .avail is white-space: nowrap in an
 * `auto` track. Its min-content width is therefore the entire unbroken label,
 * measured at 236px of a 360px row, which leaves the middle minmax(0, 1fr)
 * track resolving to ONE PIXEL. The ingredient name then wraps a character per
 * line and a single row stands 693px tall.
 *
 * Both rules are needed and neither works alone. Measured in a 360px viewport
 * against the real stylesheet and the embedded fonts:
 *
 *   - a floor without the wrap: the nowrap pill overflows its capped track and
 *     the document scrolls sideways (449px against a 360px client), which
 *     breaks the rule three blocks up.
 *   - the wrap without a floor: the `auto` track still grows to max-content, so
 *     the pill simply stays on one line and the name stays at 1px.
 *   - a percentage max-width on the pill: inert. It resolves against the grid
 *     area being sized, so it never reaches track sizing at all.
 *
 * Together: that row goes from 693px to 144px, with the name at 96px and no
 * horizontal scroll. Scoped to the narrow breakpoint so the wider layout, which
 * has room for all three columns, is untouched.
 *
 * nowrap stays on .avail generally. "Rigiplatz ✓" is an instruction and must
 * stay on one line; only the reason sentence needs to break. */
@media (max-width: 39.99rem) {
  .row { grid-template-columns: auto minmax(6rem, 1fr) auto; }
  .avail--unknown { white-space: normal; }
}

/* ------------------------------------------------------ the article page -- */

/* /tools/sourcing/settings: which Migros article each row is bought as.
 *
 * A different shape from the shopping list on purpose. The list is read at
 * arm's length while walking and is one line a row; this is read standing
 * still, deciding between two bags of the same thing, and it needs to show
 * enough of each article to tell them apart. Blocks, not a grid of columns. */
.cat {
  padding: 0.85rem 1rem;
  border-bottom: 1px solid var(--rule);
}

.cat__name {
  margin: 0 0 0.5rem;
  font-family: var(--heading);
  font-size: 1em;
}

.cat__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.5rem;
}

.cat__item {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.2rem 0.5rem;
  padding: 0.5rem 0.7rem;
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  background: var(--panel);
}

/* The article the week is actually bought and costed from.
 *
 * A LEFT EDGE and a word, never position alone. "First in the list" is where a
 * thing sits; "this is the one that gets bought" is a fact about it, and on a
 * phone the two are indistinguishable unless one of them is said. */
.cat__item--pref {
  border-left: 3px solid var(--ok);
}

.cat__pref {
  color: var(--ok);
  font-weight: 700;
  font-size: 0.82em;
}

.cat__label { font-weight: 700; }

.cat__meta {
  color: var(--ink-2);
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: 0.82em;
}

/* Organic, in three states that never collapse into two.
 *
 * The unchecked one is deliberately the quiet grey of --unknown and not a
 * warning colour: "nobody has read this label" is a gap in our records, not an
 * accusation against the article. Each carries its own WORD — see the
 * settings.bio* keys — so none of the three is signalled by hue. */
.cat__bio {
  font-size: 0.78em;
  font-weight: 700;
  padding: 0.1em 0.45em;
  border-radius: 999px;
  white-space: nowrap;
}

.cat__bio--yes     { background: var(--ok-bg);      color: var(--ok); }
.cat__bio--no      { background: var(--warn-bg);    color: var(--warn); }
.cat__bio--unknown { background: var(--unknown-bg); color: var(--unknown); }

/* A row with nothing linked. It says so in words: the blank it replaces is
 * indistinguishable from a page that failed to load its own catalogue. */
.cat__none {
  margin: 0;
  color: var(--ink-2);
  font-size: 0.92em;
}

/* The paste form, one per row. A url field and a button, because only the URL
 * is ever typed here — everything else about an article is read from Migros. */
.cat__add {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.6rem;
}

.cat__add input {
  flex: 1 1 12rem;
  min-width: 0;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  /* 16px or larger, or iOS zooms the whole page when the field is focused and
   * the sourcer is left scrolled sideways in a shop. */
  font-size: 1rem;
  font-family: var(--mono);
}

/* The two per-article actions. Each is its own <form> — a POST, so a link
 * prefetch or a crawler can never rewrite the catalogue — laid side by side. */
.cat__acts {
  display: flex;
  gap: 0.35rem;
  margin-left: auto;
}

.cat__acts form { margin: 0; }

/* A refused article, offered back for a deliberate override. Amber, and it
 * carries the reason in words above it: the colour is never the message. */
.cat__confirm {
  margin: 0 1rem 1rem;
  padding: 0.7rem 0.9rem;
  border-left: 3px solid var(--warn);
  border-radius: 0 var(--radius) var(--radius) 0;
  background: var(--warn-bg);
  color: var(--warn);
}

.cat__confirm p { margin: 0 0 0.5rem; font-weight: 700; }

/* The article's own photo, inside the availability pill.
 *
 * 24px, which is the size of the word beside it — the pill is the smallest
 * thing on the row and a taller picture would push every row's height up for a
 * decoration. object-fit keeps a tall bottle and a wide punnet both square
 * without either being squashed.
 *
 * It is never the only signal: the branch word sits next to it and the article
 * is named in the row. A photo that fails to load costs the row nothing. */
.avail__pic {
  width: 24px;
  height: 24px;
  object-fit: contain;
  border-radius: 4px;
  /* Migros' photos are cut out on white, which disappears into a light pill
   * and floats on a dark one. A constant white tile behind them makes both
   * schemes look deliberate rather than accidental. */
  background: #fff;
  flex: 0 0 auto;
}

/* "neu prüfen" — asking the shop again about one article.
 *
 * Its own cell under the pill rather than inside it: the pill sits in an
 * `auto` grid track shared by every row, so anything added within it widens
 * that column for the whole list. Quiet, because it is an escape hatch and not
 * an instruction — the rows still to pick up are what the page is for. */
.recheck {
  grid-column: 3;
  margin: 0.2rem 0 0;
  justify-self: start;
}

@media (min-width: 40rem) {
  .recheck {
    grid-column: 5;
    grid-row: 2;
  }
}

/* A Coop article, marked on the settings page. Two articles for one row at two
 * different shops is the ordinary case now, and "3350849" beside
 * "104294800000" says nothing to anybody about which is which. */
.cat__shop {
  font-weight: 700;
  color: var(--ink-2);
}

/* The by-hand fields. They stack under the URL because there are three of them
 * and a 360px screen has room for one at a time. */
.cat__byhand {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  flex: 1 1 100%;
}

.cat__byhand input {
  flex: 1 1 8rem;
  min-width: 0;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
  color: var(--ink);
  font-size: 1rem;
}

/* Coop on the shopping list: the shop's name, and no claim about its shelf.
 * Grey rather than the availability greens and ambers, because those colours
 * all mean "we asked and this is the answer" and nobody asked. */
.avail--othershop {
  background: var(--unknown-bg);
  color: var(--unknown);
}

/* The "what did you mean?" dialog on a stale availability label.
 *
 * Native <dialog>, so the browser handles the backdrop, the focus trap and the
 * Escape key — three things that are easy to write badly and that a phone
 * already knows how to do properly. */
.ask {
  border: none;
  border-radius: var(--radius);
  padding: 1rem;
  max-width: 22rem;
  background: var(--panel);
  color: var(--ink);
  box-shadow: 0 10px 30px rgb(2 9 18 / 25%);
}

.ask::backdrop { background: rgb(2 9 18 / 45%); }

.ask__title { margin: 0 0 0.8rem; font-weight: 700; }

/* Stacked, not side by side. These are two different actions rather than a
 * yes/no, and two buttons in a row invite the thumb to hit the wrong one. */
.ask__acts {
  display: grid;
  gap: 0.5rem;
  margin-bottom: 0.8rem;
}

.ask__acts .btn { text-align: center; }

/* The label is now a control, so it says so. */
.avail__ask { cursor: pointer; }

/* The article's photo on the settings page. Same file and same size as the
 * shopping list's, so opening this page warms the cache for the walk — and a
 * wrong article is recognisable here as a picture rather than as twelve
 * digits. */
.cat__pic {
  width: 24px;
  height: 24px;
  object-fit: contain;
  border-radius: 4px;
  background: #fff;
  flex: 0 0 auto;
}

/* -------------------------------------------------------------- tabbar -- */
/* The tools, docked under the thumb on a phone. The phone is held in one hand
 * in a shop, and the top-left hamburger is the corner that hand cannot reach.
 * Five tabs, each an icon AND its word: an icon alone is a guess, and the
 * reader may not be fluent in either language. The active tab is marked with
 * the brand yellow as a pill behind the icon — ink on yellow, never yellow as
 * text. Above 40rem there is room and a pointer, and the menu is enough. */
.tabbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 10;
  display: flex;
  background: var(--panel);
  border-top: 1px solid var(--rule);
  padding-bottom: env(safe-area-inset-bottom);
}

.tabbar a {
  flex: 1 1 0;
  min-width: 0;
  min-height: 60px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 6px 2px 5px;
  color: var(--ink-2);
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}

.tabbar__icon {
  display: block;
  width: 56px;
  height: 30px;
  padding: 3px 16px;
  border-radius: 15px;
  box-sizing: border-box;
}

.tabbar__label {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-family: var(--heading);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}

.tabbar a[aria-current="page"] { color: var(--ink); }
.tabbar a[aria-current="page"] .tabbar__icon { background: var(--zm-yellow); color: var(--zm-ink); }
.tabbar a:focus-visible { outline: 3px solid var(--zm-blue); outline-offset: -3px; }

/* The last row and the last button must not sit under the bar. */
body:has(.tabbar) main { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }

@media (min-width: 40rem) {
  .tabbar { display: none; }
  body:has(.tabbar) main { padding-bottom: 0; }
}

@media print {
  .tabbar { display: none; }
}

/* ---------------------------------------------------------------- vouch -- */
/* The way out of a refused "Ausgeliefert", directly under the refusal. */
.vouch {
  margin: 0 0 1rem;
  padding: 0.8rem 1rem;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius);
  background: var(--panel);
}
.vouch p { margin: 0 0 0.6rem; }

/* ----------------------------------------------------------------- sync -- */
.sync { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0 0 0.8rem; }
.sync__btn { display: inline-flex; align-items: center; gap: 0.3em; }
/* The glyph borrows the tab bar's class for its SVG, so it is sized here. */
.sync__btn .tabbar__icon { width: 1.15em; height: 1.15em; padding: 0; border-radius: 0; }

/* ------------------------------------------------------------ neworder -- */
/* The new-order dialog. The same native <dialog> as .ask, for the same
 * reasons, with a yellow rule on top: the brand's colour for good news. */
.neworder {
  border: none;
  border-top: 6px solid var(--zm-yellow);
  border-radius: var(--radius);
  padding: 1.4rem 1.2rem 1.2rem;
  max-width: 22rem;
  width: calc(100% - 2rem);
  text-align: center;
  background: var(--panel);
  color: var(--ink);
  box-shadow: 0 10px 30px rgb(2 9 18 / 25%);
}
.neworder::backdrop { background: rgb(2 9 18 / 45%); }
.neworder__mascot { width: 96px; height: auto; margin: 0 0 0.4rem; }
.neworder h2 { font-family: var(--heading); font-size: 1.35rem; margin: 0 0 0.6rem; }
.neworder p { margin: 0 0 0.6rem; }
.neworder .btn { width: 100%; margin-top: 0.4rem; }

/* -------------------------------------------------------------- actcal -- */
/* The activity calendar — GitHub's commit graph, rotated 90°: weeks as ROWS
 * instead of columns, so twelve of them fit a 360px phone with no sideways
 * scroll. See calendar.go for why. */
.actcal {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin: 0.4rem 0 0.6rem;
}

/* minmax(0, 1fr) and NOT a bare 1fr — the same trap .portions documents: a
 * bare 1fr means minmax(auto, 1fr), and the SVG's own width="24" attribute
 * would be read as this track's content floor, pushing the row wider than
 * the page and the whole thing scrolling sideways on a phone. */
.actcal__row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 3px; }

.actcal__row--head { margin-bottom: 1px; }

.actcal__label {
  text-align: center;
  font-size: 0.68rem;
  font-weight: 700;
  color: var(--ink-3);
}

.actcal__day {
  position: relative;
  min-width: 0;
  aspect-ratio: 1;
  border-radius: 3px;
  border: 1px solid var(--rule);
  background: var(--panel-2);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* The day of the month, top-left, small: the box's real content is its colour
 * and the skillet, and the number is only there to find "the 15th" without
 * counting rows. Corner-anchored rather than centred so it never sits on the
 * skillet. Its colour follows the box's ground — --ink-2 on the plain and
 * future boxes (7:1 on paper, 6:1 on the dark scheme), ink on yellow, paper on
 * the cook box — because each of those grounds is fixed, not themed, and a
 * number that only reads on one of them is a number that vanishes on another. */
.actcal__num {
  position: absolute;
  top: 2px;
  left: 3px;
  z-index: 1;
  font-size: 0.62rem;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--ink-2);
}
.actcal__day--order .actcal__num { color: #020912; }
.actcal__day--cook .actcal__num { color: #fcfcfc; }

/* A day nobody has reached yet: the current week's Thursday through Sunday.
 * Faint rather than absent — its column still holds the grid's shape. */
.actcal__day--out { background: transparent; border-color: transparent; }

/* Bestellung: the brand yellow, plain. */
.actcal__day--order { background: var(--zm-yellow); border-color: var(--zm-yellow); }

/* Gekocht: a fixed dark box with the skillet — fixed rather than the theme's
 * --ink, because in dark mode --ink IS the page and the box would disappear
 * into it. The border keeps it readable on paper too, when this page is ever
 * printed. */
.actcal__day--cook {
  background: #020912;
  border-color: #020912;
}
.actcal__day--cook .tabbar__icon {
  width: 68%;
  height: 68%;
  padding: 0;
  border-radius: 0;
  color: #ffcc03;
}

/* Both, the same day: the skillet stays the box's story, and a small yellow
 * dot in the corner says an order ALSO came in — neither fact swallows the
 * other. */
.actcal__day--cook.actcal__day--order::after {
  content: "";
  position: absolute;
  top: -2px;
  right: -2px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--zm-yellow);
  border: 1px solid var(--panel);
}
.actcal__day--cook.actcal__day--order { background: #020912; border-color: #020912; }

.actcal__legend {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.3rem 0.9rem;
  margin: 0 0 1.2rem;
  font-size: 0.8rem;
  color: var(--ink-2);
}
.actcal__swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  border-radius: 3px;
  margin-right: 0.35rem;
  vertical-align: -1px;
}
.actcal__swatch--order { background: var(--zm-yellow); }
/* A border, the same reason the day boxes carry one: dark mode's --ground
 * IS #020912, and a swatch with no edge of its own would vanish into it. */
.actcal__swatch--cook { background: #020912; border: 1px solid var(--rule-strong); }
