/* =============================================================
   Shared component primitives: the third single-source-of-truth
   sheet, alongside theme.css (colour) and layout.css (width + grid).

   Linked in every page's <head> AFTER theme.css and BEFORE that
   page's own inline <style>. It defines the canonical card chassis,
   the page header, the eyebrow label and the 38x38 icon button, so
   every page reads as one app instead of seven hand-built ones.

   A page should just USE these class names. To retune the whole
   app's card look / header / buttons, edit THIS file and nothing else.
   Pages must not redefine .card / .icon-btn chassis in their inline
   <style> (inline would win and re-fragment the look); add any
   page-specific extras in a MORE specific rule (e.g. .recovery-card)
   that layers on top of the shared chassis.

   Pure CSS, no build step.
   ============================================================= */

/* ---- Card chassis: the canonical tile. One hairline themed border,
   the e1 elevation (a soft shadow in light, surface only in dark), an
   18px radius and a flat themed surface. No glass blur, no heavy
   baked-in black shadow. Both skins themed automatically because every
   value is a theme var. ---- */
.card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-1); /* e1: a soft lift in light, none in dark */
  padding: 20px 22px;
}

/* viz.js chart cards. A card that names a skeleton shape (data-skel, or a
   skel field in its LFViz.mount spec) holds its final height from first paint:
   a min-height while it is still empty, then a grey placeholder until its data
   lands, so nothing pops in and shoves the page down. A card with no shape
   keeps the old behaviour and stays hidden while empty. A render with nothing
   to show hides its card through the hidden attribute (viz.js paint()). */
.viz-card:empty:not([data-skel]) { display: none; }
.viz-card[hidden] { display: none; }
/* The same height holds while the skeleton is up (aria-busy): the empty
   card before viz.js has run and the skeleton it then draws are one box. */
.viz-card[data-skel="kpi"]:is(:empty, [aria-busy="true"])    { min-height: 146px; }
.viz-card[data-skel="hero"]:is(:empty, [aria-busy="true"])   { min-height: 174px; }
/* A list or bars card longer than five or six rows says how many with
   --skel-rows on its own style; the skeleton draws that many (viz.js) and
   the reserve grows by a row's height for each: 44 px a list row, 30 a bar. */
.viz-card[data-skel="list"]:is(:empty, [aria-busy="true"])   { min-height: calc(288px + (var(--skel-rows, 5) - 5) * 44px); }
.viz-card[data-skel="bars"]:is(:empty, [aria-busy="true"])   { min-height: calc(250px + (var(--skel-rows, 6) - 6) * 30px); }
.viz-card[data-skel="line"]:is(:empty, [aria-busy="true"])   { min-height: 240px; }
.viz-card[data-skel="pixels"]:is(:empty, [aria-busy="true"]) { min-height: 200px; }
.viz-card[data-skel="map"]:is(:empty, [aria-busy="true"])    { min-height: 240px; }

.skel-wrap { display: flex; flex-direction: column; }
.skel {
  display: block;
  max-width: 100%;
  border-radius: var(--r-xs);
  background: color-mix(in srgb, var(--text-primary) 7%, transparent);
}
/* A skeleton word: a bar that sits in a line of text the way the word it
   stands in for would, so a slot keeps its line box, and wraps where the
   text will, before its data lands. Its width is set in em from the words it
   stands in for. Middle-aligned and under 1em tall, it never makes the line
   taller than the line-height of the text around it. */
.skel.skel-word { display: inline-block; vertical-align: middle; height: 0.7em; }
/* The same bar a whole line tall, for a flex row with no line box to sit in
   (a .source-strip): the box is the line (1.4, the strip's line-height), and
   the bar is drawn in its middle 0.7em. */
