:root {
  --brand: #2563eb;
  --brand-soft: #93b4f5;
  --ink: #1f2933;
  --muted: #6c7682;
  --line: #d8dee6;
  --bg: #e9edf2;
  /* The trackchat signature green. It marks what you tap to choose a view — the tab bar,
     the range row, the content pills — while the data itself stays blue. The site's #16A34A
     only clears 3.3:1 on white, so this takes the deeper #117A39 (5.4:1), which also carries
     white pill text at AA. */
  --signature: #117A39;
  /* The soft track under a signature-green progress bar — the green-tinted sibling of --bg,
     matched to its lightness so the achievements bars sit as quietly on the card as the blue
     ones did. Only the bars use it; the rest of the page keeps the neutral --bg. */
  --signature-soft: #dfece4;
}

* { box-sizing: border-box; }

/* The WebView paints a translucent grey overlay on every tapped control, which blinks a frame
   before our own state (the green .active pill/tab, the .selected overlay) lands. Every control
   here signals its own state, so the flash is never the feedback we want — suppressed once,
   globally, rather than per control as each one is noticed. */
button, a, .photo-tile, .cal-cell.has-activity { -webkit-tap-highlight-color: transparent; }

html, body {
  margin: 0;
  height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

.screen { display: none; }
.screen:not([hidden]) { display: block; }

/* `:not([hidden])` lifts specificity above `.screen:not([hidden])`, so flex
   centering wins over the generic `display: block` screen rule. */
.loading:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 24px;
}

/* The loading ring spins in the signature green, matching the tab bar (an active, "working" cue
   rather than a resting glyph).
   The SVG is centered on its viewBox, so rotating it about its own center never makes it orbit. */
.loading-spinner {
  color: var(--signature);
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* Connect screen — also the refresh-limit screen: both are a single centered card.
   These two wear the website's warm paper palette instead of the dashboard's blue-grey:
   they are brochure screens, met before any data exists, and they are the same object as
   the post-checkout page (web/src/thankyou.html) — one card, one sentence. The palette is
   swapped by redefining the tokens on the section, so the rules below stay token-driven and
   the dashboard proper keeps its own :root (blue for data, --signature for chrome).
   The system font stays: no webfont is worth shipping for two screens. */
#connect,
#limit {
  --bg: #F2EFEC;      /* paper */
  --ink: #161311;
  --muted: #6E675E;
  --line: #DCDAD4;
}
#connect:not([hidden]),
#limit:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 24px;
  background: var(--bg);
}
/* The section paints its own ground, but a rubber-band overscroll exposes the page behind it —
   so the paper reaches html/body too while either card is the screen on show. */
