html, body {
    font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;
    height: 100%;
    overflow: hidden; /* the document itself never scrolls — .body-container is the one and only scroll container */
}

/* Make all buttons flex containers so icons center by layout, not line-box math.
   min-height matches Bootstrap's form-control height so icon-only buttons (which shrink
   to icon height via line-height:1 on .btn .bi) stay the same height as adjacent inputs. */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: calc(1.5em + 0.75rem + 2px); /* = form-control height */
    gap: 0.375em;
}

/* Bootstrap Icons render the glyph via ::before as inline-block with vertical-align:-.125em.
   Flexbox on the button only centers the <i> element, not the glyph inside it.
   Making <i> itself a flex container turns ::before into a flex item where vertical-align is ignored,
   so the glyph is centered by layout rather than font metrics. */
.btn .bi {
    display: inline-flex;
    align-items: center;
    line-height: 1;
}

/* Add-to-meal-plan picker rows: override .btn's centered justify-content so the
   thumbnail/icon and name are left-aligned. */
.api-item-btn {
    justify-content: flex-start;
}

/* Dialog pickers built from buttons instead of a select, because the fridge's native dropdown is a
   system picker that covers the dialog and is hard to hit. auto-fit means seven days sit on one row on
   a desktop and wrap to two or three on the fridge without a media query. */
.dialog-picker-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(5.25rem, 1fr));
    gap: 0.375rem;
}

.dialog-picker-btn {
    min-height: 48px;
}

/* The picked chip. Cyan text and border alone is a thin signal on a seven-across day grid, so the
   pick also carries the elevated band — the same "this is the current one" language the active nav
   link uses, and still not the solid fill that belongs to the footer's commit. */
.dialog-picker-btn.btn-outline-primary {
    background-color: var(--color-bg-elevated);
    border-width: 2px;
}

/* .btn is a centred flex container here, so text-start alone does nothing to a picker button whose
   label runs to two lines. */
.dialog-picker-btn.text-start {
    justify-content: flex-start;
    text-align: left;
}

/* The week stays on one row. Wrapped, it reads as two half-weeks and the day you want is never where
   you look for it, so these are seven equal columns rather than auto-fit. Stacking the day name over
   the date is what makes seven fit in a dialog this narrow, and it matches how the meal plan's own day
   headers read. minmax(0, 1fr) lets a column shrink below its label instead of overflowing the row. */
.dialog-picker-grid-days {
    display: grid;
    grid-template-columns: repeat(7, minmax(0, 1fr));
    gap: 0.25rem;
}

.dialog-picker-grid-days .dialog-picker-btn {
    flex-direction: column;
    gap: 0;
    padding-left: 0.25rem;
    padding-right: 0.25rem;
    line-height: 1.2;
    overflow: hidden;
}

.dialog-picker-day-name {
    font-weight: 700;
    font-size: 0.8rem;
}

.dialog-picker-day-date {
    font-size: 0.68rem;
    opacity: 0.85;
    white-space: nowrap;
}

/* Swap picker rows: one per day, so the day labels need a shared column width to read as a column
   rather than as ragged text. 48px min-height is the fridge touch target. */
.swap-target-row {
    min-height: 48px;
}

.swap-target-day {
    flex: 0 0 5.5rem;
    font-weight: 600;
    font-size: 0.8rem;
}

.swap-target-thumb {
    width: 32px;
    height: 32px;
    object-fit: cover;
    border-radius: 4px;
    flex-shrink: 0;
}

.swap-target-icon {
    width: 32px;
    text-align: center;
    flex-shrink: 0;
}

/* Icon-only buttons: equal padding on all sides, sized to the 48px touch floor (--touch-min).
   The fridge is a touch panel, so nothing interactive ships smaller — btn-sm included.
   Override Bootstrap's CSS custom properties so var(--bs-btn-padding-x/y)
   resolves to the same value, making buttons square regardless of btn-sm etc. */
.btn.btn-icon {
    --bs-btn-padding-x: 0.375rem;
    --bs-btn-padding-y: 0.375rem;
    padding: 0.375rem;
    min-width: var(--touch-min);
    min-height: var(--touch-min);
}

/* The back link on detail pages (BackLink.razor). This is the only
   way off the page on the fridge, so it gets the full 48px touch-target floor.
   inline-flex so the arrow and the label centre against that height. */
.btn.back-link {
    display: inline-flex;
    align-items: center;
    min-height: 48px;
}

/* AB#911 restyled the back link on .arcade-page into an arcade-font uppercase button, because a
   Bootstrap outline button under an arcade title row read as a stray piece of another app. AB#925
   removed those title rows: the admin pages now wear the same page-header, panels and tables as
   every other page, so the plain back link is the one that belongs and the override is gone rather
   than kept. .arcade-page itself stays on those pages — see the arcade block near the end of this
   file for what it still carries. */

/* ── The pantry's nested table: a product and its packages are one card ─────────────────────────────
   Design system rule 15 (AB#932). Everything from .pantry-list-toolbar down to .pantry-subtable
   belongs to that archetype.

   What it replaced: one continuous striped table in which the product row, the repeated column
   headings and every package underneath were rows of the same grid, told apart only by a Bootstrap
   contextual row colour. Nothing said where one product ended and the next began except a slightly
   different shade — and that shade was already spoken for by selection, by the category bands and by
   the expanded band itself. Cards make the boundary whitespace, which nothing else is using.

   The names are prefixed rather than generic because there is exactly one nested table in the app.
   Extracting a shared control before a second one exists would be guessing at what it needs. */

/* Stands in for the <thead> the card list no longer has, and keeps its two controls (select-all,
   expand-all) and the column name where the eye already looks for them. Sticky and outlined the same
   way .table thead is, and for the same reason: an outline rather than a border, because a border on
   a sticky element flush with the scroll box's top edge is clipped away. */
.pantry-list-toolbar {
    position: sticky;
    top: 0;
    z-index: 2;
    display: flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0 0.5rem;
    background-color: var(--color-bg-subtle);
    outline: 1px solid var(--color-border);
    outline-offset: -1px;
    font-size: 0.8125rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--color-text-secondary);
    text-transform: uppercase;
}

.pantry-group-list {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    /* Top padding separates the first card from the sticky toolbar; the side padding is what keeps a
       selected card's left rule from being shaved off by .table-container's clip. */
    padding: 0.6rem 0.15rem 0.15rem;
}

/* A category band is a heading over a run of cards now, not a full-width tinted <tr>. That treatment
   only ever worked because there was one table for it to be a row of. */
.pantry-group-section {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-top: 0.4rem;
    font-size: 0.8125rem;
    font-weight: 600;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.pantry-group-section:first-child { margin-top: 0; }
.pantry-group-section .rule { flex: 1 1 auto; height: 1px; background-color: var(--color-border-subtle); }

.pantry-group-card {
    background-color: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--bs-border-radius, 0.375rem);
    /* An inset shadow rather than a `border-left: 3px solid transparent`. The border version was
       written first and is wrong: the longhand replaces the left side of the `border` shorthand set
       one line above, so an unselected card kept its top, right and bottom rule and silently lost its
       left one. An inset shadow paints inside the border box, so the border stays whole, nothing has
       to be reserved transparent, and the group still cannot shift 3px when it lights up. */
    box-shadow: inset 3px 0 0 0 transparent;
}

/* The selection mark: a rule down the left of the entire block, in the same language the active nav
   link uses. The checkbox alone is a 16px answer to a question asked about a group six rows tall. */
.pantry-group-card.is-selected {
    background-color: var(--color-bg-elevated);
    box-shadow: inset 3px 0 0 0 var(--color-cyan);
}

.pantry-group-head {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.35rem 0.5rem;
    cursor: pointer;
}

.pantry-group-chevron { flex: 0 0 auto; width: 0.9rem; font-size: 0.8rem; color: var(--color-text-muted); }

/* The identity block. min-width: 0 is what lets the name and the meta line actually ellipsis: a flex
   item's default min-width is its content, so without it a long product name pushes the badges and the
   buttons off the card instead of truncating. */
.pantry-group-id { flex: 1 1 auto; min-width: 0; }