.skel.skel-text { display: inline-block; box-sizing: border-box; height: 1.4em; padding: 0.35em 0; background-clip: content-box; }
.skel-kpi  { min-height: 104px; }
.skel-hero { min-height: 132px; }
.skel-list { min-height: 246px; }
.skel-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  height: 44px;
}
.skel-bar { height: 30px; }
.skel-track { flex: 1; min-width: 0; }
.skel-plot {
  position: relative;
  display: block;
  aspect-ratio: 700 / 220;
  border-bottom: 1px solid var(--border-soft);
}
.skel-line {
  position: absolute;
  left: 0; right: 0; top: 40%;
  height: 2px;
  border-radius: 1px;
  background: color-mix(in srgb, var(--text-primary) 10%, transparent);
}
.skel-pixel-grid {
  display: grid;
  grid-template-columns: repeat(26, 1fr);
  gap: 3px;
  max-width: 460px;
}
.skel-pixel-grid .skel { aspect-ratio: 1; border-radius: 2px; }
.skel-map { width: 100%; aspect-ratio: 2.5 / 1; background: var(--card-2); }
@media (max-width: 640px) { .skel-map { aspect-ratio: 1.6 / 1; } }
/* The shimmer: one soft sweep across each bar. Opt-in on motion, so under
   prefers-reduced-motion the bars stay still (no loop is ever declared). */
@media (prefers-reduced-motion: no-preference) {
  .skel {
    background-image: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--text-primary) 6%, transparent) 50%, transparent 100%);
    background-size: 200% 100%;
    background-repeat: no-repeat;
    animation: skel-shimmer 1.2s linear infinite;
  }
}
@keyframes skel-shimmer {
  from { background-position: 150% 0; }
  to   { background-position: -50% 0; }
}

/* Charts have an intrinsic readable size. A .viz-grid lays them out two-up on
   wide screens (each ~900px, the legible size) and one-up on laptop/phone,
   instead of stretching a single chart across the whole monitor. A standalone
   chart card caps its own width so it can't blow up either. */
.viz-grid {
  display: grid;
  gap: 12px;
  align-items: stretch; /* paired charts share a row height: no dead gap below the shorter one */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 700px), 1fr));
  margin-bottom: 12px;
}
.viz-solo { max-width: 760px; }
/* Two cards sharing one column of a .viz-grid, so a row stays two across
   (Finances: the Apple Pay taps above the radar). The last card stretches so
   the column ends level with its neighbour. */
.viz-stack { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.viz-stack > .card { flex: 0 0 auto; }
.viz-stack > .card:last-child { flex: 1 1 auto; }
.viz-stack.is-loose > .card:last-child { flex: 0 0 auto; }
/* The time spine is a tall vertical timeline. On phones keep it a compact
   scrollable box so it doesn't run the whole screen. On wide screens it's
   paired beside the task cards (.todo-row) and shows the full day inline, with
   no nested scroll, so it fills its column instead of leaving a gap beside it. */
#viz-timeSpine .viz-time_spine { max-height: 560px; overflow-y: auto; }
@media (min-width: 1080px) {
  #viz-timeSpine .viz-time_spine { max-height: none; overflow: visible; }
}

/* ---- Page header: a flex row with the title on the left and any
   action icon-buttons on the right. The home page (index) keeps its
   own time-aware greeting header; every other page uses this so the
   title size, weight and action placement match. ---- */
.page-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 16px;
}
.page-head .page-title,
.page-head h1 {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text-primary);
  line-height: 1.15;
}
.page-head-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

/* ---- Icon button: a 38x38 themed action (settings, refresh, back).
   Replaces every page's bespoke ~32-38px action button so they all
   look identical. ---- */
.icon-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  padding: 0;
  border-radius: 12px;
  color: var(--text-secondary);
  background: var(--card);
  border: 1px solid var(--border);
  cursor: pointer;
  font: inherit;
  -webkit-tap-highlight-color: transparent;
  transition: background .15s, color .15s, border-color .15s;
}
.icon-btn:hover {
  color: var(--text-primary);
  background: var(--card-2);
  border-color: var(--border-soft);
}
.icon-btn svg { width: 18px; height: 18px; display: block; }

/* ---- Eyebrow: the small uppercase label used above card content
   app-wide. It is the type ramp's label step: 11 px (the floor) at 0.08em.
   Tracking was 0.18em for the retired monospace face, then 0.14em; Hanken
   caps at 11 px read best around 0.06 to 0.08em. ---- */