html:has(#connect:not([hidden])),
body:has(#connect:not([hidden])),
html:has(#limit:not([hidden])),
body:has(#limit:not([hidden])) {
  background: #F2EFEC;
}
.connect-card {
  max-width: 380px;
  text-align: center;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 22px;
  padding: 32px 24px;
  box-shadow: 0 20px 50px rgba(22, 19, 17, 0.08);
}
/* The lockup — the site's own mark + wordmark (sprite in index.html). Both are paths, so this
   costs no webfont: the heading below stays in the system font by design. The wordmark's two
   halves are driven by --wm-track/--wm-chat, exactly as on trackchat.fit. */
.connect-card .lockup {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 1.35rem;
  margin-bottom: 1.5rem;
}
.connect-card .mark {
  width: 30px;
  height: 30px;
  flex: none;
  fill: #16A34A;
  stroke: #fff;  /* the tick, knocked out of the green badge */
}
.connect-card .wordmark {
  --wm-track: #16A34A;
  --wm-chat: var(--ink);
  height: 0.78em;
  width: auto;
  aspect-ratio: 4279 / 715;
  display: block;
  flex: none;
}
.connect-card p { margin: 0; color: var(--muted); line-height: 1.5; }
.connect-card strong { color: var(--ink); }

/* Tab strip — the dashboard's static flex header (see #dashboard scroller layout below),
   horizontally scrollable on overflow. Static, not sticky: the panel beneath it is the scroller,
   so the nav stays put by layout and no scrollbar ever crosses it. */
/* Edge-fade mask — shared by the tab strip and the content pill rows (#workout-exercises,
   #measurement-items). Scrollbars are hidden, so a fade on the overflowing edge is the only cue
   that more items sit off-screen: the edge item dissolves under it, reading as "scroll me" (it
   also peeks the next one). app.js toggles can-scroll-left/right by scroll position so the fade
   shows only on the side that actually overflows — no phantom fade at either end. The fade stays
   fixed to the element's box edges (the mask lives in the box, not the scrolled content). Each
   user sets its own --fade width and eases --fade-l/-r in its own transition.
   The @property registrations make --fade-l/-r interpolate as lengths — an unregistered custom
   property is just a token string and can't be interpolated, so the fade would snap. */
@property --fade-l { syntax: "<length>"; inherits: false; initial-value: 0px; }
@property --fade-r { syntax: "<length>"; inherits: false; initial-value: 0px; }
.edge-faded {
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--fade-l, 0px), #000 calc(100% - var(--fade-r, 0px)), transparent 100%);
          mask-image: linear-gradient(to right, transparent 0, #000 var(--fade-l, 0px), #000 calc(100% - var(--fade-r, 0px)), transparent 100%);
}
.edge-faded.can-scroll-left  { --fade-l: var(--fade); }
.edge-faded.can-scroll-right { --fade-r: var(--fade); }
/* The bar itself: background + top/bottom borders, full-width and unmasked. The scrolling tabs
   live in .tabstrip-track inside it, so the edge fade dissolves only the tab labels — the bar
   and its borders stay solid to either end. */
.tabstrip {
  padding-top: 8px;
  background: #fff;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  /* Recolours instantly when entering/leaving the Photos tab (see html.photos-active .tabstrip):
     the panel beneath snaps to dark on show (it can't transition a display:none→shown element),
     so easing the bar here just made it lag the panel — a white slab lingering over dark content.
     Snapping it together with the panel reads as one clean flip. */
}
/* The horizontal scroller holding the tabs. Carries the edge-fade mask (transparent background,
   so a tab scrolling under the fade reveals the solid bar beneath, not the page). */
.tabstrip-track {
  display: flex;
  gap: 4px;
  padding: 0 8px;
  /* The two --fade-* are eased so an edge fade glides out as you reach that end, not snaps off. */
  transition: --fade-l 0.25s ease, --fade-r 0.25s ease;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  --fade: 68px;
}
.tabstrip-track::-webkit-scrollbar { display: none; }
.tab {
  flex: 0 0 auto;
  border: none;
  background: none;
  padding: 12px 14px;
  font-size: 14px;
  font-weight: 600;
  color: var(--muted);
  border-bottom: 2px solid transparent;
  cursor: pointer;
  white-space: nowrap;
  /* Eased so labels fade with the bar's light↔dark flip on the Photos tab. */
  transition: color 0.2s ease, border-color 0.2s ease;
}
.tab.active { color: var(--signature); border-bottom-color: var(--signature); }

/* The dashboard is a viewport-tall flex column: the nav a static header (see .tabstrip) and the
   active panel the scroller beneath it. So every tab's vertical scrollbar lives inside the panel,
   below the nav — a page scroller's bar would instead span the full viewport and streak across
   the tab labels on a fast scroll to the top. `:has` scopes the page lock to when the dashboard
   is actually showing, so the connect/loading screens still scroll the page normally. */
html:has(#dashboard:not([hidden])),
body:has(#dashboard:not([hidden])) {
  overflow: hidden;
}
#dashboard:not([hidden]) {
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: 100dvh;  /* dvh tracks the in-app browser's collapsing toolbar; vh is the fallback */
}
.panel {
  padding: 16px;
  flex: 1;
  min-height: 0;             /* let the flex child shrink so its content scrolls, not the page */
  overflow-y: auto;
  overflow-x: hidden;        /* horizontal control rows scroll themselves; the panel never should */
  overscroll-behavior: contain;  /* keep the rubber-band in the panel, off the locked page */
}
.panel[hidden] { display: none; }

/* Range selector row */
.range-row { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; }
.range-row[hidden] { display: none; }
.range {
  border: 1px solid var(--line);
  background: #fff;
  border-radius: 999px;
  padding: 6px 12px;
  font-size: 13px;
  color: var(--muted);
  cursor: pointer;
}
.range.active { background: var(--signature); border-color: var(--signature); color: #fff; }

/* Time-range selector (1/3/6 months) — restyled as lightweight underline tabs so it
   reads as a view filter, not a tappable pill. This keeps the pill shape exclusive to
   content pickers (the exercise row), so the two rows never look like the same control.
   Universal across tabs: the Weight, Workouts, and Measurements range rows. */
#weight-range, #workout-range, #measurement-range { gap: 16px; }
#weight-range .range, #workout-range .range, #measurement-range .range {
  border: none;
  background: none;
  border-radius: 0;
  padding: 6px 2px;
  font-weight: 600;  /* all tabs bold; selection changes only colour + underline, never weight */
  border-bottom: 2px solid transparent;  /* reserved so the active underline never shifts the row */
}
#weight-range .range.active, #workout-range .range.active, #measurement-range .range.active {
  background: none;
  color: var(--signature);
  border-bottom-color: var(--signature);
}

/* Content pill rows — the Workouts exercise selector and the Measurements pill row.
   The catalog can hold many entries, so they scroll horizontally instead of wrapping
   into a tall block (matching the tab strip's overflow behaviour). */
#workout-exercises, #measurement-items {
  flex-wrap: nowrap;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  /* Narrower than the tab strip's 68px: pills are small, so a wide fade would swallow a whole
     one rather than just hint at it. Eased like .tabstrip so the fade glides out at each end. */
  --fade: 40px;
  transition: --fade-l 0.25s ease, --fade-r 0.25s ease;
}
#workout-exercises::-webkit-scrollbar, #measurement-items::-webkit-scrollbar { display: none; }
#workout-exercises .range, #measurement-items .range { flex: 0 0 auto; white-space: nowrap; }

/* All-time-best caption beneath the Workouts chart — muted, small, centred. */
.workout-pb {
  margin: 12px 0 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
.workout-pb:empty { display: none; }
.workout-pb-value { margin-top: 2px; color: var(--ink); font-weight: 600; }
/* The estimated-1RM row sits under the logged set as a lighter, secondary caption. */
.workout-pb-1rm { margin-top: 1px; color: var(--muted); font-weight: 400; font-size: 12px; }

.chart-wrap {
  position: relative;
  height: 60vh;
  min-height: 280px;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 12px;
}

.empty {
  text-align: center;
  color: var(--ink);
  padding: 48px 16px;
}
.empty .muted, .muted { color: var(--muted); }
.empty p { margin: 6px 0; }

/* Food tab — reverse-chronological day journal */
:root {
  --good: #15803d;  /* met: in-band calories, protein goal reached (the ✓) */
  --under: #b45309; /* any miss: calories over/under + protein floor — amber, not alarmist red */
  --water: #0e7490; /* cyan — a different hue from --brand so water never reads as the tappable "entries" link */
}

/* Cap the journal width and centre it: on wide desktop screens a full-bleed card
   flings the macros (right-aligned) far from the food name (left), leaving an
   unreadable gap. The cap pulls the two columns back together; below it (mobile)
   the cap never applies and cards stay full-width. */
.food-list {
  display: flex;
  flex-direction: column;
  gap: 18px;
  max-width: 500px;
  margin: 0 auto;
}

/* Wider gap between calendar weeks (the boundary falls between Sun and Mon),
   adding to the base flex gap so weeks read as distinct blocks. */
.day.week-gap { margin-top: 90px; }

.day {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 14px 16px;
}
.day-header {
  margin: 0 0 8px;
  font-size: 19px;
  font-weight: 700;
}

/* A "good" day (calories in band + protein met) gets a faint green wash and a
   slightly greener border — a quiet "keep the streak green" nudge. Good-only: a
   non-good day stays plain white, never amber/red, so the journal never shames. */
.day.good {
  background: #f1faf4;
  border-color: #c6e7d0;
}

/* Fasting day — a logged-food gap. Dashed border + muted note so it reads as
   intentionally empty, not a broken card. */
.fasting-day { border-style: dashed; background: #fcfcfd; }
.fasting-day .day-header { color: var(--muted); }
.fasting-note { font-size: 14px; font-weight: 600; color: var(--muted); }
/* Each metric (kcal, protein, water) is its own row sharing one grid so the columns line up down
   the card. The amount column is 1fr (fills the left, so amounts hug the left padding); the rest
   are the verdict's four columns — icon, phrase, dot, target — pushed to the right by the 1fr.
   Splitting the verdict this way lets every part align in its own column: the ✓/↘/↗ marks under
   each other, the miss phrase ("627 under" / "974 over") under each other, the dots (own column,
   blank on hits) under each other, and the "target …" under each other, with the widest target
   filling its column to the card's right padding. The phrase is right-aligned (see .v-phrase), so
   the word ends flush against the dot whatever the number's width — the slack of differing phrase
   widths falls between the icon and the phrase instead. Each card is its own grid, so plain
   max-content would size these to that card's content and leave them misaligned card-to-card;
   instead the widths are shared via the --col-* vars that alignVerdictColumns() (app.js) measures
   across all cards after render. Until that runs (or if it can't) each falls back to per-card
   max-content. The small column-gap is the spacing *between* the verdict columns (≈ the spaces in
   "↘ 627 under · target"); the amount↔icon gap is mostly the 1fr's empty span, so it stays wide
   regardless. Each .day-stat is display:contents so its five cells drop straight into the shared
   grid (same as .entry-list). */
.day-stats {
  display: grid;
  grid-template-columns:
    1fr
    var(--col-icon, max-content)
    var(--col-phrase, max-content)
    var(--col-dot, max-content)
    var(--col-target, max-content);
  column-gap: 0.25em;
  row-gap: 4px;
  align-items: baseline;
  font-size: 14px;
}
/* The miss phrase ("627 under" / "974 over") is right-aligned so its word ends flush against the
   dot regardless of the number's width — "over" hugs its dot exactly like "under". */
.v-phrase { text-align: right; }
.day-stat { display: contents; }
/* The amount stays black; only the verdict is tinted — green when hit, amber on a miss — so
   colour marks the judgement, not the raw number. Water is the exception: its whole line keeps
   the cyan hue, since the coloured amount is how you recognise the water row at a glance. */
.day-stat.water { color: var(--water); }
.day-stat.hit .verdict-part { color: var(--good); }
.day-stat.miss .verdict-part { color: var(--under); }
/* The consumed amount: the number is the bold anchor, its unit trails in normal weight. The whole
   amount stays on one line so a long unit label (e.g. French "protéines") never breaks under "6g"
   when the 1fr column is squeezed by the shared verdict columns. */
.amount { white-space: nowrap; }
.amount-num { font-weight: 700; }
/* The verdict cells (icon/num/word/dot/target); colour inherited from the row tint. Each sits in its
   own right-hand grid column, the rightmost flush to the card's right padding (see .day-stats). */
.verdict-part { font-weight: 400; }
/* Nudge just the ✓/↘/↗ marker a hair toward the delta phrase on its right — the icon
   column's gap to the phrase reads a touch wide otherwise. Transform, so only the glyph
   shifts; the column box and every other cell stay put. */
.v-icon { transform: translateX(2px); }

.entries { margin-top: 10px; }
.entries summary {
  cursor: pointer;
  font-size: 13px;
  color: var(--brand);
  font-weight: 600;
  list-style-position: inside;
}
/* Gap between the disclosure arrow and the label. */
.entries-label { margin-left: 2px; }
/* Five aligned columns — number · unit · food · kcal · protein — so names and
   numbers line up perfectly down the list. Splitting the amount into a
   right-aligned number and a left-aligned unit keeps mixed units (g, ml, slices)
   from looking scattered: the digits align on their right edge, the units sit one
   space off. The grid lives on the <ul>; each <li> is display:contents so its
   spans become direct grid items. Zero column-gap keeps the per-cell top borders
   touching into one continuous hairline; spacing comes from cell padding. */
.entry-list {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: auto 1fr auto auto;
}
.entry { display: contents; }
.entry > * {
  padding: 6px 0;
  border-top: 1px solid var(--line);
  font-size: 14px;
}
.entry-amount {
  text-align: right;
  white-space: nowrap;
  color: var(--ink);
  padding-left: 8px;
  padding-right: 18px;
  font-variant-numeric: tabular-nums;
}
.entry-amount-unit {
  color: var(--ink);
  padding-left: 4px;
}
/* min-width:0 lets the 1fr track shrink to its share (grid tracks default to
   min-width:auto); overflow-wrap breaks a long unbroken token (URL, SKU, compound
   word) across lines instead of forcing the column wide. */
.entry-food {
  color: var(--ink);
  min-width: 0;
  overflow-wrap: anywhere;
}
.entry-kcal, .entry-protein {
  text-align: right;
  white-space: nowrap;
  color: var(--ink);
  padding-left: 18px;
  font-variant-numeric: tabular-nums;
}
/* Nudge the last column's text off the right edge; the divider line (the cell's
   border-top) still spans the full padding box, so it stays put. */
.entry-protein { padding-right: 8px; }
/* Column labels, shown once at the top — no divider line, smaller and muted. */
.entry-head > * {
  border-top: none;
  padding-top: 0;
  padding-bottom: 4px;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
}

/* Activities tab — month calendar of per-activity colored dots, with a consistency
   strip, legend, stats/PB strip, and a tap-driven day-detail panel. Capped + centred
   like the Food journal so the grid doesn't sprawl on wide screens. */
#activity-view {
  max-width: 500px;
  margin: 0 auto;
  /* Shared, ALL-FIXED column template for the stats strip and the day-detail (below): lead swatch,
     name, then TWO number/word pairs — volume (amount + unit) and the count (count + label). In each
     pair the number right-aligns and the word left-aligns, so all numbers end on shared x's and all
     words start on shared x's (the number↔word gap is just the small column-gap, keeping "10.1 km"
     and "3 sessions" tight). The day-detail reuses the same columns, so a tapped day's "min" starts
     under the month summary's "sessions". No divider dot — the aligned columns do the separating.
     Every column is a fixed width — no `fr`, no `max-content` that varies by content — so the track
     group is the same total width on both grids, every row, every tapped day and every month.
     Nothing is measured, so nothing jumps. The grids set `justify-content: center`, which centres
     that fixed track group within the full calendar-width inset → the whole block sits centred under
     the calendar, and because both grids are the same width with identical tracks they centre
     identically, so their columns line up.

     Knobs:
       --act-name  : name-column width. Kept just clear of the longest catalog name ("Indoor Cycling",
                     ~96px) so the whole block stays compact — extra slack here only spreads the rows
                     wider. A longer custom name is ellipsised, never overlapping the amount.
       --act-amount: amount column (right-aligned). Must hold the widest number ("195 972") or it
                     clips; widen it and short values just float further right of the name.
       --act-unit  : unit column (left-aligned). Kept just clear of the widest unit ("steps", ~30px) —
                     trimming it pulls the metric column left and tightens the row.
       --act-metric: rightmost column (right-aligned), holding the count and its word as one unit
                     flush to the divider's right edge — "147 ×" in the strip, "9716 min" / "9:59 hrs"
                     in the day-detail. Must clear the widest of those ("9:59 hrs", ~52px). Trimming it
                     (via --act-count) and adding the same to --act-name slides the volume right without
                     changing the total track width.
       --act-count : count sub-width feeding --act-metric (was its own column once); --act-label : the
                     word sub-width. Their sum (+ the folded gap) is --act-metric, kept ≥ ~52px. */
  --act-name: 106px;
  --act-amount: 52px;
  --act-unit: 34px;
  --act-count: 23px;
  --act-label: 26px;
  --act-metric: calc(var(--act-count) + var(--act-label) + 4.9px); /* + the gap the merged column drops */
  --act-cols: 10px var(--act-name) var(--act-amount) var(--act-unit) var(--act-metric);
  /* Total width of the (centred) track group: the five column widths above + the four 0.35em
     column-gaps (≈4.9px each at the grids' 14px); the fifth gap is folded into --act-metric. The
     strip↔detail divider is sized to this so the line spans exactly the content block, not the wider
     calendar inset. Keep in sync with --act-cols and the grids' column-gap. */
  --act-track: calc(10px + var(--act-name) + var(--act-amount) + var(--act-unit) + var(--act-metric) + 19.6px);
}

.activity-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 18px;
  margin-bottom: 6px;
  /* A disabled arrow ignores its own touch-action (no pointer events reach it), so the
     double-tap-to-zoom guard is set on the nav region too — covering taps on the greyed-out
     arrow at a month boundary, not just the enabled one. */
  touch-action: manipulation;
}
.activity-month-label { font-size: 17px; font-weight: 700; min-width: 130px; text-align: center; }
.activity-arrow {
  border: 1px solid var(--line);
  background: #fff;
  border-radius: 999px;
  width: 32px;
  height: 32px;
  font-size: 13px;
  color: var(--ink);
  cursor: pointer;
  /* Quick consecutive month-paging taps shouldn't register as a double-tap-to-zoom
     gesture; manipulation keeps panning/pinch but drops double-tap zoom (and its delay). */
  touch-action: manipulation;
}
/* Greyed via class, not the native :disabled — see buildActivityNav. The button stays
   enabled so its touch-action: manipulation keeps holding (no double-tap zoom at a boundary). */
.activity-arrow.is-disabled { color: var(--line); cursor: default; }

.activity-consistency {
  text-align: center;
  font-size: 13px;
  color: var(--muted);
  margin-bottom: 14px;
}

/* Seven equal columns; the weekday header shares the grid so cells line up under it. */
.cal-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 10px;
}
.cal-head {
  text-align: center;
  font-size: 11px;
  font-weight: 600;
  color: var(--muted);
  padding-bottom: 4px;
}
/* Fixed (not min-) height so every week row is identical whether or not its days hold
   activity: a day's dots/🏋️ reserve their space even on empty days, so paging from a
   0-activity month to a busy one doesn't change the grid's height — no layout jump.
   Tall enough for the day number plus a couple of wrapped dot rows. */
.cal-cell {
  height: 64px;
  border-radius: 8px;
  padding: 3px;
  display: flex;
  flex-direction: column;
  align-items: center;
  overflow: hidden; /* hard guarantee: a busy day's dots never bleed into neighbouring cells */
}
.cal-cell.cal-blank { height: 0; }
.cal-day { font-size: 11px; color: var(--muted); line-height: 1.4; }
.cal-cell.has-activity {
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none; /* a double-tap to deselect shouldn't flash a text selection on the day number */
}
/* Hover feedback only on real pointers: on touch there's no pointer-leave after a
   tap, so an unguarded :hover background would stick on the day even once deselected. */
@media (hover: hover) {
  .cal-cell.has-activity:hover { background: var(--bg); }
}
/* Days still to come this month: faded back so they don't compete with today and the
   past, but keep the grid's shape and number style. They're never active/interactive. */
.cal-cell.cal-future { opacity: 0.3; }
.cal-cell.selected { background: var(--bg); box-shadow: inset 0 0 0 2px var(--brand); }

/* The day's dots cluster centred below the number; each is a colored circle whose
   diameter (set inline) encodes how long-for-that-type the session was. */
.cal-dots {
  display: flex;
  flex: 1; /* fill the cell's leftover height (fixed cal-cell) so the dots… */
  flex-wrap: wrap;
  align-items: center;
  align-content: center; /* …center in the room below the number instead of hugging it */
  justify-content: center;
  gap: 3px;
  margin-top: 2px;
  transform: translateY(2px); /* optical nudge: the cluster reads high when centred geometrically */
}
/* A "crowded" day — the 4-glyph cap reached — piles its glyphs into a centred, overlapping
   cluster conveying "this day is packed". Pure CSS, no JS measurement: each glyph is anchored
   to the cluster's centre (left/top:50% + translate(-50%,-50%)) then nudged to one of the four
   diagonals, so they overlap in the middle. Dots drop their per-session magnitude size for a
   uniform compact diameter — four full-size (up to xl) dots can't read as distinct circles in
   the small cell, and "packed" doesn't need magnitude. The cell's overflow:hidden contains it. */
.cal-dots.crowded { position: relative; }
.cal-dots.crowded > * { position: absolute; left: 50%; top: 50%; margin: 0; }
.cal-dots.crowded .dot { width: 13px !important; height: 13px !important; }
.cal-dots.crowded > *:nth-child(1) { transform: translate(-50%, -50%) translate(-7px, -7px); }
.cal-dots.crowded > *:nth-child(2) { transform: translate(-50%, -50%) translate(7px, -7px); }
.cal-dots.crowded > *:nth-child(3) { transform: translate(-50%, -50%) translate(-7px, 7px); }
.cal-dots.crowded > *:nth-child(4) { transform: translate(-50%, -50%) translate(7px, 7px); }
.cal-dots.crowded > *:nth-child(5) { transform: translate(-50%, -50%); } /* the fifth sits dead centre */
.dot-wrap { position: relative; display: inline-flex; }
.dot { display: inline-block; border-radius: 50%; }
/* A strength-workout day's 🏋️ marker, sitting inline with any cardio dots. */
.cal-workout { font-size: 13px; line-height: 1; }

/* Shared color swatch: the stats strip's per-type rows and the day-detail lines key
   the calendar's dots off it. */
.legend-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  display: inline-block;
  flex: none;
}

