/* ==========================================================================
   MAPLE WEB — shared design tokens
   maple.css · 6 August 2026

   Every colour in this file is contrast-measured against WCAG 2.1, not
   estimated. Do not change a hex without re-checking the pairs it affects.

   Every app on this site imports this file. Consistency is enforced here,
   not by memory.
   ========================================================================== */


/* ==========================================================================
   1 · COLOUR
   ==========================================================================
   light-dark() returns the first value in light mode, the second in dark,
   with no media query. It requires color-scheme to be set, which it is below.
   Baseline since May 2024 — Safari 17.5 / iOS 17.5, Chrome 123 (MDN).

   THE SITE IS DARK-ONLY, decided 16 August. color-scheme below reads
   `only dark`, so every light-dark() pair resolves to its SECOND value
   whatever the device prefers, and the browser draws its own chrome — form
   controls, scrollbars — dark to match.

   The light halves are RETAINED DELIBERATELY. They are not dead weight, they
   are the reversal path: the trigger is a second account that prefers light,
   and restoring two-mode operation means changing that one declaration back
   to `light dark`. Nothing else moves.

   The obligation that comes with keeping them: a colour added or changed from
   here on is measured in BOTH modes, not just the one being looked at. A
   reversal that finds half the palette unmeasured is not a reversal. */