.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  font-weight: 700;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text-tertiary);
}

/* ---- Hero stat ----
   One hero-stat treatment app-wide: every big number carries the same
   size, weight and tracking, so scanning is mechanical. ---- */
.stat-num {
  font-size: clamp(34px, 4vw, 44px);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.stat-unit {
  font-size: 14px;
  font-weight: 500;
  color: var(--text-secondary);
}
.stat-cap {
  font-size: var(--fs-label);
  font-weight: 700;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text-tertiary);
}

/* ---- Type ramp ----
   Seven steps, sizes from theme.css. Use these rather than a bespoke
   font-size: a page should render seven sizes, not nineteen. Weights stop
   at 800 (display only) and 700 (kpi, title, label). Numbers are already
   tabular on body (theme.css). ---- */
.t-display { font-size: var(--fs-display); font-weight: 800; letter-spacing: -0.025em; line-height: 1; }
.t-kpi     { font-size: var(--fs-kpi);     font-weight: 700; letter-spacing: -0.02em;  line-height: 1.05; }
.t-title   { font-size: var(--fs-title);   font-weight: 700; letter-spacing: -0.02em;  line-height: 1.15; }
.t-head    { font-size: var(--fs-head);    font-weight: 600; letter-spacing: -0.01em;  line-height: 1.3; }
.t-body    { font-size: var(--fs-body);    font-weight: 400; line-height: 1.5; }
.t-meta    { font-size: var(--fs-meta);    font-weight: 500; line-height: 1.4; }
.t-label   { font-size: var(--fs-label);   font-weight: 700; letter-spacing: var(--tracking-label); line-height: 1.2; text-transform: uppercase; }

/* ---- Tap-spark confirm ----
   The spark answers a tap at the point of contact (no toast). .lf-armed
   is the visual arm state for two-tap deletes (lfArmConfirm in JS). ---- */
.lf-sparkable { position: relative; }
.lf-sparkable.charge::after {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 12px; height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent) 55%, transparent);
  transform: scale(0);
  opacity: 0.9;
  pointer-events: none;
  animation: lf-spark 620ms var(--ease-premium, ease-out) forwards;
}
@keyframes lf-spark { to { transform: scale(9); opacity: 0; } }

.lf-armed {
  background: color-mix(in srgb, var(--warning) 22%, var(--card)) !important;
  border-color: var(--warning) !important;
  color: var(--warning) !important;
}

@media (prefers-reduced-motion: reduce) {
  .lf-sparkable.charge::after { animation: none; opacity: 0; }
}

/* ---- Reduced motion: one global switch (design spec 4.6) ----
   Under prefers-reduced-motion: reduce every CSS animation and transition on
   every page lands on its end state at once: chart draw-ins, card rises,
   skeleton shimmer, hover and toggle transitions. Durations go to
   a hair above zero rather than none so animationend and transitionend still
   fire for any script waiting on them. The number tweens, the finance view
   fade and the skeleton crossfade run from JS (LFMotion in viz.js) and check
   the same media query there. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---- Focus ring ----
   One keyboard focus ring for every page, in --focus (≥3:1 against every
   surface in both skins). :where() keeps it at zero specificity so a page can
   still tune one control, but that means a page rule that sets a bare
   outline: none would silently beat it. Pages must not do that:
   scripts/lf-focus.test.mjs fails on any outline: none that is not inside a
   focus rule carrying its own visible replacement.
   Text fields swap the outline for a border and a soft ring, which reads
   better on a filled box. No border-radius here: inherit would copy the
   parent's radius and square off a focused chip. ---- */
:where(a, button, input, select, textarea, summary, [tabindex], [role=tab], [contenteditable]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent);
}

/* ---- Tap targets ----
   On a touch screen every control gets at least 44x44 of hit area. Where the
   visual size is deliberately smaller (the nav chips, the topbar pills), the
   hit area is an invisible ::before that reaches past the edge into the gap;
   those live beside the component (nav.js, topbar.js, theme.css). Buttons
   that can simply grow do so here. ---- */