/* Stats strip — the month's strength-workout count, then one row per cardio type active this
   month: swatch/🏋️ · name · month volume · log count. Doubles as the legend. Laid out on the
   shared --act-cols grid (see #activity-view) so the volumes end at a fixed dot column and the
   log counts start at a fixed column after it — identical to the day-detail grid below, so every
   dot lines up across both. Each .stat-row is display:contents so its five cells drop straight
   into the grid (even the empty volume/dot of a log-count-only workout row). Inset slightly from
   the calendar with a fixed margin, so the divider line below keeps a constant length. */
.activity-stats {
  padding-top: 12px;
  margin: 0 16px;
  display: grid;
  grid-template-columns: var(--act-cols);
  justify-content: center;
  column-gap: 0.35em;
  row-gap: 10px;
  align-items: center;
  font-size: 14px;
}
.stat-row { display: contents; }
/* A name wider than --act-name wraps to a second line (at spaces/hyphens); only a token still too
   long for two lines is ellipsised rather than spilling over the amount column. */
.stat-name {
  font-weight: 600;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  overflow-wrap: anywhere;
}
/* Two number/word pairs: the volume's amount + unit and the count + its label. In each, the number
   right-aligns (numbers end on a shared x) and the word left-aligns just past it (words start on a
   shared x). All muted, all nowrap so nothing wraps inside its fixed column. */
.stat-amount { color: var(--muted); text-align: right; white-space: nowrap; }
.stat-unit { color: var(--muted); white-space: nowrap; }
.stat-metric { color: var(--muted); text-align: right; white-space: nowrap; }
/* The workout row's 🏋️ key, boxed to the swatch's width so the names stay column-aligned. */
.stat-workout-glyph {
  width: 10px;
  display: inline-flex;
  justify-content: center;
  font-size: 12px;
  line-height: 1;
}

/* Tap-driven day-detail panel beneath the strip. Sized to the centred track group plus a small
   symmetric overhang and centred, so its top border (the strip↔detail divider) reaches a touch past
   the content block on both sides for a softer, less boxed-in line. The inner grid still centres to
   the same offset as the strip, keeping the dots/columns aligned — only the border edges overhang. */
.activity-detail {
  border-top: 1px solid var(--line);
  width: calc(var(--act-track) + 20px);
  max-width: calc(100% - 32px);
  margin: 12px auto 0;
  padding-top: 12px;
  min-height: 24px;
}
.detail-hint { text-align: center; font-size: 13px; margin: 0; }
.detail-header { margin: 0 0 8px; font-size: 16px; font-weight: 700; text-align: center; }
/* The detail lines reuse the strip's fixed column template (--act-cols), so each line's dividing
   dot lines up under the strip's and the distance/duration sit in the same columns. */
.detail-lines {
  display: grid;
  grid-template-columns: var(--act-cols);
  justify-content: center;
  column-gap: 0.35em;
  row-gap: 10px;
  align-items: center;
  font-size: 14px;
}
.detail-line { display: contents; }
/* Wraps like .stat-name, but to three lines: the daily entry appends a virtual-cycling platform
   sub-label ("Virtuelles Radfahren · Zwift"), which alone can fill two lines, so the vendor needs a
   third. Only a token still too long for three lines is ellipsised rather than spilling over. */
.detail-name {
  font-weight: 600;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
  overflow-wrap: anywhere;
}
/* The workout split name has no trailing data, so it spans the rest of the row (the amount, unit
   and metric columns) — full-line width before it ellipsises, instead of clipping to the narrow
   name column the cardio rows use. */
.detail-workout-name {
  grid-column: span 4;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The distance's amount/unit and the duration's number/"min" occupy the strip's amount/unit and
   count/label columns, with the same alignments — so a tapped day's figures line up under the month
   summary's, and "min" starts under "sessions". */
.detail-amount { color: var(--muted); text-align: right; white-space: nowrap; }
.detail-unit { color: var(--muted); white-space: nowrap; }
.detail-metric { color: var(--muted); text-align: right; white-space: nowrap; }
/* 🏋️ on the workout detail line, boxed to 10px so its name aligns with the cardio
   lines' colored swatch. */
.detail-workout-glyph {
  font-size: 14px;
  line-height: 1;
  width: 10px;
  display: inline-flex;
  justify-content: center;
}

/* Progress Photos tab — reverse-chronological days, the photo itself the card (no
   chrome). Days stack vertically and centre on the page. Wider than the other views so a
   day's photos can show side by side without scrolling: the cap holds room for ~4 tiles
   (4*290 + 3*20 gaps); past that, or on a narrower screen, a day's strip scrolls. */
/* Photos breaks the unified light background: a warm near-black field (not pure black,
   not cool grey) sits the physique shots on a gallery surround — it expands the photo's
   own tonal range and flatters skin where blue-black would render it ashen. Scoped to the
   panel only; the nav keeps its white chrome, so the tab switch isn't a full flash. As the
   flex-filling scroller (see #dashboard layout) the panel already covers the area below the nav,
   so a short/empty day list never reveals the light body bg beneath. The full-width block lets
   the dark bleed edge to edge. */
.panel[data-panel="photos"] {
  background: #161311;
}
/* Empty state lives on the dark field now — relight its text. */
.panel[data-panel="photos"] .empty { color: #f2efec; }
.panel[data-panel="photos"] .empty .muted { color: #9a8f86; }

/* Adaptive top bar: on Photos the nav adopts the dark field, so the white slab no longer floats
   between Telegram's dark browser chrome above and the dark field below — the tab reads as one
   immersive dark surface. Scoped to photos-active, NOT a light/dark theme: it's one section's
   chrome matching its content (the standard adaptive-bar pattern). Labels are relit for the dark
   bar — inactive takes the warm photos-muted, active the lighter brand tint (the #2563eb brand
   goes muddy on near-black). A faint warm hairline keeps the header's bottom edge legible against
   the dark panel scrolling below it. */
html.photos-active .tabstrip {
  background: #161311;
  border-top-color: rgba(245, 232, 214, 0.12);
  border-bottom-color: rgba(245, 232, 214, 0.12);
}
html.photos-active .tab { color: #9a8f86; }
html.photos-active .tab.active {
  color: #e0a96d;
  border-bottom-color: #e0a96d;
}

/* Vertical scroll-snap so a day's photos settle framed in view rather than half-cut. Gated to
   the photos tab (app.js toggles `photos-active` on <html>): every tab scrolls inside its panel,
   but only photos snaps. `proximity` not `mandatory`: a flick
   scrolls freely and only a slow stop settles onto the nearest day — natural, never locked, and
   never trapped if a portrait shot is taller than the viewport. scroll-padding-top = the panel's
   16px top padding, so a snapped row lands with the exact dark gap the first photo shows at rest
   (which sits below that same 16px) — every day settles identically, including when you scroll
   back up to the top one. Snap targets are the day rows (.photo-day); the span connectors flow
   through, so it's day → day. */
/* Match the page canvas to the photos field so iOS rubber-band overscroll past the last day
   reveals the same near-black, not the light body bg. Both html AND body are darkened: iOS
   Safari is inconsistent about which one paints the bottom overscroll, and both otherwise carry
   the light `var(--bg)`. Reverts when photos-active drops; eased via the base html/body rule. */
html.photos-active,
html.photos-active body {
  background: #161311;
}
.panel[data-panel="photos"] {
  scroll-padding-top: 16px;  /* = the panel's own top padding; the nav is outside the scrollport,
                                so a snapped day settles with the same 16px dark gap the first
                                photo shows at rest */
}
/* The panel is already the scroller for every tab (see #dashboard layout); on photos it just
   takes the warm thumb on a background-matching track of the horizontal photo-strip bars, so the
   vertical bar belongs to the photos chrome rather than the OS default. */
html.photos-active .panel[data-panel="photos"] {
  scrollbar-width: thin;
  scrollbar-color: #7a736a #161311;
}
/* WebKit (Chrome/Safari) equivalent of scrollbar-color above — mirrors .photo-day's bar but
   vertical (width, not height). */
html.photos-active .panel[data-panel="photos"]::-webkit-scrollbar { width: 11px; }
html.photos-active .panel[data-panel="photos"]::-webkit-scrollbar-track { background: #161311; }
html.photos-active .panel[data-panel="photos"]::-webkit-scrollbar-thumb {
  background: #7a736a;
  border-radius: 8px;
  border: 2px solid #161311;
  background-clip: padding-box;
}
/* Snap is for touch: a finger flick wants a soft settle onto a day row, a mouse wheel does
   not. Gate it on the primary pointer being coarse so desktop (mouse/trackpad, incl. 2-in-1s
   used with a trackpad) scrolls freely and only true touch devices settle. Cross-axis x-snap
   on .photo-day is gated the same way. The rows' scroll-snap-align stays put — inert without an
   active snap container. */
@media (pointer: coarse) {
  html.photos-active .panel[data-panel="photos"] {
    scroll-snap-type: y proximity;
  }
}

.photo-days {
  display: flex;
  flex-direction: column;
  gap: 14px;
  max-width: 1220px;
  margin: 0 auto;
}
/* Time-span connector between two days — a "dimension line": a thick up-arrow (pointing at
   the newer day above) with the elapsed gap labelled mid-shaft. Fixed height: the label
   carries the magnitude, so a "4 months" gap is no taller than "3 days". Warm muted light —
   quiet chrome on the near-black field, not competing with the photos. */
.photo-span {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 96px;
  /* The day above reserves padding-bottom for its scrollbar (14px vs the 4px padding-top of
     the day below), so without this the arrow would sit 10px farther from the top photos than
     the bottom ones. Pull the span up by that 10px reserve to centre it between both days. */
  margin-top: -10px;
}
.photo-span-arrow {
  display: block;
  /* Solid (not translucent): the shaft top and arrowhead apex overlap, and a translucent
     stroke would double its opacity there into a bright tip. This is the opaque equivalent
     of the warm light at ~0.45 over the #161311 field, so the whole arrow reads uniform. */
  stroke: #7a736a;
  fill: none;
}
/* The label is a rounded-rectangle node the shaft runs into and continues out of: its solid
   background masks the line behind it and the warm border frames it, so the arrow reads as
   passing through a labelled box rather than being snapped in two around floating text. */
.photo-span-label {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  padding: 4px 14px;
  background: #161311;
  border: 2px solid #7a736a;  /* same warm tone AND thickness as the arrow, so it reads as one piece */
  border-radius: 10px;
  color: rgba(245, 232, 214, 0.62);
  font-size: 17px;  /* matches the date (.photo-datestr) and caption (.photo-caption-text) */
  font-weight: 600;
  white-space: nowrap;
}
/* Centre the text by its ink, not its line box. The line box carries font-specific leading,
   so San Francisco (macOS) and Roboto (Android) centre differently — a static padding nudge
   that looks right on one drifts ~1px on the other. Trimming the box to cap-height/baseline
   removes that leading, so true-centre + symmetric padding lands identically on every Chrome.
   Supported on modern Chrome (desktop + Android, incl. Telegram's in-app browser); older
   engines fall back to the symmetric padding above. */
@supports (text-box-trim: trim-both) {
  .photo-span-label {
    text-box-trim: trim-both;
    text-box-edge: cap alphabetic;
  }
}
/* A day's photos sit side by side as one horizontal line, centred within the strip. As
   many as fit the screen show in full; the rest are reached by scrolling sideways (the
   page scrolls vertically across days). justify-content:safe center centres the row when
   it fits and falls back to flex-start when it overflows, so the leftmost tile never
   centres off-screen out of scroll reach. align-items:flex-start so a tile sizes to its
   image, keeping the bottom overlay pinned to the photo's edge not below a stretched box. */
.photo-day {
  display: flex;
  flex-wrap: nowrap;
  gap: 20px;
  align-items: flex-start;
  justify-content: safe center;
  overflow-x: auto;
  /* Pinned: overflow-x:auto would otherwise compute overflow-y to auto, and on a day
     whose strip scrolls horizontally the h-scrollbar steals height and spawns a bogus
     v-scrollbar. We only ever scroll sideways here. */
  overflow-y: hidden;
  /* x-snap of the tiles is touch-only (gated below, mirroring html.photos-active). As a child
     of the page scroller, each day row is also a vertical snap target — settles the row framed
     under the nav on a slow scroll. snap-type (set below) governs this row's own tiles,
     snap-align governs how this row settles in the page. The two don't conflict. */
  scroll-snap-align: start;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
  /* Track melts into the dark photos field (#161311 panel bg) so only the thumb reads;
     thumb takes the warm arrow tone (#7a736a) so it belongs to the photos chrome rather
     than the OS default light bar that broke the flow. (Firefox / modern Chrome.) */
  scrollbar-color: #7a736a #161311;
  /* Vertical breathing room inside the overflow-y:hidden clip: padding-top keeps a selected
     tile's 2px outer box-shadow frame off the strip's edge; the larger padding-bottom opens a
     gap so the scrollbar sits clearly below the photos rather than touching their bottom edge. */
  padding-top: 4px;
  padding-bottom: 14px;
}
/* WebKit (Chrome/Safari) equivalent of scrollbar-color above — same warm thumb on a
   background-matching track. Thicker (11px) and inset from the track by a transparent border
   (clipped to the padding box) so the rounded thumb reads as a distinct pill below the strip. */
.photo-day::-webkit-scrollbar { height: 11px; }
.photo-day::-webkit-scrollbar-track { background: #161311; }
.photo-day::-webkit-scrollbar-thumb {
  background: #7a736a;
  border-radius: 8px;
  border: 2px solid #161311;
  background-clip: padding-box;
}
/* Touch-only, mirroring html.photos-active: a sideways finger flick settles onto a tile;
   a mouse wheel / trackpad just scrolls. */
@media (pointer: coarse) {
  .photo-day {
    scroll-snap-type: x proximity;
  }
}

/* Floating action bar — shown only in select mode (entered by long-pressing a photo, see
   app.js). Pinned bottom-centre over the stream, thumb-reachable: Cancel + a red trash that
   deletes the ticked photos. Tapping empty space cancels (handled in app.js). */
.photos-actionbar {
  position: fixed;
  left: 50%;
  bottom: 20px;
  transform: translateX(-50%);
  display: flex;
  padding: 8px 12px;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 999px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
  z-index: 50;
}
.photos-actionbar[hidden] { display: none; }
/* The bar shows one face at a time: select (select-all + cancel + trash) or confirm
   (prompt + keep/delete). */
.photos-bar-face { display: flex; align-items: center; }
.photos-bar-face[hidden] { display: none; }
/* Select-all and Cancel are both harmless, so they sit close on the left; the destructive
   trash is pushed well clear of a mis-tap (its margin + the gap ≈ the original 64px). */
.photos-bar-face[data-face="select"] { gap: 10px; }
.photos-bar-face[data-face="select"] #photos-delete { margin-left: 96px; }
.photos-bar-face[data-face="confirm"] { gap: 12px; }
/* Select-all tints brand when every photo is ticked, so it reads as an on/off toggle. */
#photos-select-all.active { color: var(--brand); border-color: var(--brand-soft); }
.photos-confirm-text {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  /* Wraps for the longer all-delete wording ("…This can't be undone.") rather than running
     the bar off-screen; short single-photo prompts stay on one line. */
  max-width: 220px;
  padding-left: 4px;
}
.photos-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  border: 1px solid var(--line);
  background: #fff;
  border-radius: 999px;
  padding: 6px 14px;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
}
.photos-btn.danger { color: #fff; background: #b91c1c; border-color: #b91c1c; }
.photos-btn:disabled { opacity: 0.45; cursor: default; }
.photos-btn svg { display: block; }
/* With nothing selected the count span is empty; drop it from the flex layout so its 4px
   gap doesn't shove the lone trash icon off-centre in the button. */
#photos-delete-count:empty { display: none; }

/* Each photo is a rounded rectangle of fixed width (so tile size stays constant whatever
   a day's photo count); overflow:hidden clips the bottom scrim to the rounding. flex 0 0
   so tiles keep that width and overflow into the scroll rather than shrinking to fit. A
   lone photo, or any row that fits, is centred by the strip's justify-content. */
.photo-tile {
  position: relative;
  margin: 0;
  flex: 0 0 290px;
  scroll-snap-align: start;
  border-radius: 14px;
  overflow: hidden;
  /* A warm 1px edge frames the tile against the near-black field so photos don't bleed into
     the background; translucent warm light reads clearly on the dark surround (and stays
     subtle over the photo's own bright edges) rather than vanishing like a near-bg tone. */
  border: 1px solid rgba(245, 232, 214, 0.28);
  /* A long-press selects (app.js); suppress the OS image-callout/selection it would otherwise
     trigger so the gesture reads as a press, not a "save image". The WebView's own grey flash
     over the tile — which on a long-press paints a rectangle over the photo until release — is
     killed by the global tap-highlight rule at the top of this file. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
.photo-img {
  display: block;
  width: 100%;
  /* height:auto overrides the intrinsic height HTML attribute (set on the <img> to
     reserve space pre-load) — without it the browser keeps that pixel height and the
     photo renders as a tall sliver. Natural ratio, so heads/feet are never cropped. */
  height: auto;
  /* Match the photos field, not the light canvas (var(--bg)): a tile reserves its height
     before its lazy image decodes, and a light placeholder flashed as a bright rectangle on
     the dark field until each photo popped in. Now an unloaded tile is just its framed dark box. */
  background: #161311;
  -webkit-user-drag: none;  /* a press shouldn't start an image drag (desktop) */
}
/* A single bottom gradient scrim carries everything — kept low so text never hovers
   over the subject's head/face (the bottom of a progress photo is usually legs/empty).
   Reads on any photo, fades to clear so it doesn't dim the image. pointer-events:none so
   select-mode clicks reach the tile beneath. Stacked: a full-width caption band on its
   own line, then a row with the date bottom-left and the pose chip bottom-right — so the
   caption never competes with the date for horizontal room (which was wrapping the date).
   No horizontal padding here: the caption band bleeds edge to edge; the row pads itself. */
.photo-overlay-bottom {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding-top: 34px;
  display: flex;
  flex-direction: column;
  color: #fff;
  background: linear-gradient(to top,
    rgba(0, 0, 0, 0.62) 0%, rgba(0, 0, 0, 0.30) 50%, rgba(0, 0, 0, 0) 100%);
  pointer-events: none;
}
/* Caption: its own full-width band (not a floating pill) — amber for flair, the band
   spanning edge to edge so it reads as a subtitle bar. The band (padding + background)
   is the outer element; the text + 2-line clamp live in an inner, padding-free span. */
.photo-caption {
  align-self: stretch;
  padding: 6px 14px;
  background: rgba(0, 0, 0, 0.40);
  text-align: center;
}
/* Text + 2-line clip, with NO padding, so overflow:hidden cuts flush at the bottom of
   line 2 — a long caption can't let a third line peek into the band's bottom padding.
   max-height is pinned to exactly two line-boxes. We deliberately avoid -webkit-line-clamp:
   it places its ellipsis mid-word. Instead clampCaptionToTwoLines() in app.js trims whole
   words and appends the ellipsis, so the cutoff lands on a word boundary. overflow-wrap:
   anywhere is the escape hatch for a no-space caption (one giant token): it lets that word
   wrap across both lines so the JS char-trim fallback can clamp it like any other caption,
   rather than the word running off line 1 and being clipped flush. */
.photo-caption-text {
  display: block;
  font-size: 17px;  /* matches the date (.photo-datestr) and arrow label (.photo-span-label) */
  font-weight: 700;
  line-height: 1.3;
  color: #fde047;
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.75);
  overflow-wrap: anywhere;
  word-break: normal;
  max-height: calc(17px * 1.3 * 2);  /* exactly two line-boxes, no padding — tracks font-size */
  overflow: hidden;
}
/* The date/pose row mirrors the caption band's height so the strip below the band reads
   as a matching counterpart: band height = 6px+6px padding + 15px*1.3 line = 31.5px.
   Content is vertically centered in that band. */
.photo-overlay-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  padding: 0 14px;
  height: 44px;
  box-sizing: border-box;
}
/* Date bottom-left: the date is what you navigate by, so it's the big top line; the
   weekday is secondary on the small line beneath. nowrap so it never breaks mid-date. */
.photo-date {
  display: flex;
  flex-direction: column;
  line-height: 1.15;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.photo-datestr { font-size: 17px; font-weight: 700; white-space: nowrap; }
.photo-weekday { font-size: 13px; font-weight: 500; opacity: 0.92; }  /* matches .pose-chip */
/* Pose chip bottom-right: translucent pill that reads against the scrim. margin-left:auto
   keeps it right-aligned even when it's the row's only child (no date on this tile). */
.pose-chip {
  align-self: center;
  margin-left: auto;
  white-space: nowrap;
  background: rgba(255, 255, 255, 0.18);
  border: 1px solid rgba(255, 255, 255, 0.35);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  padding: 4px 10px;
  border-radius: 999px;
  text-transform: capitalize;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}

/* Select mode: tiles take a pointer + a faint hover; a ticked tile gets a brand ring
   and a ✓ badge in the corner. */
.photo-days.selecting .photo-tile { cursor: pointer; }
/* Selected: a translucent brand wash over the whole tile plus an inset ring, so a picked
   photo reads as picked at a glance — not just by the corner check. (The old outer ring sat
   on the <img> and was clipped away by the tile's overflow:hidden, so nothing showed.)
   pointer-events:none so taps still reach the tile; z-index keeps the check above the wash. */
/* Selected: the tile's own frame turns brand-blue, thickened by a 1px outer box-shadow so
   the visible frame reads as ~2px (matching the next-day arrow) without changing the border
   WIDTH — the border stays 1px, so the content box (and thus the photo) never resizes between
   states. The shadow lives outside the tile (in the gap), isn't clipped by overflow:hidden,
   and follows the radius. */
.photo-tile.selected {
  border-color: var(--brand);
  box-shadow: 0 0 0 1px var(--brand);
}
.photo-tile.selected::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  /* Inner rounding = 14px outer radius − 1px border, so the wash curves with the corners. */
  border-radius: 13px;
  background: rgba(37, 99, 235, 0.32);
  pointer-events: none;
}
.photo-tile.selected::after {
  content: "✓";
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 2;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--brand);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Expired presigned-link placeholder, sized to the tile's 3:4 footprint so the
   column keeps its shape when a link 403s. */
.photo-tile.expired {
  width: 100%;
  aspect-ratio: 3 / 4;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed var(--line);
  border-radius: 14px;
  padding: 10px;
}
.photo-expired { font-size: 11px; color: var(--muted); text-align: center; line-height: 1.4; }

/* "+N more" tile closing a day's strip when it runs past PHOTOS_PER_DAY_CAP — same
   fixed width as a photo, a 3:4 box so the row keeps its shape. */
.photo-more {
  flex: 0 0 290px;
  scroll-snap-align: start;
  aspect-ratio: 3 / 4;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed var(--line);
  border-radius: 14px;
  background: var(--bg);
  color: var(--muted);
  font-size: 14px;
  font-weight: 600;
}

.banner {
  margin: 16px;
  padding: 12px 14px;
  border-radius: 10px;
  background: #fff4e5;
  color: #8a5a00;
  border: 1px solid #ffd9a0;
  font-size: 14px;
}
.banner[hidden] { display: none; }

/* Personal Bests tab — a single scrollable records screen of section cards: three
   achieved-only record families, the laddered milestones, and the distance journey.
   Cards reuse the white/--line/14px idiom shared with the chart wraps. */
#bests-content { display: flex; flex-direction: column; gap: 16px; max-width: 500px; margin: 0 auto; }
.bests-card {
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 16px;
}
.bests-card-title { font-size: 19px; font-weight: 700; margin-bottom: 8px; }

/* A card with no records yet: an encouraging "start logging" note in place of the rows. */
.bests-empty-note { font-size: 13px; color: var(--muted); margin: 0; }

/* A titled sub-group within a card (a strength split, or one activity's metrics). */
.bests-group + .bests-group { margin-top: 14px; }
.bests-group-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  margin: 6px 0 4px;
}

/* A record row: label (with optional caption) left, value right. */
.bests-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 7px 0;
  border-top: 1px solid var(--line);
}
.bests-group-title + .bests-row, .bests-card-title + .bests-row { border-top: none; }
/* Strength: divide only between exercise types, not between exercises within a type. */
.bests-card-strength .bests-row { border-top: none; }
.bests-card-strength .bests-group + .bests-group { border-top: 1px solid var(--line); padding-top: 8px; }
/* Activities: divide only between activities, not between the PBs within one. */
.bests-card-activity .bests-row { border-top: none; }
.bests-card-activity .bests-group + .bests-group { border-top: 1px solid var(--line); padding-top: 8px; }
/* Measurements: no divider directly below the title (keep them between items). */
.bests-card-measurement .bests-group > .bests-row:first-child { border-top: none; }
/* The card's last row/milestone drops its bottom padding: that padding would otherwise stack
   onto the card's own 16px, leaving the bottom content sitting lower than the title sits below
   the top border. Zeroing it makes both edges a clean 16px (matching the Journey card). */
.bests-card .bests-group:last-child > .bests-row:last-child { padding-bottom: 0; }
.bests-card > .bests-milestone:last-child { padding-bottom: 0; }
.bests-row-label { font-size: 15px; }
.bests-row-caption { font-size: 12px; color: var(--muted); margin-top: 2px; }
.bests-row-value {
  font-size: 15px;
  font-weight: 600;
  text-align: right;
  white-space: nowrap;
}
.bests-row-value-sub { font-weight: 400; color: var(--muted); }
/* A secondary stat beneath the bold value (e.g. a strength record's est. 1RM), mirroring the
   left caption but right-aligned and un-bolded against the 600-weight value above it. */
.bests-row-value-caption { font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 2px; }

/* A milestone: label + current value, a next-rung progress bar, the next-rung caption. */
.bests-milestone { padding: 10px 0; border-top: 1px solid var(--line); }
.bests-card-title + .bests-milestone { border-top: none; }
.bests-milestone-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}
.bests-milestone-label { font-size: 15px; }
.bests-milestone-value { font-size: 15px; font-weight: 600; }
.bests-bar {
  position: relative;
  height: 8px;
  background: var(--signature-soft);
  border-radius: 999px;
  margin: 8px 0 6px;
}
.bests-bar-fill {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--signature);
  border-radius: 999px;
}
/* Nudge the milestone traveler glyph up 1px so it sits visually centered on the bar.
   The streak flame reads low, so lift it 3px more. */
.bests-bar .journey-traveler { top: calc(50% - 1px); }
.bests-bar .bests-traveler-streak { top: calc(50% - 4px); }
/* On an empty bar (0%) the glyph would overhang the left edge; anchor it flush to the
   start instead so it sits fully visible under the label text. */
.bests-bar .journey-traveler.at-start { transform: translate(-4px, -50%); }
.bests-bar .journey-traveler.at-start.flip { transform: translate(-4px, -50%) scaleX(-1); }
/* On an empty bar, tuck the track's left end under the glyph so it doesn't poke out. A
   ::before paints the inset track; the bar's own box stays full-width so the glyph
   (positioned relative to it) doesn't shift. */
.bests-bar.is-empty { background: transparent; }
.bests-bar.is-empty::before {
  content: "";
  position: absolute;
  inset: 0 0 0 10px;
  background: var(--signature-soft);
  border-radius: 999px;
}
.bests-milestone-next { font-size: 12px; color: var(--muted); }

/* Distance Journey — a horizontal track per leg (Earth lap, then Moon) with a traveler
   glyph riding the filled portion. Pure CSS; no Chart.js. */
.journey-caption { font-size: 13px; color: var(--muted); margin: 4px 0 10px; }
/* The open-frontier caption (current journey / current tonnage, above its progress bar). */
.journey-caption-current { font-weight: 700; }
.journey-track + .journey-caption { margin-top: 16px; }
/* Separates one travel mode from the next in the Distance Journey ladder. */
.journey-divider { border-top: 1px solid var(--line); margin-top: 16px; }
.journey-divider + .journey-caption { margin-top: 14px; }
.journey-track {
  display: flex;
  align-items: center;
  gap: 8px;
}
.journey-track + .journey-track { margin-top: 4px; }
.journey-marker { font-size: 18px; flex: none; line-height: 1; }
.journey-line {
  position: relative;
  flex: 1;
  height: 8px;
  background: var(--signature-soft);
  border-radius: 999px;
}
.journey-fill {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--signature);
  border-radius: 999px;
}
/* The traveler sits on the fill's leading edge; translate back half its width so it
   doesn't overhang the track ends at 0%/100%. */