:root {
  color-scheme: only dark;

  /* Surfaces and text
     Light is cream, not white — for every RESTING surface. White is reserved
     for the one surface that floats above them, --surface-raised. In this
     system white is not a neutral, it is a signal: white means raised.
     Under dark-only that white is unreachable — #33302C is the raised surface
     that actually renders — so the principle is held for the reversal, not in
     force today.
     Dark is near-black, not black: pure black halates against bright text on
     OLED and leaves nowhere to step a surface above the page. */
  /* ⚠ --page's DARK value is DUPLICATED as literal hex in every HTML head, as
     a single <meta name="theme-color" content="#1A1917"> with no media query.
     Meta tags cannot read a custom property, so this is the one place in the
     project where a token value is copied rather than referenced.
     Until 16 August each head carried a media-qualified PAIR; the light half
     went with the move to dark-only. If --page's dark value changes, grep the
     .html files for theme-color and change all six by hand — and a reversal to
     two modes has to put the light meta back in all six too. */
  --page:     light-dark(#EBE2D2, #1A1917);
  --tile:     light-dark(#F7F1E4, #242220);
  --hairline: light-dark(#D5C9B2, #35332F);
  --text-1:   light-dark(#26241F, #EDEAE4);
  --text-2:   light-dark(#645E53, #9A968E);   /* light was #6B655A: 4.50 → 5.00 */

  /* The surface for elements that FLOAT ABOVE the page — the undo toast now,
     any future sheet or floating panel. --tile is for furniture that belongs
     to the page: rows, cards, fields, things the eye reads as part of the
     list. This is for interruptions, which have to read as standing off it.

     It steps further from --page than --tile does (1.34 vs 1.11 dark, 1.29 vs
     1.14 light), which is the whole point: a thing that arrives over the top
     needs more separation than a thing that was always there.

     Measured against --text-1: 15.50 light, 10.93 dark. ⚠ --text-2 measures
     4.45 on the DARK value, which is under the 4.5 floor — secondary text
     does not belong on this surface until that value is revisited. The fix is
     already measured, so it need not be worked out again: darkening the dark
     value one step to #322F2B takes --text-2 to 4.52 and --text-1 to 11.09,
     at the cost of separation from --page dropping 1.34 → 1.32. */
  --surface-raised: light-dark(#FFFFFF, #33302C);

  /* The identity mark's ochre.
     Two values because #D4B483 measures only 1.53 against the light page and
     would be nearly invisible there; #8A6520 measures 4.12. On the dark page
     #D4B483 measures 8.91. A graphical object needs 3:1, so both pass. */
  --mark: light-dark(#8A6520, #D4B483);

  /* Swipe panels
     Both values are measured for a WHITE label specifically.
     Do not substitute a different label colour. */
  --swipe-done:  #478232;   /* was #5FA845: white label 2.93 → 4.66 */
  --swipe-edit:  #A65F39;   /* was #C4784F: white label 3.42 → 4.86 */
  --swipe-label: #FFFFFF;

  /* Neutral accent — the fallback for any app without its own colour,
     and the correct accent for the login and placeholder screens, which
     belong to no app. */
  --accent: light-dark(#6B655A, #9A968E);

  /* Label on an accent FILL. Deliberately not one value: the light accents
     are dark enough to carry white, the dark-mode accents are pastels and
     need the page colour back on top.
     Measured against the previous five accents at 5.30–6.90 light and
     5.14–8.91 dark. NOT re-measured against pine and claret — outstanding. */
  --on-accent: light-dark(#FFFFFF, #1A1917);

  /* Destructive. TEXT ONLY, never a fill: a filled destructive control would
     compete with the screen's one primary action, which is the thing that
     should be easiest to hit.

     It is NOT under the accents' restriction, and an earlier version of this
     comment wrongly said it was. Both live uses sit on --page — .btn--danger
     inside .danger-zone, which has no fill of its own, and .form__error on the
     login and exercise forms — and both measure safely there.
     Measured on --page: 5.86 light, 7.86 dark.
     Measured on --tile: 6.69 light, 7.09 dark. */
  --danger: light-dark(#96331F, #E39C8B);
}

/* Per-app accent. A page declares itself on the root element:
     <html data-app="exercises">
   Any slug not listed below falls through to the neutral default above,
   so a newly added app renders correctly before it has a colour. */

[data-app="memory"]     { --accent: light-dark(#46698F, #8FA9C4); }
[data-app="exercises"]  { --accent: light-dark(#9C5228, #C4784F); }
[data-app="music"]      { --accent: light-dark(#3F7355, #86BFA0); }
[data-app="food"]       { --accent: light-dark(#7A4C63, #B992A8); }
[data-app="youtube"]    { --accent: light-dark(#9E3340, #DD8E98); }
[data-app="employment"] { --accent: light-dark(#46698F, #8FA9C4); }

/* Measured on --tile, both modes, 8 August: blue 5.07/6.52 · rust 5.11/4.64
   · pine 4.92/7.53 · plum 6.13/5.84 · claret 6.22/6.34.

   Memory and Employment deliberately SHARE the blue. Colour is therefore a
   hint, not a key — reusing a hue is preferred to inventing a ninth autumn
   value that would sit awkwardly beside the others. They are at opposite
   ends of the list.

   Pine is cooler than the Done panel #478232 so a green launcher row does
   not echo the completion colour. Claret leans crimson rather than orange,
   which separates it from the rust above it and from --danger #96331F.

   Superseded slugs: memories, reading, notes. Their values live on above —
   ochre is now unused by any app and belongs solely to --mark. */

/* RULE — not obvious from the values above:
   Accent colours may carry TEXT on --tile. They may NOT carry text on
   --page: in light mode they fall into the 3.8–4.5 band there.
   Stripes and icon marks are graphical objects and clear 3:1 everywhere. */


/* ==========================================================================
   2 · TYPE
   ==========================================================================
   Fraunces (serif) for screen titles and app names ONLY.
   System sans for everything else — list content, labels, buttons, fields.
   Warmth in the identity; the working surface stays in the font the eye
   is already trained on.
   -------------------------------------------------------------------------- */

@font-face {
  font-family: 'Fraunces';
  src: url('/assets/fonts/fraunces-subset.woff2') format('woff2-variations');
  font-weight: 400 700;          /* variable font: one file, full weight range */
  font-display: swap;
}
/* ⚠ THIS FILE DOES NOT EXIST YET. Until it is fetched and committed,
   headings fall back to Georgia and the page still renders correctly.
   Check the file size before committing — if the variable font is heavy,
   fall back to a static single-weight cut. */

:root {
  --font-serif: 'Fraunces', Georgia, serif;
  --font-sans:  -apple-system, system-ui, sans-serif;

  /* 17px is the base of the scale, so 1rem = 17px.
     106.25% rather than a fixed 17px, so a desktop browser's own
     text-size setting still applies. */
  font-size: 106.25%;
  font-family: var(--font-sans);

  --text-13: 0.765rem;   /* timestamps */
  --text-15: 0.882rem;   /* row details */
  --text-17: 1rem;       /* body, row names, form fields */
  --text-20: 1.176rem;
  --text-24: 1.412rem;   /* screen titles */
}

/* iOS Dynamic Type opt-in.
   Without this, rem does almost nothing on the target device: iOS text-size
   preference is Dynamic Type, and web pages ignore it by default.

   -webkit-touch-callout is iOS-only, so this @supports pair excludes desktop
   Safari, where the shorthand would drag the baseline downward.
   The shorthand also imposes the system typeface, hence the reset after it.

   🟡 MEDIUM CONFIDENCE — non-standard WebKit extension. Verify on a real
   iPhone: change Settings → Accessibility → Display & Text Size and reload.
   If nothing moves, the @supports pair is not matching on your iOS version.
   If desktop Safari text goes oddly small, the guard is leaking. */
@supports (font: -apple-system-body) and (-webkit-touch-callout: none) {
  :root {
    font: -apple-system-body;
    font-family: var(--font-sans);
  }
}

/* Consequence of the above: rows must tolerate growth.
   A long name at a large accessibility size will wrap. */


/* ==========================================================================
   3 · SPACING AND STRUCTURE
   ==========================================================================
   4px base. An 8px base is too coarse for this type scale — the gap
   between an exercise name and its details wants 4px.
   Compact density: 12px row padding, 8px between rows.
   -------------------------------------------------------------------------- */

:root {
  --s-1:  4px;
  --s-2:  8px;
  --s-3: 12px;
  --s-4: 16px;
  --s-5: 24px;
  --s-6: 32px;
  --s-7: 48px;

  --radius:      12px;   /* matches the 12px row padding */
  --hairline-w: 0.5px;   /* not 1px */
  --stripe-w:     3px;   /* left-edge accent stripe: decorative and
                            structural, carries no per-row meaning */
  --tap-min:     44px;   /* Apple HIG minimum where tap is an action */
  --col-max:    480px;   /* desktop shows the phone layout, centred */

  /* Swipe panel width. Repeated across the panel itself and both held-open
     offsets, which must agree exactly or the face and the panel come apart. */
  --panel-w:     88px;

  /* Floating add button. Comfortably above --tap-min: it is the one control
     reached one-handed at the bottom of the screen. */
  --fab-size:    56px;
}

/* Separation is fill-led: the tile sits above the page by being slightly
   lighter, with the hairline supporting it — not a heavy border doing the
   work alone. */


/* ==========================================================================
   4 · MOTION
   ==========================================================================
   Three durations, not five: enough range to be expressive, few enough
   that choosing is obvious.

   The easing split does the real work — things ARRIVING decelerate,
   things LEAVING accelerate away. A tray that opens fast and settles feels
   responsive; one that moves at a constant speed feels mechanical.
   -------------------------------------------------------------------------- */

:root {
  --dur-fast: 120ms;   /* completion highlight, button feedback */
  --dur-base: 200ms;   /* tray open/close, toast in/out */
  --dur-slow: 320ms;   /* page transitions, row collapse */

  --ease-out: cubic-bezier(0.2, 0, 0, 1);   /* arriving */
  --ease-in:  cubic-bezier(0.4, 0, 1, 1);   /* leaving */

  --shadow-float: 0 2px 8px rgb(0 0 0 / 0.18);   /* the two floating bottom-corner
                                                    elements: the + and the toast */
  --disabled-opacity: 0.4;                       /* e.g. Save before Name is filled */
}

/* One block covering every animation in every app on this site, including
   apps that do not exist yet. This is the argument for tokens in three lines. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-fast: 0.01ms;
    --dur-base: 0.01ms;
    --dur-slow: 0.01ms;
  }
}


/* ==========================================================================
   5 · APP-LIKE BEHAVIOUR
   ==========================================================================
   These rules are what stop the site feeling like a web page.
   -------------------------------------------------------------------------- */

html {
  background: var(--page);

  /* THE fix for "I can drag the page partly off screen and it stays there".
     That is horizontal overflow, not the bounce — sideways scroll does not
     snap back, because the browser thinks you are scrolling.

     clip rather than hidden: hidden quietly turns the element into a scroll
     container, which breaks position: sticky and still allows programmatic
     sideways scrolling. Safari 16.x+, Chrome 90+.

     TREAT THIS AS A SMOKE ALARM, NOT A FIX. It hides the symptom. If the
     page ever shifts sideways, something is genuinely wider than the
     viewport. Usual culprits, in order of likelihood:
       1. width: 100vw  — includes the scrollbar, so routinely a few px too
          wide. Use 100%.
       2. images or embeds without max-width: 100%
       3. a long unbroken string (URL, filename) with nowhere to wrap
       4. negative margins pushing a child past the edge */
  overflow-x: clip;
}

/* NOTE: overscroll-behavior is deliberately NOT set. The vertical rubber-band
   bounce is wanted — it is normal iOS behaviour and reads as native.
   overscroll-behavior: contain has one legitimate future use: on the
   add/edit form screen in Milestone 1b, so scrolling inside the form does
   not chain through to the list behind it. */

body {
  margin: 0;
  background: var(--page);
  color: var(--text-1);
  font-family: var(--font-sans);
  font-size: var(--text-17);

  /* Vertical scroll only. This kills pinch-zoom AND double-tap zoom.
     The viewport-meta approach (user-scalable=no, maximum-scale=1) has been
     ignored by iOS Safari since iOS 10 and does nothing — almost every
     article online still recommends it. */
  touch-action: pan-y;

  /* No long-press Copy / Look Up popup and loupe. Also stops iOS text
     selection competing with the swipe gestures for the same drag. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;

  /* Requires viewport-fit=cover in the HTML viewport meta tag. */
  padding:
    env(safe-area-inset-top)
    env(safe-area-inset-right)
    env(safe-area-inset-bottom)
    env(safe-area-inset-left);
}

/* PROHIBITED ANYWHERE IN THIS PROJECT:
     touch-action: manipulation;
     touch-action: pinch-zoom;
   ONE element with either value re-enables zoom for the ENTIRE page.
   `manipulation` is a very commonly pasted snippet (people use it to remove
   the legacy 300ms tap delay). If zoom ever comes back, search for it first. */

input,
textarea,
select {
  font-family: inherit;
  font-size: var(--text-17);   /* ≥16px, so iOS never auto-zooms on focus */
  -webkit-user-select: text;
  user-select: text;
}

/* `hidden` is a browser-level primitive and must win. Its built-in
   rule is [hidden] { display: none } — the weakest selector there
   is — so any component rule setting `display` silently defeats it.
   .fab-layer did exactly that: requireSession set hidden = true on
   the could-not-verify branch and the button stayed on screen.
   This is the one case where !important is correct; it is not a
   precedent for anything else in this file. */
[hidden] { display: none !important; }


/* ==========================================================================
   6 · CROSS-DOCUMENT VIEW TRANSITIONS
   ==========================================================================
   Each app is its own page. This makes navigation between them cross-fade
   instead of flashing white. One opt-in covers every page on the site,
   including pages not yet built. Safari 18.2+, Chrome 126+; unsupported
   browsers navigate exactly as if this were absent, so there is no
   fallback to build.
   -------------------------------------------------------------------------- */

@view-transition {
  navigation: auto;
}

/* Respecting reduced motion is a REQUIREMENT here, not an enhancement:
   animation can cause physical nausea.

   🟡 MEDIUM CONFIDENCE — nesting an at-rule inside @media. Verify on device:
   turn on Reduce Motion and navigate between two pages. If it still
   cross-fades, this nesting is not taking effect and the transition needs
   disabling another way. */
@media (prefers-reduced-motion: reduce) {
  @view-transition {
    navigation: none;
  }
}



/* ==========================================================================
   7 · SCREEN SHELL
   ==========================================================================
   iPhone first. Desktop is the SAME layout in a centred 480px column —
   not a second design. Nothing here has a wide-screen branch.
   -------------------------------------------------------------------------- */

.screen {
  max-width: var(--col-max);
  margin-inline: auto;
  padding-inline: var(--s-4);
  padding-bottom: var(--s-7);
}

/* The header owns the top safe-area inset rather than <body>, so that when it
   sticks it sits flush under the notch instead of scrolling into it.
   body keeps the other three insets (see §5). */
body { padding-top: 0; }

.screen__header {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--page);
  border-bottom: var(--hairline-w) solid var(--hairline);
  padding-top: calc(env(safe-area-inset-top) + var(--s-3));
  padding-bottom: var(--s-3);
  margin-bottom: var(--s-4);

  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

/* Fraunces is for screen titles and app names only — never list content,
   labels, buttons or fields. */
.screen__title {
  font-family: var(--font-serif);
  font-size: var(--text-24);
  font-weight: 600;
  line-height: 1.15;
  margin: 0;
  color: var(--text-1);
  text-wrap: pretty;
}

/* Mark and title on one line, centred against each other. The launcher is the
   only screen that uses this: working screens have a title and nothing else
   (§13). */
.screen__titlerow {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

.screen__subtitle {
  font-size: var(--text-13);
  color: var(--text-2);
  margin: 0;
}


/* Back-to-launcher control.
   Same element, same position — first child of the header, above the title —
   on every screen of every app. Consistency of POSITION is the whole point;
   do not move it per app. */
.back {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  align-self: flex-start;

  /* Tap target reaches 44px while the visible text does not, so the control
     meets the HIG minimum without a 44px band of colour. */
  min-height: var(--tap-min);
  margin-block: calc(var(--s-3) * -1);
  padding-inline: var(--s-1);
  margin-inline-start: calc(var(--s-1) * -1);

  font-size: var(--text-15);
  color: var(--text-2);
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}

.back:hover,
.back:focus-visible { color: var(--text-1); }
.back:active { opacity: 0.5; }

.back::before {
  content: "";
  width: 7px;
  height: 7px;
  border-left: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg);
}


/* ==========================================================================
   8 · TILE AND STRIPE
   ==========================================================================
   Separation is fill-led: the tile reads as sitting on the page because it
   is lighter, with the hairline supporting it — not a heavy border doing
   the work alone.

   Every tile carries a 3px accent stripe on its LEFT edge in the current
   --accent. It is decorative and structural. It carries no per-row meaning:
   it does not encode state, priority or category, and must never be
   recoloured per row to imply that it does.
   -------------------------------------------------------------------------- */

.tile {
  background: var(--tile);
  border: var(--hairline-w) solid var(--hairline);
  border-left: var(--stripe-w) solid var(--accent);
  border-radius: var(--radius);
}


/* ==========================================================================
   9 · LIST
   ==========================================================================
   THE LIST SCROLLS. ITS CHILDREN NEVER SHRINK.

   This is stated because shrinking children is the DEFAULT behaviour of both
   flex and grid: flex items are flex-shrink: 1, and grid tracks size to
   min-content. Adding a twentieth row to a launcher laid out either way will
   silently squeeze all twenty. The zero-shrink rule below is what prevents
   it — do not remove it, and do not put the list inside a fixed-height
   container that would reintroduce the pressure.
   -------------------------------------------------------------------------- */

.list {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);          /* 8px between rows */
  list-style: none;
  margin: 0;
  padding: 0;
}

.list > * {
  flex: 0 0 auto;           /* never shrink · see the note above */
}

/* Rows are VARIABLE HEIGHT and grow to fit their content. Details text is
   always fully visible: there is deliberately no line-clamp, no ellipsis,
   no max-height and no "more" affordance anywhere in this file.
   The row grows; the list scrolls. */
.row {
  position: relative;
  border-radius: var(--radius);
  overflow: hidden;         /* clips the swipe panels to the row corners */
}

.row__face {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  min-height: var(--tap-min);
  padding: var(--s-3);      /* 12px · compact density */
  background: var(--tile);  /* opaque: it slides over the panels */
  border: var(--hairline-w) solid var(--hairline);
  border-left: var(--stripe-w) solid var(--accent);
  border-radius: var(--radius);
  box-sizing: border-box;
  transition: transform var(--dur-base) var(--ease-out);
}

.row__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);          /* 4px · the reason the base is 4 and not 8 */
  min-width: 0;             /* lets long words wrap instead of widening the row */
  flex: 1;
}

.row__name {
  font-size: var(--text-17);
  font-weight: 600;
  color: var(--text-1);
  line-height: 1.3;
  text-wrap: pretty;
}

.row__details {
  font-size: var(--text-15);
  color: var(--text-2);
  line-height: 1.4;
  text-wrap: pretty;
  overflow-wrap: anywhere;  /* a pasted URL wraps rather than widening the page */
}

/* Relative time left, exact date right. A never-completed row prints
   "not yet done" on the left and NOTHING on the right — the right cell is
   omitted from the markup entirely rather than rendered empty. */
.row__meta {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s-3);
  font-size: var(--text-13);
  color: var(--text-2);
  margin-top: var(--s-1);
}

/* Fixed-width so the date column does not jitter row to row: tabular figures
   plus a floor wide enough for "22 Jul, 14:30". */
.row__meta time {
  font-variant-numeric: tabular-nums;
  min-width: 12ch;
  text-align: right;
}
.row__meta--pending { font-style: italic; }

/* Launcher row: stripe, name, chevron. The row declares data-app on ITSELF,
   so one launcher shows every accent at once from the map in §1.

   The label is SYSTEM SANS, not the serif. Fraunces is reserved for the
   "Maple" title; setting the labels in it too flattens the hierarchy and the
   title stops reading as a header. The mark is not repeated per row either —
   it identifies the site, and the stripe already carries the colour key. */
.row--app {
  display: block;
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}

.row--app .row__face { min-height: 60px; }
.row--app:active .row__face { background: color-mix(in oklab, var(--tile), var(--text-1) 6%); }

.row__name--app {
  font-size: var(--text-20);
  font-weight: 600;
  color: var(--text-1);
}

.row__chevron {
  flex: 0 0 auto;
  width: 7px;
  height: 7px;
  border-right: 1.5px solid var(--text-2);
  border-top: 1.5px solid var(--text-2);
  transform: rotate(45deg);
}

.empty {
  padding: var(--s-6) var(--s-3);
  text-align: center;
  color: var(--text-2);
  font-size: var(--text-15);
}


/* ==========================================================================
   10 · SWIPE PANELS — appearance only
   ==========================================================================
   Gesture mechanics are a build concern and are not in this file. What is
   here is the visual treatment and, importantly, the SIZING rule.

   Panels are inset-block: 0, so each is exactly as tall as its own row.
   Rows vary in height; a fixed panel height misaligns on every long row.

   Colours are measured for a WHITE label (§1). Do not relabel in another
   colour without re-measuring.

   ASYMMETRY, deliberate:
     right swipe → Done, green, from the LEFT edge. May fire on a full swipe.
     left  swipe → Edit, rust, from the RIGHT edge. NEVER fires on a full
                   swipe; it opens and waits for a tap.
   A warm-coloured left swipe resembles Delete in most iOS apps, and delete
   is deliberately not reachable from the list at all. The panel therefore
   must not be able to fire by momentum. Tapping the row itself does nothing.
   -------------------------------------------------------------------------- */

.row__panel {
  position: absolute;
  inset-block: 0;           /* sizes to THIS row's actual height */
  display: flex;
  align-items: center;
  padding-inline: var(--s-4);
  min-width: var(--panel-w);

  font-family: var(--font-sans);
  font-size: var(--text-15);
  font-weight: 600;
  color: var(--swipe-label);
  border: 0;
  -webkit-tap-highlight-color: transparent;
}

.row__panel--done {
  left: 0;
  justify-content: flex-start;
  background: var(--swipe-done);
}

.row__panel--edit {
  right: 0;
  justify-content: flex-end;
  background: var(--swipe-edit);
}

/* Open tray — LIVE, as of the right-swipe build.

   Transform, not margin. The mockup that preceded this used margin-left, to
   keep a still frame readable: translating the face inside overflow: hidden
   pushes 88px of it past the clip boundary and takes the exact date with it.
   That reasoning held for a screenshot and does not hold for a running list —
   margin relayouts every frame of a gesture, transform is compositor-driven,
   and the clipping is what makes the panel appear from underneath rather than
   the row appearing to shrink. The date is clipped while a tray is open, which
   is what every list of this kind does.

   The gesture writes an inline transform frame by frame and clears it on
   release; this rule is the settled open position, so the two never disagree.
   .row__face already carries transition: transform var(--dur-base)
   var(--ease-out), which is the snap — the handler suppresses it inline while
   the finger is down. */
.row[data-swipe="done"] .row__face { transform: translateX(var(--panel-w)); }

/* The mirror of the rule above, and LIVE as of the left-swipe build. The sign
   is the whole difference: done reveals its panel from the left edge, edit from
   the right, so the face travels the other way by the same distance. Both are
   transforms, so a gesture and its settled state agree on either side.

   The cost is the mirror of the one above too, and larger: sliding left clips
   the START of the face, which is where the accent stripe and the beginning of
   every name live. An open edit tray shows names cut off at the left. */
.row[data-swipe="edit"] .row__face { transform: translateX(calc(-1 * var(--panel-w))); }

/* The face keeps its radius on the three outer corners, but the two that now
   butt against a square-edged panel must go flat. Left rounded, the curve
   carves a 12px wedge out of the join and the page shows through it — .row is
   transparent, so the notch reads as a dark chip bitten out of the panel.
   Giving .row a background only recolours the chip; the radius is the cause. */
.row[data-swipe="done"] .row__face {
  border-start-start-radius: 0;
  border-end-start-radius: 0;
}

.row[data-swipe="edit"] .row__face {
  border-start-end-radius: 0;
  border-end-end-radius: 0;
}


/* ==========================================================================
   11 · BUTTONS
   ==========================================================================
   ONE primary action per screen. If a screen appears to need two, one of
   them is secondary.
   -------------------------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);

  min-height: var(--tap-min);
  padding: var(--s-3) var(--s-5);
  border-radius: var(--radius);

  font-family: var(--font-sans);   /* never the serif */
  font-size: var(--text-17);
  font-weight: 600;
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;

  -webkit-tap-highlight-color: transparent;
  transition: opacity var(--dur-fast) var(--ease-out),
              background-color var(--dur-fast) var(--ease-out);
}

.btn:active { opacity: 0.7; }

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: var(--disabled-opacity);
  cursor: default;
  pointer-events: none;
}

.btn--primary {
  background: var(--accent);
  color: var(--on-accent);
  border: var(--hairline-w) solid transparent;
}

/* Accent text on --tile is permitted; accent text on --page is NOT (§1),
   so the secondary button carries a tile fill under its label rather than
   sitting bare on the page. */
.btn--secondary {
  background: var(--tile);
  color: var(--accent);
  border: var(--hairline-w) solid var(--hairline);
}

.btn--quiet {
  background: none;
  border: 0;
  color: var(--text-2);
  font-weight: 400;
  padding-inline: var(--s-2);
}

/* Destructive. Not a filled button: the screen's one primary action is Save,
   and delete must not compete with it. */
.btn--danger {
  background: none;
  border: 0;
  color: var(--danger);
  font-weight: 400;
}

.btn--block { display: flex; width: 100%; }

.btn-row {
  display: flex;
  gap: var(--s-2);
  margin-top: var(--s-5);
}


/* ==========================================================================
   12 · FORM
   ==========================================================================
   ONE screen serves both create and edit. Exactly three things differ:
     · the title
     · whether the fields arrive pre-filled
     · whether Delete is present
   Nothing else branches on mode. Everything below is shared.
   -------------------------------------------------------------------------- */

.field {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  margin-bottom: var(--s-4);
}

.field__label {
  font-size: var(--text-13);
  font-weight: 600;
  color: var(--text-2);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.field__input,
.field__textarea {
  width: 100%;
  box-sizing: border-box;
  padding: var(--s-3);
  background: var(--tile);
  color: var(--text-1);
  border: var(--hairline-w) solid var(--hairline);
  border-left: var(--stripe-w) solid var(--accent);
  border-radius: var(--radius);
  font-size: var(--text-17);   /* ≥16px · iOS will not auto-zoom on focus */
  line-height: 1.4;
  -webkit-appearance: none;
  appearance: none;
}

.field__textarea {
  min-height: 132px;
  resize: vertical;
}

.field__input:focus-visible,
.field__textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.field__hint {
  font-size: var(--text-13);
  color: var(--text-2);
}

/* Present only in edit mode. The form element carries data-mode. */
.form[data-mode="create"] .edit-only { display: none; }

.danger-zone {
  margin-top: var(--s-7);
  padding-top: var(--s-4);
  border-top: var(--hairline-w) solid var(--hairline);
  display: flex;
  justify-content: center;
}


/* ==========================================================================
   13 · THE MARK
   ==========================================================================
   The in-app sibling of the home-screen icon: same silhouette, no frame,
   drawn in --mark rather than the app accent — it identifies the site, not
   the app you are in. Inline the SVG (or <svg><use>) so currentColor reaches
   it — an <img> cannot inherit a custom property.

   RULE: identity screens carry the mark; working screens do not.
   -------------------------------------------------------------------------- */

.mark {
  display: block;
  color: var(--mark);
  fill: currentColor;
  flex: 0 0 auto;
}

/* Beside the launcher title. Sized in em off the title's own token rather
   than in pixels, so it grows with the type instead of shrinking against it
   at large Dynamic Type sizes.

   1.8em lands near 43px at the default text size — large enough to read as a
   control rather than a flourish, since on the launcher it is one. Raised
   from 1.2em when the mark became the link to Settings. */
.mark--title {
  font-size: var(--text-24);
  width: 1.8em;
  height: 1.8em;
}

/* The launcher mark is the way in to Settings. Negative margin cancels the
   padding so the glyph sits exactly where it did — the padding exists only to
   carry the tap target past 44px, and must not push the title sideways.

   Only the LAUNCHER mark is a link. The login screen's copy stays inert:
   there is nowhere to navigate to before you are signed in. */
.mark-link {
  display: flex;
  align-items: center;
  padding: var(--s-2);
  margin: calc(var(--s-2) * -1);
  border-radius: var(--radius);
  -webkit-tap-highlight-color: transparent;
}

.mark-link:active .mark {
  opacity: var(--disabled-opacity);
}


/* ==========================================================================
   14 · FLOATING ADD BUTTON
   ==========================================================================
   Bottom-right, clear of the safe-area inset. Not a button at the end of the
   list: that scrolls out of reach the moment the list is longer than a
   screen, and this one has to stay put.

   It shares its corner with the undo toast, so it is built to leave —
   set data-hidden and it fades with --ease-in. --shadow-float belongs to the
   two floating bottom-corner elements, this one and the toast (§15), and to
   nothing else.
   -------------------------------------------------------------------------- */

/* A fixed layer that mirrors the content column, so on desktop the button
   sits at the edge of the 480px column and not the edge of the window. */
.fab-layer {
  position: fixed;
  inset-inline: 0;
  bottom: 0;
  z-index: 3;
  margin-inline: auto;
  max-width: var(--col-max);
  padding: var(--s-4);
  padding-bottom: calc(env(safe-area-inset-bottom) + var(--s-4));
  display: flex;
  justify-content: flex-end;
  pointer-events: none;     /* the layer must not swallow taps on the list */
}

.fab {
  pointer-events: auto;
  width: var(--fab-size);
  height: var(--fab-size);
  display: grid;
  place-items: center;
  border: 0;
  border-radius: var(--radius);   /* §6: one radius across buttons, tiles and
                                     cards — this is a button, so it complies */
  background: var(--accent);
  color: var(--on-accent);
  box-shadow: var(--shadow-float);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity var(--dur-base) var(--ease-in),
              transform var(--dur-base) var(--ease-in);
}

.fab:active { transform: scale(0.94); }

/* While the undo toast occupies the same corner. */
.fab[data-hidden] {
  opacity: 0;
  transform: scale(0.8);
  pointer-events: none;
}

/* The toast code owns this toggle — see §15 and exercises/exercises.js. */

/* The + is drawn, not typed. A text glyph sits off-centre in most system
   faces and would resize with Dynamic Type, which this control should not. */
.fab__plus {
  position: relative;
  display: block;
  width: 22px;
  height: 2.5px;
  border-radius: 2px;
  background: currentColor;
}

.fab__plus::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: currentColor;
  transform: rotate(90deg);
}

/* Applied to the screen that carries a .fab: reserves the button's height
   plus its margin so the last row is never trapped underneath it. */
.screen--fab {
  padding-bottom: calc(env(safe-area-inset-bottom) + var(--fab-size)
                       + var(--s-4) + var(--s-5));
}

/* DECIDED, so it does not get re-litigated: the exact date is NOT inset to
   clear this button, and the meta line keeps the row's own right edge.

   A reserved gutter was tried. It reads as a hole, because every row pays for
   it while only the one row in the button's corner benefits. The button is a
   floating object passing over the list, not a thing the content beneath it
   has to make room for; the case that actually matters is the LAST row, and
   the bottom padding above already protects that one. */


/* ==========================================================================
   15 · UNDO TOAST
   ==========================================================================
   Shares the bottom corner with the + button, which leaves while this is up.
   The layer is a copy of .fab-layer's idiom on purpose: same fixed box, same
   column centring, same safe-area inset. Two things in the same corner that
   compute their position differently would drift apart the first time either
   token changed.

   The toast is not a state layer (§16) — those replace content that failed to
   arrive. This one reports on something that succeeded and offers to reverse
   it, so it sits above the list rather than in place of it.
   -------------------------------------------------------------------------- */

.toast-layer {
  position: fixed;
  inset-inline: 0;
  bottom: 0;
  z-index: 3;
  margin-inline: auto;
  max-width: var(--col-max);
  padding: var(--s-4);
  padding-bottom: calc(env(safe-area-inset-bottom) + var(--s-4));
  pointer-events: none;     /* the layer must not swallow taps on the list */
}

/* --surface-raised, not --tile. The toast outgrew tile the moment it became a
   thing that ARRIVES: tile is the fill for furniture the eye already accepts as
   part of the page, and a row and an interruption reading at the same depth is
   what makes an interruption easy to miss. The raised surface steps further
   from --page and the shadow does the rest. Hairline and shadow unchanged.
   The Undo button carries the accent as a FILL rather than as text, so §1's
   rule about accent text on --page never comes into it. */
.toast {
  pointer-events: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);

  /* Tighter on the right: the Undo button carries its own 44px of tap target
     and its own inline padding, so a full --s-4 there would read as a gap. */
  padding: var(--s-2) var(--s-2) var(--s-2) var(--s-4);
  background: var(--surface-raised);
  border: var(--hairline-w) solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: var(--shadow-float);

  /* Leaving is the resting state, so the toast is built already gone and the
     [data-open] rule brings it in. Arriving decelerates, leaving accelerates
     away — §4's split. No reduced-motion branch is needed: --dur-base is
     rewritten to 0.01ms there and both transitions collapse with it. */
  opacity: 0;
  transform: translateY(var(--s-4));
  transition: opacity var(--dur-base) var(--ease-in),
              transform var(--dur-base) var(--ease-in);
}

.toast-layer[data-open] .toast {
  opacity: 1;
  transform: translateY(0);
  transition: opacity var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out);
}

.toast__label {
  font-size: var(--text-15);
  color: var(--text-1);
}

/* Filled, and the toast's trailing element. It carries the accent fill with
   --on-accent on top — the same pairing as .btn--primary (§11), which is what
   --on-accent exists for. min-height holds the 44px tap target; the inline
   padding keeps a four-letter label from reading as a swatch. */
.toast__undo {
  flex: 0 0 auto;
  min-height: var(--tap-min);
  padding-inline: var(--s-4);

  background: var(--accent);
  color: var(--on-accent);
  border: 0;
  border-radius: var(--radius);
  font-family: var(--font-sans);
  font-size: var(--text-15);
  font-weight: 600;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.toast__undo:active { opacity: 0.7; }

.toast__undo:disabled {
  opacity: var(--disabled-opacity);
  cursor: default;
  pointer-events: none;
}


/* ==========================================================================
   16 · STATES
   ==========================================================================
   Loading, error and the auth guard. Every screen that reads the database
   passes through all three, so they live here rather than per app.
   -------------------------------------------------------------------------- */

/* AUTH GUARD
   Every page except the login screen ships with class="auth-pending" on
   <html>, in the markup — not added by script, which would be too late.
   Everything inside .guarded stays invisible until the session check
   resolves and app.js removes the class.

   The skeletons are INSIDE the guard, deliberately. A logged-out visitor on
   their way to the login screen must never glimpse a fake list; a skeleton
   in front of the guard is exactly that.

   visibility, not display: the layout is already settled underneath, so
   releasing the guard reveals the page rather than reflowing it. */
.auth-pending .guarded { visibility: hidden; }


/* LOADING
   Skeleton rows approximate the real row: three lines at the three real
   sizes, in the real row box, so releasing them does not shift the layout.

   They are suppressed for the first 250ms (see LOAD_DELAY in app.js). A fast
   load therefore shows NO loading state at all — a skeleton that appears and
   vanishes inside a couple of frames reads as a flicker, which is worse than
   the brief pause it was meant to cover.

   Static blocks. No shimmer: a shimmer is an animation running on content
   that does not exist yet, it draws the eye to the emptiest part of the
   screen, and it needs a prefers-reduced-motion branch to be safe. Nothing
   here animates, so there is nothing to disable. */
.skel__line {
  display: block;
  background: var(--hairline);
  border-radius: 4px;
}

.skel__line--name    { width: 52%; height: var(--text-17); }
.skel__line--details { width: 88%; height: var(--text-15); }
.skel__line--meta    { width: 34%; height: var(--text-13); margin-top: var(--s-1); }


/* ERROR
   Replaces the list area entirely: a half-loaded list with an error under it
   invites the reader to trust the half. The message never carries the raw
   error — that goes to the console, where it is useful and not alarming. */
.state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-6) var(--s-4);
  text-align: center;
}

.state__msg {
  margin: 0;
  font-size: var(--text-15);
  color: var(--text-2);
}


/* ==========================================================================
   17 · LOGIN
   ==========================================================================
   The one screen outside the auth guard, and the one screen with no back
   control — there is nowhere above it.

   No site name: the mark is the identifier, and a name under it would be the
   only branding on a private tool that its single user already recognises.
   Neutral accent: this screen belongs to no app.
   -------------------------------------------------------------------------- */

.login {
  box-sizing: border-box;
  min-height: 100svh;
  max-width: var(--col-max);
  margin-inline: auto;
  padding-inline: var(--s-4);
  /* body gives up its top inset to sticky headers (§7); this screen has no
     header, so it takes the inset back. */
  padding-block: calc(env(safe-area-inset-top) + var(--s-7))
                 calc(env(safe-area-inset-bottom) + var(--s-7));

  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* Well above the form, not tucked against it. */
.login__mark {
  width: 64px;
  height: 64px;
  margin: 0 auto calc(var(--s-7) * 2);
}

.login__note {
  margin: var(--s-5) 0 0;
  text-align: center;
  font-size: var(--text-13);
  color: var(--text-2);
}

/* Inline, above the fields, and only after a failed attempt. Deliberately
   does not say WHICH of the two was wrong. */
.form__error {
  margin: 0 0 var(--s-4);
  font-size: var(--text-15);
  color: var(--danger);
}

.form__error[hidden] { display: none; }


/* ==========================================================================
   OUTSTANDING DEVICE CHECKS — none can be judged in a desktop browser
   --------------------------------------------------------------------------
   1. Dynamic Type          → change the iOS text-size setting, reload
   2. Sideways drag         → drag the list left/right; it must not move
   3. Reduced-motion + view transition → see note above
   4. Light mode outdoors   → SUPERSEDED 16 August by the dark-only decision.
                               Light is unreachable, so there is nothing to
                               check. The original concern stands if two-mode
                               operation is ever restored: tile sits only
                               1.14:1 above page, so confirm the separation
                               still reads in direct sunlight.
   ========================================================================== */