@media (pointer: coarse) {
  .icon-btn { width: 44px; height: 44px; }
}

/* ---- Data age ----
   How old the data behind a figure is, from vizAge() in viz.js. One cadence
   table decides the state: fresh shows nothing beside a figure (or, for a
   figure that always says when it is from, the same quiet "as of"), aging is
   a quiet "as of DD/MM/YYYY" in the tertiary text colour, and stale adds a
   6px dot and turns --stale (amber).
   Never --danger: old data is a fact to know, not a failure.
   .age sits beside one figure; .source-strip is the one line at the top of a
   page listing every source it reads. The strip wraps on phones. ---- */
.age {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin-left: 0.5em;
  font-size: max(var(--fs-label), 1em); /* the size of the line it sits in, never under the floor */
  font-weight: 500;
  line-height: 1.4;
  letter-spacing: normal;
  text-transform: none;
  white-space: nowrap;
  color: var(--text-tertiary);
  font-variant-numeric: tabular-nums;
}
.age[data-state="stale"] { color: var(--stale); }
.age-dot, .src-dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--stale);
  flex: none;
}
.source-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-1) var(--sp-4);
  margin: 0 0 var(--sp-3);
  font-size: var(--fs-meta);
  font-weight: 500;
  line-height: 1.4;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.source-strip .src { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.source-strip .src-name { color: var(--text-primary); font-weight: 600; }
.source-strip .src:not([data-state="stale"]) .src-dot { display: none; }
.source-strip .src[data-state="stale"] .src-when { color: var(--stale); }
.source-strip .src-note { color: var(--text-secondary); }
/* One action that belongs with the sources, inline in the strip (Finances:
   "Add a statement", which opens the statement card and the file picker). */
.source-strip .src-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  min-height: 32px;
  padding: 0 var(--sp-3);
  font: inherit;
  font-weight: 600;
  color: var(--accent-strong);
  background: var(--accent-soft);
  border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
  border-radius: var(--r-pill);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.source-strip .src-chip:hover { border-color: var(--accent); }
.source-strip .src-chip-mark { font-weight: 700; }
@media (pointer: coarse) { .source-strip .src-chip { min-height: 44px; } }
/* A strip's skeleton (the page ships one where its strip is tall enough to
   move the page): skeleton text as wide as each source, and the chip's pill
   at the chip's height, so it wraps into the same rows. */
.source-strip .skel-chip { display: inline-block; height: 32px; border-radius: var(--r-pill); }
@media (pointer: coarse) { .source-strip .skel-chip { height: 44px; } }

/* ---- KPI band (design spec 4.11) ----
   A row of two to four figure cells that answers a page's question before
   any chart does. Not a chart, so it is not in .viz-grid; it spans the row.
   Two across on phones and tablets (the spec's 2x2 at 375), then as many 220
   px cells as fit: four across on a laptop and on the 49" monitor, never a
   cell stranded in a column of its own.
   Each cell is a .card: label (.kpi-label, the type ramp's label step), the
   figure (.kpi-value, --fs-kpi), one comparison line (.kpi-meta), an optional
   quieter line (.kpi-sub), and a foot for the data-age marker or a
   vizSpark() sparkline, pinned to the bottom so the four feet line up.
   Every cell rule is scoped to .kpi-band: the home page's KPI rail (.kpi,
   .kpi-sub) and gym.html (.kpi-unit) use the same class names for their own
   components, and unscoped rules here re-weighted and re-spaced them. ---- */
.kpi-band {
  display: grid;
  gap: var(--sp-3);
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-bottom: var(--sp-3);
}
@media (min-width: 1080px) {
  .kpi-band { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
}
.kpi-band > .kpi {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  min-width: 0;
  padding: var(--sp-4) var(--sp-5);
}
.kpi-band > .kpi[hidden] { display: none; }
/* Every cell holds its five lines from the first paint: label, figure, meta,
   sub (not on phones, where it is hidden) and foot, one line each. An empty
   cell before viz.js has run, its skeleton (data-skel="band": the same five
   slots in skeleton words), a figure still being read and a filled cell with
   fewer lines are all this tall, so the band keeps its size when the rows
   land; only a slot that runs to a second line grows it (review, 23/09/2026:
   the Year band moved the page 190 px, Finances' 100). The sum is the type
   ramp and the spacing scale: a 1 px border either side, the padding, the
   label at 1.2, the figure at 1.05, the meta-size lines at 1.4, the foot's
   rule gap, and the flex gaps with the figure's margin.
   A page whose cells usually run a slot to a second line says so with
   --kpi-extra-lines (Finances: the age marker wraps in the foot; the Year
   band on a phone: the nights line wraps in the meta), and every cell holds
   that many more meta-size lines. */
.kpi-band > .kpi {
  min-height: calc(2px + 2 * var(--sp-4) + var(--fs-label) * 1.2 + 5 * var(--sp-1) + var(--fs-kpi) * 1.05 + (3 + var(--kpi-extra-lines, 0)) * (var(--fs-meta) * 1.4) + var(--sp-2));
}
.kpi-band .kpi-label {
  font-size: var(--fs-label);
  font-weight: 700;
  letter-spacing: var(--tracking-label);
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--text-tertiary);
}
.kpi-band .kpi-value {
  margin-top: var(--sp-1);
  font-size: var(--fs-kpi);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.05;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}
.kpi-band .kpi-unit {
  margin-left: var(--sp-1);
  font-size: var(--fs-meta);
  font-weight: 500;
  letter-spacing: normal;
  color: var(--text-secondary);
}
/* A figure that is not a number yet ("Not yet"): quieter and at the head
   size, so it does not shout as loud as a real count (vizKpiCell's muted). */
.kpi-band .kpi-value.is-muted { color: var(--text-tertiary); font-size: var(--fs-head); font-weight: 600; line-height: 1.3; }
/* A figure's line, whatever is in it: "Not yet" at the head size and a
   skeleton word hold the same line as a number. */
.kpi-band .kpi-value { min-height: calc(var(--fs-kpi) * 1.05); }
.kpi-band .kpi-meta { font-size: var(--fs-meta); font-weight: 500; line-height: 1.4; color: var(--text-secondary); }
.kpi-band .kpi-sub  { font-size: var(--fs-meta); font-weight: 500; line-height: 1.4; color: var(--text-tertiary); }
.kpi-band .kpi-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-1) var(--sp-2);
  margin-top: auto;
  padding-top: var(--sp-2);
  font-size: var(--fs-meta);
  color: var(--text-tertiary);
}
.kpi-band .kpi-foot .age { margin-left: 0; white-space: normal; }
.kpi-band .kpi-foot .viz-spark { display: block; width: 100%; max-width: 240px; height: auto; }
@media (max-width: 559px) {
  .kpi-band > .kpi {
    padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
    /* Four lines: the sub is hidden here, and its gap with it. */
    min-height: calc(2px + 2 * var(--sp-3) + var(--fs-label) * 1.2 + 4 * var(--sp-1) + var(--fs-kpi) * 1.05 + (2 + var(--kpi-extra-lines, 0)) * (var(--fs-meta) * 1.4) + var(--sp-2));
  }
  .kpi-band .kpi-sub { display: none; }
}

/* Signed out of Access (23/09/2026, docs/PRIVATE_ROWS_MOVE.md). A card whose
   data is served only behind Access says what signing in would show, with
   one link, instead of drawing an empty card that reads as "nothing logged".
   The link is a 44 px target; the dashed edge marks it as a state of the
   card, not content. */
.signin-note {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2) var(--sp-3);
  margin: 0;
  padding: var(--sp-3) var(--sp-4);
  border: 1px dashed var(--border);
  border-radius: var(--r-md);
  font-size: var(--fs-meta);
  line-height: 1.4;
  color: var(--text-secondary);
}
.signin-note[hidden] { display: none; }
.signin-note b { color: var(--text-primary); font-weight: 600; }
.signin-note a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 var(--sp-4);
  border-radius: var(--r-sm);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
}
.signin-note a:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }
