/*
  Admin app shell — mapped onto the DataraFlow design tokens (design.css,
  loaded just before this file). This file never restates a token's value;
  every color, radius and shadow below is `var(--...)` (or `color-mix()`
  of two tokens) so the token layer stays the single source of truth. The
  four `df-age-*` aging-heatmap pairs near the bottom are the one exception
  — see the comment there.

  Bootstrap 5 ships its own component theming through CSS custom
  properties (`--bs-btn-bg`, `--bs-alert-bg`, `--bs-*-rgb`, ...); where a
  component reads one of those, the shortest honest fix is to redeclare
  that property against a token rather than write a parallel rule that
  fights Bootstrap's cascade. Where a component instead hardcodes a literal
  color inside its own selector (`.form-control:focus`, `.form-check-input
  :checked`), this file overrides that selector directly — app.css loads
  after bootstrap.min.css, so same-specificity rules here win normally,
  with no `!important` needed anywhere in this file.

  The Bootstrap theme attribute on the two layouts is hardcoded to "light"
  and nothing ever toggles it, so the old dark-mode variable block (keyed
  off that attribute) was dead code — deleted here, not ported.
*/

/* ---------------------------------------------------------------------- */
/* Token aliases — every existing `df-*` hook keeps working, now pointed  */
/* at the shared design tokens instead of its own hardcoded palette.      */
/* ---------------------------------------------------------------------- */

:root {
  --df-bg: var(--paper);
  --df-surface: var(--surface);
  --df-border: var(--line);
  --df-text: var(--ink-900);
  --df-muted: var(--ink-500);
  --df-accent: var(--accent-deep);
  --df-radius: var(--r-lg);
}

/* `padding: var(--s3)` (24px), not the 1.25rem (20px) it carried before.
   20px is the one spacing value in this file that sat BETWEEN two stops of
   the design layer's 8px scale, and it was on the component every tile,
   every form and every table card in the admin is built from — so the whole
   product's rhythm was set by the single number that did not belong to the
   scale. 24px is the next stop up and the one `.df-install-banner` already
   uses, which is the same visual weight class; the two now agree by sharing
   a token rather than by being close.

   Sub-8px values elsewhere in this file (`padding: 1px 8px` on a chip,
   `gap: 2px` on a stat) are optical adjustments below the scale's first
   stop, not off-scale values, and are left alone. The distinction is what
   tests/test_admin_design.py::
   test_admin_spacing_stays_on_the_design_layers_scale enforces. */
.df-card {
  background: var(--df-surface);
  border: 1px solid var(--df-border);
  border-radius: var(--df-radius);
  box-shadow: var(--shadow-1);
  padding: var(--s3);
}

/* ---------------------------------------------------------------------- */
/* Auth pages (spec §2.3) — the front door                                */
/* ---------------------------------------------------------------------- */

/* The five auth templates (login, signup, change-password, no-access and
   the application confirmation) are the only content templates redesigned
   rather than restyled: a card on --paper, the marketing wordmark linking
   home, a display-font heading, token forms and buttons. `.df-auth` is the
   centering wrapper; `.df-auth__card` is the surface itself (a `.df-card`,
   so it shares the token border/radius/shadow every other card on the site
   uses).

   2026-09-02: brought onto the marketing pages' register, since this is the
   screen "Get started" opens onto and it has to be recognisably the same
   product as the page the visitor just left. Three changes, all of them the
   marketing devices rather than new ones: the wordmark and the heading are
   left-aligned to the card's own edge instead of centred (the marketing
   pages lead left, and a centred stack of wordmark-over-card is the layout
   every login page has); the heading uses the display face at the subpage
   scale; and the card carries the ledger mark — the short accent run that
   opens an entry everywhere else on the site — on its top edge.

   The run is inset by one radius so it starts where the corner curve ends,
   rather than clipping the card with `overflow: hidden`: these cards
   contain form controls whose focus ring is a box-shadow, and clipping the
   card would clip the focus ring with it. */
.df-auth {
  max-width: 26rem;
  margin: var(--s9) auto var(--s10);
  padding: 0 var(--s3);
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--s4);
}

/* The exact marketing wordmark (site-nav__brand in marketing/layout.html),
   copied rather than reinvented, so the logo on this page and the logo on
   the homepage are the same logo. */
/* The application form (spec §3) asks for seven answers, two of them free
   text, where login asks for two. At 26rem the textareas are a slot, not a
   place to describe a business in — so that one page opts into a wider
   card. A modifier rather than a second wrapper class: everything else
   about the page (the centering, the wordmark, the card surface, the error
   styling) is `.df-auth` and must not be duplicated to change one number. */
.df-auth--wide {
  max-width: 34rem;
}

.df-auth__brand {
  display: inline-flex;
  align-self: flex-start;
  align-items: center;
  gap: var(--s1);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.1rem;
  color: var(--ink-900);
}

.df-auth__brand svg { display: block; }

.df-auth__card {
  position: relative;
  width: 100%;
  padding: var(--s4);
}

/* The ledger mark at card scale. Not `.ledger-rule` itself (design.css):
   that class draws its own border-top, and this card already has one on all
   four sides — so the run sits on the border the card already has. */
.df-auth__card::before {
  content: "";
  position: absolute;
  top: -1px;
  left: var(--r-lg);
  width: var(--s7);
  height: 2px;
  background: var(--accent);
}

.df-auth__title {
  font-size: clamp(1.4rem, 1.2rem + 0.7vw, 1.75rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
}

/* The line under the heading on the pages that have one: what the applicant
   is being told before they start typing. Reading size and reading ink
   (--ink-700 on --surface is 15.72:1 in design.css's ledger), not the 0.875rem
   `.small` grey it used to be — this is the sentence that sets the
   expectation the whole application flow rests on. */
.df-auth__lede {
  margin: var(--s2) 0 var(--s5);
  color: var(--ink-700);
  font-size: 1rem;
  line-height: 1.6;
}

/* The footer note under a card's action: "New here?", "Already have a
   workspace?", "Questions in the meantime?". Separated from what is above
   it by the site's hairline, so the card reads as an entry with a foot
   rather than a stack of paragraphs. The accent run is deliberately absent
   here — the card already carries one, and two on one small surface is
   decoration. */
.df-auth__note {
  margin: var(--s4) 0 0;
  padding-top: var(--s3);
  border-top: 1px solid var(--line);
  font-size: 0.9rem;
  line-height: 1.6;
}

/* Labels at the same weight and size the marketing contact form uses, so a
   field looks like a field on either side of the sign-in line. */
.df-auth .form-label {
  font-weight: 600;
  font-size: 0.9rem;
  color: var(--ink-700);
  margin-bottom: var(--s1);
}

/* The 429 lockout explanation and every other field error render through
   Bootstrap's `.text-danger`, already retinted onto --stop via the
   `--bs-danger-rgb` redeclaration in the Badges section below. This just
   gives it a touch more weight inside a form, where a thin red line is
   easy to skim past. */
.df-auth .text-danger {
  font-weight: 500;
}

.df-muted { color: var(--df-muted); }

/* ---------------------------------------------------------------------- */
/* Buttons                                                                 */
/* ---------------------------------------------------------------------- */

/* `.btn-primary` hardcodes its own `--bs-btn-*` values in bootstrap.min.css
   rather than reading `--bs-primary`, so every one is redeclared here.
   Fill is `--accent-deep`, never `--accent` — the same AA ruling as the
   marketing button (5.01:1 for white text; `--accent` alone is 3.34:1 and
   fails). Hover/active go one step darker still via `color-mix`, plus the
   marketing button's lift-and-shadow motion. */
.btn-primary {
  --bs-btn-color: #fff;
  --bs-btn-bg: var(--accent-deep);
  --bs-btn-border-color: var(--accent-deep);
  --bs-btn-hover-color: #fff;
  --bs-btn-hover-bg: color-mix(in srgb, var(--accent-deep) 82%, var(--ink-900));
  --bs-btn-hover-border-color: color-mix(in srgb, var(--accent-deep) 82%, var(--ink-900));
  --bs-btn-active-color: #fff;
  --bs-btn-active-bg: color-mix(in srgb, var(--accent-deep) 74%, var(--ink-900));
  --bs-btn-active-border-color: color-mix(in srgb, var(--accent-deep) 74%, var(--ink-900));
  --bs-btn-disabled-color: #fff;
  --bs-btn-disabled-bg: var(--accent-deep);
  --bs-btn-disabled-border-color: var(--accent-deep);
  --bs-btn-focus-shadow-rgb: 201, 63, 0;  /* --accent-deep #C93F00 — Bootstrap's focus-shadow variable takes an rgb triplet, not var() */
  --bs-btn-border-radius: var(--r-sm);
  box-shadow: var(--shadow-1);
  transition: transform var(--fast) var(--ease), box-shadow var(--fast) var(--ease),
              background-color var(--fast) var(--ease), border-color var(--fast) var(--ease);
}

.btn-primary:hover {
  box-shadow: var(--shadow-2);
  transform: translateY(-1px);
}

/* Quiet actions: outline-primary/outline-secondary/link all read as the
   same "secondary action" today, so they get one shared ink treatment
   rather than three subtly different blues. */
.btn-outline-primary,
.btn-outline-secondary,
.btn-link {
  --bs-btn-color: var(--ink-700);
  --bs-btn-border-color: var(--line);
  --bs-btn-hover-color: var(--ink-900);
  --bs-btn-hover-bg: var(--surface-sunken);
  --bs-btn-hover-border-color: var(--ink-500);
  --bs-btn-active-color: var(--ink-900);
  --bs-btn-active-bg: var(--surface-sunken);
  --bs-btn-active-border-color: var(--ink-500);
  --bs-btn-border-radius: var(--r-sm);
}

.btn-link {
  --bs-btn-border-color: transparent;
  --bs-btn-hover-border-color: transparent;
}

/* Destructive / affirmative actions keep semantic color, retinted onto
   the same --stop/--go tokens the badges use below. */
.btn-danger,
.btn-outline-danger {
  --bs-btn-color: #fff;
  --bs-btn-bg: var(--stop);
  --bs-btn-border-color: var(--stop);
  --bs-btn-hover-color: #fff;
  --bs-btn-hover-bg: color-mix(in srgb, var(--stop) 85%, var(--ink-900));
  --bs-btn-hover-border-color: color-mix(in srgb, var(--stop) 85%, var(--ink-900));
  --bs-btn-active-color: #fff;
  --bs-btn-active-bg: color-mix(in srgb, var(--stop) 75%, var(--ink-900));
  --bs-btn-active-border-color: color-mix(in srgb, var(--stop) 75%, var(--ink-900));
  --bs-btn-border-radius: var(--r-sm);
}

.btn-outline-danger {
  --bs-btn-color: var(--stop);
  --bs-btn-bg: transparent;
}

.btn-success {
  --bs-btn-color: #fff;
  --bs-btn-bg: var(--go);
  --bs-btn-border-color: var(--go);
  --bs-btn-hover-color: #fff;
  --bs-btn-hover-bg: color-mix(in srgb, var(--go) 85%, var(--ink-900));
  --bs-btn-hover-border-color: color-mix(in srgb, var(--go) 85%, var(--ink-900));
  --bs-btn-active-color: #fff;
  --bs-btn-active-bg: color-mix(in srgb, var(--go) 75%, var(--ink-900));
  --bs-btn-active-border-color: color-mix(in srgb, var(--go) 75%, var(--ink-900));
  --bs-btn-border-radius: var(--r-sm);
}

.btn { font-family: var(--font-ui); font-weight: 600; }

/* ---------------------------------------------------------------------- */
/* Forms                                                                   */
/* ---------------------------------------------------------------------- */

.form-control,
.form-select {
  background-color: var(--surface);
  border-color: var(--line);
  border-radius: var(--r-sm);
  color: var(--ink-900);
}

/* Bootstrap's own focus ring hardcodes a blue border + box-shadow inside
   the selector itself (not behind a variable), so it is replaced outright
   rather than retinted through a custom property — the marketing site's
   `:focus-visible` treatment, translated to the `:focus` Bootstrap already
   wires these inputs to. */
.form-control:focus,
.form-select:focus {
  border-color: var(--accent);
  outline: 0;
  box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 30%, transparent);
}

.form-check-input:checked {
  background-color: var(--accent-deep);
  border-color: var(--accent-deep);
}

.form-check-input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 30%, transparent);
}

/* The one focus gap in the admin (found in the admin round's sweep).
   design.css rings `a` and `button`; the three rules above ring anything
   carrying a Bootstrap form class. A form control carrying NO class at all
   falls through both and is left with whatever ring the browser draws,
   which is the only inconsistent focus treatment in the product.

   There is exactly one such control today and it is the worst possible
   place for it: the staff permission matrix (staff/form.html), two bare
   `<input type="checkbox">` per permission key, which is the longest
   keyboard traversal in the app and the one where losing your place has
   consequences.

   `:not([class])` is what makes this surgical rather than a second, rival
   ring: it matches only controls with no class attribute, so nothing
   Bootstrap-classed is touched and no element ever gets two rings. It is
   also self-retiring — put a form class on those inputs and this rule stops
   matching them, because the rules above have started to.

   `--accent` rather than `--accent-deep`, deliberately: this is the ring
   every other focusable thing on the site already draws, and one system
   with a known margin beats two systems. That margin is real and is
   recorded, not waved through — see the `--accent on --surface-sunken`
   REFUSED line in design.css's ledger. */
input:not([class]):focus-visible,
select:not([class]):focus-visible,
textarea:not([class]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ---------------------------------------------------------------------- */
/* Tables                                                                  */
/* ---------------------------------------------------------------------- */

.table > :not(caption) > * > * { border-color: var(--df-border); }

.table thead th {
  font-family: var(--font-ui);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-500);
  border-bottom-width: 1px;
}

.table > tbody > tr:hover > * {
  background-color: var(--surface-sunken);
}

/* A card that carries a table scrolls the table, not the page. Without
   this, a wide table (inventory, sales, the P&L report — anything with
   enough columns) forces the whole page to grow past the viewport on a
   phone, so the page itself gains a horizontal scrollbar instead of just
   the card. `:has()` needs Chrome 105+ / Safari 15.4+ (both 2022); on an
   older browser this selector simply never matches and the page falls
   back to today's whole-page-scrolls behavior — a real degradation, but
   not a broken one, and this app's audience clears that bar. */