.pantry-group-name {
    display: block;
    font-weight: 600;
    color: var(--color-text-primary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Amount, package count and brands live here rather than beside the name. On one line they were four
   things competing for the same rank; on a muted second line they read as what they are — details
   about the thing named above them. */
.pantry-group-meta {
    display: block;
    font-size: 0.8125rem;
    line-height: 1.3;
    color: var(--color-text-secondary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.pantry-group-meta .sep { color: var(--color-text-muted); margin: 0 0.3rem; }

.pantry-group-flags { display: flex; align-items: center; gap: 0.35rem; flex: 0 0 auto; }

/* The same box whether or not there is a photo, so a missing one never changes a row's height. */
.pantry-thumb {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    overflow: hidden;
    border: 1px solid var(--color-border);
    border-radius: var(--bs-border-radius, 0.375rem);
    background-color: var(--color-bg-elevated);
    color: var(--color-text-muted);
}

.pantry-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pantry-thumb-sm { width: 1.9rem; height: 1.9rem; font-size: 0.85rem; }

/* Parent and child actions both hug the right edge, so the overflow trigger — the last control in
   each — lines up down the card however many buttons sit inline beside it. This is the one place the
   centring .row-actions applies elsewhere is overridden, and it is overridden because here there are
   two different rows to line up rather than one repeated one. */
.pantry-group-card .row-actions { justify-content: flex-end; }

.pantry-group-body { border-top: 1px solid var(--color-border-subtle); }

/* Deliberately not .table: Bootstrap's striping and borders are the continuous-grid reading the card
   just replaced, and its `.table tbody td` clipping rules are set for a full-width list. */
.pantry-subtable {
    width: 100%;
    margin: 0;
    border-collapse: collapse;
    /* Fixed, with the widths declared below, so the child columns still line up down the page even
       though every group draws its own headings. */
    table-layout: fixed;
    font-size: 0.875rem;
}

/* Muted and a size down. The headings repeat once per expanded group, so at the product row's weight
   they would be the loudest thing in a long list. */
.pantry-subtable thead th {
    padding: 0.2rem 0.5rem;
    border-bottom: 1px solid var(--color-border-subtle);
    color: var(--color-text-muted);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    white-space: nowrap;
}

/* nowrap is what makes the ellipsis mean anything: text-overflow only applies to text that cannot
   wrap, so without it a long brand name in a narrow column quietly grew the row instead of
   truncating, and one card ended up taller than its neighbours. .col-amount opts back out below,
   because it is the one cell that carries sub-lines. */
.pantry-subtable tbody td {
    padding: 0.2rem 0.5rem;
    border-bottom: 1px solid var(--color-border-subtle);
    color: var(--color-text-secondary);
    vertical-align: middle;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.pantry-subtable tbody tr:last-child td { border-bottom: 0; }

/* No striping inside a card. The card is already the grouping; stripes would set a second rhythm
   running inside the one the whitespace just established. */

/* Four columns at every width (AB#973). Store and price used to sit between amount and purchased and
   drop out below 768px, which meant the fridge and the desktop were reading two different tables; they
   are gone at both sizes now and live on the package's own detail page. The percentages stop at 89% to
   leave the fixed 7rem actions column its room. */
.pantry-subtable .col-pkg       { width: 32%; }
.pantry-subtable .col-amount    { width: 25%; }
.pantry-subtable .col-purchased { width: 16%; }
.pantry-subtable .col-expires   { width: 16%; }

/* Two slots — edit and the kebab — plus the gap and the cell's padding. Right-aligned rather than
   centred so the kebab sits under the product row's. */
.pantry-subtable .pantry-actions-col {
    width: 7rem;
    text-align: right;
    /* The menu is position: fixed and JS-anchored, but the cell must not clip its trigger's focus ring. */
    overflow: visible;
    white-space: nowrap;
}

.pantry-pkg-cell { display: flex; align-items: center; gap: 0.45rem; min-width: 0; }
.pantry-pkg-cell .pkg-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* This one identity link is a chip rather than a run of text, so .row-link's hover underline is moved
   off the anchor and onto the name (AB#974). On the anchor it ran the full width of the cell, under
   the thumbnail and the gap as well, which reads as a rule through the row rather than as a link. */
a.pantry-pkg-cell:hover,
a.pantry-pkg-cell:focus-visible { text-decoration: none; }

a.pantry-pkg-cell:hover .pkg-name,
a.pantry-pkg-cell:focus-visible .pkg-name { text-decoration: underline; }

/* The amount cell is the one that carries sub-lines (part-used, per-item weight, nutrition status), so
   it opts out of the single-line clipping the other cells want. */
.pantry-subtable td.col-amount { overflow: visible; white-space: normal; }

/* Bootstrap's contextual row classes each carry their own light --bs-table-border-color alongside the
   background they are used for. On this theme that paints a near-white rule across the row, which reads
   as a seam rather than a divider: the highlighted line on a receipt had one, as did every row of the
   pantry grid before AB#932 replaced it with cards.

   Every one of them is used here for its background. The border colour is the light theme arriving
   uninvited with it, so it is put back to the theme's for all of them at once rather than patched per
   row type, which would leave the next contextual class anyone reaches for to reintroduce it.

   Set on the row rather than its cells: the 1px that actually paints is the <tr>'s own, and a custom
   property set there inherits down to the cells anyway. Overriding only the cells changes a border
   nothing is drawing and leaves the visible line exactly where it was. */
.table tr:is(.table-primary, .table-secondary, .table-light, .table-active, .table-info, .table-success, .table-warning, .table-danger) {
    --bs-table-border-color: var(--color-border);
}

/* "Buy this many every time" on a staple row, shown only in the Staples view (AB#904). Narrow because
   it holds a small count and sits inline beside the product name, where a full-width control would push
   the name off its own row.

   form-control-sm is Bootstrap's 31px height, so the touch floor is restated here rather than inherited:
   --touch-min is opted into per surface (the filter bar, the search field, dialog bodies) and a table row
   is none of them, which would leave this input at half the height of the icon buttons beside it on the
   fridge. */
/* Centred, unlike the amount fields elsewhere. This holds a standing order that is almost always a
   single digit, so left-aligned it sits against one edge of a box sized for the touch floor and reads as
   a half-filled field rather than a value.

   The spinner has to go for that to actually look centred. Chromium reserves a slice of the content box
   on the right for the up/down buttons whether or not they are visible, so text-align: center centres
   within what is left and lands visibly left of the middle. Same treatment as .quantity-stepper-input,
   which hit this first. */
.pantry-staple-qty {
    width: 4.5rem;
    min-height: var(--touch-min);
    text-align: center;
    -moz-appearance: textfield;
    appearance: textfield;
}

.pantry-staple-qty::-webkit-outer-spin-button,
.pantry-staple-qty::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/* Keeps the control clear of the actions column it now sits beside, and stops the label wrapping under
   its own input when a long product name squeezes the row. It used to need an ms-auto to reach that
   position; since AB#932 the product name's own block is the flexible one, so the control simply lands
   where the badges do and this margin is all that is left of it. */
.pantry-staple-buy {
    margin-right: 1rem;
    flex: 0 0 auto;
}

/* QuantityStepper: hide the tiny native number spinner and replace it with - / +
   buttons sized to match the row's other icon buttons (e.g. the delete button).
   The stepper fills its column; the input flexes between the two buttons. */
.quantity-stepper {
    flex-wrap: nowrap;
    width: 100%;
}

.quantity-stepper-btn {
    flex: 0 0 auto;
    min-width: 2rem;
}

.quantity-stepper-input {
    min-width: 2.5rem;
    -moz-appearance: textfield;
    appearance: textfield;
}

.quantity-stepper-input::-webkit-outer-spin-button,
.quantity-stepper-input::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/* Ingredient editor table: keep Qty/Unit/actions tight so the free-text
   Ingredient and Preparation columns get the width instead. */
.recipe-ingredient-table .col-qty {
    width: 8.5rem;
}

.recipe-ingredient-table .col-unit {
    width: 6rem;
}

.recipe-ingredient-table .col-actions {
    width: 5.5rem;
}

/* An imported line that didn't read as food. Outlined rather than dimmed: it is asking
   for a decision, and it is dropped on save unless the cook keeps it.

   Every side needs !important. atebit-theme.css forces `border-bottom-color` on every
   table cell with !important of its own, to undo the inset box-shadow Bootstrap 5 paints
   cells with, and !important beats specificity however specific this selector gets. The
   first version of this rule left the box open at the bottom: three red sides and a
   fourth silently repainted the theme's border colour. The other three sides are marked
   the same way so the rule cannot half-survive a future theme change. */
.recipe-ingredient-table tr.recipe-ingredient-unconfirmed td {
    border-top: 2px solid var(--bs-danger) !important;
    border-bottom: 2px solid var(--bs-danger) !important;
}

.recipe-ingredient-table tr.recipe-ingredient-unconfirmed td:first-child {
    border-left: 2px solid var(--bs-danger) !important;
}

.recipe-ingredient-table tr.recipe-ingredient-unconfirmed td:last-child {
    border-right: 2px solid var(--bs-danger) !important;
}

.content {
    padding-top: 1.1rem;
}

h1:focus {
    outline: none;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid #e50000;
}

.validation-message {
    color: #e50000;
}

.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBvdmVyZmxvdz0iaGlkZGVuIj48ZGVmcz48Y2xpcFBhdGggaWQ9ImNsaXAwIj48cmVjdCB4PSIyMzUiIHk9IjUxIiB3aWR0aD0iNTYiIGhlaWdodD0iNDkiLz48L2NsaXBQYXRoPjwvZGVmcz48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDApIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjM1IC01MSkiPjxwYXRoIGQ9Ik0yNjMuNTA2IDUxQzI2NC43MTcgNTEgMjY1LjgxMyA1MS40ODM3IDI2Ni42MDYgNTIuMjY1OEwyNjcuMDUyIDUyLjc5ODcgMjY3LjUzOSA1My42MjgzIDI5MC4xODUgOTIuMTgzMSAyOTAuNTQ1IDkyLjc5NSAyOTAuNjU2IDkyLjk5NkMyOTAuODc3IDkzLjUxMyAyOTEgOTQuMDgxNSAyOTEgOTQuNjc4MiAyOTEgOTcuMDY1MSAyODkuMDM4IDk5IDI4Ni42MTcgOTlMMjQwLjM4MyA5OUMyMzcuOTYzIDk5IDIzNiA5Ny4wNjUxIDIzNiA5NC42NzgyIDIzNiA5NC4zNzk5IDIzNi4wMzEgOTQuMDg4NiAyMzYuMDg5IDkzLjgwNzJMMjM2LjMzOCA5My4wMTYyIDIzNi44NTggOTIuMTMxNCAyNTkuNDczIDUzLjYyOTQgMjU5Ljk2MSA1Mi43OTg1IDI2MC40MDcgNTIuMjY1OEMyNjEuMiA1MS40ODM3IDI2Mi4yOTYgNTEgMjYzLjUwNiA1MVpNMjYzLjU4NiA2Ni4wMTgzQzI2MC43MzcgNjYuMDE4MyAyNTkuMzEzIDY3LjEyNDUgMjU5LjMxMyA2OS4zMzcgMjU5LjMxMyA2OS42MTAyIDI1OS4zMzIgNjkuODYwOCAyNTkuMzcxIDcwLjA4ODdMMjYxLjc5NSA4NC4wMTYxIDI2NS4zOCA4NC4wMTYxIDI2Ny44MjEgNjkuNzQ3NSAyNjcuODYgNjkuNzMwOSAyNjcuODc5IDY5LjU4NzcyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
    padding: 1rem 1rem 1rem 3.7rem;
    color: white;
}

    .blazor-error-boundary::after {
        content: "An error has occurred."
    }

/* Admin impersonation banner (AB#978): an in-flow bar at the very top of the page - above the nav bar
   (AB#1078) - shown on every page while a platform admin is viewing as another member. Because it pushes
   the nav bar down by its own height, the fixed nav fold-out menu and backdrop are re-anchored via
   --nav-overlay-top below, and the scroll container's max-height is reduced by the same height so nothing
   is clipped. */
.impersonating {
    --nav-overlay-top: calc(3.5rem + 3rem); /* nav height + impersonation-banner min-height */
}

/* Height of everything stacked above the one scroll container: the 3.5rem nav/utility bar plus the
   1.1rem of content padding, plus each banner that happens to be showing. Kept as one variable
   because the banners combine - a platform admin impersonating over a local dev-bypass session gets
   both, and hard-coded pairs of subtractions had already missed that (AB#928). */
.page {
    --chrome-height: 4.6rem;
}

.page.impersonating {
    --chrome-height: calc(4.6rem + 3rem);
}

.page.dev-bypass {
    --chrome-height: calc(4.6rem + 2.25rem); /* local-dev-bypass-banner min-height */
}

.page.impersonating.dev-bypass {
    --chrome-height: calc(4.6rem + 3rem + 2.25rem);
}

/* The fold-out hangs off the bottom of the nav bar, so it moves down by the same banner stack. */
.page.dev-bypass {
    --nav-overlay-top: calc(3.5rem + 2.25rem);
}

.page.impersonating.dev-bypass {
    --nav-overlay-top: calc(3.5rem + 3rem + 2.25rem);
}

.impersonation-banner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    min-height: 3rem;
    padding: 0.25rem 0.75rem;
    background-color: #b45309; /* amber-700 — high-contrast, clearly not a normal chrome colour */
    color: #fff;
    font-size: 0.9rem;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
    z-index: 900;
}

.impersonation-banner-text {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.impersonation-banner-form {
    margin: 0;
    flex-shrink: 0;
}

.impersonation-banner-exit {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    min-height: 2.5rem;
    padding: 0.375rem 0.75rem;
    background-color: rgba(0, 0, 0, 0.25);
    color: #fff;
    border: 1px solid rgba(255, 255, 255, 0.6);
    border-radius: 4px;
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;
}

.impersonation-banner-exit:hover {
    background-color: rgba(0, 0, 0, 0.4);
}


/* Local-only dev sign-in bypass banner (AB#710). Development + direct-localhost only, so it never
   renders on a deployed instance. Distinct hazard colour so a bypassed session is never mistaken for
   a real one. */
.local-dev-bypass-banner {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    min-height: 2.25rem;
    padding: 0.25rem 0.75rem;
    background-color: #6d28d9; /* violet-700 — distinct from the amber impersonation banner */
    color: #fff;
    font-size: 0.85rem;
    font-weight: 600;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
    z-index: 900;
}

/* Dev-only shortcut to the authenticated home, shown on the anonymous landing over direct localhost
   (AB#710). Fixed to a corner so it overlays the marketing landing without disturbing its layout. */
.local-dev-login-link {
    position: fixed;
    top: 0.75rem;
    right: 0.75rem;
    z-index: 1000;
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.4rem 0.75rem;
    min-height: 2.25rem;
    background-color: #6d28d9; /* matches the bypass banner */
    color: #fff;
    font-size: 0.85rem;
    font-weight: 600;
    text-decoration: none;
    border-radius: 4px;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}

.local-dev-login-link:hover {
    background-color: #5b21b6; /* violet-800 */
    color: #fff;
}

/* Admin member-directory search box: cap the width so it sits neatly in the card header. */
.impersonate-search {
    max-width: 18rem;
}

/* Idle-timeout overlay shown once the client disconnects an inactive Blazor circuit (AB#1160).
   Covers the full viewport since the app underneath is inert (circuit is down) - "click
   anywhere" needs the whole screen as the target. Styled as an arcade "PAUSED" screen to match
   the arcade theme (AB#959).

   z-index note: Blazor.pauseCircuit() closes the SignalR transport, which triggers this
   app's built-in Blazor reconnect UI (#components-reconnect-modal) - an unstyled white
   "Resume" dialog rendered inside an open shadow root at z-index 10001. That state lives
   entirely inside the shadow tree (the host div itself carries no distinguishing class or
   attribute), so it can't be targeted/suppressed from outside via a state-specific CSS
   selector. Instead of hiding it, out-stack it: keep our z-index above 10001 so this overlay
   fully covers it, and since our click-to-reconnect handler is a document-level listener
   (idleTimeout.js), clicks land on us regardless of paint order - the native "Resume" button
   underneath never becomes the actual click target. Leave the native modal itself alone so
   genuine network-drop reconnection UI unrelated to idle timeout still renders normally. */
.idle-timeout-banner {
    position: fixed;
    inset: 0;
    z-index: 10010;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 1.25rem;
    padding: 1rem;
    background: radial-gradient(ellipse at center, rgba(20, 26, 46, 0.96) 0%, rgba(6, 8, 16, 0.98) 70%);
    color: #fff;
    text-align: center;
    cursor: pointer;
    overflow: hidden;
}

/* CRT scanline overlay, painted behind the content (default stacking order for a
   position-less child following a positioned ::before). */
.idle-timeout-banner::before {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        rgba(0, 0, 0, 0) 0px,
        rgba(0, 0, 0, 0) 2px,
        rgba(0, 212, 255, 0.04) 3px,
        rgba(0, 0, 0, 0) 4px
    );
    mix-blend-mode: overlay;
}

.idle-pause-icon {
    display: flex;
    gap: 0.5rem;
}

.idle-pause-icon span {
    width: 0.9rem;
    height: 3rem;
    background: var(--color-cyan);
    box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 24px rgba(0, 212, 255, 0.5);
    animation: idle-pause-flicker 2.4s ease-in-out infinite;
}

.idle-pause-icon span:last-child {
    animation-delay: 0.15s;
}

@keyframes idle-pause-flicker {
    0%, 100% { opacity: 1; }
    92%      { opacity: 1; }
    94%      { opacity: 0.4; }
    96%      { opacity: 1; }
}

.idle-pause-title {
    position: relative;
    font-family: 'Press Start 2P', 'Space Grotesk', system-ui, sans-serif;
    font-size: clamp(2.25rem, 8vw, 4.5rem);
    letter-spacing: 0.15em;
    color: #fff;
    text-shadow: 0 0 10px rgba(255, 255, 255, 0.6), 0 0 30px rgba(0, 212, 255, 0.5);
    margin: 0;
}

/* RGB-split glitch: two color copies of the same text, clipped to opposite halves and
   jittered on independent loops so they never quite line up with the white base layer. */
.idle-pause-title::before,
.idle-pause-title::after {
    content: attr(data-text);
    position: absolute;
    inset: 0;
    background: transparent;
}

.idle-pause-title::before {
    color: var(--color-cyan);
    clip-path: inset(0 0 55% 0);
    animation: idle-glitch-a 3.2s infinite linear alternate-reverse;
}

.idle-pause-title::after {
    color: var(--color-magenta);
    clip-path: inset(55% 0 0 0);
    animation: idle-glitch-b 2.6s infinite linear alternate-reverse;
}

@keyframes idle-glitch-a {
    0%   { transform: translate(0, 0); }
    20%  { transform: translate(-2px, -1px); }
    40%  { transform: translate(2px, 1px); }
    60%  { transform: translate(-1px, 1px); }
    80%  { transform: translate(1px, -1px); }
    100% { transform: translate(0, 0); }
}

@keyframes idle-glitch-b {
    0%   { transform: translate(0, 0); }
    25%  { transform: translate(2px, 1px); }
    50%  { transform: translate(-2px, -1px); }
    75%  { transform: translate(1px, -1px); }
    100% { transform: translate(0, 0); }
}

.idle-pause-subtitle {
    font-family: 'Press Start 2P', 'Space Grotesk', system-ui, sans-serif;
    font-size: clamp(0.6rem, 2vw, 0.85rem);
    letter-spacing: 0.05em;
    color: var(--color-magenta);
    text-shadow: 0 0 8px rgba(200, 80, 192, 0.6);
}

.idle-cursor {
    animation: idle-blink 1s steps(1) infinite;
}

@keyframes idle-blink {
    0%, 50%      { opacity: 1; }
    50.01%, 100% { opacity: 0; }
}

.idle-reconnect {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.idle-reconnect-text {
    font-family: 'Press Start 2P', 'Space Grotesk', system-ui, sans-serif;
    font-size: clamp(0.6rem, 2vw, 0.8rem);
    letter-spacing: 0.05em;
    color: var(--color-cyan);
    text-shadow: 0 0 8px rgba(0, 212, 255, 0.6);
}

@media (prefers-reduced-motion: reduce) {
    .idle-pause-icon span,
    .idle-pause-title::before,
    .idle-pause-title::after,
    .idle-cursor {
        animation: none;
    }
}

.body-container {
    display: flex;
    flex-direction: column;
    /* A fixed height, not a max: list pages need the table area (.table-responsive, flex: 1) to
       stretch even when the list is short, so the count footer pins to the bottom of the viewport on
       every list page instead of only the ones that overflow (AB#922). Short non-list pages just
       leave the extra space empty, as before. --chrome-height is what sits above it; the fallback
       covers the public pages that render outside MainLayout and so never see the variable. */
    height: calc(100vh - var(--chrome-height, 4.6rem));
    overflow-y: auto;
    overflow-x: hidden;
    -webkit-overflow-scrolling: touch; /* momentum touch-scroll on older iOS Safari (iPad target); ignored by the fridge's Chromium engine */
    padding-bottom: 2rem; /* a bit of headroom against near-boundary pages tipping into a 1-2px phantom scrollbar */
    /* Push our own scrollbar out to the true window edge (canceling article.content's
       px-4 right padding) instead of leaving it inset, then restore the same visual
       gap from our own padding so content still has breathing room before the scrollbar. */
    margin-right: -1.5rem;
    padding-right: 1.5rem;
    scrollbar-width: thin; /* Firefox */
    scrollbar-color: var(--color-border) transparent; /* Firefox */
}

/* Set on <body> while the chat dock is being dragged (AB#1013). It lives here rather than in the
   widget's scoped stylesheet because <body> is outside every Blazor component, so a scoped selector
   would never match it. Both matter: the cursor has to survive the pointer leaving the 16px grip, and
   without user-select a drag across the page selects every word it crosses. */
body.chat-dock-resizing {
    cursor: col-resize;
    user-select: none;
    -webkit-user-select: none;
}

.body-container::-webkit-scrollbar {
    width: 10px;
}

.body-container::-webkit-scrollbar-track {
    background: transparent;
}

.body-container::-webkit-scrollbar-thumb {
    background-color: var(--color-border);
    border-radius: 6px;
}

.body-container::-webkit-scrollbar-thumb:hover {
    background-color: var(--color-text-muted);
}

.table-responsive {
    flex: 1;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
}

.table-container {
    flex: 1;
    overflow-y: auto;
    min-height: 250px; /* Set a minimum height to prevent collapse */
}

/* An outline rather than a border, because a border on a sticky cell in a border-collapse table scrolls
   away with the rows instead of staying with the header.

   outline-offset pulls it inside the box. An outline is painted outside the element, and a sticky header
   at top:0 sits flush against the top of .table-container's scroll box, so the outer edge landed one
   pixel beyond the clip boundary and the table lost its top line entirely. Every list page shares this
   rule, so every one of them was missing it. */
.table thead {
    position: sticky;
    top: 0;
    background-color: var(--color-bg-subtle);
    outline: 1px solid var(--color-border);
    outline-offset: -1px;
    z-index: 1;
}

.table tr {
    height: 48px;
}

.table tbody td {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    vertical-align: middle;
}

.badge-expiry-expired { background-color: rgba(255, 77, 106, 0.18); color: #FF8FA3; border: 1px solid rgba(255, 77, 106, 0.3); }
.badge-expiry-soon    { background-color: rgba(245, 158, 11, 0.18); color: #FBB03B; border: 1px solid rgba(245, 158, 11, 0.3); }
.badge-expiry-good    { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }

.recipe-tag { background-color: rgba(0, 212, 255, 0.12); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.25); font-weight: normal; }

/* Recipe badges */
.badge-draft { background-color: rgba(245, 158, 11, 0.2); color: #FBB03B; border: 1px solid rgba(245, 158, 11, 0.3); }
.badge-shared { background-color: rgba(0, 212, 255, 0.15); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.3); }
.badge-recipe-meta { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
.badge-recipe-total { background-color: rgba(0, 212, 255, 0.15); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.3); }
.badge-recipe-category { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
.badge-recipe-source { background-color: var(--color-bg-elevated); color: var(--color-text-muted); border: 1px solid var(--color-border); }
.badge-recipe-category-chip { background-color: rgba(0, 212, 255, 0.15); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.3); }
.badge-share-count { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
.badge-cook-history-item { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
/* Six columns of forensic detail, two of which hold badges that can neither wrap nor shrink. On the
   fridge's 720px door this table gets 614px, and `table-layout: fixed` will not let a cell grow past
   its declared share — so the badges were being sliced mid-word and Status read "NotFou" (AB#972).

   The floor is what makes the percentages resolve against something wide enough to hold their content
   instead of against whatever the viewport happens to be. Its partner is the `<colgroup>` in
   CookHistory.razor, which was rebalanced in the same pass; neither works alone. 820 is the measured
   minimum: Status at 14% gives 115px for a 97px "Insufficient" plus its cell padding, and Match Method
   at 15% gives 123px for a 108px "Partial match".

   The scroll it implies costs nothing to add. The wrapping `.table-responsive` was already
   `overflow-x: auto` and simply had nothing to scroll, because the table was exactly as wide as its
   container and clipped inside its cells rather than overflowing them. Above about 900px of content
   width the floor never binds, so desktop is untouched by it. */
.cook-history-table { table-layout: fixed; width: 100%; min-width: 820px; }
.cook-history-recipe-group-header td { background-color: var(--color-bg-elevated); font-weight: 600; color: var(--color-text-primary); border-top: 2px solid var(--color-border); padding-top: 0.5rem; padding-bottom: 0.5rem; font-family: 'Space Grotesk', system-ui, sans-serif; }
.recycle-table { table-layout: fixed; width: 100%; }
.recycle-table .col-name { width: auto; }
.recycle-table .col-date { width: 130px; }
.recycle-table .col-actions { width: 120px; }

/* Brand / ingredient rule badges */
.badge-ai { background-color: rgba(200, 80, 192, 0.15); color: var(--color-magenta); border: 1px solid rgba(200, 80, 192, 0.3); }
.badge-manual { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
.badge-needs-review { background-color: rgba(245, 158, 11, 0.18); color: #FBB03B; border: 1px solid rgba(245, 158, 11, 0.3); }

/* Merge-brands candidate list: a household with a receipt-built catalog can have a hundred brands,
   and the dialog's footer has to stay reachable on a 1080px-tall fridge, so the list scrolls itself
   rather than pushing the buttons off screen. */
.merge-candidate-list { max-height: 18rem; overflow-y: auto; }
.merge-candidate-list .list-group-item { min-height: 48px; cursor: pointer; }

/* Household member badges */
.badge-email { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }

/* Grocery store editor */
.badge-store-filter { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }

/* Category type badges */
.badge-category-product { background-color: rgba(0, 212, 255, 0.15); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.3); }
.badge-category-recipe { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }

/* Feedback category badges */
.badge-feedback-bug { background-color: rgba(255, 77, 106, 0.18); color: #FF8FA3; border: 1px solid rgba(255, 77, 106, 0.3); }
.badge-feedback-suggestion { background-color: rgba(0, 212, 255, 0.15); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.3); }
.badge-feedback-store-request { background-color: rgba(0, 212, 255, 0.12); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.25); }
.badge-feedback-other { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }

/* Feedback status badges */
.badge-feedback-resolved { background-color: rgba(0, 212, 255, 0.12); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.25); }
.badge-feedback-open { background-color: rgba(245, 158, 11, 0.18); color: #FBB03B; border: 1px solid rgba(245, 158, 11, 0.3); }
.badge-feedback-triaged { background-color: rgba(139, 92, 246, 0.18); color: #B79CFF; border: 1px solid rgba(139, 92, 246, 0.35); }
a.badge-feedback-triaged:hover { background-color: rgba(139, 92, 246, 0.3); color: #CDBEFF; }

/* Shopping list state badges (AB#914). Active takes the app's cyan and shopped goes quiet, which is
   the whole job of the pair on the index: a list finished three weeks ago must not read as loudly as
   the one being shopped tomorrow. Muted rather than secondary text, because these sit beside a list
   name that is itself a link and would otherwise compete with it. */
.badge-list-active { background-color: rgba(0, 212, 255, 0.12); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.25); }
.badge-list-shopped { background-color: var(--color-bg-elevated); color: var(--color-text-muted); border: 1px solid var(--color-border); }

/* Cook history deduction status badges */
.badge-deduction-deducted { background-color: rgba(0, 212, 255, 0.12); color: var(--color-cyan); border: 1px solid rgba(0, 212, 255, 0.25); }
.badge-deduction-not-found { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
.badge-deduction-insufficient { background-color: rgba(245, 158, 11, 0.18); color: #FBB03B; border: 1px solid rgba(245, 158, 11, 0.3); }
.badge-deduction-incompatible { background-color: rgba(255, 77, 106, 0.18); color: #FF8FA3; border: 1px solid rgba(255, 77, 106, 0.3); }
.badge-deduction-skipped { background-color: var(--color-bg-elevated); color: var(--color-text-muted); border: 1px solid var(--color-border); }
.badge-deduction-unknown { background-color: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }

/* Low-confidence ingredient match badge */
.badge-match-low-confidence { background-color: rgba(245, 158, 11, 0.18); color: #FBB03B; border: 1px solid rgba(245, 158, 11, 0.3); }

.w-10 { width: 10%; }
.w-15 { width: 15%; }
.w-35 { width: 35%; }
.w-40 { width: 40%; }
.w-45 { width: 45%; }

.table-footer {
    background-color: var(--color-bg-subtle);
    padding: 10px;
    border-top: 1px solid var(--color-border);
    color: var(--color-text-secondary);
}

/* ── Design-system list page layout (AB#922) ──────────────────────────
   The shared chrome every list page renders through ListPageHeader / ListFilterBar /
   ListEmptyState in Components/Controls. One source for the layout; pages supply content. */

.page-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    flex-wrap: wrap;
    margin-bottom: 0.75rem;
}

.page-header h1 {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin: 0;
    font-size: 1.375rem;
    font-weight: 600;
}

.page-header h1 .bi {
    color: var(--color-cyan);
}

/* The primary action (and the empty-state CTA that repeats it): icon + label at desktop width,
   icon-only below md, always clearing the touch floor either way. */
.btn.btn-add {
    min-width: var(--touch-min);
    min-height: var(--touch-min);
}

.filter-bar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin-bottom: 0.75rem;
}

.search-field {
    position: relative;
    flex: 1 1 14rem;
    max-width: 22rem;
}

.search-field > .bi {
    position: absolute;
    left: 0.75rem;
    top: 50%;
    transform: translateY(-50%);
    color: var(--color-text-muted);
    pointer-events: none;
}

.search-field input[type=search] {
    width: 100%;
    min-height: var(--touch-min);
    padding-left: 2.5rem;
}

/* Rule 10 across the whole filter bar, not just its search field. The filter buttons and selects
   beside it are equally what a finger has to land on, and AB#922 left them at Bootstrap's default
   height — which is under the floor on the fridge. */
.filter-bar .btn,
.filter-bar .form-select {
    min-height: var(--touch-min);
}

/* Row-level actions are quiet ghosts. The filled .btn-danger is reserved for the moment of
   commitment (a destructive dialog's confirm); repeating it per row makes delete the loudest
   thing on the page. The icon carries the meaning, the chrome stays out of the way.

   The outline is --color-border-ghost, never --color-border: the fixed hue was picked against the
   base background and vanishes the moment a row is lighter than it, which is every group header,
   every selected row and the whole expanded detail band on the pantry grid. */
.btn.btn-row {
    --bs-btn-color: var(--color-text-secondary);
    --bs-btn-bg: transparent;
    --bs-btn-border-color: var(--color-border-ghost);
    --bs-btn-hover-color: var(--color-text-primary);
    --bs-btn-hover-bg: var(--color-bg-elevated);
    --bs-btn-hover-border-color: var(--color-border-ghost);
    --bs-btn-active-color: var(--color-text-primary);
    --bs-btn-active-bg: var(--color-bg-elevated);
    --bs-btn-active-border-color: var(--color-border-ghost);
}

/* The affirmative half of a row-level pair (keep/remove, resolve/delete). Same quiet chrome as
   .btn-row, cyan glyph — a filled button here would be the second solid fill on the screen. */
.btn.btn-row-confirm {
    --bs-btn-color: var(--color-cyan);
    --bs-btn-bg: transparent;
    --bs-btn-border-color: var(--color-border-ghost);
    --bs-btn-hover-color: var(--color-cyan);
    --bs-btn-hover-bg: rgba(0, 212, 255, 0.08);
    --bs-btn-hover-border-color: rgba(0, 212, 255, 0.4);
    --bs-btn-active-color: var(--color-cyan);
    --bs-btn-active-bg: rgba(0, 212, 255, 0.12);
    --bs-btn-active-border-color: rgba(0, 212, 255, 0.4);
}

.btn.btn-row-danger {
    --bs-btn-color: var(--color-danger);
    --bs-btn-bg: transparent;
    --bs-btn-border-color: var(--color-border-ghost);
    --bs-btn-hover-color: var(--color-danger);
    --bs-btn-hover-bg: rgba(255, 77, 106, 0.08);
    --bs-btn-hover-border-color: rgba(255, 77, 106, 0.4);
    --bs-btn-active-color: var(--color-danger);
    --bs-btn-active-bg: rgba(255, 77, 106, 0.12);
    --bs-btn-active-border-color: rgba(255, 77, 106, 0.4);
}

/* The disabled state of all three, which drew itself backwards until AB#925.

   Bootstrap's base `.btn` declares --bs-btn-disabled-opacity but leaves --bs-btn-disabled-color,
   -bg and -border-color to the variant classes to supply. These three variants are ours: there is no
   .btn-row in Bootstrap to fall back on, so those tokens were undefined, which makes
   `border-color: var(--bs-btn-disabled-border-color)` invalid at computed-value time. border-color
   then fell back to its initial value (currentColor) and color fell back to what it inherited — the
   cell's --color-text-secondary. So a disabled row button drew a solid #A0AABB outline: heavier and
   brighter than the translucent white an *enabled* one gets, which reads as the available control.

   Declaring the tokens is the whole fix. Reusing --color-border-ghost rather than inventing a
   fainter hue keeps one outline colour on these buttons; Bootstrap's own 0.65 disabled opacity is
   what separates the two states, in the direction it should have gone all along. */
.btn.btn-row,
.btn.btn-row-confirm,
.btn.btn-row-danger {
    --bs-btn-disabled-color: var(--color-text-muted);
    --bs-btn-disabled-bg: transparent;
    --bs-btn-disabled-border-color: var(--color-border-ghost);
}

/* Trailing Actions column: fixed (shrink-to-content) width, centered, on every list table. */
.table th.col-actions,
.table td.col-actions {
    width: 1%;
    white-space: nowrap;
    text-align: center;
}

.row-actions {
    display: flex;
    gap: 0.25rem;
    justify-content: center;
}

/* Every link inside a list table (AB#974). It started as the identity column's alone, the cell that
   names the row and opens it, and then took in the secondary ones too — the Inventory Journal's Reason
   column, which points at the cook event or receipt behind a number. Recipes arrived at the treatment
   first and it is the one that reads best down a long table, so it is a named class rather than the
   two utilities repeated at every list.

   No underline at rest is the whole point. Every row carries one of these, so underlining them draws
   a ladder of lines down the column and turns the list into a page of links rather than a table with
   a way in. Semibold does the same job with no lines: the cell sits a shade above the ones beside it,
   which is exactly what it means.

   The underline comes back on hover and focus, because with it gone at rest, weight and colour are
   all that is left, and neither survives a colour-blind reader or a keyboard tab well enough on its
   own. Colour is inherited rather than declared so this stays one theme change away from following. */
.row-link {
    text-decoration: none;
    font-weight: 600;
}

.row-link:hover,
.row-link:focus-visible {
    text-decoration: underline;
}

/* The identity cell that opens a dialog rather than a route (admin Feedback's date). It has to be a
   button, because it performs an action and there is no URL to give an anchor, but it has to read as
   the same affordance as the seven anchors above or the column stops looking like a way in. So the
   button chrome comes off entirely and .row-link does the rest.
   `font: inherit` is what makes it match the surrounding cell instead of the UA's button font, and it
   resets weight too — hence font-weight repeated here, since this rule outranks .row-link. */
button.row-link {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
    /* The anchors get their colour from Bootstrap's `a` rule, which this element is not. `inherit`
       here took the cell's body colour and the link came out grey next to every other list's cyan.
       Naming the same variable the anchors resolve keeps it one theme change away from following,
       which is the whole reason .row-link itself declares no colour. */
    color: var(--bs-link-color);
    /* Buttons do not wrap by default the way the text in a cell does, and on the fridge the Feedback
       date column is 123px against a 145px timestamp — without this the label spills into the next
       column instead of taking a second line. */
    white-space: normal;
}

/* Bootstrap darkens `a` on hover via --bs-link-hover-color, and again this element is not an `a`.
   Without it the button underlines on hover but does not shift colour, which reads as a different
   control to anyone who moves between this grid and any other list. */
button.row-link:hover,
button.row-link:focus-visible {
    color: var(--bs-link-hover-color);
}

.table td.cell-name {
    color: var(--color-text-primary);
}

/* One empty-state treatment: icon, one-line message, one CTA, optional hint. Rendered inside
   .table-container so the header row stays visible — that is what tells the user "empty is
   the answer, not still loading". */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 3rem 1rem;
    text-align: center;
}

.empty-state > .bi {
    font-size: 2.5rem;
    color: var(--color-text-muted);
}

.empty-state p {
    margin: 0;
    color: var(--color-text-secondary);
}

.empty-state .btn {
    margin-top: 0.25rem;
}

.empty-state .empty-hint {
    font-size: 0.875rem;
    color: var(--color-text-muted);
}

/* ── Design-system detail page layout (AB#923) ────────────────────────
   The shell every detail page renders through DetailPageLayout / DetailPanel / DetailStatList in
   Components/Controls, sharing the title row and ghost buttons the list pages already use.

   Prefixed `.detail-` rather than the design project's bare `.panel`: the marketing pages already
   own that name under `.landing`, and a global rule of lower specificity would still reach them for
   every property those rules do not set. */

.page-actions {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem;
    justify-content: flex-end;
}

.page-subtitle {
    margin-bottom: 0.75rem;
    color: var(--color-text-secondary);
}

/* The touch floor, applied to the whole of a detail page's chrome rather than to icon buttons alone.
   These are the controls the page is operated by — cook the meal, edit the item, load more history,
   switch units — and on the fridge they are the ones a finger has to land on. */
.page-actions .btn,
.detail-panel .btn {
    min-height: var(--touch-min);
}

/* One column by default, a rail only from 992px up. The fridge's 720px door reads the record top to
   bottom; desktop puts the metadata beside it. Deliberately not the reverse (a desktop-first rule
   undone at a max-width) so the narrow case needs no override to be correct. */
.detail-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1rem;
    align-items: start;
}

@media (min-width: 992px) {
    .detail-grid-with-rail {
        grid-template-columns: minmax(0, 1fr) 20rem;
    }
}

/* Grid items refuse to shrink below their content by default, so one long unbroken string — a UPC,
   a source URL — would otherwise widen its column and push the rail off the screen. */
.detail-main,
.detail-rail {
    min-width: 0;
}

.detail-panel {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--bs-border-radius);
    padding: 1rem;
    margin-bottom: 1rem;
}

.detail-panel:last-child {
    margin-bottom: 0;
}

.detail-panel-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 0.75rem;
}

.detail-panel-header h2 {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin: 0;
    font-size: 0.8125rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-text-muted);
}

/* Cyan, like the page title's glyph (AB#925). The heading text stays muted — it is a label, not a
   thing to read — and the glyph is what makes one panel findable among ten. Sized above the
   heading's 0.8125rem so it reads as a mark rather than as a taller letter. */
.detail-panel-header h2 .bi {
    flex: 0 0 auto;
    font-size: 1rem;
    color: var(--color-cyan);
}

/* The danger zone's heading is red, so its glyph cannot stay cyan. */
.settings-danger .detail-panel-header h2 .bi {
    color: var(--color-danger);
}

/* A panel whose heading is a record's own name (a recipe inside a meal) rather than a section
   label. Uppercasing what somebody named their recipe reads as shouting, not as structure. */
.detail-panel-header h2.detail-panel-title-plain {
    font-size: 1.05rem;
    text-transform: none;
    letter-spacing: normal;
    color: var(--color-text-primary);
}

/* A list group inside a panel sits on the panel's own surface, so its default fill would read as a
   slab inside a slab. Only the separators between rows carry. */
.detail-panel .list-group {
    --bs-list-group-bg: transparent;
}

.detail-stat-list {
    margin: 0;
}

.detail-stat-list > div {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 0.75rem;
    padding: 0.4rem 0;
    border-bottom: 1px solid var(--color-border-subtle);
    font-size: 0.875rem;
}

.detail-stat-list > div:last-child {
    border-bottom: 0;
}

.detail-stat-list dt {
    font-weight: 400;
    color: var(--color-text-secondary);
    white-space: nowrap;
}

/* Right-aligned against the label, and free to wrap: a category list or a long store name is the
   one thing in the rail that will not fit on its line. */
.detail-stat-list dd {
    margin: 0;
    min-width: 0;
    text-align: right;
    color: var(--color-text-primary);
    overflow-wrap: break-word;
}

/* ── Design-system settings page layout (AB#924) ──────────────────────
   The shell both settings pages render through SettingsPageLayout in Components/Controls. It reuses
   the detail pages' `.detail-panel` rather than restyling a second kind of section, because a
   settings page reading as the same family as a record page is the whole point of standardizing it.

   A setting is a label and one control, so a full-bleed row strands the control a screen away from
   the name of the thing it changes. 42rem is where that pair still reads as one line, and that stays
   the cap on a *column* rather than on the page: past 1200px the panels flow into two of them, which
   fills a desktop window and roughly halves how far Account settings scrolls. The cap never binds
   below 1200px, so the fridge's 720px door needs no override to be correct. */

.settings-column {
    max-width: 42rem;
}

/* One scope of the page: what is yours, then what is the household's. The rule under the heading is
   what makes the boundary read as a boundary on a page with no tabs to divide it. */
.settings-section + .settings-section {
    margin-top: 2rem;
}

.settings-section-title {
    margin: 0 0 0.75rem;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--color-border);
    font-size: 1rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text-secondary);
}

@media (min-width: 1200px) {
    /* Two 42rem columns plus the gap. Lifting the cap instead of adding a column would only trade a
       ribbon of empty space for rows a metre wide, which is the problem the cap exists to prevent.

       A column per scope, rather than each section flowing its own panels across both: Account down
       the left, Household down the right. The title row and the legal links span the pair. */
    .settings-column {
        max-width: 85.5rem;
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 42rem));
        gap: 0 1.5rem;
        align-items: start;
    }

    .settings-column > .page-header,
    .settings-column > .page-subtitle,
    .settings-column > .settings-footer {
        grid-column: 1 / -1;
    }

    /* Each section is one grid column, so its panels stack rather than flowing across two. */
    .settings-section + .settings-section {
        margin-top: 0;
    }

}

/* A third column, once the window can hold one without the columns going thin (AB#975). Two 42rem
   columns stop at 85.5rem, and on a 2560px window that left roughly 900px of empty page beside a
   page that still scrolled — the reader scrolling for settings that would have fitted on screen.

   2050px is where the third column earns its place rather than where it first fits. The page's own
   chrome (the sidebar, the container's padding) takes about 275px before a column gets any width, so
   this is the point where three columns are still 36rem each. One pixel below it the two columns are
   at their full 42rem cap, which is the property worth having: the third column is never bought by
   squeezing the pair, only by spending space they were already refusing. Measured, not derived — at
   1800px the three came out near 31rem and read as cramped.

   Below this, nothing here changes: two columns from 1200px, one below, and the fridge's 720px door
   reaches none of it.

   Three and no more. The page's height is set by whichever section is tallest once divided, and
   Account runs to roughly twice Household, so splitting them two-to-one leaves both bottoming out
   together — the floor. A fourth column can only be taken from one of them (3+1 or 2+2), and either
   way the other is untouched and still sets the height. It would narrow every column to buy nothing.

   The cap is still per column and still 42rem. Lifting it instead of adding a column would trade a
   ribbon of empty space for rows a metre wide, which is the problem the cap exists to prevent. */
@media (min-width: 2050px) {
    .settings-column {
        max-width: 129rem; /* 3 columns plus the two gaps between them */
        grid-template-columns: repeat(3, minmax(0, 42rem));
    }

    /* The section that asked for two of the three, via SettingsSection's PanelColumns. Which one that
       is belongs to the page, not to this file: naming #account here would break the moment a section
       was added or reordered. */
    .settings-section-wide {
        grid-column: span 2;
    }

    /* Multi-column rather than a nested grid, because the panels are of unequal height and what is
       wanted is a flow — down the first column and up the second, balanced — not a row of pairs with
       a gap under the shorter one of each. The gap matches the page grid's, so the two inner columns
       land on the same two tracks the section spans. */
    .settings-section-wide .settings-panels {
        columns: 2;
        column-gap: 1.5rem;
    }

    /* A panel is a unit: half its rows at the foot of one column and half at the head of the next is
       not a shorter page, it is an unreadable one. `.detail-panel` carries margin-bottom only, with
       no top margin to strand at the head of the second column. */
    .settings-section-wide .settings-panels > * {
        break-inside: avoid;
    }
}

/* The touch floor on the controls themselves. `.detail-panel .btn` already covers the buttons; on a
   settings page the selects and text fields are just as much what a finger has to land on. */
.settings-column .form-control,
.settings-column .form-select {
    min-height: var(--touch-min);
}

/* Label beside its control, wrapping to stacked once they no longer fit on one line. */
.settings-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.5rem 1rem;
}

.settings-row + .settings-row {
    margin-top: 0.75rem;
}

.settings-hint {
    margin: 0.5rem 0 0;
    font-size: 0.875rem;
    color: var(--color-text-muted);
}

/* Rows in a settings panel carry a hover band (atebit-theme.css gives every .list-group-item one).
   Two things have to be true at once: the band should reach the panel's edges rather than stop short
   of them, and a row's text should still line up with the description above it. So the list is
   pulled out to the panel's padding and each row keeps its own 1rem back — which is Bootstrap's
   default, hence no padding declared here, only the pull. Rows must not carry `px-0`. */
.settings-panels .list-group {
    margin-left: -1rem;
    margin-right: -1rem;
}

/* A panel's own explanatory line, above the controls it introduces rather than under them. */
.settings-lede {
    margin: 0 0 0.75rem;
    font-size: 0.875rem;
    color: var(--color-text-secondary);
}

/* AB#924 gave this one dialog a touch floor and left a note that raising every dialog to it was a
   change to make deliberately. AB#926 is that change: `.blazor-dialog-footer .btn` and
   `.blazor-dialog-body .form-control` in atebit-theme.css now cover every dialog, so the rule that
   stood here is gone rather than duplicated. */

/* The danger zone is fenced by its border and its heading, not by a filled red bar. A red slab
   shouts before the user has asked for anything, and it spends the one colour that should still
   mean something at the moment of commitment — the confirm dialog's filled submit. */
.settings-danger {
    border-color: var(--color-danger);
}

.settings-danger .detail-panel-header h2 {
    color: var(--color-danger);
}

/* ── Design-system dashboard layout (AB#925) ──────────────────────────
   Rule 13: a dashboard is stat tiles, then summary panels. A tile carries the number and its
   label and nothing else, and every panel that summarizes a list links to that list rather than
   growing row actions of its own — the dashboard points, lists do.

   Panels are `.detail-panel` from AB#923 rather than a second kind of section, for the reason the
   settings pages reuse it too: a dashboard reading as the same family as a record page is the
   point of standardizing it. Only the tiles and the panel's "View X →" link are new here. */

/* The backdrop treatment. Fixed attachment so the picture stays pinned as the page scrolls rather
   than sliding away under a tall column — the same reason .public-screen pins its own (AB#723/#744).

   The tint is mixed in `--color-bg-base`, not the near-black the public screen uses, and that
   matters more than its strength. The app's depth runs base #1A1F2E -> surface #222840 ->
   elevated, and a card reads as a card only because it sits one step above the page. Tinting
   toward #0A0E1A drops the page below the bottom of that ladder, so cards look washed out, and
   anywhere a bright part of the photograph comes through, the page goes locally *brighter* than
   the card on top of it and the depth inverts outright.

   0.58 rather than the 0.88 an opaque-card page needed: the cards below are translucent, so the
   picture is being read through them and no longer has to survive only in the gaps.

   The picture itself is not set here. `.page-backdrop` owns the treatment — tint, fit, bleed, and
   the glass on everything inside it — and each page names its own photograph through
   --page-backdrop-image, the same split as `.panel-art` and its per-panel `.panel-art-*` classes
   below. AB#941 baked the dashboard's shot into this rule; AB#950 needed a second picture and there
   was nowhere to put it that did not restate the tint.

   No fallback value on the var, deliberately. A page that takes the class without a picture class
   renders flat — background-image is invalid at computed-value time and drops the tint with it —
   which looks like the treatment never applied, and that is the honest failure. Defaulting to the
   dashboard's photograph would instead put a night kitchen behind Brands and look like a choice. */
.page-backdrop {
    background-color: var(--color-bg-base);
    background-image: linear-gradient(rgba(26, 31, 46, 0.58), rgba(26, 31, 46, 0.58)), var(--page-backdrop-image);
    background-position: center center;
    background-size: cover;
    background-repeat: no-repeat;
    background-attachment: fixed;
    /* Bleed out to the edges of the content area. The wrapper is `article.content px-4`, so the
       container sits 1.5rem in from the left and 1.1rem down from the top, and a background painted
       inside that stops short of both — a framed picture with a margin, which is not what a backdrop
       is. Cancel each with a negative margin and give the padding straight back so nothing on the
       page actually moves. The right edge already does this for the scrollbar. */
    margin-left: -1.5rem;
    padding-left: 1.5rem;
    margin-top: -1.1rem;
    padding-top: 1.1rem;
    /* And give the height back what the negative top margin took. .body-container is a fixed
       `100vh - chrome`, so pulling the box up by 1.1rem does not make it any taller — it just moves
       its bottom edge up by the same amount, leaving a 1.1rem strip of bare page under the backdrop
       at every window size. */
    height: calc(100vh - var(--chrome-height, 4.6rem) + 1.1rem);
}

/* The home dashboard's picture (AB#941): a night kitchen looking out at a defocused city, shot for
   this job rather than borrowed from the signed-out shell. */
.backdrop-home {
    --page-backdrop-image: url('/images/bg-dashboard.webp');
}

/* The small management pages' picture (AB#950): steam drifting through a dark room, lit cool on one
   side and warm on the other, with most of the frame falling to black. Shared by all five rather
   than one photograph each — they are the same archetype at five different sizes, and five distinct
   shots behind five identical layouts would be five answers to a question nobody asked.

   Vapour rather than a surface or a scene, which is what makes it neither of the two things it
   could have been. Stone was tried first and rejected: honed slate, soapstone and brushed steel all
   worked, and all of them read as a texture swatch behind a table, which is a look the brand has
   enough of. A literal scene was the other trap — the defocused pantry shelf came out as a liquor
   cabinet, and the bokeh plates were the dashboard's photograph in a different hue. Steam has the
   dashboard's depth without repeating its subject, and it is naturally low-frequency, which is what
   the glass over it needs. */
.backdrop-manage {
    --page-backdrop-image: url('/images/bg-manage.webp');
}

/* The pantry's picture (AB#942): a long run of dark open shelving seen down its length, ceramic
   canisters and glass jars of dried goods through the middle, the far corner falling to black.

   The subject repeats on purpose. A defocused pantry shelf was tried for AB#950 and rejected there
   for coming out as a liquor cabinet — a glass-fronted cabinet of bottles catching amber — and the
   standing rule against reusing a subject was lifted for this page rather than worked around. The
   reasoning is that a pantry shelf behind the five management pages is arbitrary, and behind the
   Pantry page it is the page's own subject; the AB#950 rejection was of that photograph, not of the
   idea. This one is open shelving with sacks and canisters and no cabinet doors, which is what keeps
   it from reading as the shot that was turned down.

   The busiest of the four generated, and picked over three quieter ones anyway. A backdrop wants to
   be low-frequency, but this page puts it under the widest cards in the app, and the alternates that
   were softer were also emptier — a near-black frame behind a full-width stack of group cards is a
   backdrop nobody can tell is there. */
.backdrop-pantry {
    --page-backdrop-image: url('/images/bg-pantry.webp');
}

/* The recipes pages' picture (AB#943): a dark saucepan on a lit gas ring shot at counter height, the
   burner crown a band of blue-cyan flame low left, a magenta reflection on the tiled wall behind it,
   the right half of the frame falling to black.

   Chosen from twelve because the flame is the app's own cyan arriving as a light *source* rather
   than as a graded wash over a scene, which is what every other candidate needed to fake. It is also
   the quietest of the twelve: nothing in the frame is bright enough to trouble the glass, so unlike
   the pantry's this ships as a straight resize of its master with no blur or darkening applied.

   The eight it beat are archived and described, and two failure shapes recur often enough to be
   worth naming here. A *bright mass anywhere near the middle* is fatal — an open cookbook, a lit
   doorway, a cloth-covered bowl, an oil-filled skillet all put a pale block behind the header or the
   grid, showed it straight through the glass, and inverted the page-over-card depth this file's tint
   rule is careful about. And *the busiest strip lands in the header band*, which is the one part of
   a list page the cards do not cover: the utensil rail read as clutter beside the Add recipe button
   at exactly the crop `cover` picks. */
.backdrop-recipes {
    --page-backdrop-image: url('/images/bg-recipes.webp');
}

/* The meal plan's picture (AB#944): a long dark refectory table set for seven, shot from a high angle
   so the settings recede into shallow focus, chairs ranged along the far side, the room behind and the
   right of the frame falling to near black.

   The only backdrop in the app with no cyan or magenta in it, and that is the whole argument for this
   one. A meal time carries its own Color and the week grid spends it in every rowheader and as a tint
   inside every cell, so on this page the slot hues are the one thing whose colour is information. The
   other four backdrops can afford a brand gradient because nothing they sit under is colour-coded.
   Fourteen candidates were generated and every one was shot deliberately near-monochrome.

   Chosen over six flat overhead takes of the same subject. Straight-down was the obvious framing for a
   row of seven and it is the one that fails: an overhead on wood grain is a texture swatch with objects
   on it, which is the reading that got the marble, walnut, slate and brushed-steel candidates killed on
   AB#941 and AB#950. The high angle keeps a receding table, a far edge and a dark room behind it, so
   the picture has somewhere to go.

   The pale window sliver in the master's top-left corner never reaches the page. `cover` scales this
   16:9 frame to the height of a much squarer content box and crops the sides: about 11% comes off each
   edge at 1280 wide, and roughly a third at the fridge's 720x1080. The sliver is inside the first 5%. */
.backdrop-mealplan {
    --page-backdrop-image: url('/images/bg-mealplan.webp');
}

/* The shopping lists' picture (AB#945): a night market with its strings of stall lights thrown
   completely out of focus into a dense field of saturated cyan and magenta bokeh filling the middle of
   the frame, the dark silhouettes of produce crates along the bottom edge.

   Sixteen were generated over two rounds, and the first eight were all wrong in the same way, which is
   the part worth keeping. They were shot near-monochrome, because that is what the meal plan's prompt
   asks for, and its prompt was copied here as though it were the house formula. It is not. The house
   formula is this picture and the dashboard's: cyan and magenta arriving as *practical light inside
   the scene*, near-black everywhere else. A backdrop that is only dark reads as bleak, and under the
   0.58 tint it stops reading as a photograph at all.

   Do not read the meal plan as a standing exception either. Its own note argues the colourless case
   from its colour-coded grid, and that page is expected to be redone rather than kept as the one
   sanctioned counter-example — so nothing here should be built on it holding.

   The second finding is the crop, and the first round is what exposed it. A desktop window crops a 16:9
   frame gently; the fridge's 720x1080 takes roughly a third off *each side*, so anything whose light
   sits out near an edge is simply gone on the device this page matters most on. Two round-one
   candidates went completely black at 720 — a stack of nested baskets, and a market stall whose string
   of bulbs, the only bright thing in it, cropped clean out of frame. Round two put the light
   centre-frame on purpose and all three of its finalists survived. Put the colour in the middle.

   Bokeh is what makes this survivable at the brightness it runs. Rule 17's usual failure is a bright
   mass inverting the page-over-card depth, and a defocused point light is the one bright thing that
   does not do it: it has no edges, so the tint flattens it to a wash a glass card still sits clearly on
   top of. Every candidate that failed at this brightness had structure in it — a lit chiller run, the
   wire of a basket stack, a fluorescent tube over a checkout belt. The tube is the sharpest lesson: at
   720 it landed straight across the back-link.

   This is the second page to take a bokeh field, after the dashboard, and that is the one standing rule
   it bends. It is a different scene rather than a second crop of the same one — a market at ground
   level against a city seen through a window — and the two are far enough apart in the app that they
   are never adjacent. Judged rendered, the way AB#943 settled the same kind of question.

   No panel art on either page — see the note on ShoppingListDetail.razor for why. */
.backdrop-shopping {
    --page-backdrop-image: url('/images/bg-shopping.webp');
}

/* The two history pages' picture (AB#947): an empty stainless kitchen pass at the end of service, shot
   down its length so it recedes to a vanishing point, a saturated cyan strip along one side and a hot
   magenta wash down the other, the two meeting in the middle.

   One picture across both pages rather than one each. Cook History and the Inventory Journal are the
   same archetype seen twice — a record of what already happened — and two unrelated photographs would
   make them read as two different products, which is the whole reason the story grouped them. The five
   management pages share `backdrop-manage` on the same argument.

   Chosen against a constraint that is the *opposite* of the one AB#945 recorded two rules above, which
   is why both notes have to stand. Shopping Lists needed its light near the middle of the frame,
   because the fridge crop eats the outer thirds. Cook History needs the reverse: its cards cover
   almost the entire content box, so the only backdrop anyone ever sees is the header band across the
   top and the two side margins. A picture whose subject lives in the centre is completely hidden here.
   Three candidates died on exactly that — a stack of plates, a dish rack and a sink of still water all
   read beautifully on their own and rendered as a black page. The rule that survives both is about the
   page, not the photograph: look at where the cards are not, and put the light there.

   This one satisfies both at once, which is the real reason it ships across two pages of different
   density. Its colour runs the full width as two bands rather than sitting in one place, so the fridge
   crop cannot take it and the cards cannot cover it. */
.backdrop-history {
    --page-backdrop-image: url('/images/bg-history.webp');
}

/* The receipts pages' picture (AB#946): a scatter of blank paper slips lying overlapping on a dark
   wooden counter, receding into shallow focus, cyan raking across them from the left into a hot magenta
   pool beyond.

   Blank on purpose, and it is the one constraint this subject has that none of the others did. A
   receipt is a printed object, so every prompt in the round had to ask for paper with nothing on it —
   an actual printed receipt in the picture would put unreadable pseudo-text behind a page whose whole
   content is a table of real text, and the eye tries to read it.

   Chosen over a curling till roll on the fridge test, which is the discriminator AB#945 established
   and which keeps earning its place. The roll is the better photograph at 1280 by some distance and it
   is the more obvious subject; at 720 it collapses to a sliver of cyan at the left edge and nothing
   else. This one still carries colour across the header band and down the margin there. Two of the four
   backdrops in this parent have now been decided by a viewport the desktop render gives no hint about. */
.backdrop-receipts {
    --page-backdrop-image: url('/images/bg-receipts.webp');
}

/* Settings' picture (AB#948): a kitchen drawer of ordered flatware standing open in an unlit room,
   cyan pooling across the tray from above and a hot magenta glow rising off the drawer front below.

   The subject is the page rather than the domain. Every other backdrop in the app is a picture of the
   food or of the room it happens in; this one is a picture of *everything in its place*, which is the
   only thing all fourteen panels here have in common — a display name, a week start day, a passkey
   and an API key share no subject matter at all.

   It is also the first backdrop in the app shot to a compositional rule rather than to a subject
   brief, and the rule came from failing first. Six candidates were generated with the colour running
   left to right across the frame, in the manner of every backdrop before this one, and all six read
   at 1280 and arrived at the fridge's 720 as grey. `background-size: cover` fits by height on a
   content box that much squarer than 16:9, so at 720 it keeps roughly the middle *third* of the
   frame's width: any picture whose cyan sits at the left edge and whose magenta sits at the right
   edge has both cropped away, and what is left is the unlit part in between. AB#947 recorded this
   property as something its master happened to have. This is the round where it became the brief:
   the colour is stacked top-to-bottom through the central third, so the crop cannot take it. Any
   future backdrop is cheaper to shoot this way than to judge at 1280 and discover at 720. */
.backdrop-settings {
    --page-backdrop-image: url('/images/bg-settings.webp');
}

/* The admin console's picture (AB#949), shared by all four of its pages: a long row of arcade
   cabinets receding into an unlit room, their screens dimmed to a deep blue afterglow, a magenta
   neon wash along the wall behind them and cyan catching the cabinet edges.

   The only backdrop in the app whose subject is the *page* rather than the app's own trade, and it
   earns that because the admin section already had this identity before it had a photograph. These
   four pages still wear `.arcade-page` for its scanline overlay, kept through AB#925 when the title
   screens and neon headings went; a room full of cabinets is what that overlay has been an artefact
   of all along. One picture across the console rather than four, for the reason Cook History and the
   Inventory Journal share theirs: these are four views of one job.

   The story asked for something quieter here than the member pages, because density is what these
   pages are for, and a lit arcade is not the obvious way to be quiet. What makes it work is the
   exposure rather than the subject. Nothing in the frame is brighter than a saturated colour: the
   screens are off, the light is all wall wash and edge catch, and the cabinet row is a rhythm of
   dark verticals rather than a mass of anything. A back-of-house prep line was the safer candidate
   and it is archived beside this one.

   The first arcade frame generated for this was rejected and the reason is worth keeping, because it
   is invisible at the width you would judge it. Two of its cabinets had lit screens — near-white
   blocks in the left middle of the frame, the failure the art rules call fatal. At 1280 the content
   box is squarer than 16:9, so `cover` fits by height and crops ~11% off each side, which happened
   to hide them; past about 1900px the box is *wider* than 16:9, `cover` flips to fitting by width,
   and the full frame comes back with both screens at full size. A backdrop that passes at 1280 has
   not been checked. Judge every one of them at a wide window as well as at the fridge. */
.backdrop-admin {
    --page-backdrop-image: url('/images/bg-admin.webp');
}

/* The How It Works guide's picture (AB#1024): a dark kitchen thrown so far out of focus that its
   lights become a cluster of large cyan and magenta bokeh circles across the left of the frame, a
   counter edge and cabinet run just readable in soft focus to the right.

   The one backdrop in the app that was picked from the archive rather than shot for its page. Its
   own batch of six was queued and never ran — an account rate limit, not a failure of the prompts —
   and rather than hold the page flat behind it, this is a variant generated for the home dashboard
   (AB#941) and not used there.

   It is the most literally low-frequency picture in the archive, which is the property that matters
   most here: everything in it is already a soft circle, so there is no detail for a translucent card
   to turn into noise, and this page's cards are wide. The bokeh is also the brand's two colours
   arriving as practical light in the frame rather than as a graded wash over a scene, which is the
   formula the dashboard's own shot and AB#943's gas ring both landed on.

   Two things to watch if this is ever revisited. The circles are the brightest things in the frame
   and they cluster left, which is where the loop figure's card sits; the figure carries its own
   glass (see HowItWorksGuide.razor) so it has a floor, but a future layout that puts loose text
   there would not. And the colour is spread across the frame rather than stacked through the centre,
   so AB#948's crop rule is satisfied by luck rather than by composition: the cluster is wide enough
   that the fridge's middle third still lands on it. Verified at 720x1080 rather than assumed. */
.backdrop-howitworks {
    --page-backdrop-image: url('/images/bg-howitworks.webp');
}

/* No portrait swap on any of them. .public-screen carries a 9x16 crop because its content is one
   centred card on a full-bleed photograph, where a landscape shot squeezed into a tall viewport
   loses its subject entirely. None of these has a subject that a centre crop loses — defocused city
   light, a texture, a run of shelving that is the same run of shelving in its middle fifth, and a
   burner low enough in frame to survive the crop — so `cover` cropping to the middle at 720x1080
   costs nothing a second asset would buy back. */

/* The cards on a backdrop page are translucent, so the backdrop is read *through* them rather than
   only in the gaps between them. The blur is what makes that survivable: without it, text sits
   directly on whatever the photograph happens to be doing underneath. It is also why a backdrop has
   to be shot low-frequency — sharp detail behind a translucent card is noise, not texture.

   `.table-responsive` is in this list because on a list page it *is* the card: it wraps the grid and
   the count footer, which are one object with a line across it rather than two. Putting the glass on
   the grid's own container instead would leave the footer sitting outside it, and putting it on the
   table cells would run one backdrop-filter per cell in a virtualized grid. One blur per page.

   The blur is painted by a ::before pane rather than by the surface itself, and that is load-bearing
   rather than stylistic (AB#1037, the same fault AB#1035 fixed on the meal plan). A backdrop-filter
   makes its element a containing block for every position: fixed descendant, exactly as a transform
   does. A row menu is fixed precisely so it can escape the list's own overflow clipping, so with the
   filter on the wrapper the viewport coordinates anchoredMenu.js writes were resolved against the
   glass instead: measured on the pantry, a menu asked for (875, 384) landed at (1119, 623), off the
   side of the list or inside the clip where nothing in it could be tapped. Its dismiss backdrop is
   fixed and full-viewport for the same reason and was trapped with it, so a tap outside the card no
   longer closed the menu either. The trap has to go rather than be corrected for: once trapped, the
   menu is also clipped by the scroll container it exists to escape.

   A pseudo-element paints the same glass without being an ancestor of anything, which is what makes
   it safe for *any* popover a surface comes to hold rather than for the one that found this.

   The precondition is that none of these surfaces scrolls itself. An absolutely positioned pane
   scrolls away with its content, and AB#1035 read that as ruling `.table-responsive` out. It does
   not: `.table-responsive` is a flex column whose `.table-container` child takes the overflow, so
   measured on every list page the wrapper's scrollHeight equals its clientHeight and the pane stays
   where it was put. That is an invariant now rather than an accident, asserted in GlassSurfaceTests
   alongside the trap probe. A surface that starts scrolling has to hand its scrolling to a child the
   way this one does, or hang its glass somewhere that does not move. */
.page-backdrop .stat-tile,
.page-backdrop .detail-panel,
.page-backdrop .table-responsive,
.page-backdrop .cook-history-list,
.page-backdrop .card {
    position: relative;
    background-color: transparent;
    border-color: rgba(255, 255, 255, 0.12);
}

.page-backdrop .stat-tile::before,
.page-backdrop .detail-panel::before,
.page-backdrop .table-responsive::before,
.page-backdrop .cook-history-list::before,
.page-backdrop .card::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background-color: rgba(34, 40, 64, 0.55);
    -webkit-backdrop-filter: blur(14px) saturate(120%);
    backdrop-filter: blur(14px) saturate(120%);
    pointer-events: none;
}