.journey-traveler {
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%);
  font-size: 16px;
  line-height: 1;
}
/* Most glyphs (runner, swimmer) default to facing left; mirror them so the traveler
   faces the direction of travel. The rocket already points rightward, so it opts out. */
.journey-traveler.flip { transform: translate(-50%, -50%) scaleX(-1); }

/* A conquered leg — collapsed to a one-line badge instead of a full track, so an advanced
   user's card isn't a wall of progress bars. The glyph (left) and ✓ (right) echo the open
   frontier track's start marker and finish flag, keeping the row visually aligned with it. */
.journey-conquered {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--muted);
}
.journey-divider + .journey-conquered { margin-top: 14px; }
.journey-conquered + .journey-conquered { margin-top: 8px; }
.journey-conquered-glyph { font-size: 18px; flex: none; line-height: 1; }
.journey-conquered-text { flex: 1; min-width: 0; }
.journey-conquered-check { flex: none; color: var(--signature); font-weight: 700; font-size: 14px; }

/* Pull-down-to-refresh indicator (decision 8) — a circular ↻ glyph that rides down from
   the top edge as the active panel is pulled, brightening once past the refresh threshold.
   Positioned + transformed entirely from JS (wirePullToRefresh); this is just its look. */
.pull-refresh {
  position: fixed;
  top: -36px;                 /* parked just above the viewport; JS translates it into view */
  left: 50%;
  transform: translateX(-50%);
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: #fff;
  color: var(--muted);
  border: 1px solid var(--line);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
  font-size: 18px;
  line-height: 1;
  z-index: 50;
  opacity: 0;
  /* opacity: slow fade out on release (the fast fade-in is overridden on .visible below, so
     the circle pops in right at the top edge instead of materializing part-way down the pull).
     The pull position itself is animated per-frame from JS (wirePullToRefresh), NOT via a
     transform transition — a transition here would fight the manual parking and produce
     chaotic start positions. */
  transition: opacity 0.3s ease;
  pointer-events: none;       /* never intercept touches — it's a passive indicator */
}
.pull-refresh.visible { opacity: 1; transition: opacity 0.08s ease; }
/* Past the threshold it takes the signature green, matching the tab bar and the loading ring. */
.pull-refresh.ready { color: var(--signature); border-color: var(--signature); }

/* Dark variant on the Photos tab — warm tones matching its palette (panel #161311,
   muted #9a8f86, brand-soft #93b4f5). The chip is a small warm lift off the panel bg. */
html.photos-active .pull-refresh {
  background: #241f1b;
  color: #9a8f86;
  border-color: rgba(245, 232, 214, 0.20);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}
html.photos-active .pull-refresh.ready {
  color: #e0a96d;
  border-color: #e0a96d;
}