.df-card:has(table) { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* Money is sacred: every cell gets tabular figures so digits line up down
   a column. This is a blanket `td` rule rather than a class on the money
   cells themselves — the 58-template surface has none to hang a selector
   off, and tabular figures are a no-op on plain text, so it can't misfire.
   Right alignment is already on the money `<td>`s via Bootstrap's
   `text-end` in the templates that need it; that is template markup, not
   something this stylesheet can add without editing those templates, so
   it is left alone here. */
td {
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------------- */
/* Badges                                                                  */
/* ---------------------------------------------------------------------- */

/* `.text-bg-*` (bootstrap.min.css) reads `--bs-{color}-rgb` for its
   background and hardcodes white/black text with `!important`; redeclaring
   the rgb triplets here retints every badge without ever writing
   `!important` ourselves. Only the three variants this app actually uses
   for status (`text-bg-success`, `text-bg-danger`, `text-bg-secondary`)
   are remapped — `text-bg-warning`/`info`/`light` keep Bootstrap's stock
   palette, which already clears AA under the black text those keep. */
:root {
  --bs-success-rgb: 14, 131, 69;   /* --go   #0E8345 */
  --bs-danger-rgb: 180, 35, 24;    /* --stop #B42318 */
  --bs-secondary-rgb: 51, 65, 92;  /* --ink-500 #33415C */

  /* The warning emphasis ink, declared here rather than only inside
     `.alert-warning` below (admin round).

     The note above says warning keeps Bootstrap's stock palette because it
     "already clears AA under the black text those keep". That is true of
     `.text-bg-warning`, the BADGE — black on amber — and it is why
     `--bs-warning-rgb` is deliberately still not redeclared here: retinting
     it would darken the badge's fill under black text and break the one
     case the note was reasoning about.

     What the note did not consider is the same amber used as TEXT.
     `.text-warning` is `rgba(var(--bs-warning-rgb), 1)` — #FFC107 — which
     on --surface is 1.63:1. Not a near miss: effectively unreadable, and it
     was carrying the sentence that tells an owner their shop is not
     published (settings/storefront.html) and the count of import rows that
     need checking (data/import dry run). Both templates now ask for
     `.text-warning-emphasis`, Bootstrap's own readable variant, which reads
     this property; it is the token-derived ink the alerts use, 6.32:1 on
     --surface and 6.12:1 on --paper, both in design.css's ledger. */
  --bs-warning-text-emphasis: color-mix(in srgb, var(--accent-deep) 85%, var(--ink-900));
}

/* ---------------------------------------------------------------------- */
/* Alerts / flash messages                                                */
/* ---------------------------------------------------------------------- */

/* Same trick as badges, one layer up: `.alert-success` etc. read
   `--bs-{color}-text-emphasis` / `-bg-subtle` / `-border-subtle`, which
   bootstrap.min.css hardcodes as literal hex rather than deriving from
   `--bs-{color}-rgb` — so those three custom properties are redeclared
   per color, as token tints via `color-mix`, never a new hex literal.
   Only success/danger/warning are retinted; those are the only alert
   categories this app renders (flash uses "success"/"danger"; a few
   templates hardcode "alert-warning" directly). */
/* Not flex: flexbox blockifies every in-flow child, so an alert body with
   more than one contiguous text run — a `<strong>`, then prose, then a link,
   as in staff/credentials.html and base.html's upload-root banner — gets
   torn into side-by-side flex columns instead of flowing as one sentence.
   Bootstrap already gives `.alert` `position: relative` and
   `padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x)`, so the
   icon is pulled out of flow and absolutely positioned into a left gutter
   reserved by widening that same padding — the body stays ordinary prose,
   single-child or not. */
.alert {
  padding-left: calc(var(--bs-alert-padding-x) + 1.1em + var(--s2));
  border-radius: var(--r-sm);
}

.alert::before {
  content: "";
  position: absolute;
  top: calc(var(--bs-alert-padding-y) + 0.15em);
  left: var(--bs-alert-padding-x);
  width: 1.1em;
  height: 1.1em;
  background-color: currentColor;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-position: center;
  mask-position: center;
}

/* THE EMPHASIS INK, one rule for all three (admin round).
   Each alert paints a 12-14% tint of a semantic token over --surface and
   then set its text to that same token. A colour is never far enough from a
   tint of itself: success came out at 4.11:1 and warning at 4.24:1 — both
   WCAG AA failures, on the flash messages every admin page can render — and
   danger passed at 5.40:1 only by being the darkest of the three. So the
   emphasis ink is now the token darkened 85% toward --ink-900, the same
   `color-mix` step `.btn-primary`'s hover already takes, which lifts them to
   5.35 / 5.11 / 6.59:1. Applied to danger as well, though danger was
   passing: one rule followed everywhere beats a patch on the two that
   failed. All three pairs are recomputed in design.css's ledger. */
.alert-success {
  --bs-success-text-emphasis: color-mix(in srgb, var(--go) 85%, var(--ink-900));
  --bs-success-bg-subtle: color-mix(in srgb, var(--go) 12%, var(--surface));
  --bs-success-border-subtle: color-mix(in srgb, var(--go) 35%, var(--surface));
}

.alert-success::before {
  --mask-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.2l3.2 3.2L13 4.6' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--mask-icon);
  mask-image: var(--mask-icon);
}

.alert-danger {
  --bs-danger-text-emphasis: color-mix(in srgb, var(--stop) 85%, var(--ink-900));
  --bs-danger-bg-subtle: color-mix(in srgb, var(--stop) 12%, var(--surface));
  --bs-danger-border-subtle: color-mix(in srgb, var(--stop) 35%, var(--surface));
}

.alert-danger::before {
  --mask-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 4l8 8M12 4l-8 8' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--mask-icon);
  mask-image: var(--mask-icon);
}

/* `--bs-warning-text-emphasis` is not restated here: it is declared once on
   `:root` above, where `.text-warning-emphasis` can read it too, and this
   alert inherits it. The other two are alert-only and stay local. */
.alert-warning {
  --bs-warning-bg-subtle: color-mix(in srgb, var(--accent) 14%, var(--surface));
  --bs-warning-border-subtle: color-mix(in srgb, var(--accent) 40%, var(--surface));
}

.alert-warning::before {
  --mask-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 3v6' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3Ccircle cx='8' cy='12.3' r='1' fill='black'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--mask-icon);
  mask-image: var(--mask-icon);
}

/* ---------------------------------------------------------------------- */
/* Install banner                                                          */
/* ---------------------------------------------------------------------- */

/* The app shell's install affordance (layouts/base.html) — a quiet card,
   not an alert: this is an invitation, not a problem being reported, so it
   is deliberately not `.alert-*`. `[hidden]` is restated with the class's
   own specificity because `display: flex` below would otherwise outrank
   the user-agent stylesheet's `[hidden] { display: none }` and the banner
   would show even before install.js reveals it. */
.df-install-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--s3);
  background: var(--surface-sunken);
  border-radius: var(--r-lg);
  padding: var(--s3);
  margin-bottom: var(--s4);
}

.df-install-banner[hidden] {
  display: none;
}

.df-install-banner__text {
  margin: 0;
  color: var(--ink-700);
}

.df-install-banner__actions {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-shrink: 0;
}