/* The pane is positioned and the surface's contents are not, so without this the glass paints over
   the card instead of behind it. Position the content rather than pushing the pane to z-index: -1
   (a negative layer would fall behind the page's own background) or raising a stacking context on
   the surface (it would cap the menu's z-index against the rest of the page, which is the whole
   point of the menu carrying one). Direct children only, and every one of them is statically
   positioned today: a sticky <thead> or the pantry's sticky toolbar sits deeper than this. */
.page-backdrop .stat-tile > *,
.page-backdrop .detail-panel > *,
.page-backdrop .table-responsive > *,
.page-backdrop .cook-history-list > *,
.page-backdrop .card > * {
    position: relative;
}

/* A linked tile's hover, restated as a tint on the pane for the reason a list's rows restate theirs:
   the resting fill is now the pane's, so an opaque `--color-bg-elevated` on the tile itself would
   paint *behind* the glass and read as mud rather than as a tile lighting up. The cyan edge is the
   affordance and is untouched. */
.page-backdrop a.stat-tile-link:hover,
.page-backdrop a.stat-tile-link:focus-visible {
    background-color: transparent;
}

.page-backdrop a.stat-tile-link:hover::before,
.page-backdrop a.stat-tile-link:focus-visible::before {
    background-color: rgba(42, 48, 80, 0.62);
}