/* No fill, no border — the same quiet register as .btn-quiet, so it reads
   as an invitation rather than a bootstrap-default button.

   It used to ink `--accent-deep`, and the banner it sits in is a
   --surface-sunken plane (above), so this was the REFUSED 4.44:1 pair a
   second time. The class-name guard could not see it for a reason worth
   writing down: the surface is painted by `.df-install-banner` and the text
   is inked by `.df-install-banner__install`, two DIFFERENT class names, so
   the set intersection the guard does came up empty even though one element
   is plainly inside the other. BEM says they are the same block; the guard
   now says so too.

   Same ruling as `.df-navlink:hover`: ink carries the text, the accent
   carries the underline. The underline is at rest here rather than on hover
   — a text-only button with no border and no fill needs a resting
   affordance, and this is the one the site already uses. */
.df-install-banner__install {
  background: none;
  border: none;
  padding: var(--s1) var(--s2);
  color: var(--ink-900);
  font-weight: 600;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-color: var(--accent-deep);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}

.df-install-banner__install:hover {
  text-decoration-color: var(--ink-900);
}

.df-install-banner__dismiss {
  background: none;
  border: none;
  padding: var(--s1);
  color: var(--ink-500);
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
}

.df-install-banner__dismiss:hover {
  color: var(--ink-900);
}

/* ---------------------------------------------------------------------- */
/* The app shell and the grouped sidebar (spec §2.2)                      */
/* ---------------------------------------------------------------------- */

/* Flex, not grid: the sidebar is absent entirely on the auth pages (see
   layouts/base.html), and a flex row with one child simply gives that child
   the full width — no reserved empty column, and no `:has()` needed to tell
   the two cases apart. Mobile-first, so the stacked layout is the default
   and the two-column shell is the ≥992px override. */
.df-shell {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.df-main {
  flex: 1 1 auto;
  /* Without this a wide table inside a flex item refuses to shrink and
     pushes the sidebar off-screen: flex items default to min-width:auto. */
  min-width: 0;
  /* What `.container` used to supply. Bootstrap's `.row` uses negative
     margins of half a gutter (12px by default), so anything less than that
     would let rows bleed past the edge of the page. */
  padding-left: var(--s3);
  padding-right: var(--s3);
  /* Fluid below the cap, capped above it. Uncapped, a 3440px monitor gives
     table rows about 3150px wide — a line length no one tracks across, and
     the reason `.container` had a max-width in the first place. Centred
     rather than left-anchored: the auto margins absorb the overflow on
     both sides, so the content stays optically centred in the space beside
     the rail instead of stranding a 1400px void to its right.

     `width: 100%` is load-bearing, not decoration, at mobile widths: in the
     column-flex `.df-shell` (mobile has no `flex-direction: row`, see the
     992px breakpoint below), an item with auto inline margins stops
     stretching to the container's cross size and instead shrink-to-fits its
     content — the same trap `min-width: 0` above does not reach, because
     that only stops a flex item refusing to shrink, it does not stop one
     choosing to size itself by its widest child. Without a definite width
     here, a 650px-wide table inside a card sizes `.df-main` itself to
     650px, and every 100%-wide card downstream inherits that width, so the
     whole page — not just the table — grows past the viewport. `width:
     100%` gives `.df-main` a definite size to shrink-to-fit *from*; the
     `max-width` + auto margins above still take over and center it once a
     screen is wide enough for the cap to bite. */
  width: 100%;
  max-width: 90rem;
  margin-left: auto;
  margin-right: auto;
}

.df-sidebar {
  background: var(--df-surface);
  border-bottom: 1px solid var(--df-border);
}

.df-sidebar__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
}

.df-brand {
  color: var(--df-text);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.05rem;
  text-decoration: none;
  line-height: 1.2;
  /* Long business names wrap rather than widening the rail. */
  overflow-wrap: anywhere;
}

.df-brand:hover { color: var(--df-accent); }

.df-nav-toggle {
  display: inline-flex;
  align-items: center;
  background: none;
  border: none;
  padding: var(--s1);
  cursor: pointer;
  color: var(--df-text);
}

/* Closed by default at mobile widths; the disclosure button in _nav.html
   adds `.is-open`. Above the breakpoint the nav is always shown and this
   rule is overridden, so a drawer left open on a phone cannot survive into
   the desktop layout as a hidden sidebar. */
.df-sidebar__nav { display: none; }
.df-sidebar__nav.is-open { display: block; padding: 0 var(--s3) var(--s3); }

.df-navgroup { margin-bottom: var(--s2); }

.df-navgroup__title {
  font-family: var(--font-ui);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--df-muted);
  margin: 0 0 var(--s1);
  padding: 0 var(--s1);
}

.df-navlink {
  display: block;
  padding: 0.4rem var(--s1);
  color: var(--df-text);
  text-decoration: none;
  font-size: 0.925rem;
  border-radius: var(--r-sm);
  /* The active rail is drawn as a left border on every link, transparent
     until the link is current, so nothing shifts by 3px when a section
     becomes active. */
  border-left: 3px solid transparent;
}

/* The hover no longer inks the accent. `--df-accent` is an alias for
   --accent-deep, and --accent-deep on --surface-sunken is 4.44:1 — the pair
   design.css's REFUSED ledger already names and already rules on. It shipped
   here anyway, on the sidebar of every admin page, because the guard written
   to catch it (tests/test_design_layer.py::
   test_no_rule_puts_accent_deep_text_on_a_sunken_surface) matched the literal
   string `var(--accent-deep)` and this rule spells the same token
   `var(--df-accent)`. The alias was the blind spot; the guard now resolves it.

   The treatment is the ledger's own ruling, verbatim: --ink-900 ink
   (16.60:1) with the accent carried by an underline instead of by the text.
   `color` is restated rather than dropped so the rule says what it paints
   and the guard has something to read.

   It also keeps hover legibly different from `--active` below, which is the
   left rail plus a bold weight and no underline: the rail means "you are
   here", the underline means "you could go here". */
.df-navlink:hover {
  background: var(--surface-sunken);
  color: var(--df-text);
  text-decoration: underline;
  text-decoration-color: var(--accent-deep);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}

.df-navlink--active {
  background: var(--surface-sunken);
  border-left-color: var(--df-accent);
  color: var(--df-text);
  font-weight: 600;
}

/* Sign out is a submit button inside the CSRF form it has always been, and
   has to look like the links beside it. Only the button-specific resets
   live here; everything visual comes from `.df-navlink` above. */
.df-navlink--button {
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-left: 3px solid transparent;
  font: inherit;
  font-size: 0.925rem;
  cursor: pointer;
}

.df-sidebar__foot {
  border-top: 1px solid var(--df-border);
  margin-top: var(--s2);
  padding-top: var(--s2);
}

@media (min-width: 992px) {
  .df-shell { flex-direction: row; align-items: flex-start; }

  .df-sidebar {
    flex: 0 0 15rem;
    width: 15rem;
    border-bottom: none;
    border-right: 1px solid var(--df-border);
    /* Sticky rather than fixed: it stays in the flow, so it needs no
       compensating offset on `.df-main` and cannot overlap the content. */
    position: sticky;
    top: 0;
    max-height: 100vh;
    overflow-y: auto;
    padding-bottom: var(--s3);
  }

  .df-nav-toggle { display: none; }

  /* The drawer's `.is-open` class is mobile-only state; at this width the
     list is simply always visible, open or not. */
  .df-sidebar__nav,
  .df-sidebar__nav.is-open { display: block; padding: 0 var(--s2) 0; }

  .df-main { padding-left: var(--s4); padding-right: var(--s4); }
}

/* ---------------------------------------------------------------------- */
/* Platform top bar (/admin) — the shell without a tenant sidebar         */
/* ---------------------------------------------------------------------- */

/* `/admin` has no business, so `layouts/_nav.html` renders nothing there
   and `app/templates/platform/layout.html` puts this bar in the same
   `{% block nav %}` instead. It is the sidebar's surface, border and link
   styles laid out horizontally — not a second design.

   `.df-shell` is `flex-direction: column` by default and switches to `row`
   at 992px so the sidebar can sit beside `.df-main`. A top bar must stay
   full-width at every size, so this selector — specificity (0,2,0) against
   the media query's (0,1,0) — puts the shell back into a column whenever a
   top bar is what it contains, regardless of source order. Keyed on `:has`
   rather than on a second shell class so `layouts/base.html` needs no new
   variable and no template can get the pairing wrong: the rule follows the
   bar itself. `.df-card:has(table)` above already relies on the same
   selector.

   `align-items: stretch` is the other half of that reset, and is not
   optional. The same 992px media query also sets `align-items: flex-start`
   — right for the sidebar layout it was written for — and putting the
   shell back into a column without resetting it leaves every child
   shrink-to-fitting its inline size instead of stretching. The top bar
   then sizes to its links (`.df-topbar__inner`'s `width: 100%` being 100%
   of a shrink-to-fit parent) and its white background stops about halfway
   across the viewport. `.df-main` escapes only because it carries its own
   explicit `width: 100%` for a different, already-documented reason. */
.df-shell:has(.df-topbar) { flex-direction: column; align-items: stretch; }

.df-topbar {
  background: var(--df-surface);
  border-bottom: 1px solid var(--df-border);
}

.df-topbar__inner {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s2);
  padding: var(--s2) var(--s3);
  max-width: 90rem;
  margin: 0 auto;
  width: 100%;
}

/* The one place the product names itself inside a tenant-free screen. Small
   caps-ish label rather than a badge with an accent fill: `--accent` is
   never a fill under white text (see the AA note at the top of this file),
   and a quiet chip is the right weight for a word that only ever says
   "you are not inside a workspace". */
.df-topbar__badge {
  font-family: var(--font-ui);
  font-size: .75rem;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--df-muted);
  border: 1px solid var(--df-border);
  border-radius: var(--r-sm);
  padding: 2px 8px;
}

.df-topbar__nav {
  display: flex;
  align-items: center;
  gap: var(--s1);
  flex-wrap: wrap;
}

/* Sign out sits at the far end of the bar on any screen wide enough for the
   row not to wrap; on a narrow one the flex-wrap above simply drops it to
   the next line rather than squeezing the section links. */
.df-topbar__out { margin-left: auto; }

/* Whitespace an applicant typed into a textarea is theirs; collapsing a
   three-line address into one line makes it harder to read back to them on
   the phone. Same rule `.df-item-description` applies to a storefront
   description. */
.df-prewrap { white-space: pre-line; }

.df-capital-bar { height: .5rem; background: var(--df-border); }
.df-capital-bar .progress-bar { background: var(--df-accent); }

/* ---------------------------------------------------------------------- */
/* Aging heatmap — kept as literal hex, not tokenized                     */
/* ---------------------------------------------------------------------- */

/* Four distinguishable hues (green / amber / red / purple) escalating
   stock age; the token set only has two semantic colors (--go, --stop).
   Mapping two of the four buckets onto tokens and leaving amber/purple
   literal would read as an inconsistent, partial rewrite for a hook §2.1
   never named — inventory aging wasn't in the component-mapping list, so
   it is left exactly as it shipped, minus the dead dark-mode duplicates
   below (which fall under the mandatory dark-block deletion regardless of
   this decision). */
.df-age-0_30 { background: #e7f3ea; color: #1c6b34; }
.df-age-31_60 { background: #fdf3e0; color: #8a5a12; }
.df-age-61_90 { background: #fdecec; color: #97321f; }
.df-age-90plus { background: #f6e4f7; color: #6d2277; }

/* ---------------------------------------------------------------------- */
/* Workspace dashboard polish (spec §2.4)                                 */
/* ---------------------------------------------------------------------- */

/* Every stat tile is already a `.df-card`; `.df-stat` just tightens the
   vertical rhythm between the muted label and the number underneath it. */
.df-stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* Display-font numerals — same face as every other heading, so the numbers
   a shop owner cares about most read with the same weight as the page
   titles around them, not as Bootstrap's default `.h4`. */
.df-stat-value {
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--df-text);
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------------- */
/* Projects — status chips, the budget bar, and the progress feed          */
/* ---------------------------------------------------------------------- */

/* Every value below is a token or a `color-mix()` of two, like the rest of
   this file; the four `df-age-*` pairs above remain the only literal-hex
   exception, and this section deliberately does not add a fifth. A project's
   four statuses map onto what the token set already means: --go for work
   finished, --stop for work abandoned or overdue, --accent-deep for work in
   hand, and the muted ink for work not started. */
/* THE CHIP, rebuilt in the admin round on the recipe `.df-chip` (storefront,
   below) already uses: an OPAQUE sunken plate, ink for the word, and the
   semantic colour in the 1px border.

   Two defects it closes, both of them the same arithmetic.

   1. Contrast. The colour-on-a-tint-of-itself pattern always lands in the
      4.1-4.4 band, so `--active` was --accent-deep at 4.21:1 and
      `--completed` was --go at 4.11:1 — WCAG AA failures at 0.75rem/600,
      shipped on every projects list and detail page. --ink-700 on the plate
      is 13.94:1 and does not care which status it is.

   2. Indeterminacy. The old fills were `color-mix(..., transparent)`, so
      the chip's real background was whatever happened to be under it. The
      table rule above paints a hovered row --surface-sunken, which dragged
      `--completed` down to 3.67:1 with no rule anywhere saying so and no
      ledger line able to name a colour that changes on hover. An opaque
      plate is a pair the ledger can hold.

   Nothing is lost by moving colour off the word: every chip prints its
   status as text, so colour was never the only carrier, and the three
   borders are ledgered against the 3:1 non-text floor WCAG 1.4.11 asks of a
   component boundary (4.44 / 4.28 / 5.83:1) rather than the 4.5:1 the text
   they no longer carry would have needed. */
.df-status {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.6;
  white-space: nowrap;
  background: var(--surface-sunken);
  border: 1px solid var(--df-border);
  color: var(--ink-700);
}

.df-status--active {
  border-color: var(--accent-deep);
}

.df-status--completed {
  border-color: var(--go);
}

.df-status--abandoned,
.df-status--overdue {
  border-color: var(--stop);
}

/* Budget against spend. The bar is decoration over figures that are printed
   underneath it in full — it carries an aria-label rather than a value of
   its own, and it is never the only place a number appears. */
.df-bar {
  height: 6px;
  border-radius: 999px;
  background: var(--surface-sunken);
  overflow: hidden;
}

.df-bar__fill {
  display: block;
  height: 100%;
  background: var(--accent-deep);
}

.df-bar__fill--over {
  background: var(--stop);
}

/* The progress feed: one rule of ink down the left, each entry hanging off
   it. An <ol> because the order is the point — newest first. */
.df-feed {
  list-style: none;
  margin: 0;
  padding: 0 0 0 var(--s3);
  border-left: 2px solid var(--df-border);
}

.df-feed__item {
  position: relative;
  padding-bottom: var(--s3);
}

.df-feed__item:last-child {
  padding-bottom: 0;
}

.df-feed__item::before {
  content: "";
  position: absolute;
  left: calc(-1 * var(--s3) - 5px);
  top: 6px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent-deep);
}

.df-feed__photos {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s1);
  margin-top: var(--s1);
}

/* A fixed box with `object-fit: cover` so a feed of portrait phone photos and
   landscape site photos still reads as one row rather than a ragged stack. */
.df-feed__photo {
  width: 96px;
  height: 96px;
  object-fit: cover;
  border-radius: var(--r-sm);
  border: 1px solid var(--df-border);
}

/* ========================================================================= */
/* == Storefront ==                                                       == */
/* == The public page a shop's own customers see. THE SHOP'S IDENTITY     == */
/* == LEADS: its name, its logo, its goods, its projects. DataraFlow      == */
/* == appears exactly once, as the quiet footer credit at the bottom of   == */
/* == this section.                                                       == */
/* ==                                                                     == */
/* == NO BRAND ACCENT ON A TENANT'S PAGE. --accent/--accent-deep are      == */
/* == DataraFlow's voice, and this page is not DataraFlow's to speak on.  == */
/* == Nothing below fills a background or inks text with either token.    == */
/* == The accent survives on this surface only where it is an             == */
/* == ACCESSIBILITY affordance rather than a brand statement: the focus   == */
/* == ring every input on the site shares, and the 2px underline slide    == */
/* == design.css gives every link on hover. That distinction is drawn by  == */
/* == PROPERTY, not by promise, and pinned by                             == */
/* == tests/test_storefront_design.py::test_no_rule_reachable_from_a_     == */
/* == storefront_page_fills_or_inks_the_brand_accent, which harvests the  == */
/* == classes the three storefront pages really render and scans every    == */
/* == rule that can reach them.                                           == */
/* ========================================================================= */

/* THE 2026-09-02 TASTE ROUND, in one place so the rules below can be short.
   This is a Redesign-Preserve: the token layer, the type scale and the
   motion discipline come from the marketing register; none of its
   personality does. Composition, rhythm and typography were the levers.

   THREE PLANES. A sunken header band (the shop's sign), the paper main
   column (the goods), a sunken footer (the shop's details). One theme
   throughout, no section inverting; the bookends are --surface-sunken, the
   warm paper stop the token layer already carries, never a colour of ours.

   TWO RADII, and no third system. Anything that HOLDS content is --r-lg
   (cards, the lead photograph, thumbnails). Anything you CLICK or read as a
   LABEL is --r-sm (buttons, inputs, the logo, photo tiles, chips).

   ONE TYPE SCALE, declared once as --sf-* below and consumed by name
   everywhere, so the relationship between a shop's name and an item's name
   is a number two rules share rather than two numbers that agree by luck. */

/* -- The type scale ------------------------------------------------------ */

/* On the body, so every rule below reads a name instead of a number.

   A clamp() never leaves [floor, ceiling], so --sf-title's FLOOR sitting at
   or above --sf-name-echo's CEILING makes "the thing's own name outweighs
   the shop's name on the thing's own page" true at every viewport width,
   not just at the two ends somebody happened to open a browser at. Pinned
   by tests/test_storefront_design.py::
   test_an_items_own_name_outweighs_the_shops_name_at_every_viewport. */
.df-storefront {
  --sf-name: clamp(1.5rem, 1.15rem + 1.4vw, 2.15rem);        /* the shop's sign: the index <h1> */
  --sf-name-echo: clamp(1.15rem, 1.05rem + 0.4vw, 1.35rem);  /* the same name, one step down */
  --sf-title: clamp(1.65rem, 1.25rem + 1.6vw, 2.4rem);       /* an item's or a project's <h1> */
  --sf-price: clamp(1.35rem, 1.15rem + 0.8vw, 1.6rem);
  --sf-head: 1.1rem;
  --sf-meta: 0.9rem;
}

/* -- The measure --------------------------------------------------------- */

/* One column width for all three planes, declared once. The header, the
   main column and the footer used to restate the same three properties. */
.df-storefront-header__inner,
.df-storefront-main,
.df-storefront-footer__inner {
  max-width: 74rem;
  margin-inline: auto;
  padding-inline: var(--s3);
}

/* -- Header: the shop's sign --------------------------------------------- */

/* A band, not a hairline. The shop's name, logo and tagline occupy a plane
   of their own above the goods, which is what makes the page read as this
   shop's rather than as a catalogue that happens to have a title. */
.df-storefront-header {
  background: var(--surface-sunken);
  border-bottom: 1px solid var(--df-border);
  margin-bottom: var(--s6);
  padding-block: var(--s4);
}

.df-storefront-header__inner {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.df-storefront-logo {
  max-height: 64px;
  width: auto;
  border-radius: var(--r-sm);
}

/* `min-width: 0` so a long shop name wraps inside the flex row rather than
   pushing the logo off the left edge of a narrow phone. */
.df-storefront-sign { min-width: 0; }

/* The shop's own name, in the same display face every heading on these
   surfaces uses. Two sizes, one class: as the index page's <h1> it is the
   subject and takes --sf-name; everywhere else it is the header echo of the
   shop an item or a project belongs to, sits under that page's own <h1>,
   and takes --sf-name-echo. See storefront/layout.html's `header_heading`
   block, and the type-scale comment above for why the two never cross. */
.df-storefront-name {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--sf-name-echo);
  line-height: 1.15;
  letter-spacing: -0.015em;
  color: var(--ink-900);
}

h1.df-storefront-name { font-size: var(--sf-name); }

.df-storefront-name a { color: inherit; }

.df-storefront-tagline {
  margin: var(--s1) 0 0;
  max-width: 52ch;
  font-size: var(--sf-meta);
  color: var(--ink-500);
}

@media (max-width: 575px) {
  .df-storefront-header { padding-block: var(--s3); }
  .df-storefront-logo { max-height: 48px; }
}

/* -- Main column --------------------------------------------------------- */

.df-storefront-main { padding-bottom: var(--s8); }

/* Capped: a search box stretched across 74rem is a search box for a
   database, not for a shop of a few dozen things. */
.df-storefront-toolbar {
  max-width: 46rem;
  margin-bottom: var(--s5);
}

/* -- The quiet chip ------------------------------------------------------ */

/* An item's condition, a project's status. Replaces Bootstrap's
   `.text-bg-secondary`, which is a solid --ink-500 pill: on a page whose
   loudest element should be a photograph, a filled navy badge on every card
   outranked the goods. Ink on the sunken surface instead — --ink-700 on
   --surface-sunken is 13.94:1 in design.css's contrast ledger. */
.df-chip {
  display: inline-block;
  padding: 2px var(--s1);
  border: 1px solid var(--df-border);
  border-radius: var(--r-sm);
  background: var(--surface-sunken);
  color: var(--ink-700);
  font-size: 0.78rem;
  font-weight: 600;
  line-height: 1.5;
  white-space: nowrap;
}

/* -- Product grid (index page) ------------------------------------------- */

/* The card IS the photograph plus its label, not a box with a photograph
   inside it: the image runs to the card's own edges, the type block
   underneath carries the padding, and there is no resting shadow. Two dozen
   shadowed rectangles is two dozen things asking for attention, and on a
   shop's page the goods should be the only ones. The lift arrives on hover,
   where it means "this one" instead of meaning nothing.

   The whole card is one link, so the underline-on-hover design.css grows
   under every `a` — right for prose, wrong for a card — is switched off
   here and the card itself answers instead. */
.df-storefront-card-link {
  display: block;
  height: 100%;
  color: inherit;
  text-decoration: none;
  background-image: none;
}

.df-item-card {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background: var(--df-surface);
  border: 1px solid var(--df-border);
  border-radius: var(--df-radius);
  transition: transform var(--fast) var(--ease), box-shadow var(--fast) var(--ease),
              border-color var(--fast) var(--ease);
}

.df-storefront-card-link:hover .df-item-card {
  transform: translateY(-2px);
  box-shadow: var(--shadow-2);
  border-color: var(--ink-500);
}

.df-item-thumb {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  background: var(--surface-sunken);
}

/* No photograph yet: a field with a hairline under it, so the card reads as
   awaiting a picture rather than as a picture that failed to load. */
.df-item-thumb-empty { border-bottom: 1px dashed var(--df-border); }

.df-item-card__body {
  display: flex;
  flex-direction: column;
  flex: 1;
  gap: 2px;
  padding: var(--s2);
}

.df-item-card__name {
  font-weight: 600;
  line-height: 1.35;
  color: var(--ink-900);
}

.df-item-card__spec {
  margin: 0;
  font-size: var(--sf-meta);
  color: var(--ink-500);
}

/* Condition and price share one baseline at the card's foot, pushed there
   by `margin-top: auto`, so every price in a row lands on the same line
   regardless of how many lines the name above it wrapped to. The price is
   held to the right end by its own auto margin rather than by
   `space-between`, because the condition chip is optional and
   `space-between` on a one-child row would leave the price on the left. */
.df-item-card__foot {
  display: flex;
  align-items: baseline;
  gap: var(--s1);
  margin-top: auto;
  padding-top: var(--s2);
}

.df-item-card__price {
  margin-left: auto;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

/* An empty shop is still a composed page: a field that says the shelves are
   waiting, not a paragraph adrift in the middle of nothing. */
.df-storefront-empty {
  padding: var(--s8) var(--s3);
  border: 1px dashed var(--df-border);
  border-radius: var(--df-radius);
  background: var(--surface-sunken);
  text-align: center;
  color: var(--ink-500);
}

/* -- Projects strip (index page) ------------------------------------------ */

/* Below the goods, opened by a rule rather than by a colour: this page is a
   shop first and the strip is a strip.

   Ruled rows, not cards. A project carries no photograph, so a card built
   to hold one renders as an empty box with two lines of text in it —
   elevation communicating nothing, which is the one thing a card may not
   do. Rows also give the index its second layout family, so the page reads
   as goods and then work rather than as two grids of the same tile. Two
   columns from 768px up, because a shop's projects are a curated handful
   (app/services/storefront.py says so and does not paginate them) and a
   handful in one long column is a lot of vertical for very little. */
.df-project-strip {
  margin-top: var(--s8);
  padding-top: var(--s4);
  border-top: 1px solid var(--df-border);
}

.df-project-strip__heading {
  margin: 0 0 var(--s2);
  font-family: var(--font-display);
  font-size: var(--sf-head);
  color: var(--ink-900);
}

.df-project-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  column-gap: var(--s6);
}

@media (min-width: 768px) {
  .df-project-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.df-project-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s2);
  padding: var(--s2) 0;
  border-top: 1px solid var(--df-border);
  color: inherit;
  text-decoration: none;
  background-image: none;
}

.df-project-row__name {
  font-family: var(--font-display);
  font-weight: 600;
  color: var(--ink-900);
}

.df-project-row:hover .df-project-row__name {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* -- Item page: the photograph leads -------------------------------------- */

/* Media on one side, the decision on the other. The photograph is what a
   shopper is actually buying from, and it used to arrive AFTER the price as
   one of N equal squares below the fold. Now the first photograph leads at
   size and the rest hang under it as tiles, with name, code, price,
   condition, the CTA and the description in a column beside it.

   DOM order is media then decision, which is also the right reading order
   on a phone (see the thing, then the price, then the button) — so nothing
   here depends on `order`, and the keyboard walks the page in the order it
   is drawn. */
.df-item-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--s4);
}

@media (min-width: 900px) {
  .df-item-layout {
    grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
    gap: var(--s6);
    align-items: start;
  }
}

/* A grid item's default `min-width: auto` refuses to shrink below its
   content, so one long unbroken word in a description would push the whole
   two-column layout wider than the page. */
.df-item-media,
.df-item-buy { min-width: 0; }

/* Square, `cover`, --surface-sunken beneath: the same crop the grid
   thumbnails use, so what a shopper saw in the grid is what opens. */
.df-item-lead {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border: 1px solid var(--df-border);
  border-radius: var(--df-radius);
  background: var(--surface-sunken);
}

.df-item-lead--empty { border-style: dashed; }

.df-item-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
  gap: var(--s1);
  margin-top: var(--s1);
}

.df-item-tile {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border: 1px solid var(--df-border);
  border-radius: var(--r-sm);
  background: var(--surface-sunken);
}

.df-storefront-back {
  margin: 0 0 var(--s3);
  font-size: var(--sf-meta);
}

.df-item-hero__name {
  margin: 0 0 var(--s1);
  font-family: var(--font-display);
  font-size: var(--sf-title);
  line-height: 1.1;
  letter-spacing: -0.02em;
  text-wrap: balance;
}

.df-item-meta {
  margin: 0 0 var(--s3);
  font-size: var(--sf-meta);
  color: var(--ink-500);
}

/* Price and condition over a rule that closes the "what this is" block and
   opens the "what you do about it" one. */
.df-item-price-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2);
  padding-bottom: var(--s3);
  border-bottom: 1px solid var(--df-border);
}

.df-item-price {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--sf-price);
  font-variant-numeric: tabular-nums;
  color: var(--ink-900);
}

.df-item-action { margin-top: var(--s3); }

.df-item-desc { margin-top: var(--s5); }

.df-item-desc__heading {
  margin: 0 0 var(--s1);
  font-family: var(--font-display);
  font-size: var(--sf-head);
  color: var(--ink-900);
}

/* Whitespace the owner typed into a textarea is theirs; collapsing a
   deliberate blank line between two paragraphs of a description would
   rewrite what they wrote. Same rule `.df-answer` applies above. */
.df-item-description {
  margin: 0;
  max-width: 62ch;
  white-space: pre-line;
}

/* WhatsApp CTA — the one sanctioned non-token color on the storefront,
   because this button opens WhatsApp and has to read as WhatsApp, not as
   this product's own accent. The WhatsApp brand teal, #128C7E, computes
   4.135:1 against white text (WCAG 2.x relative-luminance formula) — under
   the 4.5:1 AA floor for normal-weight text — so it is darkened one step,
   within the same teal-green family, to #0E7A6B, which computes 5.231:1.
   Both figures verified by direct calculation, not assumed, and both fills
   now carried in design.css's CONTRAST LEDGER, which recomputes them from
   these hexes rather than trusting this paragraph. */
.df-whatsapp-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s1);
  background: #0E7A6B;
  color: #FFFFFF;
  border: none;
  border-radius: var(--r-sm);
  padding: var(--s2) var(--s4);
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: 1.05rem;
  text-decoration: none;
  box-shadow: var(--shadow-1);
  transition: transform var(--fast) var(--ease), box-shadow var(--fast) var(--ease),
              background-color var(--fast) var(--ease);
}

.df-whatsapp-btn:hover {
  /* One step darker still. This was `color-mix(in srgb, #0E7A6B 85%, #000000)`
     with the argument that contrast can only improve going darker — true,
     but a value no ledger can resolve and therefore a value nothing
     recomputes. #0C685B IS that mix, computed (14, 122, 107 x 0.85, rounded)
     and written down, so the hover fill is now checked at 6.67:1 alongside
     the resting one instead of being reasoned about. */
  background: #0C685B;
  box-shadow: var(--shadow-2);
  transform: translateY(-1px);
  color: #FFFFFF;
}

/* Full-width on a phone: the primary action on a phone screen, not a button
   competing for thumb space with the "Call" fallback beside it. */
@media (max-width: 767px) {
  .df-whatsapp-btn,
  .df-item-call { width: 100%; }
}

/* -- Project page: the story ---------------------------------------------- */

.df-project-head { margin-bottom: var(--s5); }

.df-project-head__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2);
  margin: 0 0 var(--s3);
  font-size: var(--sf-meta);
  color: var(--ink-500);
}

.df-project-head__lede {
  margin: 0;
  max-width: 62ch;
  font-size: 1.05rem;
  color: var(--ink-700);
  white-space: pre-line;
}

.df-story-block {
  margin-top: var(--s6);
  padding-top: var(--s4);
  border-top: 1px solid var(--df-border);
}

.df-story-block__heading {
  margin: 0 0 var(--s4);
  font-family: var(--font-display);
  font-size: var(--sf-head);
  color: var(--ink-900);
}

/* Deliberately NOT `.df-feed`. That class is the OWNER's progress feed
   (app/templates/projects/detail.html) and its node is filled with
   --accent-deep — correct there, because that page is DataraFlow's, and
   wrong on a page belonging to someone else's customer. The two render the
   same rows and want different things from them, so the storefront gets its
   own: a hairline rail, hollow nodes in the page's own ink, and the DATE as
   each entry's rubric above its title. A story page is read by WHEN, where
   the owner's feed is read by WHAT. `.df-feed` is untouched, so the admin
   page is untouched — pinned in both directions by
   tests/test_storefront_design.py::
   test_the_public_project_page_does_not_borrow_the_owners_progress_feed. */
.df-story {
  list-style: none;
  margin: 0;
  padding: 0 0 0 var(--s4);
  border-left: 1px solid var(--df-border);
}

.df-story__item {
  position: relative;
  padding-bottom: var(--s5);
}

.df-story__item:last-child { padding-bottom: 0; }

/* A ring drawn over the rail rather than a dot beside it, filled with the
   page's own background so the rail appears to pass behind it. */
.df-story__item::before {
  content: "";
  position: absolute;
  left: calc(-1 * var(--s4) - 5px);
  top: 7px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--paper);
  border: 2px solid var(--ink-500);
}

.df-story__date {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: var(--sf-meta);
  font-variant-numeric: tabular-nums;
  color: var(--ink-500);
}

.df-story__title {
  margin: 2px 0 0;
  font-family: var(--font-display);
  font-size: 1.05rem;
  color: var(--ink-900);
}

.df-story__note {
  margin: var(--s1) 0 0;
  max-width: 62ch;
  font-size: 0.95rem;
}

/* A tile strip, not a wrap: fixed squares on a grid so a feed of portrait
   phone photos and landscape site photos still reads as one row. */
.df-story__photos {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: var(--s1);
  margin-top: var(--s2);
  max-width: 34rem;
}

.df-story__photo-link {
  display: block;
  background-image: none;
}

.df-story__photo {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border: 1px solid var(--df-border);
  border-radius: var(--r-sm);
  background: var(--surface-sunken);
}

.df-story-empty {
  margin: 0;
  font-size: 0.95rem;
  color: var(--ink-500);
}

/* -- Footer + the DataraFlow credit --------------------------------------- */

/* The closing plane, matching the header's: the shop's own details, sunk
   away from the goods between them. */
.df-storefront-footer {
  background: var(--surface-sunken);
  border-top: 1px solid var(--df-border);
  margin-top: var(--s8);
  padding-block: var(--s6) var(--s5);
  color: var(--ink-500);
  font-size: var(--sf-meta);
}

.df-storefront-footer__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--s3);
}

@media (min-width: 768px) {
  .df-storefront-footer__grid {
    grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
    gap: var(--s6);
  }
}

.df-storefront-about {
  margin: 0;
  max-width: 60ch;
}

.df-storefront-contact > div + div { margin-top: 2px; }

/* The only DataraFlow branding a tenant's customer ever sees. Quiet by
   design: ink-500, no logo, no fill. Doubles as this storefront's own
   honest signup channel.

   Its hover no longer inks --accent-deep. The footer is a --surface-sunken
   plane now, and --accent-deep on --surface-sunken is 4.44:1 — a WCAG AA
   failure design.css's REFUSED ledger already names and already rules on: a
   link on a sunken surface takes the site's default anchor treatment
   instead, --ink-900 ink (16.60:1) with the accent underline slide, which
   keeps the accent visible as the affordance without asking it to carry
   text. Dropping the old `background-image: none` is what restores that
   slide. Pinned by tests/test_storefront_design.py::
   test_the_powered_by_credit_clears_aa_on_the_plane_it_actually_sits_on,
   which resolves the pair through the token table rather than trusting the
   class-name intersection the design-layer guard can only reach. */
.df-storefront-credit {
  margin-top: var(--s4);
  padding-top: var(--s2);
  border-top: 1px solid var(--df-border);
  font-size: 0.8rem;
}

.df-storefront-credit a { color: var(--ink-500); }

.df-storefront-credit a:hover { color: var(--ink-900); }

/* -- Reduced motion, for everything this section moves --------------------- */

/* The storefront sets no `html.js` class and loads no reveal.js, so
   tests/test_design_layer.py's JS-gated sweep sees none of these by
   construction — it says so itself, and names the gap. They are swept
   instead by tests/test_storefront_design.py::
   test_every_transform_a_storefront_page_can_reach_is_neutralized_under_reduce. */
@media (prefers-reduced-motion: reduce) {
  .df-item-card,
  .df-whatsapp-btn { transition: none; }

  .df-storefront-card-link:hover .df-item-card { transform: none; }

  .df-whatsapp-btn:hover { transform: none; }
}

/* ---------------------------------------------------------------------- */
/* Motion                                                                  */
/* ---------------------------------------------------------------------- */

/* `.btn-primary`'s transform/transition above is a straight copy of
   design.css's `.btn-accent` lift-and-shadow motion, but only the motion
   itself was copied — not the `prefers-reduced-motion` neutralization
   design.css:275 gives `.btn-accent` for it. Same house convention,
   applied to the one place on this file that needed it. */
@media (prefers-reduced-motion: reduce) {
  .btn-primary {
    transition: none;
  }

  .btn-primary:hover {
    transform: none;
  }
}