/* Page-level text that is on the photograph rather than on a card (AB#949). A backdrop page is a
   stack of glass surfaces with a few lines loose between them — the subtitle under the title row,
   and on two pages an explanatory paragraph above the first card. Those lines have no floor of any
   kind: the blur that makes text readable inside a card is a property of the card, and there is no
   card here to put one on.

   It stayed invisible until AI Pricing, because everywhere else that slot holds one short line and
   the odds are with it. That page runs three lines of `--color-text-secondary` across the full width
   at exactly the height the admin backdrop puts its neon wash, and measured on the render the
   brightest background under the paragraph is luminance 170 against text at 157. Not low contrast:
   none.

   A shadow and a colour, rather than a surface. Giving the block a tinted panel would invent a card
   the page does not have and put a second blur on the page; darkening the photograph would fix one
   page by dulling the three where nothing is wrong. Both of these cost nothing anywhere they are not
   needed — over the dark part of any picture the shadow is invisible — so they scale to a backdrop
   nobody has shot yet.

   The colour is the half that actually does the work, and the shadow alone is what proved it. With
   the shadow and the muted grey, the brightest ground immediately around a glyph came down from 167
   to 159 against text at 160: readable, but only just, and by a margin the next photograph could
   spend. Muted grey is a colour chosen to be quiet against an opaque card. On a photograph it is not
   quiet, it is absent — the same trap the list-group border rule further down was written for. Text
   on the picture is primary text; the shadow then holds its edge where the picture is bright. */
.page-backdrop > .page-subtitle,
.page-backdrop > .settings-hint,
.page-backdrop > .settings-lede {
    color: var(--color-text-primary);
    text-shadow: 0 1px 3px rgba(10, 13, 24, 0.95), 0 0 10px rgba(10, 13, 24, 0.8);
}

/* Translucency does not compose: a photograph sitting on a see-through panel shows the backdrop
   through itself as well, and the two turn to mud. Every layer that carries its own image has to
   re-establish an opaque floor. */
.page-backdrop .panel-art .detail-panel-header {
    background-color: rgba(18, 22, 42, 0.85);
}

/* One page's glass is not here, and cannot be. The meal plan's cells and headers are declared in
   MealPlans.razor.css, which is scoped CSS: those selectors are rewritten to
   `.mp-meal-box[b-xxxxxxxxxx]` and the bundle loads after this file (see App.razor). A
   `.page-backdrop .mp-meal-box` written here would match at the same specificity and lose on source
   order, so the whole treatment for that page lives beside the rules it overrides. Anything else new
   that is styled in a scoped stylesheet has the same problem.

   That page is also the one place the app puts a backdrop-filter on more than one element (AB#944).
   The reasoning is written out there; the short version is that the week grid's cell count is hard
   bounded at seven times the household's meal times, and that no wrapper it could hang a single blur
   on survived being looked at. Do not read it as licence to glass a list's rows. */

/* ── A list page on a backdrop (AB#950) ───────────────────────────────
   Same rule as above, applied to the one surface that is painted per cell rather than per card.
   The theme paints every cell an opaque `--color-bg-surface` with !important (see the striping kill
   in atebit-theme.css), so a glass wrapper under an opaque grid shows the backdrop in the page
   margins and nowhere else — a framed photograph around a spreadsheet, which is the "backdrop under
   opaque cards" half of rule 17 that does not work on its own.

   The cells hand their colour back to the wrapper instead of each getting glass of their own. The
   !important is inherited from the rule being overridden, not a preference; the extra `.page-backdrop`
   is what wins it. */
.page-backdrop .table > :not(caption) > * > * {
    background-color: transparent !important;
}

/* Except the header, which is sticky and therefore has rows sliding underneath it. Translucency
   there would show the data scrolling *through* the column titles. It re-establishes an opaque floor
   for the same reason a panel header carrying a photograph does — near-opaque rather than opaque so
   the glass edge does not stop dead at the top of the grid. */
.page-backdrop .table thead,
.page-backdrop .table > thead > tr > * {
    background-color: rgba(18, 22, 42, 0.92) !important;
}

/* Hover, restated as a tint. The theme's hover is an opaque `--color-bg-elevated`, which over glass
   reads as a solid bar appearing across a see-through table rather than as a row lighting up. */
.page-backdrop .table > tbody > tr:not(.table-secondary):not(.table-light):not(.table-primary):hover > * {
    background-color: rgba(42, 48, 80, 0.55) !important;
}

/* A contextually marked row, restated for the same reason and found the same way (AB#946). The cell
   rule above clears *every* cell background, and a highlighted row is a cell background, so the
   treatment silently un-highlights it.

   Today that is one row on one page: the Inventory Journal's "From receipt" link arrives at the
   receipt as `?line=`, and `.table-warning` on the matching row is the whole of what says "this is the
   line you came for". Without this the link still scrolls to the right row and then has nothing to
   show, which is a worse failure than never linking — the reader is told they have arrived and cannot
   see at what.

   Amber rather than the neutral tint the hover takes, because this is the same signal the warning
   badges spend and it has to survive being read past a hover. `!important` is inherited from the rule
   being overridden; the `tr.table-warning` is what wins it. Any future contextual row class needs its
   own line here, which is the cell-background warning in the list-page standard arriving on rows
   rather than on columns. */
.page-backdrop .table > tbody > tr.table-warning > * {
    background-color: rgba(245, 158, 11, 0.22) !important;
}

/* The count footer is inside the glass wrapper, so it only needs enough darkening to stay a footer.
   Its own `--color-bg-subtle` is opaque and would cut a solid band across the bottom of the card. */
.page-backdrop .table-footer {
    background-color: rgba(18, 22, 42, 0.45);
}

/* ── The nested-table archetype on a backdrop (AB#942) ────────────────
   The pantry (rule 15) is the one list shape whose card is not the .table-responsive wrapper alone.
   The wrapper still holds the toolbar, the groups and the count footer, but inside it every group is
   its own bordered card, so a backdrop page has two layers to answer for rather than one.

   The groups do not get glass of their own. Translucency does not compose — a see-through card on a
   see-through wrapper turns to mud rather than reading as a card — and a second backdrop-filter per
   group would run one blur per group in the list, which is the per-element cost AB#950 moved off the
   table cells in the first place. A pantry with forty categories would run forty of them.

   So the wrapper keeps the single blur and a group is a tint on top of it: light enough that the
   photograph still passes through, solid enough that the card edge lands where the eye says it is.
   Note this is `--color-bg-elevated` rather than the surface colour it replaces. A group card is
   opaque-surface on an opaque page, but here it sits on the wrapper's own tint, and matching that
   tint would make the card edge disappear at exactly the point it has to hold a border. */
.page-backdrop .pantry-group-card {
    background-color: rgba(42, 48, 80, 0.38);
    border-color: rgba(255, 255, 255, 0.12);
}

/* Selection is carried by the cyan rule down the left edge, which is untouched and does all the
   work. The band behind it only has to lift — and its opaque `--color-bg-elevated` over glass reads
   as a solid card appearing in a see-through stack, the same failure as the opaque hover bar above
   at card size. */
.page-backdrop .pantry-group-card.is-selected {
    background-color: rgba(42, 48, 80, 0.62);
}

/* The toolbar stands in for the <thead> this list no longer has, and it is sticky for the same
   reason, so it takes the same near-opaque floor as one: cards slide underneath it, and a
   translucent bar would show a product name passing through the word "Item". */
.page-backdrop .pantry-list-toolbar {
    background-color: rgba(18, 22, 42, 0.92);
}

/* The child rows need nothing. .pantry-subtable is deliberately not `.table`, so the cell-clearing
   rule above does not reach it — and it does not have to, because that table paints no cell
   background of its own and already shows whatever the group card is doing underneath. It is worth
   knowing this held only by accident: any new rule that tints a subtable row has to be restated
   here, the same way rule 17 warns about cell backgrounds on a plain grid. */

/* ── List-group rows on a backdrop (AB#945) ───────────────────────────
   The third archetype to need restating, and it is the flat table's rule arriving on different
   markup: the theme paints every `.list-group-item` an opaque surface colour, so a run of rows
   inside a glass panel punches an opaque slab through it exactly the way an untouched `<td>` does.
   Same answer, for the same reason — the rows hand their colour back to the panel, which is already
   carrying the page's one blur, and the separators do the work of saying where one row ends.

   Written against `.list-group-item` rather than the shopping list's own class deliberately. Three
   surfaces were already on this markup when the rule was written — the shopping list's items, the
   recipe page's ingredients, the meal page's non-recipe items — and the latter two had been sitting
   on a backdrop since AB#943 and AB#944 with nobody having restated anything. A rule scoped to one
   page would have fixed one of the three and left the other two to be found again later.

   The separator has to become a white alpha hairline rather than keep `--color-border`. That colour
   is a near-solid dark blue-grey chosen to read against an opaque surface; over glass it lands on
   whatever the photograph is doing underneath and disappears wherever the picture is already dark.
   The same substitution the panel edges themselves take above. */
.page-backdrop .list-group-item {
    background-color: transparent;
    border-color: rgba(255, 255, 255, 0.12);
}

/* Hover, restated as a tint, for the reason the grid's rows are: the theme's hover is an opaque
   `--color-bg-elevated`, which over glass reads as a solid bar appearing across a see-through list
   rather than as a row lighting up. */
.page-backdrop .list-group-item:hover {
    background-color: rgba(42, 48, 80, 0.55);
}

/* ── Cook History on a backdrop (AB#947) ──────────────────────────────
   The nested-table archetype arriving a second time, on markup that shares none of the pantry's class
   names. A cook event is a `.card` holding a `.table-responsive` of its own, and the glass rule above
   names both — so untouched, a full page of twenty events runs a backdrop-filter per card *and* one per
   inner table. Forty blurs, on a page that is reached from the fridge. The pantry talked itself out of
   this at forty groups and the reasoning transfers whole: one blur per page, and everything inside it
   takes a tint on top of that single blur rather than glass of its own.

   `.cook-history-list` is the wrapper that carries it, added to the glass rule above. It is a plain div
   the page grew for this, not `.table-responsive`: that class is `flex: 1; overflow-y: auto`, so
   borrowing it would move the page's scrolling inside the box and pin the footer, which is a layout
   change a backdrop has no business making. It would also have collided by name with the inner tables.

   Scoped through the wrapper rather than written as `.page-backdrop .cook-event-card`, which would tie
   at (0,2,0) with `.page-backdrop .card` above and be decided by source order alone. */
.page-backdrop .cook-history-list .cook-event-card {
    /* Elevated rather than the surface colour it replaces, and a step up from the wrapper's own tint,
       for the pantry group card's reason: matching the tint underneath makes the card edge vanish at
       exactly the point it has to hold a border. */
    background-color: rgba(42, 48, 80, 0.38);
    border-color: rgba(255, 255, 255, 0.12);
}

/* The header band of each event. `--color-bg-subtle` is opaque and darker than the card it sits on, so
   left alone it cuts a solid bar across the top of every event in a see-through stack. Kept a step
   *below* the card body rather than above it, which is the relationship the opaque version had. */
.page-backdrop .cook-history-list .card-header {
    background-color: rgba(18, 22, 42, 0.42);
    border-color: rgba(255, 255, 255, 0.12);
}

/* The table each event carries. It matches the page's glass rule by class name, which would give it a
   second blur inside a card that is already translucent — translucency does not compose, and the two
   turn to mud. It hands its colour back to the card the same way a grid's cells hand theirs to the
   wrapper. */
.page-backdrop .cook-history-list .table-responsive {
    background-color: transparent;
    border-color: transparent;
}

/* Both of the above are surfaces the glass rule names by class, so both would otherwise grow a pane
   of their own inside a wrapper that is already carrying the page's one blur. Cancelling the pane is
   what "backdrop-filter: none" used to do here; a tint is left where the card wants one and nothing
   where the inner table does. */
.page-backdrop .cook-history-list .cook-event-card::before,
.page-backdrop .cook-history-list .table-responsive::before {
    content: none;
}

/* The recipe group header inside an event's table, which is the case the list-page standard warns about
   in as many words: any rule that paints a cell background has to be restated for a backdrop, or the
   cell-clearing rule above blanks it. That rule is `!important`, so this has to be too — the extra
   `.cook-history-list` is what wins it rather than the flag.

   Without this the row keeps its weight and its icon and loses its band, which on a table that only
   draws the band when an event has more than one recipe is exactly the signal being spent. */
.page-backdrop .cook-history-list .cook-history-recipe-group-header td {
    background-color: rgba(42, 48, 80, 0.55) !important;
}

/* ── Panel header art ─────────────────────────────────────────────────
   A summary panel may wear a photograph behind its header. The art is behind the *header* and
   never behind the rows: a heading is four words on a solid bar and can hold its own over a
   picture, where a list of meals and amounts could not.

   Where the tiles were tried instead (AB#941) the picture and the numbers competed, and the
   tiles had to turn opaque enough to hide most of what they were sitting on. A header band is
   the same idea with the argument removed. */
.panel-art {
    /* The header bleeds to the panel's edges, so the panel gives up its top padding and the
       header cancels the side padding below. overflow keeps the image inside the rounded corner. */
    padding-top: 0;
    overflow: hidden;
}

/* The padding is what the header gains over its plain version, and it is kept to the minimum that
   still leaves a band of picture above and below the heading. A taller header shows more photograph
   but starts pushing the rows it is labelling down the page, and the panel is there for the rows. */
.panel-art .detail-panel-header {
    position: relative;
    margin: 0 -1rem 0.75rem;
    padding: 0.7rem 1rem;
    background-size: cover;
}

/* The scrim, heaviest where the heading sits and easing off across the picture. */
.panel-art .detail-panel-header::before {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    background: linear-gradient(90deg,
        rgba(10, 14, 26, 0.86) 0%,
        rgba(10, 14, 26, 0.66) 55%,
        rgba(10, 14, 26, 0.50) 100%);
}

.panel-art .detail-panel-header h2,
.panel-art .detail-panel-header .detail-panel-link {
    position: relative;
    z-index: 1;
}

/* The one deviation from the panel's own convention. A panel heading is muted grey because it is
   a label rather than something to read, but muted grey over a photograph disappears at any scrim
   strength worth having, so over art it takes the primary text colour. */
.panel-art .detail-panel-header h2 {
    color: var(--color-text-primary);
}

/* A photograph each, shot for the panel it sits on: a row of plated dinners for the week, a nearly
   empty shelf for what is running out. Both in the AB#812 direction, so two images still read as
   one page.

   Two crops of a single shot was tried first and does not work. The source was a row of vegetables
   repeated across its width, so moving the window sideways lands on more carrots and the two
   headers come out looking like the same picture printed twice.

   Only the vertical offset is worth setting. At `cover`, a 21:9 image in a band this wide scales to
   fit the width exactly and overflows on height alone, so the X component of background-position
   has nothing to slide and is inert. Composition across the frame has to come from the photograph
   itself — which is why the pantry shot was reshot with its jars centred and both outer thirds
   left empty, so the heading and the "View pantry" link each sit over bare shelf.

   The band is short, so each Y is picked to put the subject inside roughly the middle fifth of the
   frame, which is all a header this height actually shows. */
.panel-art-week .detail-panel-header {
    background-image: url('/images/panel-week.webp');
    background-position: center 56%;
}

/* Empty produce crates. Chosen over four alternatives because a repeating horizontal rhythm is what
   survives this crop: the band shows a thin slice, so a shot has to carry across the full width
   rather than have a subject somewhere in it. Tall objects (the shelved jars this replaced) slice
   into shapes that read as nothing at all. */
.panel-art-pantry .detail-panel-header {
    background-image: url('/images/panel-pantry.webp');
    background-position: center 50%;
}


.stat-tiles {
    display: grid;
    /* auto-fit, not the card's fixed four: the fridge's 720px door has to wrap to two per row, and
       the admin console has a dozen tiles rather than the home page's four. 13rem lands on four
       across at 1280 once the pinned sidebar has taken its 220px, which is the card's reading. */
    grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
    gap: 0.75rem;
    margin-bottom: 1rem;
}

.stat-tile {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.85rem 1rem;
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--bs-border-radius);
}

/* A tile that is a link to the page its number is about. It has to keep reading as the same object
   as the plain tiles beside it, so the affordance is the cyan edge on hover and focus rather than
   underlined text or a chevron. No hover-only behaviour: the tile is fully usable without ever
   producing a hover state, which is the whole of it on the fridge's touch panel (rule 10 is met by
   the tile's own size, far past the 48px floor). */
a.stat-tile-link {
    color: inherit;
    text-decoration: none;
    transition: border-color 120ms ease, background-color 120ms ease;
}

a.stat-tile-link:hover,
a.stat-tile-link:focus-visible {
    border-color: var(--color-cyan);
    background: var(--color-bg-elevated);
}

a.stat-tile-link:focus-visible {
    outline: 2px solid var(--color-cyan);
    outline-offset: 2px;
}

.stat-tile .stat-num {
    font-size: 1.6rem;
    font-weight: 700;
    line-height: 1.1;
    color: var(--color-text-primary);
}

/* Cyan for a number that is healthy, amber only when the number is asking to be acted on. A tile
   with a tone on every number spends both colours until neither means anything, so most tiles
   take neither and read in plain white. */
.stat-tile .stat-num.stat-num-accent { color: var(--color-cyan); }
.stat-tile .stat-num.stat-num-warn   { color: var(--color-warning); }

/* The denominator in "5/7": part of the number, but not the part being reported. */
.stat-tile .stat-num-suffix {
    font-size: 1rem;
    font-weight: 400;
    color: var(--color-text-muted);
}

.stat-tile .stat-label {
    font-size: 0.8125rem;
    color: var(--color-text-secondary);
}

/* A diagnostic under one tile's label, not a second label — today the nutrition queue's liveness
   line (AB#847), which is what separates a queue that is draining from one that has stalled. */
.stat-tile .stat-sublabel {
    margin-top: 0.35rem;
    font-size: 0.75rem;
    color: var(--color-text-muted);
}

.stat-tile > .bi {
    font-size: 1.5rem;
    color: var(--color-text-muted);
}

/* The glyph's hue groups related tiles so a dozen of them read as families rather than as a wall.
   Decorative only — the glyph is aria-hidden and the label carries the meaning, so nothing here is
   information that exists in the colour alone. The number's own tone is untouched: rule 13 keeps
   amber for a number that wants acting on, and tinting glyphs is deliberately a separate budget. */
.stat-tile > .bi.stat-glyph-cyan    { color: var(--color-cyan); }
.stat-tile > .bi.stat-glyph-magenta { color: var(--color-magenta); }
.stat-tile > .bi.stat-glyph-purple  { color: var(--color-purple); }
.stat-tile > .bi.stat-glyph-amber   { color: var(--color-warning); }

/* Two panels abreast at desktop, stacked below 992px so the fridge reads them top to bottom.
   Written narrow-first, like .detail-grid, so the 720px case needs no override to be correct. */
.dashboard-panels {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1rem;
    align-items: start;
}

@media (min-width: 992px) {
    .dashboard-panels {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }
}

/* The grid's own gap does the spacing; the panel's stacking margin would double it. */
.dashboard-panels > .detail-panel {
    margin-bottom: 0;
}

/* The way out of a summary panel and into the list it summarizes. It clears the touch floor
   because on the fridge it is the only thing on the panel a finger can land on. */
.detail-panel-link {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    min-height: var(--touch-min);
    color: var(--color-cyan);
    text-decoration: none;
    font-size: 0.875rem;
    white-space: nowrap;
}

.detail-panel-link:hover,
.detail-panel-link:focus-visible {
    text-decoration: underline;
}

/* A row inside a summary panel: a leading label, the thing itself, and a trailing qualifier.
   Rows here are read, never operated — anything actionable belongs on the list page. */
.dashboard-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0.45rem 0;
    border-bottom: 1px solid var(--color-border-subtle);
    font-size: 0.9375rem;
}

.dashboard-row:last-child {
    border-bottom: 0;
}

.dashboard-row .dashboard-row-lead {
    flex: 0 0 2.6rem;
    color: var(--color-text-muted);
    font-size: 0.8125rem;
    font-weight: 600;
    text-transform: uppercase;
}

.dashboard-row .dashboard-row-main {
    min-width: 0;
    color: var(--color-text-primary);
    overflow-wrap: break-word;
}

/* The week panel's meal thumbnail — the same box on every row whether it holds a photo, the
   fallback glyph, or nothing at all, so a missing photo never changes a row's height. Deliberately
   the .pantry-thumb recipe at a smaller size rather than a second thumbnail treatment. 2rem because
   PhotoRendition.Thumbnail resolves to a 150px blob, which still has headroom at 2x. */
.dashboard-row .dashboard-row-thumb {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    overflow: hidden;
    border: 1px solid var(--color-border);
    border-radius: var(--bs-border-radius, 0.375rem);
    background-color: var(--color-bg-elevated);
    /* Muted, not the amber .dashboard-row > .bi carries: that amber means "this is running out",
       and a meal with no photo is not a warning. The child combinator on that rule cannot reach a
       glyph nested in here, so this only has to set the colour, not fight for it. */
    color: var(--color-text-muted);
    font-size: 0.875rem;
}

.dashboard-row .dashboard-row-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Pushed to the far edge whatever the main text's length: the slot name, the amount left. */
.dashboard-row .dashboard-row-trail {
    margin-left: auto;
    padding-left: 0.5rem;
    color: var(--color-text-muted);
    font-size: 0.8125rem;
    white-space: nowrap;
}

.dashboard-row .dashboard-row-main.is-empty {
    color: var(--color-text-muted);
    font-style: italic;
}

/* The trailing qualifier takes the tile's amber when it is the reason the row is listed at all —
   how little is left, not merely which slot the meal sits in. */
.dashboard-row .dashboard-row-trail.is-warn {
    color: var(--color-warning);
}

/* The only glyph a summary row carries today marks an item that is running out, so it reads in the
   same amber as the tile that counted it. */
.dashboard-row > .bi {
    flex: 0 0 auto;
    color: var(--color-warning);
}

/* A table inside a panel reaches the panel's edges. Its own cell padding is the inset the rows
   need, and the panel's 1rem on top of that is 2rem the widest column does not have — on the job
   run history that was enough to push the Actions column off the right edge at 1280. */
.detail-panel > .overflow-x-auto {
    margin-left: -1rem;
    margin-right: -1rem;
}

/* A count footer under a table that lives inside a panel rather than being the page's own list.
   It is the panel's bottom edge, so it reaches the padding instead of floating inside it. */
.detail-panel > .table-footer {
    margin: 0.75rem -1rem -1rem;
    border-radius: 0 0 var(--bs-border-radius) var(--bs-border-radius);
}

/* A panel with nothing to summarize still says so, in the space a row would have taken. */
.dashboard-empty {
    margin: 0;
    padding: 0.45rem 0;
    color: var(--color-text-muted);
    font-size: 0.9375rem;
}

/* ── Dashboard trend charts ───────────────────────────────────────────
   These lived in AdminDashboard.razor.css, which is why /admin/ai-cost drew its cost-per-day
   bars with no card around them and no grow animation: a scoped sheet binds to one component,
   and two pages render this markup. Same trap the arcade block below is annotated with. */

.dashboard-chart {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--bs-border-radius);
    padding: 1rem;
}

.dashboard-chart-bar {
    transform-origin: bottom;
    transform: scaleY(0);
    animation: dashboard-bar-grow 600ms ease-out forwards;
}

@keyframes dashboard-bar-grow {
    to { transform: scaleY(1); }
}

.dashboard-chart-labels {
    display: flex;
    margin-top: 0.35rem;
}

.dashboard-chart-labels span {
    flex: 1 1 0;
    text-align: center;
    font-size: 0.75rem;
    color: var(--color-text-muted);
    white-space: nowrap;
    overflow: hidden;
}

.brand-logo {
    width: auto;
    height: 100%;
    max-height: 40px;
    object-fit: scale-down;
    display: block;
    margin: auto;
}

input[type=search] {
    width: 20rem;
    padding: 0.75rem 0.75rem 0.75rem 2.5rem;
    color: var(--color-text-primary);
    background-color: var(--color-bg-subtle);
    border: 1px solid var(--color-border);
    border-radius: 0.5rem;
}

.search-box {
    position: relative;
}

    .search-box:before {
        content: '';
        width: 1.25rem;
        height: 1.25rem;
        position: absolute;
        left: .75rem;
        top: calc(50% - 0.625rem);
        background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z');
        background-size: contain;
        background-repeat: no-repeat;
    }

.badge {
    display: inline-flex;
    align-items: center;
    width: fit-content;
    padding: 0.5rem 1rem;
    border-radius: 1rem;
}

    .badge .btn-close {
        padding: 0.2rem;
        margin-left: 0.5rem;
    }

/* ── Tab count badge (superscript circle) ───────────────────── */
.tab-badge {
    font-size: 0.72rem;
    font-weight: 600;
    background-color: var(--color-bg-elevated);
    color: var(--color-text-secondary);
    border-radius: 50%;
    min-width: 1.5em;
    height: 1.5em;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    vertical-align: super;
    margin-left: 4px;
    line-height: 1;
    padding: 0;
}

/* ── Build version indicator ────────────────────────────────── */

.nav-version {
    padding: 0.5rem 1rem;
    font-size: 0.7rem;
    opacity: 0.4;
    color: #fff;
}

/* ── Recipe editor: dialog height ───────────────────────── */
/* Scoped to .recipe-editor-dialog (CssClass on ComponentAsDialogOptions)  */
/* so other ExtraLarge dialogs (e.g. ReceiptReviewDialog) size naturally.  */
/*                                                                         */
/* min-height, not height (AB#926). The fixed height was there to stop the */
/* box resizing as you move between tabs, and min-height still does that:  */
/* a short tab cannot shrink it. What it no longer does is cap a tab that  */
/* genuinely needs more room, which is what left the Details tab showing a */
/* scrollbar with 40px to scroll. The cap is now the viewport, less the    */
/* 2.75rem margin the wrapper carries top and bottom.                      */
.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge {
    /* min() because a bare min-height wins over max-height in CSS, which would let the floor push
       the dialog off the bottom of a 1280x800 screen. Below that the floor collapses to the cap and
       the box simply fills the viewport. */
    min-height: min(940px, calc(100vh - 5.5rem));
    max-height: calc(100vh - 5.5rem);
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

/* Flex chain: wrapper → content → form → header/body/footer */
.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .blazor-dialog-content,
.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .blazor-dialog-content > form {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
    margin: 0;
}

.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .blazor-dialog-header,
.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .blazor-dialog-footer {
    flex-shrink: 0;
}

/* Dialog body is a flex column: nav-tabs pinned at top, tab content scrolls.
   No sticky needed — avoids the peekaboo where content bleeds above sticky tabs. */
.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .blazor-dialog-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .blazor-dialog-body > .nav-tabs {
    flex-shrink: 0;
}

.recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge .recipe-tab-content {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    padding: 2px 4px 4px 4px;
}

/* ── Pantry item editor: dialog height ─────────────────────── */
/* Same flex approach as recipe editor, scoped via CssClass,    */
/* and the same min-height/max-height reasoning (AB#926). The   */
/* Details tab's field count varies with the item — a count or  */
/* weight unit adds the piece-weight row, Track low stock adds  */
/* two more — so no single fixed height fits every item.        */
.pantry-item-dialog .blazor-dialog-content-wrapper-large {
    min-height: min(860px, calc(100vh - 5.5rem));
    max-height: calc(100vh - 5.5rem);
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.pantry-item-dialog .blazor-dialog-content-wrapper-large .blazor-dialog-content,
.pantry-item-dialog .blazor-dialog-content-wrapper-large .blazor-dialog-content > form {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
    margin: 0;
}

.pantry-item-dialog .blazor-dialog-content-wrapper-large .blazor-dialog-header,
.pantry-item-dialog .blazor-dialog-content-wrapper-large .blazor-dialog-footer {
    flex-shrink: 0;
}

.pantry-item-dialog .blazor-dialog-content-wrapper-large .blazor-dialog-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

.pantry-item-dialog .blazor-dialog-content-wrapper-large .blazor-dialog-body > .nav-tabs {
    flex-shrink: 0;
}

.pantry-item-dialog .blazor-dialog-content-wrapper-large .pantry-tab-content {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    padding: 2px 4px 0 4px;
}

/* ── Soft-keyboard dialog fix ───────────────────────────────── */
/* Samsung Internet keyboard is a true overlay — JS can't detect */
/* its height. Solution: on mobile, anchor the dialog to the top */
/* of the screen with a large bottom padding so the container is */
/* scrollable. The user can drag the whole dialog up above the   */
/* keyboard, and the body scrolls for long content within it.    */

@media (max-width: 800px) {
    .blazor-dialog-container {
        overflow-y: auto;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none; /* hide scrollbar so it doesn't shift content */
    }

    .blazor-dialog-container::-webkit-scrollbar {
        display: none;
    }

    /* Switch from flex-centered to block so dialog sits at top */
    .blazor-dialog-centered {
        display: block !important;
        padding: 1rem 0 50vh;
    }

    .blazor-dialog-content-wrapper {
        margin: 0 auto;
        max-height: 70vh;
        display: flex;
        flex-direction: column;
    }

    /* The two editors set their own min/max height for the desktop, at a specificity that would
       otherwise beat the 70vh above and take back the room this rule is reserving for the soft
       keyboard. Their floor goes too: on a 720px-wide panel a 940px minimum is taller than the
       screen, so the dialog must be free to shrink to whatever 70vh leaves. */
    .recipe-editor-dialog .blazor-dialog-content-wrapper-xlarge,
    .pantry-item-dialog .blazor-dialog-content-wrapper-large {
        min-height: 0;
        max-height: 70vh;
    }

    .blazor-dialog-content {
        display: flex;
        flex-direction: column;
        min-height: 0;
        max-height: 100%;
    }

    .blazor-dialog-body {
        overflow-y: auto;
        -webkit-overflow-scrolling: touch;
        flex: 1 1 auto;
        min-height: 0;
    }
}


/* Nutrition Facts Label */
.nutrition-label {
    border: 2px solid #000;
    padding: 4px 8px;
    max-width: 380px;
    margin: 0 auto;
    font-family: 'Helvetica Neue', Arial, sans-serif;
    font-size: 0.85rem;
    color: #000;
}

.nutrition-label .nf-title {
    font-size: 2rem;
    font-weight: 900;
    line-height: 1;
    margin-bottom: 2px;
}

.nutrition-label .nf-bar-thick {
    background-color: #000;
    height: 8px;
    margin: 2px -8px;
}

.nutrition-label .nf-serving-row {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 3px 0;
}

.nutrition-label .nf-amount-per {
    font-size: 0.8rem;
    padding: 4px 0 0;
}

.nutrition-label .nf-calories-section {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    padding: 2px 0;
}

.nutrition-label .nf-calories-label {
    font-size: 2rem;
    font-weight: 900;
}

.nutrition-label .nf-dv-header {
    text-align: right;
    font-size: 0.75rem;
    font-weight: 700;
    padding: 3px 0;
    border-bottom: 1px solid #000;
}

.nutrition-label .nf-table {
    width: 100%;
    border-collapse: collapse;
    table-layout: fixed;
}

.nutrition-label .nf-table tr {
    border-bottom: 1px solid #000;
}

.nutrition-label .nf-td-label {
    padding: 2px 0;
    vertical-align: middle;
    overflow: hidden;
}

.nutrition-label .nf-td-input {
    padding: 2px 0 2px 4px;
    text-align: right;
    vertical-align: middle;
    width: 62px;
}

.nutrition-label .nf-td-unit {
    padding: 2px 0 2px 3px;
    vertical-align: middle;
    width: 30px;
    font-size: 0.85rem;
}

.nutrition-label .nf-td-pct {
    padding: 2px 0 2px 4px;
    text-align: right;
    vertical-align: middle;
    width: 42px;
    font-weight: 700;
    font-size: 0.8rem;
}

.nutrition-label .nf-bold .nf-td-label {
    font-weight: 700;
}

.nutrition-label .nf-indent .nf-td-label {
    padding-left: 1rem;
}

.nutrition-label .nf-indent2 .nf-td-label {
    padding-left: 2rem;
}

.nutrition-label .nf-footnote {
    font-size: 0.7rem;
    padding: 4px 0 2px;
    line-height: 1.3;
}

.nutrition-label input.nf-input {
    width: 100% !important;
    border: none !important;
    border-bottom: 1px solid #888 !important;
    box-shadow: none !important;
    text-align: right;
    padding: 0 2px;
    font-size: 0.85rem;
    background: transparent !important;
    outline: none;
}

.nutrition-label input.nf-input:focus {
    border-bottom-color: #1b6ec2 !important;
    background: #f0f4ff !important;
}

.nutrition-label input.nf-input-lg {
    width: 90px !important;
    font-size: 2rem !important;
    font-weight: 900;
}

.nutrition-label input.nf-input-text {
    width: 180px !important;
    border: none !important;
    border-bottom: 1px solid #888 !important;
    box-shadow: none !important;
    text-align: right;
    padding: 0 2px;
    font-size: 0.85rem;
    background: transparent !important;
    outline: none;
}

.nutrition-label input.nf-input-text:focus {
    border-bottom-color: #1b6ec2 !important;
    background: #f0f4ff !important;
}

/* ── InventoryPicker ────────────────────────────────────────── */

.inventory-picker-input {
    text-align: center;
    padding-left: 0;
    padding-right: 0;
}

.inventory-picker-input::-webkit-inner-spin-button,
.inventory-picker-input::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

.inventory-picker-input[type=number] {
    -moz-appearance: textfield;
    appearance: textfield;
}

/* ── Slot color swatches (UpsertMealTimeDialog) ─────────────── */

.slot-color-swatch {
    width: 2rem;
    height: 2rem;
    border-radius: 50%;
    border: 2px solid transparent;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: transform 0.1s, box-shadow 0.1s;
    padding: 0;
    font-size: 0.85rem;
}

.slot-color-swatch:hover {
    transform: scale(1.15);
    box-shadow: 0 0 0 2px #adb5bd;
}

.slot-color-swatch--selected {
    box-shadow: 0 0 0 3px #343a40;
    transform: scale(1.1);
}

.slot-color-swatch--sm {
    width: 1.1rem;
    height: 1.1rem;
    border-radius: 50%;
    border: 2px solid transparent;
    flex-shrink: 0;
    pointer-events: none;
}


/* Onboarding preload overlay — covers the page while the circuit connects and
   the dialog is preparing. @Body is suppressed while this is visible so nothing
   renders behind it. The dialog container renders after this in the DOM and
   sits on top. */
.onboarding-preload-overlay {
    position: fixed;
    inset: 0;
    background-color: rgba(0, 0, 0, 0.6);
    -webkit-backdrop-filter: blur(4px);
    backdrop-filter: blur(4px);
}

/* Onboarding wizard — dimmed + blurred backdrop */
.onboarding-wizard-dialog.blazor-dialog-container {
    background-color: rgba(0, 0, 0, 0.6);
    -webkit-backdrop-filter: blur(4px);
    backdrop-filter: blur(4px);
}

/* Announcements dialog */
.announcements-dialog .blazor-dialog-content-wrapper-normal {
    max-height: 50vh;
    display: flex;
    flex-direction: column;
}

.announcements-dialog .blazor-dialog-content {
    display: flex;
    flex-direction: column;
    min-height: 0;
    flex: 1;
}

.announcements-dialog .blazor-dialog-body {
    overflow-y: auto;
    flex: 1;
    min-height: 0;
}

.announcements-content {
    line-height: 1.6;
}

/* The announcements title used to be its own centred 2em heading with no close X. AB#926 folded it
   into the shared dialog header, so its styling lives with every other dialog title now. */

@media (max-width: 767.98px) {
    .announcements-dialog .blazor-dialog-content-wrapper-normal {
        min-height: 80vh !important;
        max-width: 88vw !important;
        margin: 10vh auto !important;
    }
}

/* Announcement items (AB#429). Each one is a summary the reader can unfold, so the row itself is the
   button: the whole line is the target, which is what makes it usable on the fridge. */
/* Cyan so the date reads as a divider between releases rather than as another headline competing with
   the summaries directly under it. */
.announcement-date {
    font-size: 1rem;
    font-weight: 600;
    color: var(--color-cyan, #00D4FF);
    margin: 1.25rem 0 0.35rem;
}

.announcement-date:first-child {
    margin-top: 0;
}

.announcement-list {
    list-style: none;
    padding: 0;
    margin: 0 0 0.5rem;
}

.announcement-item {
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.announcement-item:last-child {
    border-bottom: none;
}

.announcement-toggle {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    width: 100%;
    min-height: 48px;
    padding: 0.5rem 0;
    background: none;
    border: none;
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.announcement-summary {
    flex: 1;
    font-weight: 600;
}

.announcement-summary-plain {
    display: block;
    padding: 0.75rem 0;
}

.announcement-chevron {
    flex: 0 0 auto;
    opacity: 0.7;
}

.announcement-detail {
    padding: 0 0 0.85rem;
    opacity: 0.9;
}

/* Ensure toasts render above dropdowns and backdrops */
.blazored-toast-container {
    z-index: 1100 !important;
}

/* Receipt drop zone */
.receipt-drop-zone {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.5rem 0.75rem;
    border: 2px dashed var(--color-border);
    border-radius: 0.5rem;
    color: var(--color-text-muted);
    font-size: 0.875rem;
    min-height: 48px;
    cursor: default;
    transition: border-color 0.15s, background-color 0.15s;
    flex-wrap: wrap;
}

.receipt-drop-zone--active {
    border-color: var(--color-cyan);
    background-color: rgba(0, 212, 255, 0.06);
    color: var(--color-cyan);
}

/* SimpleDialog — wider than BlazorDialog's default 500px Normal to avoid wrapping on confirm messages */
@media (min-width: 576px) {
    .blazor-dialog-content-wrapper-normal {
        max-width: 550px !important;
    }
}

/* Feedback attachment thumbnail */
.feedback-attachment-thumb {
    max-width: 120px;
    max-height: 120px;
    object-fit: cover;
}

/* ── Login / signed-out page ────────────────────────────────── */

.public-screen {
    /* Fixed-height scroll container: html/body are locked to 100vh with overflow hidden, so this
       element owns the signed-out scroll. It stacks the shared public header above the page content;
       long pages (Privacy/Terms) scroll within it while the sticky header stays put. */
    height: 100vh;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    /* Arcade backdrop matching the marketing home (.landing): the neon-kitchen photo pinned behind
       everything (tint layer keeps text readable), the ambient glow blooms layered over it in ::before,
       and scanlines over the top in ::after. Fixed attachment keeps all layers pinned to the viewport
       at any scroll position - a scroll-attached background slides away below a tall card
       (AB#723/#744 legal pages exposed this). Portrait swaps to the 9x16 crop below. */
    background-color: #12161F;
    background-image: linear-gradient(rgba(10, 14, 26, 0.62), rgba(10, 14, 26, 0.62)), url('/images/bg-neon-kitchen-landscape.webp');
    background-position: center center;
    background-size: cover;
    background-repeat: no-repeat;
    background-attachment: fixed;
    position: relative;
}

@media (orientation: portrait) {
    .public-screen {
        background-image: linear-gradient(rgba(10, 14, 26, 0.62), rgba(10, 14, 26, 0.62)), url('/images/bg-neon-kitchen-portrait.webp');
    }
}

/* Ambient neon blooms over the photo, matching the marketing home's .glow-field. Fixed so they stay
   pinned as a tall page scrolls; pointer-events none keeps the page clickable. */
.public-screen::before {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background:
        radial-gradient(640px 480px at 85% -5%, rgba(0, 212, 255, 0.13), transparent 70%),
        radial-gradient(560px 560px at -10% 45%, rgba(200, 80, 192, 0.11), transparent 70%),
        radial-gradient(700px 500px at 50% 115%, rgba(139, 92, 246, 0.10), transparent 70%);
}

/* Faint CRT scanlines over the whole signed-out surface, matching the marketing home. Fixed so they
   cover the viewport regardless of scroll; pointer-events none keeps everything clickable. */
.public-screen::after {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 30;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        rgba(255, 255, 255, 0.022) 0px,
        rgba(255, 255, 255, 0.022) 1px,
        transparent 1px,
        transparent 4px);
}

/* The signed-out header and footer that used to live here (.public-header / .public-brand /
   .public-signin / .public-footer) are gone: PublicLayout now renders the same PublicSiteHeader and
   PublicSiteFooter as the marketing shell, styled under .public-surface above (AB#927). They were a
   hand-copy of the marketing chrome and had drifted — different footer contents entirely. */

/* Public page nav (AB#743), rendered by PublicSiteHeader inside .public-surface .topbar on every
   signed-out page that shows it. Global rather than scoped, with hardcoded fallbacks beside each
   --l-* token, so it survives being dropped into any surface. Fridge-safe (M94): flexbox only. */
.public-nav {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 0.25rem 0.75rem;
    flex: 1 1 auto;
}

.public-nav a {
    display: inline-flex;
    align-items: center;
    min-height: 48px;
    padding: 0 0.65rem;
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 600;
    font-size: 0.95rem;
    color: var(--l-text, #A0AABB);
    text-decoration: none;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
    transition: color 0.15s, border-color 0.15s;
}

.public-nav a:hover {
    color: var(--l-white, #FFFFFF);
    border-bottom-color: var(--l-cyan, #00D4FF);
}

/* Tablet/fridge and down: brand and Sign in share the top row, the nav drops to a full-width centred
   second row so the six links never crowd the button or stack each item onto its own line. Needs
   flex-wrap on the parent header row (set on .public-surface .topbar). The 900px cutoff covers the
   fridge (720) and tablet (768) portrait widths; wider viewports keep the nav inline between the
   brand and Sign in. */
@media (max-width: 900px) {
    .public-nav {
        order: 3;
        flex-basis: 100%;
        margin-top: 0.25rem;
    }
}

/* The page content area below the header. Grows to fill the remaining viewport so a short card
   (login) centres, but takes its natural height when the card is taller (Privacy/Terms) so the page
   scrolls instead of clipping. */
.public-content {
    flex: 1 0 auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    padding: 2.5rem 1.5rem;
}

/* Auto vertical margins center a short card but collapse to 0 when the card is taller than the space,
   so the card top is never clipped in the scroll container. flex-shrink: 0 stops the column flex from
   shrinking a too-tall card (whose overflow: hidden would then silently clip it instead of scrolling). */
.public-content > * {
    margin: auto 0;
    flex-shrink: 0;
}

.public-card {
    z-index: 1;
}

.public-card {
    background: rgba(30, 36, 56, 0.92);
    border: 1px solid rgba(255, 255, 255, 0.10);
    border-radius: 1.25rem;
    padding: 3rem 2.75rem;
    text-align: center;
    width: 100%;
    max-width: 460px;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 80px rgba(200, 80, 192, 0.08);
    position: relative;
    overflow: hidden;
}

.public-card::before {
    content: '';
    position: absolute;
    top: -60px;
    left: -60px;
    width: 240px;
    height: 240px;
    background: var(--gradient-glow-magenta);
    pointer-events: none;
}

.public-logo {
    width: 96px;
    height: 96px;
    object-fit: contain;
    margin-bottom: 1.25rem;
}

.public-app-name {
    font-size: 2.75rem;
    font-weight: 800;
    font-family: 'Space Grotesk', system-ui, sans-serif;
    color: var(--color-text-primary);
    margin-bottom: 0.35rem;
    line-height: 1.1;
    letter-spacing: -0.02em;
}

.public-tagline {
    font-size: 1rem;
    color: var(--color-text-secondary);
    margin-bottom: 2.25rem;
}

.btn-google {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    width: 100%;
    background: #fff;
    border: 1px solid #dadce0;
    border-radius: 4px;
    padding: 0.875rem 1.75rem;
    min-height: 52px;
    min-width: 240px;
    font-size: 1rem;
    font-weight: 500;
    color: #3c4043;
    text-decoration: none;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
    cursor: pointer;
}

.btn-google:hover {
    background-color: #f8f9fa;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
    color: #3c4043;
    text-decoration: none;
}

.btn-google:active {
    background-color: #f1f3f4;
}

.btn-google-icon {
    width: 20px;
    height: 20px;
    flex-shrink: 0;
}

/* Additional OAuth provider buttons (AB#969) — same footprint as the Google button */
.btn-microsoft {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    width: 100%;
    background: #fff;
    border: 1px solid #dadce0;
    border-radius: 4px;
    padding: 0.875rem 1.75rem;
    min-height: 52px;
    min-width: 240px;
    font-size: 1rem;
    font-weight: 500;
    color: #3c4043;
    text-decoration: none;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
    cursor: pointer;
}

.btn-microsoft:hover {
    background-color: #f8f9fa;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
    color: #3c4043;
    text-decoration: none;
}

.btn-microsoft:active {
    background-color: #f1f3f4;
}

.btn-microsoft-icon {
    width: 20px;
    height: 20px;
    flex-shrink: 0;
}

/* Passkey controls (AB#773).

   Hidden by default and revealed only under html.passkey-supported, which js/passkey.js adds when the
   browser actually has WebAuthn. Doing the gate in CSS rather than by toggling elements means it needs
   no re-running after Blazor swaps the DOM on an enhanced navigation, and it degrades the right way:
   a browser with JS off or WebAuthn missing never shows a control it cannot honour. The Samsung Family
   Hub fridge (Samsung Internet 10 / Chromium ~M94) is the case this exists for — it simply keeps the
   password and provider buttons it already had.

   Written as "hide when unsupported" rather than a hide/show pair on purpose. The pair would have to
   name a display value to restore, and there is no single right one: these containers are a plain div on
   the login page but a Bootstrap .card (display: flex) in Settings, so restoring `block` silently flattened
   the card's flex layout. Negating the class instead means the supported case adds no display rule at all
   and every container keeps whatever display it would otherwise have. */
html:not(.passkey-supported) [data-passkey-ui] {
    display: none;
}

/* Matches .btn-google / .btn-microsoft so the three sign-in choices read as one set, but in the app's
   own colours rather than a vendor's — a passkey belongs to Ate-bit, not to a provider. */
.btn-passkey {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    width: 100%;
    background: var(--color-bg-elevated);
    border: 1px solid var(--color-border);
    border-radius: 4px;
    padding: 0.875rem 1.75rem;
    min-height: 52px;
    min-width: 240px;
    font-size: 1rem;
    font-weight: 500;
    color: var(--color-text-primary);
    text-decoration: none;
    cursor: pointer;
}

/* The elevated background is already the lightest surface in the palette, so hover reads on the border
   rather than going lighter still. */
.btn-passkey:hover {
    border-color: var(--color-border-focus);
    color: var(--color-text-primary);
}

.btn-passkey[aria-busy="true"] {
    opacity: 0.65;
    cursor: default;
}

.btn-passkey-icon {
    font-size: 1.15rem;
    line-height: 1;
    flex-shrink: 0;
}

/* One row per registered passkey in Settings. */
.passkey-item-meta {
    font-size: 0.8125rem;
    color: var(--color-text-secondary);
}

/* Email/password auth forms on the login card (AB#966) */
.auth-form {
    text-align: left;
    margin-bottom: 1.25rem;
}

.auth-form .form-label {
    color: var(--color-text-secondary);
    font-size: 0.875rem;
    margin-bottom: 0.25rem;
}

.auth-form .form-control {
    min-height: 48px;
}

.auth-submit {
    min-height: 52px;
}

/* Primary submit matches the marketing home's signature-gradient CTA. Scoped to .btn-primary so the
   outline-secondary submit variants keep their Bootstrap look. */
.btn-primary.auth-submit {
    background: linear-gradient(90deg, #00D4FF 0%, #8B5CF6 50%, #C850C0 100%);
    border: 0;
    color: #0A0E1A;
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 700;
    box-shadow: 0 6px 26px rgba(0, 212, 255, 0.22), 0 6px 26px rgba(200, 80, 192, 0.14);
}

.btn-primary.auth-submit:hover,
.btn-primary.auth-submit:focus {
    background: linear-gradient(90deg, #00D4FF 0%, #8B5CF6 50%, #C850C0 100%);
    color: #0A0E1A;
    box-shadow: 0 10px 34px rgba(0, 212, 255, 0.34), 0 10px 34px rgba(200, 80, 192, 0.22);
}

.btn-primary.auth-submit:disabled {
    background: linear-gradient(90deg, #00D4FF 0%, #8B5CF6 50%, #C850C0 100%);
    color: #0A0E1A;
    opacity: 0.55;
}

.auth-divider {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    color: var(--color-text-secondary);
    font-size: 0.85rem;
    margin: 1.25rem 0;
}

.auth-divider::before,
.auth-divider::after {
    content: '';
    flex: 1;
    height: 1px;
    background: rgba(255, 255, 255, 0.12);
}

.auth-alt {
    color: var(--color-text-secondary);
    font-size: 0.9rem;
    margin-top: 0.5rem;
}

/* Live password requirements checklist (AB#1071) */
.password-requirements {
    list-style: none;
    margin: 0.5rem 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
}

.password-requirement {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.8rem;
    color: var(--color-text-muted);
}

.password-requirement i {
    font-size: 0.9rem;
}

.password-requirement.is-met {
    color: var(--color-success);
}

.border-dashed {
    border: 2px dashed var(--color-border) !important;
}

/* Broken / missing photo fallback — CSS-only trick.
   Pseudo-elements on <img> are hidden when the image loads successfully;
   they become visible when the src fails, covering the browser's broken-image icon. */
img {
    position: relative;
}

img::before {
    content: "";
    display: block;
    position: absolute;
    inset: 0;
    background-color: var(--color-bg-elevated);
    z-index: 1;
}

img::after {
    content: "📷  " attr(alt);
    display: flex;
    align-items: center;
    justify-content: center;
    position: absolute;
    inset: 0;
    background-color: var(--color-bg-elevated);
    border: 2px dashed var(--color-border);
    border-radius: 6px;
    color: var(--color-text-muted);
    font-size: 0.75rem;
    font-family: 'Inter', sans-serif;
    text-align: center;
    padding: 0.5rem;
    box-sizing: border-box;
    z-index: 2;
}

/* Inline SVG country flags (RegionFlag component). Emoji flags don't render on
   Windows / Samsung Internet, so flags are drawn as SVG and sized here. */
.region-flag {
    display: inline-flex;
    vertical-align: middle;
    line-height: 0;
    margin-right: 0.35rem;
}

.region-flag svg {
    height: 0.9em;
    width: auto;
    border-radius: 1px;
    box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.25);
}

/* Centered flag when used as a KPI card icon — drop the inline right margin. */
.kpi-icon .region-flag {
    margin-right: 0;
}

/* ── Public marketing landing ("/", signed-out) ─────────────────
   Ported from the former static wwwroot/home.html into a Razor page so the copy is localizable
   (AB#784). Every rule is scoped under .landing so the marketing chrome (its own .btn, .hero, .panel,
   .feature, etc.) stays isolated from Bootstrap and the app styles that share those class names.
   Fridge-safe CSS only (Samsung Internet / Chromium M94): no :has(), @container, CSS nesting, @layer,
   subgrid, or text-wrap:balance. Flexbox + grid + transforms + keyframes + custom properties only.
   Press Start 2P is decoration-tier: tiny arcade micro-labels only, never headlines or body copy. */
/* The signed-out palette. Lives on .public-surface rather than .landing because both signed-out
   wrappers carry that class (.landing for the marketing pages, .public-screen for the auth and legal
   pages) and they now share one header and one footer (AB#927) — so the tokens that chrome is built
   from have to resolve on both. Everything else stays scoped to .landing. */
.public-surface {
    --l-bg: #12161F;
    --l-card: #1E2438;
    --l-card-2: #242B44;
    --l-border: #2E3550;
    --l-border-strong: #3A4266;
    --l-text: #A0AABB;
    --l-text-strong: #C8D0E0;
    --l-white: #FFFFFF;
    --l-muted: #5A6480;
    --l-cyan: #00D4FF;
    --l-cyan-dark: #00BBDD;
    --l-magenta: #C850C0;
    --l-purple: #8B5CF6;
    --l-grad: linear-gradient(90deg, #00D4FF 0%, #8B5CF6 50%, #C850C0 100%);
    --l-maxw: 1040px;

    /* Type scale for the signed-out pages (AB#927). Before this the public pages carried six
       different body sizes (0.9 / 0.95 / 0.98 / 1 / 1.02 / 1.05rem) and two card-title sizes
       (1.15 / 1.2rem), invented per page, so cards sitting next to each other on the same screen
       disagreed. Five steps is enough for every role these pages have; anything that wants a size
       outside them is either decorative arcade type (.px, .score-*) or wrong.
         fine  — optional/helper text beside a field
         meta  — form labels, errors, nav and footer chrome
         body  — default copy inside a card, and its bullet lists
         lead  — the intro paragraph under a section heading
         title — a card's heading (h3-level) */
    --l-fs-fine:  0.85rem;
    --l-fs-meta:  0.95rem;
    --l-fs-body:  1rem;
    --l-fs-lead:  1.05rem;
    --l-fs-title: 1.2rem;

    /* Both signed-out wrappers own their own scroll (html/body are locked to overflow: hidden), and
       they centre a fixed 1040px .wrap inside it. A long page (the marketing home) paints a classic
       15px scrollbar and a short one (login) does not, so without a reserved gutter the centred wrap
       — and with it the brand in the header — slid ~7.5px sideways every time you navigated between
       them. Reserving the gutter keeps the available width identical on every page.

       scrollbar-gutter is Chromium M94, i.e. exactly the fridge's floor. If it is not honoured there
       the page simply behaves as it did before; nothing depends on it. */
    scrollbar-gutter: stable;
}

.landing {
    font-family: 'Inter', system-ui, sans-serif;
    /* Neon-kitchen photo pinned behind everything (a tint layer keeps text readable). The glow field
       and content sit above it; scanlines layer over the top of all of it. */
    background-color: var(--l-bg);
    background-image: linear-gradient(rgba(10, 14, 26, 0.62), rgba(10, 14, 26, 0.62)), url('/images/bg-neon-kitchen-landscape.webp');
    background-position: center center;
    background-size: cover;
    background-repeat: no-repeat;
    background-attachment: fixed;
    color: var(--l-text);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    /* html/body are locked to overflow:hidden, so the landing owns its own scroll (same pattern as
       .public-screen). The fixed glow/scanline layers stay pinned to the viewport while this scrolls. */
    height: 100vh;
    overflow-x: hidden;
    overflow-y: auto;
}
.landing *, .landing *::before, .landing *::after { box-sizing: border-box; }
.landing h1, .landing h2, .landing h3, .landing p, .landing ul, .landing li { margin: 0; padding: 0; }

@media (orientation: portrait) {
    .landing {
        background-image: linear-gradient(rgba(10, 14, 26, 0.62), rgba(10, 14, 26, 0.62)), url('/images/bg-neon-kitchen-portrait.webp');
    }
}

/* Ambient cabinet glow: one cyan bloom top-right, one magenta bloom left, fixed so the room feels lit
   while content scrolls through it. */
.landing .glow-field {
    position: fixed;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background:
        radial-gradient(640px 480px at 85% -5%, rgba(0, 212, 255, 0.13), transparent 70%),
        radial-gradient(560px 560px at -10% 45%, rgba(200, 80, 192, 0.11), transparent 70%),
        radial-gradient(700px 500px at 50% 115%, rgba(139, 92, 246, 0.10), transparent 70%);
}
/* CRT scanlines over everything, faint enough to read as texture, not noise. */
.landing .scanlines {
    position: fixed;
    inset: 0;
    z-index: 30;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        rgba(255, 255, 255, 0.022) 0px,
        rgba(255, 255, 255, 0.022) 1px,
        transparent 1px,
        transparent 4px);
}
.landing main { position: relative; z-index: 1; }
.landing a { color: var(--l-cyan); text-decoration: none; }
.landing a:hover { color: var(--l-cyan-dark); }
.public-surface .wrap { width: 100%; max-width: var(--l-maxw); margin: 0 auto; padding: 0 1.5rem; }

.public-surface .px {
    font-family: 'Press Start 2P', monospace;
    font-size: 0.6rem;
    letter-spacing: 0.08em;
}

/* --- Header: solid bar with an animated signature-gradient marquee rule underneath ---
   Scoped to .public-surface, not .landing: PublicSiteHeader renders on both signed-out wrappers
   (AB#927). The auth pages previously carried a hand-copied .public-header that drifted from this. */
.public-surface .site-header {
    position: sticky;
    top: 0;
    z-index: 20;
    /* .public-screen is a flex column, so the chrome has to opt out of shrinking; .landing is a plain
       block and ignores this. */
    flex-shrink: 0;
    background: rgba(18, 22, 31, 0.94);
}
.public-surface .site-header::after {
    content: '';
    display: block;
    height: 2px;
    background: linear-gradient(90deg, #00D4FF, #8B5CF6, #C850C0, #8B5CF6, #00D4FF);
    background-size: 200% 100%;
    animation: landing-marquee 9s linear infinite;
}
@keyframes landing-marquee {
    0% { background-position: 0% 0; }
    100% { background-position: 200% 0; }
}
@media (prefers-reduced-motion: reduce) {
    .public-surface .site-header::after { animation: none; }
}
/* The chrome's own links never underline. Scoped to the header and footer rather than the whole
   surface: the auth cards and the legal pages sit inside .public-screen and their body links
   (Forgot password, Create one, in-page policy links) must keep theirs. */
.public-surface .site-header a,
.public-surface .site-footer a { text-decoration: none; }

.public-surface .topbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    min-height: 64px;
}
.public-surface .brand {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 700;
    font-size: 1.2rem;
    color: var(--l-white);
    letter-spacing: -0.01em;
}
.public-surface .brand img {
    width: 32px;
    height: 32px;
    border-radius: 8px;
    box-shadow: 0 0 14px rgba(0, 212, 255, 0.35);
}
.public-surface .nav-signin {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 48px;
    padding: 0 1.2rem;
    font-weight: 600;
    font-size: var(--l-fs-meta);
    border: 1px solid var(--l-border-strong);
    border-radius: 8px;
    color: var(--l-text-strong);
    transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
}
.public-surface .nav-signin:hover {
    border-color: var(--l-cyan);
    color: var(--l-white);
    box-shadow: 0 0 16px rgba(0, 212, 255, 0.25);
}

/* --- Buttons --- */
.landing .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 52px;
    padding: 0 1.9rem;
    border-radius: 10px;
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 700;
    font-size: var(--l-fs-body);
    letter-spacing: 0.01em;
    border: 0;
    cursor: pointer;
    transition: box-shadow 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
}
.landing .btn-primary {
    background: var(--l-grad);
    color: #0A0E1A;
    box-shadow: 0 6px 26px rgba(0, 212, 255, 0.28), 0 6px 26px rgba(200, 80, 192, 0.18);
}
.landing .btn-primary:hover {
    color: #0A0E1A;
    transform: translateY(-2px);
    box-shadow: 0 10px 34px rgba(0, 212, 255, 0.4), 0 10px 34px rgba(200, 80, 192, 0.28);
}
.landing .cta-row {
    display: flex;
    flex-wrap: wrap;
    gap: 0.85rem;
    justify-content: center;
}

/* --- Hero: attract screen. Gradient headline, floating pixel-art food sprites. --- */
.landing .hero {
    position: relative;
    overflow: hidden;
    text-align: center;
    padding: clamp(4rem, 9vw, 6.5rem) 0 clamp(6.5rem, 12vw, 9rem);
}
.landing .hero-inner { position: relative; z-index: 2; }
.landing .eyebrow {
    display: inline-block;
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--l-cyan);
    margin-bottom: 1.25rem;
}
/* Pixel-art food sprites: the 8-bit nod IS the food. Drawn as inline SVG rect grids so they stay crisp
   at any size. Gentle alternating float, glow matched to each sprite's colour. */
.landing .sprite-row {
    display: flex;
    justify-content: center;
    gap: clamp(1.4rem, 5vw, 2.75rem);
    margin: 0 0 1.75rem;
}
.landing .sprite {
    width: clamp(44px, 7vw, 60px);
    height: clamp(44px, 7vw, 60px);
    animation: landing-bob 3.4s ease-in-out infinite;
}
.landing .sprite svg { width: 100%; height: 100%; shape-rendering: crispEdges; }
.landing .sprite:nth-child(2) { animation-delay: -0.9s; }
.landing .sprite:nth-child(3) { animation-delay: -1.8s; }
.landing .sprite:nth-child(4) { animation-delay: -2.6s; }
.landing .sprite--cyan { filter: drop-shadow(0 0 10px rgba(0, 212, 255, 0.35)); }
.landing .sprite--magenta { filter: drop-shadow(0 0 10px rgba(200, 80, 192, 0.35)); }
@keyframes landing-bob {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(-7px); }
}
.landing h1 {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 700;
    font-size: clamp(2.4rem, 6.5vw, 4rem);
    line-height: 1.06;
    letter-spacing: -0.025em;
    color: var(--l-white);
    margin: 0 auto 1.25rem;
}
.landing h1 .accent {
    display: block;
    background: var(--l-grad);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}
.landing .lede {
    font-size: clamp(1.05rem, 2.4vw, 1.25rem);
    line-height: 1.65;
    max-width: 600px;
    margin: 0 auto 2.25rem;
    color: var(--l-text-strong);
}
/* --- Section rhythm: target section.wrap so vertical padding beats .wrap's zero block padding --- */
.landing section.wrap {
    padding-top: clamp(2.75rem, 6vw, 4.25rem);
    padding-bottom: clamp(2.75rem, 6vw, 4.25rem);
}
.landing .section-head { text-align: center; max-width: 620px; margin: 0 auto 2.5rem; }
.landing h2 {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 700;
    font-size: clamp(1.6rem, 4vw, 2.15rem);
    line-height: 1.2;
    letter-spacing: -0.02em;
    color: var(--l-white);
    margin-bottom: 0.85rem;
}
.landing .section-head p { font-size: var(--l-fs-lead); }

/* --- Hi-score bar: three stats on a thin neon shelf --- */
.landing .hiscore {
    position: relative;
    z-index: 2;
    margin-top: -3.5rem;
}
.landing .hiscore-bar {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-around;
    gap: 1.25rem;
    background: rgba(30, 36, 56, 0.92);
    border: 1px solid var(--l-border-strong);
    border-radius: 16px;
    padding: 1.4rem 1.5rem;
    box-shadow: 0 0 30px rgba(0, 212, 255, 0.08), 0 14px 34px rgba(0, 0, 0, 0.35);
}
.landing .score { text-align: center; min-width: 150px; }
.landing .score-num {
    font-family: 'Press Start 2P', monospace;
    font-size: 1.15rem;
    line-height: 1.4;
    color: var(--l-white);
}
.landing .score-num .u { color: var(--l-cyan); }
.landing .score-label {
    font-family: 'Press Start 2P', monospace;
    font-size: 0.6rem;
    letter-spacing: 0.08em;
    color: var(--l-muted);
    text-transform: uppercase;
    margin-top: 0.5rem;
}

/* --- Panels --- */
.landing .panel {
    background: var(--l-card);
    border: 1px solid var(--l-border);
    border-radius: 20px;
    padding: clamp(2rem, 4vw, 2.75rem);
    text-align: center;
}
.landing .panel h2 { text-align: center; }
.landing .panel p {
    font-size: var(--l-fs-lead);
    max-width: 660px;
    margin: 0 auto 1rem;
}
.landing .panel p:last-child { margin-bottom: 0; }
.landing .panel strong { color: var(--l-text-strong); font-weight: 600; }
/* Final CTA: gradient hairline frame via padding trick (border-image is fussy with radius). */
.landing .panel-frame {
    background: var(--l-grad);
    border-radius: 21px;
    padding: 1px;
    box-shadow: 0 0 44px rgba(139, 92, 246, 0.22);
}
.landing .panel--final {
    border: 0;
    border-radius: 20px;
    background: linear-gradient(160deg, #232a44 0%, var(--l-bg) 100%);
}
.landing .panel--final p { max-width: 520px; margin-bottom: 1.9rem; }

/* --- Level select: feature cards with alternating neon accents --- */
.landing .features {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1.1rem;
}
/* Icon leads the title on one line, matching the Features page's .fb-head (AB#927). Done with grid
   placement rather than a wrapper element so the seven cards across HomeLanding and Fridge need no
   markup change: the icon takes row 1 / column 1, the heading row 1 / column 2, and the body copy
   spans both columns on row 2. .feature::before is absolutely positioned, so it stays out of the
   grid. M94-safe — line-based placement, not subgrid. */
.landing .feature {
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
    column-gap: 0.9rem;
    background: var(--l-card);
    border: 1px solid var(--l-border);
    border-radius: 16px;
    padding: 1.75rem 1.6rem;
    overflow: hidden;
    transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.landing .feature::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 2px;
    background: var(--l-grad);
    opacity: 0.85;
}
.landing .feature:hover { transform: translateY(-3px); }
.landing .feature--cyan:hover {
    border-color: rgba(0, 212, 255, 0.55);
    box-shadow: 0 0 26px rgba(0, 212, 255, 0.16), 0 12px 28px rgba(0, 0, 0, 0.28);
}
.landing .feature--magenta:hover {
    border-color: rgba(200, 80, 192, 0.55);
    box-shadow: 0 0 26px rgba(200, 80, 192, 0.16), 0 12px 28px rgba(0, 0, 0, 0.28);
}
/* The gap this used to carry now belongs to the body copy's row. */
.landing .feature-top { margin-bottom: 0; }
.landing .feature-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 48px;
    height: 48px;
    border-radius: 12px;
    background: var(--l-card-2);
    border: 1px solid var(--l-border-strong);
    color: var(--l-cyan);
}
.landing .feature--magenta .feature-icon { color: var(--l-magenta); }
.landing .feature-icon svg { width: 24px; height: 24px; }
.landing .feature h3 {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 600;
    font-size: var(--l-fs-title);
    color: var(--l-white);
    margin-bottom: 0;
}
/* Full width on its own row, under both the icon and the heading. */
.landing .feature p {
    grid-column: 1 / -1;
    margin-top: 0.9rem;
    font-size: var(--l-fs-body);
}

/* --- Meet Nibble (AB#1033) ---
   Portrait beside the copy, stacking to portrait-over-copy when there is not room for both. Flex with
   wrap rather than a media query so the fridge's 720px portrait and a narrow phone both get the stacked
   version without either being named. M94-safe: flex-wrap and a min-width on the text column, no
   container queries and no :has. */
.landing .nibble-intro {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 1.75rem;
}
.landing .nibble-intro-copy {
    /* Below this the two-column version is cramped enough that wrapping reads better. */
    flex: 1 1 22rem;
    min-width: 0;
    /* .panel centres its text, which is right for the single-column panels either side of this one and
       wrong here: centred bullets beside a left-hand portrait have no edge to line up on. */
    text-align: left;
}
.landing .nibble-portrait {
    --nibble-size: 132px;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 172px;
    height: 172px;
    border-radius: 50%;
    /* The same dark-centre-inside-the-gradient trick the chat bubble uses: he is white with a cyan and
       magenta edge, and both halves of that edge vanish against a mid-tone. */
    background:
        radial-gradient(circle at 50% 50%, var(--l-bg) 0 68%, transparent 69%),
        var(--l-grad);
    margin: 0 auto;
}
.landing .nibble-can {
    margin: 1.1rem 0 0;
    padding-left: 1.15rem;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    font-size: var(--l-fs-body);
}
.landing .nibble-can li::marker { color: var(--l-cyan); }

/* --- Footer ---
   Scoped to .public-surface for the same reason as the header: PublicSiteFooter is the one footer on
   every signed-out page (AB#927). The auth and legal pages used to get a stub carrying only the
   cookie-preferences button. z-index 1 clears the wrapper's fixed glow layer. */
.public-surface .site-footer {
    position: relative;
    z-index: 1;
    flex-shrink: 0;
    border-top: 1px solid var(--l-border);
    padding: 2.25rem 0 2.75rem;
    text-align: center;
    font-size: var(--l-fs-meta);
    color: var(--l-muted);
}
.public-surface .site-footer .links { margin-bottom: 0.65rem; }
.public-surface .site-footer .links a { margin: 0 0.65rem; color: var(--l-text); }
.public-surface .site-footer .links a:hover { color: var(--l-cyan); }
/* Cookie preferences (AB#776) reopens the consent banner in place rather than navigating, so it has
   to be a button — styled to sit in the footer link row as an equal. Padding keeps it a comfortable
   touch target on the fridge without changing the row's visual rhythm. */
.public-surface .site-footer .links .footer-link-btn {
    display: inline-flex;
    align-items: center;
    /* 48px is the fridge touch-target floor. Applied to the button rather than the whole row so the
       neighbouring anchors keep the footer's existing rhythm. */
    min-height: 48px;
    padding: 0 0.25rem;
    margin: 0 0.65rem;
    background: none;
    border: 0;
    color: var(--l-text);
    font: inherit;
    cursor: pointer;
}
.public-surface .site-footer .links .footer-link-btn:hover { color: var(--l-cyan); }
.public-surface .footer-tag {
    display: block;
    margin-top: 0.35rem;
    color: var(--l-muted);
    opacity: 0.75;
}

/* The credit line links to the parent company (AB#927). inline-flex + the 48px floor rather than a
   bare inline anchor: it is the only route to arcode.studio, so it is a real control and clears the
   fridge touch minimum. The margin above shrinks to absorb the height this adds. */
.public-surface .footer-tag a {
    display: inline-flex;
    align-items: center;
    min-height: var(--touch-min);
    padding: 0 0.25rem;
    color: inherit;
}

.public-surface .footer-tag a:hover,
.public-surface .footer-tag a:focus-visible {
    color: var(--l-cyan);
}

/* Two-column feature grid from tablet up; the fridge (720px portrait) gets two columns too. */
@media (min-width: 640px) {
    .landing .features { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
}
/* Respect reduced motion: freeze the attract-screen animations. */
@media (prefers-reduced-motion: reduce) {
    .landing .sprite { animation: none; }
}

/* ======================================================================
   Arcade theme — the admin section's own chrome (AB#959, AB#795)
   ----------------------------------------------------------------------
   Global rather than scoped on purpose. These started life in
   AdminDashboard.razor.css, but Blazor CSS isolation binds a scoped sheet
   to a single component, so /admin/ai-pricing, /admin/ai-cost and
   /admin/jobs rendered the same markup with none of the styling. That trap
   also caught the KPI cards and bar charts left behind in that scoped
   sheet; AB#925 moved them here as .stat-tile and .dashboard-chart, and
   deleted AdminDashboard.razor.css.

   WHAT IS STILL LIVE (AB#925): .arcade-page and .arcade-coin. The admin
   pages now wear the design system's page header, panels and tables like
   every other page, so the title screens, RPG dividers, neon section
   headings, arcade cards, arcade buttons and the segmented control below
   are no longer rendered by anything.

   They are kept rather than deleted. The scanline backdrop and the coin
   glyph are the part of this theme that survived standardizing, and the
   rest is the vocabulary to reach for if the admin section ever wants its
   title screen back — rule 14's note in the design project is about
   exactly this mistake, made in the other direction. Nothing below is
   load-bearing; delete freely if that day does not come.
   ====================================================================== */

.arcade-page {
    --arcade-font: 'Press Start 2P', 'Space Grotesk', system-ui, sans-serif;
    position: relative;
}

/* ── CRT scanline overlay ──────────────────────────────────────────── */
.arcade-page::before {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        rgba(0, 0, 0, 0) 0px,
        rgba(0, 0, 0, 0) 2px,
        rgba(0, 212, 255, 0.025) 3px,
        rgba(0, 0, 0, 0) 4px
    );
    mix-blend-mode: overlay;
}

.arcade-page > * {
    position: relative;
    z-index: 1;
}

/* ── Title screen header ───────────────────────────────────────────── */
.arcade-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
    margin-bottom: 2rem;
}

.arcade-title {
    font-family: var(--arcade-font);
    font-size: 1.5rem;
    color: var(--color-cyan);
    text-shadow: 0 0 6px rgba(0, 212, 255, 0.8), 0 0 18px rgba(0, 212, 255, 0.5);
    margin: 0;
    line-height: 1.4;
}

.arcade-subtitle {
    font-family: var(--arcade-font);
    font-size: 0.65rem;
    color: var(--color-magenta);
    text-shadow: 0 0 8px rgba(200, 80, 192, 0.6);
    margin-top: 0.5rem;
}

.arcade-subtitle .arcade-cursor {
    animation: arcade-blink 1s steps(1) infinite;
}

@keyframes arcade-blink {
    0%, 50% { opacity: 1; }
    50.01%, 100% { opacity: 0; }
}

.arcade-status {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 0.5rem;
}

.arcade-clock {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.8rem;
    color: var(--color-text-secondary);
    letter-spacing: 0.05em;
}

.arcade-btn {
    font-family: var(--arcade-font);
    font-size: 0.65rem;
    color: #0A0E1A;
    background: var(--color-cyan);
    border: none;
    border-radius: 4px;
    padding: 0.6rem 1rem;
    box-shadow: 0 4px 0 #009AB8, 0 0 14px rgba(0, 212, 255, 0.4);
    transition: transform 80ms ease, box-shadow 80ms ease;
    cursor: pointer;
}

.arcade-btn:hover {
    box-shadow: 0 4px 0 #009AB8, 0 0 20px rgba(0, 212, 255, 0.7);
}

.arcade-btn:active {
    transform: translateY(4px);
    box-shadow: 0 0 0 #009AB8, 0 0 10px rgba(0, 212, 255, 0.4);
}

.arcade-btn:disabled {
    opacity: 0.6;
    cursor: default;
    transform: none;
}

/* A back/secondary action in the header: same shape as .arcade-btn, muted so
   the primary action on the page still reads as the loud one. */
.arcade-btn-ghost {
    color: var(--color-cyan);
    background: transparent;
    border: 1px solid rgba(0, 212, 255, 0.5);
    box-shadow: none;
    text-decoration: none;
    display: inline-block;
}

.arcade-btn-ghost:hover {
    color: #0A0E1A;
    background: var(--color-cyan);
    box-shadow: 0 0 14px rgba(0, 212, 255, 0.45);
}

.arcade-btn-ghost:active {
    transform: none;
}

/* ── Top-level section headings (Metrics / AI Usage / Platform / Feedback) ── */
.arcade-section-heading {
    font-family: var(--arcade-font);
    font-size: 1.1rem;
    color: var(--color-magenta);
    text-shadow: 0 0 8px rgba(200, 80, 192, 0.55);
    letter-spacing: 0.04em;
}

/* ── RPG-style section dividers ────────────────────────────────────── */
.arcade-divider {
    font-family: var(--arcade-font);
    font-size: 0.7rem;
    color: var(--color-purple);
    text-shadow: 0 0 8px rgba(139, 92, 246, 0.6);
    text-align: center;
    margin: 1.5rem 0 1rem;
    letter-spacing: 0.1em;
}

/* ── Generic arcade card treatment (AI Usage / Platform / Feedback / Jobs) ── */
.arcade-card {
    background: var(--color-bg-surface);
    border: 1px solid var(--color-border);
    border-radius: 8px;
    box-shadow: 0 0 16px rgba(0, 212, 255, 0.08);
}

.arcade-card-header {
    font-family: var(--arcade-font);
    font-size: 0.7rem;
    color: var(--color-cyan);
    text-shadow: 0 0 8px rgba(0, 212, 255, 0.5);
    letter-spacing: 0.05em;
}

/* ── Coin glyph for cost figures ───────────────────────────────────── */
.arcade-coin {
    color: var(--color-warning);
    text-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
}

/* ── Segmented control (AI usage date range) ──────────────────────── */
.arcade-segmented {
    display: inline-flex;
    border: 1px solid var(--color-border);
    border-radius: 6px;
    overflow: hidden;
}

.arcade-segment-btn {
    font-family: var(--arcade-font);
    font-size: 0.6rem;
    color: var(--color-text-secondary);
    background: var(--color-bg-subtle);
    border: none;
    padding: 0.5rem 0.75rem;
    cursor: pointer;
    transition: background 120ms ease, color 120ms ease;
}

.arcade-segment-btn + .arcade-segment-btn {
    border-left: 1px solid var(--color-border);
}

.arcade-segment-btn.active {
    background: var(--color-cyan);
    color: #0A0E1A;
    text-shadow: none;
}

.arcade-segment-btn:hover:not(.active) {
    color: var(--color-text-primary);
    background: var(--color-bg-elevated);
}

/* The arcade title is the loudest thing on the page; on a narrow viewport
   (the fridge at 720px, a phone) it plus its subtitle plus a header action
   crowd everything below, so tighten the type and let the header breathe. */
@media (max-width: 575.98px) {
    .arcade-title { font-size: 1.1rem; }
    .arcade-subtitle { font-size: 0.55rem; }
    .arcade-section-heading { font-size: 0.85rem; }
    .arcade-header { margin-bottom: 1.5rem; row-gap: 0.75rem; }
    .arcade-status { align-items: flex-start; }
}

/* ─── Print (AB#1036) ───────────────────────────────────────────────────────────────────────────────
   The app's chrome, gone. Paper has no navigation, nothing to chat to and no toast worth reading, so
   printing any page of Ate-bit gives you the page rather than a screenshot of the application around it.

   Only the shell is dealt with here. What a given page should put on a sheet is that page's own
   question, answered in its own stylesheet — see ShoppingListDetail.razor.css, which is the first page
   with a Print action and the reason this block exists. Stating a page's layout twice, once here and
   once there, is how the two drift apart.

   Colour is dropped rather than adapted. The app is a dark theme, and a dark theme printed as-is is a
   full-bleed dark rectangle: most of a cartridge, and grey text where there should be black. The
   containers below are reset to no background so a page's own rules can put black on white. */
@media print {
    .sidebar,
    .chat-widget,
    .blazored-toast-container,
    .impersonation-banner,
    .local-dev-bypass-banner,
    .onboarding-preload-overlay,
    #blazor-error-ui,
    /* The row and page menus, which are position: fixed and would otherwise print wherever they were
       last placed. The backdrop is a full-viewport button, so left in it prints as a blank first page. */
    .row-menu,
    .row-menu-backdrop {
        display: none !important;
    }

    /* The document itself. `html, body { height: 100%; overflow: hidden }` at the top of this file is
       what makes .body-container the app's one and only scroll container, and left standing it clips a
       printed page to a single sheet: anything past the first page height is overflow on a box that
       hides it, so a 45-item shopping list printed 28 items and stopped. Height and overflow have to be
       given back here or no page in the app can ever print more than one sheet. */
    html,
    body {
        height: auto;
        overflow: visible;
    }

    /* Bootstrap's own colour is what actually paints most surfaces, and it is set on <body>. */
    body {
        background: #fff;
        color: #000;
    }

    /* The shell's own scrolling and sizing, for the same reason. .page is a viewport-height column and
       .page-body is the grid that puts the nav beside the content, so without these a printed page is
       one screenful with a column of empty space down the left where the nav used to be. */
    .page,
    .page-body,
    main,
    .content {
        display: block;
        height: auto;
        min-height: 0;
        overflow: visible;
        background: none;
        padding: 0;
        margin: 0;
    }

    /* A placeholder is a prompt to type something, and paper has nothing to type into. Left standing,
       every row of a list whose quantities nobody filled in prints the word "Qty" beside the item. The
       box itself stays: an empty one is somewhere to write the count at the shelf.

       The selector and the !important are both load-bearing (AB#1051). This started as a bare
       `::placeholder { opacity: 0 }`, which never once worked: Bootstrap ships
       `.form-control::placeholder { color: var(--bs-secondary-color); opacity: 1 }`, and a class plus a
       pseudo-element beats a pseudo-element on its own. Every field in the app is a .form-control, so
       the rule was outvoted everywhere it mattered and nobody noticed until a shopping list came off the
       printer with "Qty" down the side of it.

       Colour as well as opacity, because the two engines disagree about which of them applies to this
       pseudo-element, and a printed page is the one place there is no second chance to notice. */
    ::placeholder,
    .form-control::placeholder,
    .form-select::placeholder {
        color: transparent !important;
        opacity: 0 !important;
    }
}
