/* Fahim, shared styles.
   Mobile-first: the real capture surface is a phone held by someone standing
   at their mailbox, so that is the design target.

   Every colour lives in a variable so dark mode is a palette swap, not a
   second stylesheet. Text/background pairs are verified ≥ 4.5:1 by
   tools/check_contrast.py, change a colour, re-run the check. */

/* ---- Type: IBM Plex Sans ----
   The one typeface, self-hosted (web/static/fonts/, OFL licence alongside).
   Three families because Plex ships scripts as separate fonts: the Latin file
   also carries Cyrillic for uk/ru, the Arabic file carries ar/ur/ps glyphs,
   the Devanagari file carries hi. unicode-range means a reader downloads only
   the subsets their page actually renders — typically two files, ~40 KB.
   Weights 400/600/700; intermediate weights (the 650 on .title) resolve to
   the nearest loaded weight per the CSS font-matching algorithm. */
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-400-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-400-cyrillic.woff2) format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-600-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-600-cyrillic.woff2) format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-600-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-600-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-700-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-700-cyrillic.woff2) format('woff2');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-700-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-700-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* arabic */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-400-arabic.woff2) format('woff2');
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-400-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* arabic */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-600-arabic.woff2) format('woff2');
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-600-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-600-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-600-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* arabic */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-700-arabic.woff2) format('woff2');
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-700-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-700-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans Arabic';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-arabic-700-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-400-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* devanagari */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-400-devanagari.woff2) format('woff2');
  unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-600-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* devanagari */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-600-devanagari.woff2) format('woff2');
  unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-600-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-600-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-700-cyrillic-ext.woff2) format('woff2');
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* devanagari */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-700-devanagari.woff2) format('woff2');
  unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-700-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'IBM Plex Sans Devanagari';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url(/static/fonts/plex-devanagari-700-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* Scrollbars, form controls and the like follow this. It has to track the
     theme the page actually renders, not the OS — declaring `light dark` while
     forcing light tokens gave a dark-OS reader light cards with dark scrollbars. */
  color-scheme: light;

  /* ---- Neutrals. COOL, deliberately. ----
     These were a warm cream (#fbfaf8 / #efece7), chosen when the brand was
     teal, and warm neutrals under a blue accent are the single thing that
     makes a blue palette look cheap: the ground pulls yellow, the accent
     pulls blue, and every card edge shows the disagreement. The hues here sit
     on the same side as the accent. */
  /* THE GROUND A WHITE CARD SITS ON, and it was barely a ground at all.
     At #f7f9fc the card-to-page ratio was 1.055 — cards were within 5% of the
     surface behind them, so the separation was carried entirely by a 1px
     border and a faint shadow, and the screen read as one uninterrupted sheet.
     iOS puts its white grouped cards on #F2F2F7 for the same job and gets
     1.116; this is 1.123, so a card now looks like a card.

     NOT a brightness fix, and worth saying plainly because that is what it
     looks like. Shifting a background a few points does not make a screen feel
     dimmer — the eye re-adapts to whatever the brightest large area is, so an
     off-white simply becomes the new peak within a minute (Myndex, W3C CSS
     working group). What actually answers "this is too bright" is the theme
     following the phone, which is the change further down this file. This one
     is hierarchy: it earns its place by making the cards legible as objects,
     not by lowering the light. */
  --bg: #eef2f8;
  --card: #ffffff;
  --ink: #161b22;
  --muted: #5f6b7a;
  --line: #e3e8ef;

  /* --line is a HAIRLINE: it separates surfaces, and at 1.2:1 it is meant to be
     felt rather than seen. The drop target borrowed it for a 2px dashed border
     that is not a divider but the outline of the page's main control, so the
     one thing a first-time visitor must find scored 1.23:1 — measured, and the
     same in both themes, so this was never a dark-mode problem. WCAG 1.4.11
     wants 3:1 for the boundary of a UI component; these clear it against both
     --card and --bg. */
  --control-line: #7d838c;         /* 3.82:1 on --card, 3.40:1 on --bg, 3.37:1 on --chip-bg */
  --off-bg: #eef1f6;
  --off-line: #7f8ea6;
  --off-ink: #5f6b7a;           /* 4.79:1 on --off-bg — disabled text is exempt, but it costs nothing */

  /* NEUTRAL, and only just over the bar. The first pass at this token was
     #75849c / #5f77a0 — the same blue family as the accent, at 3.8-4.2:1 — and
     on a screen that is a stack of selects (Settings) every control read as
     ACCENTED rather than outlined: the border competed with the values inside
     it. A boundary's job is to say where the control ends, and the cheapest
     way to make it stop shouting is to take the hue out of it, not to take the
     contrast out. These sit at 3.1-3.8:1 — clear of the 3:1 that
     SC 1.4.11 asks and quiet enough to disappear into the furniture.
     Still cool-neutral (blue channel highest), because the note at the top of
     this palette is right that warm neutrals under a blue accent look cheap. */

  /* Split by role: *-ink values are for TEXT and pass 4.5:1 on their
     backgrounds; the base values are for fills/borders where the bar is
     3:1 or not applicable.

     ---- Why the brand is blue and no longer teal ----
     Not taste. `--accent` was #1a5a5e and `--ok` is #2e7d5b: two dark
     desaturated greens about 25° apart, one meaning "Fahim" and one meaning
     "money is coming back to you". On the Finances screen they appear within a
     few inches of each other, and the reader has no way to tell which green is
     saying something and which is just the furniture. Moving the brand to blue
     leaves green carrying exactly one meaning.

     It also buys the warnings contrast for free: amber (#b06f14) and red
     against blue are further apart in hue than they were against teal, so a
     deadline is louder without being made bigger. */
  --accent: #0f4c8a;        /* button fill, brand tile — 8.67:1 with white */
  --accent-ink: #0f4c8a;    /* links, accent text — 8.67:1 on card */
  --info: #5a6a80;
  --action: #b06f14;        /* borders, dots */
  --action-ink: #8a5a08;    /* amber text, #b06f14 fails 4.5:1 on white */
  --urgent: #b3261e;        /* badge fill, borders */
  --urgent-ink: #b3261e;    /* red text */
  /* Money coming back, and a signed-and-done contract. The only semantic
     colour the palette lacked, which is why two slightly different greens had
     been written straight into rules. */
  --ok: #2e7d5b;
  --ok-ink: #1f6b4a;
  /* Money the reader spent, as TEXT. `--series-1` is the indigo the charts
     draw spending with and the mark an account wears, and it is the right
     colour for the stripe down a spend's row on the Calendar — a 2px border
     needs 3:1 and it gives 3.72:1. As text it gives that same 3.72:1 on white
     and fails 4.5:1, which is the exact trap `--action-ink` two lines up was
     written for. So spending gets the same treatment amber already has: one
     value for the mark, a darker one for the words. */
  --spend-ink: #5a63a8;     /* 5.55:1 on white; --series-1 fails at 3.72 */
  /* Text on a filled accent or urgent surface. Was #fff written out nineteen
     times, which is fine until one of them needs to change. */
  --on-accent: #ffffff;

  --warn-bg: #fdf5f4;
  --warn-border: #f0d5d2;
  --warn-ink: #7a2019;

  /* ---- The categorical series ramp. ----------------------------------
     Eight colours for eight categories on a chart, and NOT a palette anybody
     picked by eye. Whether two colours can be told apart is arithmetic —
     roughly one man in twelve has some red-green deficiency, so "these look
     different to me" is a statement about one pair of eyes. Every adjacent
     pair is measured under normal vision and under simulated protanopia,
     deuteranopia and tritanopia by tools/check_series_ramp.py, which gates a
     deploy the way tools/check_contrast.py does.

     The structure, so a change can be made without re-deriving it: eight hues
     47 degrees apart starting near the brand hue, one chroma, and two
     lightness levels alternating. The alternation is what carries the ramp
     through a colourblind reading — adjacent entries differ in LIGHTNESS as
     well as hue, so the pair survives when the hue difference does not.

     FIXED ORDER, NEVER CYCLED. Colour follows the entity, not its rank: a
     filter that removes a series must not repaint the survivors. A ninth
     category folds into "Other" or the chart becomes small multiples.

     THE SAME HUE SEQUENCE IN BOTH THEMES, re-selected against the dark
     surface rather than flipped: a category must not change colour when the
     reader changes theme, and a lightness inverted by arithmetic is not a
     colour anybody chose. Only chroma and the two lightness levels move.

     These are NOT status colours. --ok and --urgent stay reserved; a series
     never borrows them, and status never becomes "series 4". */
  --series-1: #7580c5;
  --series-2: #794780;
  --series-3: #bc6a78;
  --series-4: #894c15;
  --series-5: #918935;
  --series-6: #266e3d;
  --series-7: #03999d;
  --series-8: #1d6294;
  --calm-bg: #eef4fb;
  --calm-border: #cfe0f2;
  /* A CHIP HAS TO BE A SURFACE ON BOTH GROUNDS IT SITS ON, and darkening --bg
     took one of those away. At #eef1f6 this was 1.008 against the new page —
     the same colour to three decimal places — so every chip resting on the
     page instead of on a card silently stopped having a background: the
     language pill in the header, the nav hover, the tag pills. On a card it
     still read (1.132), which is why it was easy to miss.
     #e7ecf4 restores 1.056 against the page and improves to 1.186 against a
     card. Not darker still, which would separate better: the next step down
     drops `muted` text on a chip to 4.45:1 and fails the AA gate. This is the
     darkest value that keeps that pair legal. */
  --chip-bg: #e7ecf4;
  /* THE SIX A WORD MAY BE PAINTED.
     One hue each, and the chip is built from it: a wash of it behind the text
     and a stronger line around it, mixed against the surfaces the rest of this
     file already owns. Mixing rather than storing three shades per colour means
     a chip stays a chip — same shape, same weight — and the day --chip-bg moves,
     every painted word moves with it. */
  /* The hue a chip is currently wearing. Declared here as the NEUTRAL one, not
     left to a fallback inside each rule: a token that exists only as a
     `var(--x, …)` fallback is a token nothing defines, and the rule using it is
     one typo away from silently doing nothing. The rules below rebind it per
     colour; an unpainted chip keeps this value and reads as the line it always
     had. */
  --tag-hue: var(--control-line);
  --tag-blue: #2b78c9;
  --tag-green: #2e7d55;
  --tag-amber: #a8700d;
  --tag-red: #b4453c;
  --tag-purple: #7a52b3;
  --tag-teal: #17756f;
  --low-bg: #fdf3e3;
  --hover-bg: #f1f6fd;

  /* Kept as the name every card already uses, but it is no longer its own
     value, it IS the lg step of the scale below. */
  --radius: var(--r-lg);

  /* Visual system: a flat, quiet ground. The aurora blobs, dot grid, glass
     blurs and gradient fills that used to live here are gone on purpose: they
     were the 2025 default look, and a product about paper and officialdom
     reads better as plain surfaces, one blue, and real typography. The ramp
     below survives as LINE AND TINT colours (focus ring, hover borders, the
     pending sweep), not as fills. */
  --grad-1: #0d3b6f;
  --grad-2: #2b78c9;
  --grad-3: #7fc4f0;

  /* ---- The same three colours as bare CHANNELS ----
     Eight rules elsewhere in this file needed the brand at some alpha —
     the hover wash on a language chip, the sweep across a space card, the
     glow under the active nav pill — and every one of them had the teal
     written out as a literal `rgba(47, 143, 124, .13)`. That is why the
     palette was not actually swappable: changing `--grad-2` recoloured the
     buttons and left eight tints behind at the old hue.

     A colour cannot be given an alpha through a var() unless it is stored
     without one, so the ramp is stored twice: once as hex for the places that
     want it solid, once as channels for the places that want it faint.
     They must be kept in step — the test in tests/test_design_system.py
     asserts that they are. */
  --card-rgb: 255 255 255;
  --accent-rgb: 15 76 138;
  --grad-1-rgb: 13 59 111;
  --grad-2-rgb: 43 120 201;
  --grad-3-rgb: 127 196 240;
  /* The colour a shadow is made of. Was rgba(20,45,42) — a dark GREEN — which
     is why every card on the page cast a faintly green shadow. */
  --shade-rgb: 12 22 38;

  /* Two shadows, both small. A card sits ON the page, it does not float
     over it; depth comes from the hairline border first and the shadow is
     confirmation. */
  --shadow-1: 0 1px 2px rgb(var(--shade-rgb) / .05);
  --shadow-2: 0 4px 14px -6px rgb(var(--shade-rgb) / .16);

  /* ---- The rest of the system, added 2026-07-30 ----
     The palette and shadows above were already here; the vocabulary
     for SHAPE, MOTION and FOCUS was not, so every control invented its own.
     Nine different border-radius values were in use, form fields matched no
     rule at all, and focus was whatever the browser drew. */

  /* One radius scale. sm = chips and small controls, md = inputs and buttons,
     lg = cards, xl = the big surfaces, pill = anything fully round. */
  /* The rounded end of a data mark — a bar's far edge, the top of a stacked
     column. Smaller than every other radius on purpose: a chart mark is read as
     a measured length, and a radius large enough to notice shortens the end the
     reader is measuring to. Its own token because the chart rules name the
     figure, so it is a decision and not a stray number. */
  --r-mark: 4px;
  --r-xs: 6px;
  --r-sm: 9px;
  --r-md: 12px;
  --r-lg: 16px;
  --r-xl: 22px;
  --r-pill: 999px;
  /* Inline SVG masks for the +/− on a disclosure that acts as a button. Data
     URIs rather than an <svg> in the markup, because the mark belongs to the
     control's box and not to its label — and the CSP allows a data: mask. */
  --plus-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 5v14M5 12h14' stroke='%23000' stroke-width='2.6' stroke-linecap='round' fill='none'/%3E%3C/svg%3E");
  --minus-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14' stroke='%23000' stroke-width='2.6' stroke-linecap='round' fill='none'/%3E%3C/svg%3E");
  --plus-mask: var(--plus-svg);

  /* ---- Type. Nine steps, from the twenty-one sizes that were in use ----
     Chosen by measuring, not invented: every step is a size the app already
     used, and nothing moved by more than 1px, because this audience includes
     older readers and shrinking body text to tidy a scale is a bad trade. */
  /* ---- THE TYPE SCALE IS FLUID. ------------------------------------------
     Every size here is a range, not a number: it grows with the viewport
     between a 360px phone and a 1200px laptop, and is pinned at both ends.

     WHY, measured. The scale was nine fixed pixel values, so a phone got the
     laptop's type at a third of the width. On a 412px Galaxy the ledger's own
     description — which inherits the body size and had no `font-size` of its
     own — rendered at 17px in a 377px column: about 22 characters to a line,
     against the 45-75 that prose is read at, so one sentence about a plane
     ticket took five lines. Nothing was broken; everything was simply built
     for a screen four times wider than the one this audience holds.

     Big text shrinks harder than small text, which is the whole point of a
     fluid scale rather than one multiplier: --t-4xl gives up 8px between the
     two ends and --t-xs gives up nothing, because 11px is already the floor
     below which a label stops being readable outdoors on a cheap screen.

     The formula for each line, so a new size can be added without deriving it:
        slope   = (max - min) / (1200 - 360)
        clamp(min, slope*100 vw + (min - slope*360) px, max)

     THE RAMP TOPS OUT AT 1200, not at the widest layout, and that is on
     purpose: the coefficients are rounded to three places, and a ramp that
     reaches its maximum exactly at the desktop width lands a hair BELOW it —
     the document view measured its explanation at 16.9964px and said so. Eighty
     pixels of headroom means the clamp is already pinned by the time anybody is
     reading on a laptop.

     Nothing on a desktop moves: at 1200px and above every token is exactly the
     value it was before this became a range.

     THE PHONE END WAS LOWERED A SECOND TIME (2026-09-06), on the same report:
     "the text is so big for no reason on phones, so not enough stuff fit".
     Making the scale fluid fixed the 17px-in-a-377px-column case, but it left
     the floors within a point of the desktop values — a 390px phone still got
     --t-2xl at 16.5 against 20 on a laptop, so a screen a third the width
     bought back barely a fifth of the type. Each floor now gives up roughly a
     third of its range instead of a tenth:

        --t-base 13   -> 12.75    --t-2xl  16.5 -> 16
        --t-lg   13.5 -> 13.25    --t-3xl  20   -> 19
        --t-xl   15   -> 14.5     --t-4xl  22   -> 21

     --t-md AND --t-sm DID NOT MOVE, and the first attempt at this pass is why:
     dropping --t-md to 11.5 put it 0.05px from --t-sm on a 390px phone. Two
     adjacent steps that measure the same are not a scale, and the screens that
     use both to separate a label from its caption would have lost the
     distinction on the only screen size that needed it.

     THE CHROME SHRANK; THE SENTENCES DID NOT, which is this file's own rule
     one paragraph up. `body` is prose — a letter's explanation, a ledger's
     description — and it gave up 1px against the interface tokens' 1.5 to 2.5,
     because a label the reader glances at and a paragraph they actually read
     do not want the same floor. --t-sm and --t-xs did not move at all: both are
     already at the point where a label stops surviving a cheap screen outdoors.

     Still a range, still pinned at 1200, and the coefficients are still the
     formula above — recomputed, not nudged. */
  --t-xs: 11px;                                        /* the floor: no range */
  --t-sm: clamp(11.5px, 0.060vw + 11.28px, 12px);
  --t-md: clamp(12px, 0.119vw + 11.57px, 13px);
  --t-base: clamp(12.75px, 0.149vw + 12.21px, 14px);
  --t-lg: clamp(13.25px, 0.208vw + 12.50px, 15px);
  --t-xl: clamp(14.5px, 0.298vw + 13.43px, 17px);
  --t-2xl: clamp(16px, 0.476vw + 14.29px, 20px);
  --t-3xl: clamp(19px, 0.833vw + 16.00px, 26px);
  --t-4xl: clamp(21px, 1.071vw + 17.14px, 30px);

  /* ---- Display. Two fluid steps, on a scale that is now fluid throughout. ----
     STALE UNTIL THIS EDIT, and worth naming rather than quietly deleting: this
     block claimed to hold "the ONLY fluid sizes in the file" and that the nine
     steps above were "fixed px on purpose and stay that way". They are
     `clamp()` ranges, six lines up, and have been since the scale was made
     fluid — so the comment told every later reader the opposite of what the
     file does, on the question it exists to answer. The reasoning it gives for
     interface type is still the reasoning: a label must not shrink away on a
     phone, which is why the ranges above are narrow and pinned by 1200px, not
     why they are absent.

     A landing headline is not interface. It is the one line that has to carry
     a 1440px window, and the top of the fixed scale could not: measured at
     1440, `.hero` computed 27px while the vault's "Good afternoon" computed
     38px — the sentence that has to sell the product shipped SMALLER than the
     greeting waiting behind the sign-in. These two steps are the fix, and they
     are used on the landing page only (`.hero`, `#landing`), which is why the
     app's own hierarchy is untouched by them.

     Fluid rather than a media query because there are sixteen headlines here,
     of very different lengths, and no three breakpoints suit all of them. The
     FLOOR is the number that matters: the capture surface is a phone held at a
     mailbox, so 32px is what a 360px screen gets, and the vw term only spends
     room that a desktop window actually has.

     The ceiling was briefly 68px, which was a number for a CONDENSED face.
     When that face went, 68px at a normal width put the headline on three
     lines and 214px of the first screen. 56 predates the condensed cut and is
     the right ceiling without it. */
  --t-display: clamp(32px, 5vw, 56px);
  --t-display-2: clamp(22px, 2.3vw, 31px);

  /* THE BODY STACK, AT A DISPLAY SIZE. There was a fourth family here for a
     while — IBM Plex Sans Condensed 700, as a display cut for the landing
     headline and section headings — and it is gone: squeezed letterforms at
     38-68px next to a normal-width body read as a different product's
     typography, not as this one's turned up.

     Kept as a token rather than folded back into the rules that use it,
     because "the landing page's display face" is still a decision this file
     should be able to state in one place. It just resolves to the same
     typeface as everything else now, which is what DESIGN.md means when it
     says the typeface IS the identity: the hierarchy comes from size and
     weight, not from introducing a second voice. */
  --font-display: "IBM Plex Sans", "IBM Plex Sans Arabic",
    "IBM Plex Sans Devanagari", -apple-system, BlinkMacSystemFont, "Segoe UI",
    system-ui, sans-serif;

  /* ---- Measure. How wide a thing is allowed to get. ----
     The app takes the whole screen, and it should: its screens are grids that
     fold into more columns given room. But "the page is full-bleed" and "every
     component is full-bleed" are different claims, and only the first one is
     true. A grid of tiles gains from 1400px. A row that pairs a label with a
     value LOSES: measured on the document view at 1440, the key cell ran to
     772px and its calendar toggle sat at 1349, so the eye had to cross 540px
     of nothing to connect a date to its control. Same on the ledger — the
     description started at 333 and the amount ended at 1379.

     So: grids stretch, LINES do not. Anything that reads left-to-right as a
     pair, a row or a sentence gets one of these caps.

       --m-page   the content column, centred; everything lives inside it
       --m-prose  a paragraph, at a readable line length                       */
  /* The content column. One number, shared with the landing page: there is no
     reason for the two to disagree about how wide a page is. */
  --m-page: 1240px;
  --m-prose: 68ch;

  /* ---- Space. The gaps that were already in use, named. ---- */
  --s-1: 2px;
  --s-2: 4px;
  --s-3: 6px;
  --s-4: 8px;
  --s-5: 10px;
  --s-6: 12px;
  --s-7: 16px;
  --s-8: 20px;
  --s-9: 24px;

  /* ---- Safe area: what the hardware takes out of the viewport. ----
     The status bar and Dynamic Island at the top, the home indicator at the
     bottom, the rounded corners at the sides.

     These are ZERO in a browser tab, because the address bar already stands in
     that space, and NON-ZERO the moment the page draws edge to edge: the
     installed PWA on a home screen, and the native shell in mobile/. Both
     declare `viewport-fit=cover` (index.html, vault.html), which is what asks
     iOS for the whole screen AND makes it the page's job to keep content out
     of the parts of it the reader cannot see.

     That second half was missing. The wordmark in `header.top` rendered
     underneath the clock, and the language pill behind the Dynamic Island —
     measured at 62px of top inset against a header sitting 18px down. It was
     invisible in Safari, where the address bar hides it, so it shipped: anyone
     who added Fahim to their iPhone home screen has been reading a header with
     the time printed through it.

     The `0px` fallbacks are load-bearing. `env()` with no fallback is invalid
     on a browser that does not know the variable, which takes the whole
     declaration down with it — so a desktop Firefox would lose the padding it
     shares with these rules, not just the inset it has no use for.

     --sa-side is one value for both edges rather than a start/end pair on
     purpose: `safe-area-inset-left/right` are PHYSICAL, and an Arabic reader's
     leading edge is the right one. Taking the larger of the two and applying it
     symmetrically is correct in both directions, and a landscape notch is the
     only case where the two ever differ. */
  --sa-top: env(safe-area-inset-top, 0px);
  --sa-bottom: env(safe-area-inset-bottom, 0px);
  --sa-side: max(env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px));


  /* Motion. One curve, two durations: things that respond to a press are
     fast, things that change layout are slower. Both are disabled wholesale
     under prefers-reduced-motion further down. */
  --ease: cubic-bezier(.2, .7, .3, 1);
  --dur-1: 140ms;
  --dur-2: 220ms;
  --dur-3: 700ms;

  /* Focus. A glow ring in the brand colour rather than the browser's outline,
     applied through one :focus-visible rule so it cannot be forgotten on a
     new control, and never removed, because this audience includes keyboard
     and switch users and the outline is how they know where they are. */
  --focus-ring: 0 0 0 3px rgb(var(--grad-2-rgb) / .32);
  --focus-line: var(--grad-2);

  /* Form fields. Their own surface, slightly recessed against the card, so a
     place you can type is distinguishable from a place you cannot at a
     glance rather than by reading. */
  --field: rgba(255, 255, 255, .72);
  --field-line: #dcd8d2;
  --field-hover: #ffffff;
}

/* THE PHONE DECIDES, UNTIL THE READER DOES.
 *
 * This gate was `[data-theme="system"]` only, so a dark OS meant nothing until
 * somebody went into Settings and asked for it — light was what everyone got,
 * including a reader opening this at night to check a deadline. The screen they
 * met is close to the brightest a screen can be: 60% of the home view is
 * #ffffff at full luminance, and the page behind it is 0.946, which is lighter
 * than the equivalent iOS surface (#F2F2F7, 0.891).
 *
 * WHAT THIS GIVES UP, because it was a considered decision and not an
 * oversight. The previous note argued: "A first-time visitor deciding whether
 * to hand over a residence permit meets the light page, which reads as a
 * document rather than as an app." That is a real thing to lose, and it is the
 * trade being made here — a stranger on a dark phone now meets the dark page.
 * The judgement is that a reader's own system preference is the better guess
 * about what they want to look at, and that the people this product is for
 * open it far more often at the end of a long day than for the first time.
 *
 * Reverting is one selector: drop `:root:not([data-theme])` below and light is
 * the default again. Nothing else depends on which way this goes — the three
 * stored values still mean exactly what they meant, and a reader who has
 * chosen light keeps light on a dark phone, which is the whole point of the
 * attribute winning over the media query. */
@media (prefers-color-scheme: dark) {
  :root[data-theme="system"],
  :root:not([data-theme]) {
    color-scheme: dark;      /* same block as the palette, for the reason above */
    --bg: #0b1017;
    --card: #121926;
    --ink: #e6ebf2;
    --muted: #9aa7b8;
    --line: #1f2a3a;

    /* See the light-mode note on --control-line. */
    --control-line: #68707c;       /* 3.52:1 on --card, 3.81:1 on --bg, 3.12:1 on --chip-bg */
    --off-bg: #16223a;
    --off-line: #586e93;
    --off-ink: #9aa7b8;

    --accent: #0f4c8a;      /* same fill; white text passes on it in both modes */
    --accent-ink: #7fc4f0;
    --info: #a3b3c9;
    --action: #b06f14;
    --action-ink: #e6a53e;
    --urgent: #b3261e;
    --urgent-ink: #f28b82;
    --ok: #3f9c74;
    --ok-ink: #6cc39a;
    /* On the dark card `--series-1`'s own value already gives 7.46:1, so the
       ink and the mark are the same colour here and only light needed the
       darker one. */
    --spend-ink: #96a3f9;
    --on-accent: #ffffff;

    --warn-bg: #2a1618;
    --warn-border: #4a2528;
    --warn-ink: #f0b3ac;

    /* ---- The categorical series ramp. ----------------------------------
       Eight colours for eight categories on a chart, and NOT a palette anybody
       picked by eye. Whether two colours can be told apart is arithmetic —
       roughly one man in twelve has some red-green deficiency, so "these look
       different to me" is a statement about one pair of eyes. Every adjacent
       pair is measured under normal vision and under simulated protanopia,
       deuteranopia and tritanopia by tools/check_series_ramp.py, which gates a
       deploy the way tools/check_contrast.py does.

       The structure, so a change can be made without re-deriving it: eight hues
       47 degrees apart starting near the brand hue, one chroma, and two
       lightness levels alternating. The alternation is what carries the ramp
       through a colourblind reading — adjacent entries differ in LIGHTNESS as
       well as hue, so the pair survives when the hue difference does not.

       FIXED ORDER, NEVER CYCLED. Colour follows the entity, not its rank: a
       filter that removes a series must not repaint the survivors. A ninth
       category folds into "Other" or the chart becomes small multiples.

       THE SAME HUE SEQUENCE IN BOTH THEMES, re-selected against the dark
       surface rather than flipped: a category must not change colour when the
       reader changes theme, and a lightness inverted by arithmetic is not a
       colour anybody chose. Only chroma and the two lightness levels move.

       These are NOT status colours. --ok and --urgent stay reserved; a series
       never borrows them, and status never becomes "series 4". */
    --series-1: #96a3f9;
    --series-2: #a062a8;
    --series-3: #ee8899;
    --series-4: #b46725;
    --series-5: #b8ae47;
    --series-6: #399255;
    --series-7: #09c2c7;
    --series-8: #2f83c2;
    --calm-bg: #0f1d2e;
    --calm-border: #21384f;
    --chip-bg: #1b2432;
    /* Lifted for the dark surfaces: the light set is mixed against a near-white
     card and goes muddy against a near-black one. Same six names, so nothing
     outside this block knows which theme it is in. */
    --tag-blue: #6fb2ee;
    --tag-green: #6cc493;
    --tag-amber: #d9a441;
    --tag-red: #e58379;
    --tag-purple: #b294e8;
    --tag-teal: #5cc3ba;
    --low-bg: #2b2108;
    --hover-bg: #131e2c;

    --grad-1: #14508f;
    --grad-2: #2b78c9;
    --grad-3: #7fc4f0;
    --card-rgb: 18 25 38;
    --accent-rgb: 15 76 138;
    --grad-1-rgb: 20 80 143;
    --grad-2-rgb: 43 120 201;
    --grad-3-rgb: 127 196 240;
    /* Black, in dark. A shadow on a near-black ground is an absence of light,
       not a tint of the brand. */
    --shade-rgb: 0 0 0;
    --shadow-1: 0 1px 2px rgba(0, 0, 0, .35);
    --shadow-2: 0 6px 18px -8px rgba(0, 0, 0, .5);
    --focus-ring: 0 0 0 3px rgb(var(--grad-3-rgb) / .30);
    --focus-line: var(--grad-3);
    /* Recessed rather than raised in dark: a lighter field on a dark card reads
       as a raised tile, which is the opposite of "type here". */
    --field: rgba(255, 255, 255, .045);
    --field-line: #2b3a4d;
    --field-hover: rgba(255, 255, 255, .07);
  }
}
:root[data-theme="dark"] {
  /* Declared INSIDE this block, not in a `:root[data-theme="dark"] { … }` of
     its own. tools/check_contrast.py reads the palette with _block_after(),
     which takes the FIRST block matching the selector — a second one earlier in
     the file becomes the palette it compares, and every token reads as missing. */
  color-scheme: dark;
  --bg: #0b1017;
  --card: #121926;
  --ink: #e6ebf2;
  --muted: #9aa7b8;
  --line: #1f2a3a;

  /* See the light-mode note on --control-line. */
  --control-line: #68707c;     /* 3.52:1 on --card, 3.81:1 on --bg, 3.12:1 on --chip-bg */
  --off-bg: #16223a;
  --off-line: #586e93;
  --off-ink: #9aa7b8;

  --accent: #0f4c8a;      /* same fill; white text passes on it in both modes */
  --accent-ink: #7fc4f0;
  --info: #a3b3c9;
  --action: #b06f14;
  --action-ink: #e6a53e;
  --urgent: #b3261e;
  --urgent-ink: #f28b82;
  --ok: #3f9c74;
  --spend-ink: #96a3f9;   /* see the media query above */
  --ok-ink: #6cc39a;
  --on-accent: #ffffff;

  --warn-bg: #2a1618;
  --warn-border: #4a2528;
  --warn-ink: #f0b3ac;

  /* ---- The categorical series ramp. ----------------------------------
     Eight colours for eight categories on a chart, and NOT a palette anybody
     picked by eye. Whether two colours can be told apart is arithmetic —
     roughly one man in twelve has some red-green deficiency, so "these look
     different to me" is a statement about one pair of eyes. Every adjacent
     pair is measured under normal vision and under simulated protanopia,
     deuteranopia and tritanopia by tools/check_series_ramp.py, which gates a
     deploy the way tools/check_contrast.py does.

     The structure, so a change can be made without re-deriving it: eight hues
     47 degrees apart starting near the brand hue, one chroma, and two
     lightness levels alternating. The alternation is what carries the ramp
     through a colourblind reading — adjacent entries differ in LIGHTNESS as
     well as hue, so the pair survives when the hue difference does not.

     FIXED ORDER, NEVER CYCLED. Colour follows the entity, not its rank: a
     filter that removes a series must not repaint the survivors. A ninth
     category folds into "Other" or the chart becomes small multiples.

     THE SAME HUE SEQUENCE IN BOTH THEMES, re-selected against the dark
     surface rather than flipped: a category must not change colour when the
     reader changes theme, and a lightness inverted by arithmetic is not a
     colour anybody chose. Only chroma and the two lightness levels move.

     These are NOT status colours. --ok and --urgent stay reserved; a series
     never borrows them, and status never becomes "series 4".
     Repeated verbatim in the media query above: the two must stay in step,
     and the palette-drift assertion in
     tests/test_concurrency_and_observability.py has caught this block being
     forgotten twice. */
  --series-1: #96a3f9;
  --series-2: #a062a8;
  --series-3: #ee8899;
  --series-4: #b46725;
  --series-5: #b8ae47;
  --series-6: #399255;
  --series-7: #09c2c7;
  --series-8: #2f83c2;
  --calm-bg: #0f1d2e;
  --calm-border: #21384f;
  --chip-bg: #1b2432;
  /* Lifted for the dark surfaces: the light set is mixed against a near-white
     card and goes muddy against a near-black one. Same six names, so nothing
     outside this block knows which theme it is in. */
  --tag-blue: #6fb2ee;
  --tag-green: #6cc493;
  --tag-amber: #d9a441;
  --tag-red: #e58379;
  --tag-purple: #b294e8;
  --tag-teal: #5cc3ba;
  --low-bg: #2b2108;
  --hover-bg: #131e2c;

  --grad-1: #14508f;
  --grad-2: #2b78c9;
  --grad-3: #7fc4f0;
  --card-rgb: 18 25 38;
  --accent-rgb: 15 76 138;
  --grad-1-rgb: 20 80 143;
  --grad-2-rgb: 43 120 201;
  --grad-3-rgb: 127 196 240;
  /* Black, in dark. A shadow on a near-black ground is an absence of light,
     not a tint of the brand. */
  --shade-rgb: 0 0 0;
  --shadow-1: 0 1px 2px rgba(0, 0, 0, .35);
  --shadow-2: 0 6px 18px -8px rgba(0, 0, 0, .5);
  --focus-ring: 0 0 0 3px rgb(var(--grad-3-rgb) / .30);
  --focus-line: var(--grad-3);
  /* Recessed rather than raised in dark: a lighter field on a dark card reads
     as a raised tile, which is the opposite of "type here". */
  --field: rgba(255, 255, 255, .045);
  --field-line: #2b3a4d;
  --field-hover: rgba(255, 255, 255, .07);
}

* { box-sizing: border-box; }
body {
  margin: 0; background: var(--bg); color: var(--ink);
  /* The base every unstyled paragraph inherits — the ledger's own descriptions
     among them. A range, like the scale above, and floored well ABOVE the
     interface tokens because this is the size PROSE is read at: the chrome
     around it may shrink further on a phone, the sentences may not.
     15.5 -> 14.5 in the 2026-09-06 pass, one point against the chrome's two. */
  font: clamp(14.5px, 0.298vw + 13.43px, 17px)/1.55 "IBM Plex Sans", "IBM Plex Sans Arabic", "IBM Plex Sans Devanagari", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  -webkit-text-size-adjust: 100%;
  /* TWO PIECES OF FEEDBACK FOR ONE TAP, and only one of them was ours. Every
     button here already answers a press by scaling — `button.go:active` and
     the rest, deliberate and tuned — while the WebView painted its own grey
     box over the top of it, unasked. On iOS and Android that is what a tap on
     this app looked like: the considered response, plus a flash of default
     browser chrome nobody chose. Dropping the default leaves the app's own
     :active states as the whole answer.
     NOT a substitute for visible feedback: it is removed here only because
     every interactive surface in this file already provides its own. */
  -webkit-tap-highlight-color: transparent;
}
/* WHERE A SCROLL STOPS.
 *
 * Every panel below scrolls inside a fixed height. Reaching the end of one and
 * carrying on — a flick, which is how a phone scrolls — handed the rest of the
 * gesture to the page underneath, so scrolling to the bottom of the chat log
 * kept going and took the letter behind it with it. On iOS the same chaining
 * reaches the document and rubber-bands the whole app. The reader never asked
 * to scroll two things; they asked to scroll the one under their thumb.
 *
 * `contain` rather than `none`: it stops the chain at this box's edge but
 * leaves the platform's own overscroll effect intact inside it, so the panel
 * still bounces at its end on iOS and still reports the pull the way every
 * other list on the phone does.
 *
 * Said once, here, rather than as a line in each of the seven rules — a panel
 * added later is far more likely to be given `overflow-y: auto` than to be
 * given `overflow-y: auto` AND remembered to be contained. */
.asklog, .chat-menu-list, .dlg-picks, .talklist, .talkrail, .sidebar,
.askform textarea {
  overscroll-behavior: contain;
}

/* ROOM FOR THE FOCUS RING, said once for the same reason.
 *
 * A box that scrolls on one axis CLIPS the other, and everything this app
 * draws to say "you are here" is drawn OUTSIDE the element: `:focus-visible`
 * is a 3px outline at 2px offset, and the composer's bubble ring is the same.
 * Five pixels past the edge, against a wall at the edge.
 *
 * Reported as "the left side of the box is not here" — a message composer
 * flush against the start of the conversation log, drawing three sides of a
 * rectangle. The same slice was taking the ring off every keyboard-focused row
 * in the conversation list and the chat menu.
 *
 * Padded in and pulled back out by the same 5px, so nothing moves on screen
 * and the ring has somewhere to be. Only the boxes with no padding of their
 * own are listed: .sidebar and .talkrail already have plenty, and overriding
 * theirs to 5px would be a worse bug than the one being fixed. */
.asklog, .talklist, .chat-menu-list, .demoslider {
  padding-inline: 5px; margin-inline: -5px;
}

/* The insets are ADDED to the padding rather than max()'d with it: 18px is the
   gap the design wants between the header and whatever is above it, and on a
   phone that draws under the status bar the thing above it is the status bar.
   max() would put the wordmark flush against the clock instead of below it.
   The sides are max()'d, because a rounded corner eats into the 18px rather
   than sitting on top of it. */
.wrap {
  max-width: 760px; margin: 0 auto;
  padding: calc(18px + var(--sa-top)) max(18px, var(--sa-side)) calc(40px + var(--sa-bottom));
}
/* THE VAULT IS A WORKSPACE, NOT AN ARTICLE. 760px is a reading measure, and
   the app's screens are grids: `repeat(auto-fill, minmax(...))` tiles, a seven
   column calendar, three panels of a conversation. They fold into more columns
   given room and waste the screen without it.
   Carried in vault.html's own markup, on <html> AND <body>, so the first
   paint is already wide — set from script it arrived after the page had drawn
   itself narrow, and every reload flinched outward half a second later. Only
   that one document has it: the landing page, the legal pages and the
   checkout share `.wrap` and ARE prose. */
/* TWO TIERS, because the two kinds of page want different things.

   Everything gets much wider than the old 760px reading column — that was the
   complaint and it was fair. But a landing page at 2560px edge to edge is not
   the fix: rendered that way the hero sets at a fifth of the screen, the
   three "how it works" cards stretch into ribbons, and it reads as a bug
   rather than a design. 1240px is generous and still composed. */
.wide-space .wrap { max-width: var(--m-page); }

/* THE APP GETS A COLUMN, AND IT IS THE SAME NUMBER AS THE LANDING PAGE'S.
   This was `max-width: none`, and full bleed is not the same claim as "use the
   screen": at 2560 a stat tile held the number 8 across 450px, and the space
   cards stretched into ribbons.

   1240 rather than a second invented width, because there is no reason for the
   marketing page and the app to disagree about how wide a page is. Inside it
   the grids still fold into more columns given room — that was always the real
   argument for space, and a column does not take it away. */
.app-space .wrap { max-width: var(--m-page); }
a { color: var(--accent-ink); }

/* Keyboard users get one consistent, visible focus everywhere. */
/* ONE focus rule for the whole application.
   The OUTLINE is the accessible baseline and is not negotiable: unlike a
   box-shadow it is never clipped by an overflow:hidden ancestor, and it
   survives forced-colours mode. The ring and the border tint are additions on
   top, not replacements, an earlier draft of the design system replaced the
   outline with the glow alone, which looks better and is worse.

   It does NOT set border-radius. It used to set 4px, which squared off a
   13px field the moment it was focused, the element changed shape under the
   cursor. Every current browser draws the outline following the element's own
   radius, so there is nothing to correct. */
:focus-visible {
  outline: 3px solid var(--focus-line); outline-offset: 2px;
  box-shadow: var(--focus-ring);
}

/* Skip link: first tab stop, invisible until focused. */
.skip {
  position: absolute; inset-inline-start: -9999px; top: 0; z-index: 10;
  background: var(--accent); color: var(--on-accent); padding: 10px 16px;
  border-radius: 0 0 9px 0; text-decoration: none;
}
.skip:focus { inset-inline-start: 0; }

/* ---- top bar ---- */
.top { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
/* `.brand` is the same wordmark as an ordinary div, which is what index.html
   uses so its <h1> can be the promise rather than the company name. Both
   spellings, one rule: the other seven pages still say <h1> here and should. */
/* font-weight STATED, not inherited from the tag. This rule was written when
   the wordmark was only ever an <h1>, which a browser bolds for you — so the
   700 was never in the CSS, it was a UA default the rule quietly leaned on.
   The moment index.html made its wordmark a <div> (so the <h1> could be the
   promise) the brand dropped to 400 on the one page that matters most, and
   nothing in the stylesheet said it should have been bold. */
/* FLEX, so the wordmark is centred by its ink rather than by its line box.
   The link inside is `inline-flex`, which sits on the BASELINE of this
   element's line box — and a 22px font on a 34px line leaves about seven
   pixels of descender space underneath it. The header centres this box, the
   box is 7px taller than what it draws, and the logo therefore rode 4px above
   the middle of the bar on a desktop and 5px on a phone. Reported by eye
   before it was measured, which is how far off it was. */
.top h1, .top .brand { display: flex; align-items: center;
  /* Was a flat 22px, and it sits above the guard's start line so nothing
     caught it: the wordmark was as tall on a 412px phone as on a 27in screen. */
  font-size: clamp(18px, 0.476vw + 16.29px, 22px);
  font-weight: 700; margin: 0; letter-spacing: -0.01em; }
.top h1 a, .top .brand a { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 9px; }
.logo { display: block; border-radius: var(--r-xs); flex: none; }
.badge {
  font-size: var(--t-xs); text-transform: uppercase; letter-spacing: .07em;
  background: var(--chip-bg); color: var(--muted); padding: 3px 7px; border-radius: var(--r-xs);
}
.spacer { flex: 1; }
.navlink { font-size: var(--t-base); text-decoration: none; padding: 10px 6px; display: inline-block; }

/* Language switcher: a globe + select chip, sat top-left by the logo.
   Deliberately prominent and always visible (header, so on mobile too)
   someone may land in a language they can't read, and the globe reads as
   "language" whatever the current UI language is. */
/* --card, matching `.icon-btn` beside it. These two sit shoulder to shoulder
   in the header with the same border, the same radius and the same 40px
   height — they are plainly one pair — and they were reaching for different
   surface tokens, so the language pill was --chip-bg and the theme toggle was
   --card. Not a hierarchy: the pill came out DARKER than the button in light
   and LIGHTER in dark, which is what two independently chosen tokens look
   like, not a decision. Both are controls you press; both get the raised
   surface. */
.langpick { display: inline-flex; align-items: center; gap: 5px; padding: 0 8px;
  border: 1px solid var(--control-line); border-radius: var(--r-sm); background: var(--card);
  min-height: 40px; }
.langpick .globe { font-size: var(--t-lg); line-height: 1; }
.langpick select {
  border: 0; background: transparent; min-height: 0; padding: 8px 4px;
  font-size: var(--t-base); color: inherit; cursor: pointer;
}
/* `.sub` is a LOOK — muted, one step down in size — and the 22px belongs to
   the paragraph, not to the look. It was on the class, so all 35 inline
   `<span class="sub">` in the app carried a paragraph's bottom margin: in any
   flex row with align-items:center, that margin box is what gets centred, and
   the text floats half of it above everything beside it. "Page 1 of 11" sat
   11px above its own Previous and Next that way. The 118 `<p class="sub">`
   keep exactly what they had. */
.sub { color: var(--muted); font-size: var(--t-md); }
p.sub { margin: 0 0 22px; }

/* ---- hero ---- */
.hero {
  /* line-height TIGHTENS as the type grows — 1.3 is a reading leading and at
     56px it opens a gap you could park a card in. The max-width stays in `em`
     rather than moving to `ch`: em holds the same number of CHARACTERS per
     line at every step of the clamp, and it does that in Arabic and Devanagari
     too, where a `ch` (the width of a zero) describes nothing about the script
     actually being set. 12em is about two lines of a typical headline; the
     sixteen translations vary, which is what text-wrap: balance is for. */
  font-size: var(--t-display); line-height: 1.05; letter-spacing: -0.025em;
  margin: 18px 0 20px; font-weight: 700; max-width: 12em;
  /* Evens the lines instead of filling the first one and orphaning whatever is
     left. "Your paperwork, understood and / remembered." hung a conjunction off
     the end of line one; balanced it breaks after the comma, where the sentence
     actually breaks. Per-language, which is the point — sixteen headlines of
     different lengths cannot share one hand-placed line break. */
  text-wrap: balance;
}
/* The two places the display cut is used, and the only two. Landing page
   only: `.hero` is removed for a signed-in reader, and `#landing` is hidden
   outright, so nobody who has an account ever sees this face. */
.hero,
#landing section.land > h2 { font-family: var(--font-display); }

/* ---- Negative tracking is a LATIN adjustment ----
   Tightening a headline works because Latin letters are separate shapes with
   air between them that grows visually as the type gets bigger. Arabic, Farsi,
   Urdu and Pashto are cursive: the letters are joined along a baseline stroke,
   and the space `letter-spacing` takes out is the join itself. Four of the
   sixteen languages, so it is a quarter of the readers, and it only became
   worth naming here because this headline went from 27px at -0.015em to 56px
   at -0.025em — the same proportion, more than twice the pixels of it.

   Scoped to the display steps rather than swept across the file: the interface
   sizes carry small negative tracking in a dozen rules, at sizes where the
   effect is under a third of a pixel, and changing all of them is a different
   piece of work from this one. */
[dir="rtl"] .hero,
[dir="rtl"] #landing section.land > h2 { letter-spacing: normal; }

/* The claim and the instruction under it are ONE block, so the 20px the hero
   carries for a standalone heading is pulled back rather than added to the
   sub's own paragraph gap. Wider than the hero: it is a sentence, not a
   headline, and the hero's 12em would break it into a stack of stubs.

   It also stops taking `.sub`'s 13px. That size is for a hint beside a
   control; sitting under a 56px headline it read as a caption for the
   headline rather than the second half of the pitch, which is the one line
   after the claim that says what the product does. --t-2xl is one step above the
   feature-card titles, which is where it has to sit: it is the only line
   between a 68px headline and 13px body copy. The pull-back shrinks with it: -12px was
   measured against a 27px hero at 1.3 leading, and the new one sets 1.05, so
   there is far less space under the last line to reclaim. */
.hero + .herosub {
  margin-top: 0; max-width: 30em; line-height: 1.5; font-size: var(--t-2xl);
}

/* ---- controls ----
   44px minimum: the touch-target floor, and this audience includes older
   users and shaking hands.

   ⚠️ THE SELECTOR LIST IS THE WHOLE POINT. It used to read
   `select, button, input[type=email], input[type=date]`, which matches
   NEITHER a bare <input> NOR any <textarea>, so those two fell through to
   the browser's own styling on every screen that used them, while the
   buttons beside them were carefully designed. That is the entire reason the
   app looked half-finished wherever there was something to type. Keep this
   list matching every text control there is; add to it before adding a
   one-off rule somewhere else. */
/* :where() around the exclusions so they cost NOTHING in specificity. A base
   primitive has to be the easiest thing in the file to override; with a bare
   :not() chain this rule scored higher than the focus rule meant to beat it,
   so an <input> kept its resting border colour while focused and a <textarea>
   did not, the same control, two behaviours, found by reading the computed
   style rather than by looking. */
select, button,
input:where(:not([type=checkbox],[type=radio],[type=file],[type=range])),
textarea {
  font: inherit; font-size: var(--t-lg); padding: 10px 14px; min-height: 44px;
  border: 1px solid var(--control-line); border-radius: var(--r-md);
  background: var(--card); color: var(--ink);
}
/* Fields are a recessed surface, not a card: somewhere you can type should be
   distinguishable from somewhere you cannot without having to read it. */
input:where(:not([type=checkbox],[type=radio],[type=file],[type=range])),
textarea, select {
  background: var(--field); border-color: var(--field-line);
  transition: border-color var(--dur-1) var(--ease),
              box-shadow var(--dur-2) var(--ease),
              background-color var(--dur-1) var(--ease);
}
input:where(:not([type=checkbox],[type=radio],[type=file])):hover:not(:disabled),
textarea:hover:not(:disabled), select:hover:not(:disabled) {
  background: var(--field-hover);
}
textarea { line-height: 1.5; resize: vertical; }
::placeholder { color: var(--muted); opacity: .75; }

/* Fields tint their border on focus too, on top of the shared outline above. */
input:focus-visible, textarea:focus-visible, select:focus-visible {
  border-color: var(--focus-line);
}
button { cursor: pointer; }

/* ---- ICON-ONLY CONTROLS ----
   A button whose whole content is a 16px glyph is not the same object as a
   button with a label in it, and the base rule above is written for the one
   with the label: `padding: 10px 14px` and `min-height: 44px`.

   Both of those quietly broke every icon button in the app. `.blockicon`
   declares `width: 28px; height: 28px` — and 28px of horizontal padding leaves
   a content box exactly 0px wide, so its glyph rendered 8px LEFT of the middle
   of its own button. That is the translate toggle looking off-centre. The
   min-height did the rest: measured, the toggle was 28×44 rather than 28×28,
   which is why it overhung the header row it sits in. Six controls were
   affected and every one of them had declared a size that was fiction.

   So: one reset, once, for the shape they all actually want — no padding, no
   inherited floor, and the glyph centred by the box rather than by luck. A
   control that genuinely wants padding (`.blockcopy` has a label beside its
   icon) sets it in its own rule further down and wins on order.

   Losing the 44px floor here costs nothing: the touch-target block at the end
   of this file grows the HIT AREA to 44px on coarse pointers without changing
   the drawn box, which is the whole reason it exists. */
.lact, .del, .factx, .talkdel, .chat-del, .mtx, .eye, .caltoggle, .blockicon,
.blockcopy, .pweye, .talkicon, .foldbtn, .editbtn, .infobtn, .viewbtn,
.calnav, .icon-btn, .pwcopy, .linkbtn, .seg {
  padding: 0; min-height: 0; line-height: 1;
}
.lact, .factx, .talkdel, .chat-del, .mtx, .eye, .caltoggle, .blockicon,
.pweye, .talkicon, .foldbtn, .editbtn, .infobtn, .viewbtn, .calnav, .icon-btn {
  display: inline-grid; place-items: center;
}
/* AND `hidden` HAS TO WIN AGAINST THAT.

   `[hidden]` is `display: none` in the browser's own stylesheet, which any
   author `display` beats — so every one of the buttons above stayed laid out
   after `el.hidden = true`. Measured on the field editors: opening the one on
   the title hid the pencil's ink and kept its 26x26 box, leaving a pencil-
   shaped hole on its own line under the input. It reads as a second, broken
   control rather than as the one you are using.

   Fixed here rather than at each call site: `hidden` means hidden, and the
   next member added to that list should not have to rediscover this. */
.lact[hidden], .factx[hidden], .talkdel[hidden], .chat-del[hidden], .mtx[hidden],
.eye[hidden], .caltoggle[hidden], .blockicon[hidden], .pweye[hidden],
.talkicon[hidden], .foldbtn[hidden], .editbtn[hidden], .infobtn[hidden],
.viewbtn[hidden], .calnav[hidden], .icon-btn[hidden] { display: none; }
button.go, a.go {
  background: var(--accent);
  color: var(--on-accent); border: 1px solid transparent;
  font-weight: 600; padding: 11px 22px; border-radius: var(--r-md);
  text-decoration: none; display: inline-flex; align-items: center; justify-content: center;
  box-shadow: 0 1px 2px rgb(var(--shade-rgb) / .2);
  transition: background-color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease);
}
button.go:hover:not(:disabled), a.go:hover { background: var(--grad-1); }
button.go:active, a.go:active { transform: scale(.99); }
/* NOT `opacity: .45`. Opacity dims the whole button toward whatever is behind
   it, and on the dark page that painted the primary call to action at 1.33:1
   against its own background — a shape you cannot see, under an empty drop
   zone, on the screen where a first-time visitor is deciding what to do. The
   same rule in light mode painted 2.25:1, so this was worse in dark but weak
   in both. Explicit colours instead: an inactive surface with a boundary that
   clears 3:1, so the control stays visible while still reading as off. */
button.go:disabled {
  opacity: 1; cursor: default;
  background: var(--off-bg); color: var(--off-ink);
  box-shadow: inset 0 0 0 1px var(--off-line);
}
/* A quiet button is still a button: it gets the same shape and the same
   response to a press, just no fill. */
button.ghost, a.ghost {
  border-radius: var(--r-md); background: transparent;
  /* A ghost ANCHOR needs the box drawn explicitly. Buttons inherit padding, a
     border and a colour from the base button rule; an <a> inherits none of it,
     so `class="ghost"` on a link rendered as underlined body text sitting next
     to a real button. `.go` already says all of this for the same reason. */
  border: 1px solid var(--control-line); color: var(--ink); font-weight: 600;
  padding: 11px 22px; text-decoration: none;
  display: inline-flex; align-items: center; justify-content: center;
  transition: background-color var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease);
}
button.ghost:hover:not(:disabled) {
  background: var(--hover-bg); border-color: var(--focus-line);
}
button.ghost:active:not(:disabled) { transform: scale(.985); }
/* PHONES GET A SMALLER BUTTON, and an anchor gets the same one as a button.
   Two things compounded on a 320–390px screen. The control reset near the top
   of this file sizes `button, input, select, textarea` — an <a class="go"> is
   none of those, so it inherited 17px body text where its <button> twin got
   15px, and the pair sat side by side at visibly different sizes. On top of
   that, 22px of padding either side of a 17px label left "+ New document"
   no room on one line, so it wrapped and the button became 151x77 — a block,
   not a button, and the tallest thing on the screen.
   Trimming the padding and matching the anchor to the button puts it back to
   one line at 44px, which is still the full touch target. Nothing changes
   above 430px, so the landing page and the desktop layout are untouched. */
@media (max-width: 430px) {
  button.go, a.go, button.ghost, a.ghost {
    padding: 10px 14px; font-size: var(--t-lg);
  }
}
/* Generic dim for any other disabled control in the document detail view
   the doc-mutating buttons frozen while a translation runs (_lockDocActions
   in app-vault.js). button.go and .caltoggle already dim themselves above;
   this covers .danger / .langchip / .linkbtn / a bare <select>, none of
   which change appearance on :disabled otherwise. */
#view button:disabled, #view select:disabled { opacity: .45; cursor: not-allowed; }
button.danger { color: var(--urgent-ink); border-color: var(--warn-border); background: transparent; }
.controls { display: flex; gap: var(--s-5); margin-top: 14px; flex-wrap: wrap; align-items: center; }
/* The `display: flex` above outranks the user-agent [hidden]{display:none},
   so without this every `.hidden = true` on a .controls row is a no-op — the
   camera button would show in a browser, where there is no camera to open. */
.controls[hidden] { display: none; }

/* ---- buying documents outright: the quantity stepper ----
   Column, not a row, because the buy button carries the total: it has to sit
   UNDER the count it is pricing, or a reader changes the number and the figure
   that moved is off to one side where nothing drew their eye to it. */
/* Sized to its contents, EXCEPT in a pricing card. The landing page's cards
   are a column of full-width buttons and the stepper has to match them; the
   settings card is 900px of one row, and a full-width buy button there made
   the €1.99 option the largest thing on a card whose primary action is the
   subscription beside it. */
.qtybuy { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-3);
          width: fit-content; }
/* In a pricing card it becomes a column of full-width controls instead: the
   card is one narrow column of buttons, and the total belongs directly under
   the count it is pricing rather than off to its right. */
.plan .qtybuy { flex-direction: column; align-items: stretch; width: 100%; }
.qtyrow { display: flex; align-items: center; gap: var(--s-3); }
/* Square and finger-sized. These are the two controls on the card most likely
   to be pressed repeatedly and on a phone, so they get a real target rather
   than the 24px a symbol would suggest.

   Two classes deep, and it has to be: `.plan button { width: 100% }` is more
   specific than a lone `.qtybtn`, so inside a pricing card these stretched to
   the full width and then shrank to whatever was left over — a tall narrow
   sliver with a `+` in it. `flex` says the same thing again to the flex row,
   which would otherwise shrink them however the label beside them grows. */
.qtyrow .qtybtn {
  /* 47px, not 44: that is what `.go` and `.ghost` come out at with their
     11px padding, and these sit in a row beside one. A 44px square next to a
     47px button is a three-pixel step nobody can name and everybody sees. */
  width: 47px; height: 47px; flex: 0 0 47px;
  padding: 0; font-size: var(--t-xl); line-height: 1;
  border: 1px solid var(--line); border-radius: var(--r-md); background: transparent;
  color: var(--ink); font-weight: 600; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  transition: background-color var(--dur-1) var(--ease);
}
.qtybtn:hover:not(:disabled) { background: var(--hover-bg); }
.qtybtn:disabled { opacity: .4; cursor: default; }
/* Tabular figures so the count does not shift the + button sideways when it
   goes from 9 to 10 — a stepper whose buttons move under the finger holding
   them is a stepper that gets pressed twice. */
.qtyn {
  min-width: 2.5ch; text-align: center; font-size: var(--t-xl); font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* The discount. Stated as a saving rather than as a struck-through price:
   "save 41%" is the reason to press +, and it is the reader's own arithmetic
   done for them. */
.qtysave {
  font-size: var(--t-sm); font-weight: 600; color: var(--ok-ink);
  background: var(--chip-bg); border-radius: var(--r-pill); padding: 4px 10px;
  /* One line. In the narrow pricing card "Save 41%" wrapped to two and grew
     the row taller than the buttons beside it, which moved the + under the
     finger already on it. */
  white-space: nowrap;
}
.qtysave[hidden] { display: none; }

/* ---- capture ---- */
.drop {
  border: 2px dashed var(--control-line); border-radius: var(--radius); background: var(--card);
  padding: 40px 20px; text-align: center; cursor: pointer;
  transition: border-color var(--dur-2) ease, background var(--dur-2) ease, box-shadow var(--dur-2) ease, transform var(--dur-2) ease;
}
.drop:hover, .drop.over {
  border-color: var(--grad-2); background: var(--hover-bg);
}
/* The chip from the feature cards, one step up, centred above the label. It is
   `.sicon` rather than a new component on purpose: the drop area is the same
   kind of object as the tiles it sits above, and a second icon chip with its
   own size and its own tint is how two things that mean "here" stop looking
   related. */
.drop .sicon {
  width: 48px; height: 48px; border-radius: var(--r-md);
  margin: 0 auto var(--s-6);
  transition: background-color var(--dur-2) var(--ease),
              border-color var(--dur-2) var(--ease);
}
.drop .sicon .icon { width: 24px; height: 24px; }
/* The chip follows the border on hover and on drag-over, so the whole target
   answers as one thing rather than the frame moving and the glyph sitting
   still in the middle of it. */
.drop:hover .sicon, .drop.over .sicon {
  border-color: rgb(var(--grad-2-rgb) / .45);
  background: rgb(var(--grad-2-rgb) / .14);
}
.drop strong { display: block; font-size: var(--t-xl); margin-bottom: 6px; }
.drop span { color: var(--muted); font-size: var(--t-base); }
.files { font-size: var(--t-md); color: var(--muted); margin-top: 10px; min-height: 1.2em; }

/* The pages assembled so far, one per row, each removable. A list rather than
   a run-together line of filenames: what the reader is building is "page 1,
   page 2" of one letter, and on a phone — where a second page means a second
   trip to the camera — that count is the thing they need to see. */
.pagelist { list-style: none; margin: 0; padding: 0; }
.pagelist li {
  display: flex; align-items: baseline; gap: var(--s-4);
  padding: var(--s-3) 0; border-bottom: 1px solid var(--line);
}
.pagelist li:last-child { border-bottom: 0; }
.pagelist .pageno { color: var(--ink); font-weight: 600; white-space: nowrap; }
/* The filename is the least useful thing here (IMG_20260803_014233.png) and the
   only part that can be long, so it yields the space and truncates. */
.pagelist .pagename {
  flex: 1; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
}
.pagelist .linkbtn { font-size: var(--t-md); padding: 0; white-space: nowrap; }
.saved { color: var(--accent-ink); font-weight: 600; }

/* ---- waiting ---- */
.waiting { text-align: center; padding: 50px 20px; }
.pulse {
  width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 20px;
  border: 3px solid var(--line); border-top-color: var(--accent-ink);
  animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* One switch for the whole system: zeroing the duration tokens disables every
   transition built on them, so a control added later cannot forget to opt in.
   The named animations below still need their own rules. */
@media (prefers-reduced-motion: reduce) {
  /* Zeroing the duration tokens switches off every TRANSITION built on them,
     so a control added later cannot forget to honour the preference. Named
     animations keep their own timings and are slowed by hand: the point is
     less movement, and a spinner told to take 0s does not stop, it flickers. */
  :root { --dur-1: 0ms; --dur-2: 0ms; --dur-3: 0ms; }
  .pulse { animation-duration: 3s; }
}
.elapsed { font-size: var(--t-4xl); font-variant-numeric: tabular-nums; font-weight: 600; }

/* ---- progress ------------------------------------------------------------
   TWO MODES, AND THEY MUST NOT LOOK ALIKE.

   `.determinate` fills to a real fraction: bytes sent over bytes total during
   an upload, translated blocks over blocks sent during a translation. Both are
   counted, neither is guessed.

   `.indeterminate` is the state before anything has been counted. It sweeps,
   carries no number, and drops aria-valuenow — "working, amount unknown".

   The visual difference is deliberate: a reader must never have to work out
   whether the number in front of them means anything. */
.pbar {
  position: relative; height: 10px; margin: 0 auto var(--s-5);
  max-width: 340px; border-radius: var(--r-pill); overflow: hidden;
  /* Recessed track: the fill should read as light INSIDE a channel rather
     than a sticker on top of a bar. */
  background: color-mix(in oklab, var(--line) 62%, transparent);
  box-shadow: inset 0 1px 3px rgb(0 0 0 / .16), inset 0 0 0 1px rgb(0 0 0 / .04);
}
.pbar > i {
  display: block; height: 100%; width: 0%; border-radius: inherit;
  position: relative; overflow: hidden;
  /* Three stops, not two: a flat gradient looks printed. The lighter band at
     70% is what gives the fill a leading edge to read as motion. */
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--accent-ink) 62%, transparent) 0%,
    var(--accent-ink) 48%,
    color-mix(in oklab, var(--accent-ink) 62%, white) 78%,
    var(--accent-ink) 100%);
  box-shadow: 0 0 12px color-mix(in oklab, var(--accent-ink) 55%, transparent),
              0 0 2px color-mix(in oklab, var(--accent-ink) 80%, transparent);
  /* The SLOW token, deliberately: the translation bar updates every 2.5s, and
     a fast transition makes each poll land as a snap rather than a fill. */
  transition: width var(--dur-3) var(--ease);
}
/* The sheen rides the fill and never encodes state. */
.pbar > i::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(100deg, transparent 20%,
              rgb(255 255 255 / .42) 50%, transparent 80%);
  transform: translateX(-100%);
  animation: pbar-sheen 2.1s cubic-bezier(.45, 0, .25, 1) infinite;
}
@keyframes pbar-sheen { 0% { transform: translateX(-100%); } 60%, 100% { transform: translateX(220%); } }

/* Nothing counted yet, so nothing pretends to be a fraction: a lozenge
   travels the track instead of filling it. */
.pbar.indeterminate > i {
  width: 36%; transition: none;
  animation: pbar-sweep 1.65s cubic-bezier(.45, .05, .35, .95) infinite;
}
.pbar.indeterminate > i::after { animation: none; }
@keyframes pbar-sweep {
  0%   { transform: translateX(-115%); }
  100% { transform: translateX(300%); }
}

.pstage { color: var(--muted); font-size: var(--t-base); margin: 0 0 var(--s-4);
  min-height: 1.2em;
  /* The rotating lines cross-fade rather than snapping, so a glance away and
     back does not look like a different screen. */
  transition: opacity var(--dur-1) var(--ease); }
.ppct { font-variant-numeric: tabular-nums; }

/* The translation wait: a panel, not a bar floating in a card.
   A bare track with one grey line under it reads as chrome that failed to
   load. Giving the numbers somewhere to sit makes the same information look
   deliberate. */
.trwait { margin-top: 12px; padding: 14px 16px 12px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: color-mix(in oklab, var(--card) 92%, var(--accent-ink) 8%); }
.trwait .pbar { max-width: none; margin-bottom: var(--s-4); }
.trwait-top { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s-5); margin-bottom: var(--s-4); }
.trwait-pct { font-size: var(--t-3xl); font-weight: 600;
  font-variant-numeric: tabular-nums; letter-spacing: -.01em;
  color: var(--accent-ink); }
.trwait-el { font-size: var(--t-md); color: var(--muted);
  font-variant-numeric: tabular-nums; }
.trwait .pstage { margin: 0 0 2px; font-weight: 500; color: var(--ink); }
.trwait-note { margin: 0; font-size: var(--t-md); color: var(--muted); }

/* Motion is decoration here, never the message: the stage text and the
   percentage carry the state on their own. */
@media (prefers-reduced-motion: reduce) {
  .pbar > i::after { animation: none; }
  .pbar.indeterminate > i { animation-duration: 4s; }
}
.waiting p { color: var(--muted); font-size: var(--t-base); margin: 8px 0 0; }

/* ---- cards ---- */
.card {
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 18px; margin-bottom: 14px;
  box-shadow: var(--shadow-1);
  transition: box-shadow var(--dur-2) var(--ease), transform var(--dur-2) var(--ease),
              border-color var(--dur-1) var(--ease);
}
/* Sentence case, in ink. These were 12px letterspaced ALL-CAPS in grey —
   the dashboard-template register. A card is headed the way a paragraph is
   headed: a short bold line in the text colour. */
h3 { font-size: var(--t-lg); text-transform: none; letter-spacing: -0.01em;
     color: var(--ink); margin: 0 0 9px; font-weight: 700; }
.card p { margin: 0 0 10px; }
.card p:last-child { margin-bottom: 0; }

/* ---- urgency ---- */
.verdict { border-left: 5px solid var(--info); }
.verdict.action-needed { border-left-color: var(--action); }
.verdict.URGENT { border-left-color: var(--urgent-ink); background: var(--warn-bg); }
/* A reminder the reader has switched off. Still listed — it is the only place
   to switch it back on — but plainly not in force, so it cannot be mistaken for
   one that will arrive. Same treatment `.orow.lv-context` gives a date that is
   not a to-do: present, quiet, not competing with the live rows above it. */
/* A REMINDER THE READER SWITCHED OFF, given the same treatment `.kv tr.outrow`
   gives an amount kept out of the totals or a date kept off the calendar:
   muted, with the value struck through. Three switches on one screen doing the
   same kind of thing, so they say it the same way — a reader learns the mark
   once.

   MUTED, NOT `opacity`. Opacity composites the whole subtree, so a child cannot
   be brighter than its parent, and dimming the row took the bell down to
   .55 x .45 = a quarter. That bell is the only way back from an off reminder,
   which makes it the one thing in the row that has to stay findable. Colour
   dims the words and leaves the control alone. */
.rem.remoff .when, .rem.remoff .what { color: var(--muted); }
.rem.remoff .when, .rem.remoff .remsched { text-decoration: line-through; }
/* One divider, not two: the row's own bottom border met the bulk row's top
   border and drew a double rule across the card. */
.rem:has(+ .bulkrow) { border-bottom: none; }

.utag { font-size: var(--t-sm); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--info); }
/* The badge, when it is also the control that changes the urgency. Strips the
   button's own chrome so it stays the same badge — `.utag` above still supplies
   the size, weight, tracking and colour, and the `.verdict.URGENT .utag` rules
   below still win on colour because three classes beat one. No `color` here for
   exactly that reason: setting it would tie with `.utag` and, being later,
   quietly flatten the amber and the red to one grey.
   The affordance is a dotted underline on hover and on keyboard focus only —
   a badge that is underlined at rest reads as a link to somewhere. */
.utag-edit { font-family: inherit; background: none; border: 0; padding: 0;
  cursor: pointer; text-align: inherit; }
.utag-edit:hover, .utag-edit:focus-visible {
  text-decoration: underline dotted; text-underline-offset: 3px; }
.utag-edit[hidden] { display: none; }
.verdict.action-needed .utag { color: var(--action-ink); }
.verdict.URGENT .utag { color: var(--urgent-ink); }
/* `.title` is an <h2> on the document screen now, so the element defaults —
   a browser's own h2 size and margin — have to be overridden here rather than
   inherited. The figures are the ones the div already had. */
.title { font-size: var(--t-2xl); font-weight: 650; margin: 6px 0 4px; letter-spacing: -0.01em; }
.from { color: var(--muted); font-size: var(--t-base); }

.deadline { display: flex; align-items: baseline; gap: var(--s-6); flex-wrap: wrap; }
.deadline .d { font-size: var(--t-3xl); font-weight: 700; font-variant-numeric: tabular-nums; }
.deadline .until { font-size: var(--t-base); color: var(--muted); }
.hard { background: var(--urgent); color: var(--on-accent); font-size: var(--t-xs); padding: 2px 7px;
        border-radius: var(--r-xs); font-weight: 700; letter-spacing: .05em; }

/* WHERE YOU NEED TO GO.
   No map image and no tile host — see _locationCard in render.js. The card is
   the address itself, set large enough to read off a phone held at arm's length
   outside the building, plus one tap to each maps app.
   Logical properties throughout, so an Arabic or Urdu reader gets the pin on
   the correct side without a mirrored rule. */
.places .place { display: flex; gap: var(--s-4); align-items: flex-start; }
.places .place + .place { margin-block-start: var(--s-6);
  padding-block-start: var(--s-6); border-block-start: 1px solid var(--line); }
.places .picon { flex: none; color: var(--muted); line-height: 0; margin-block-start: var(--s-1); }
.places .pbody { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
.places .pname { font-size: var(--t-lg); }
/* The address is the one thing a reader copies by eye, so it is not muted. */
.places .paddr { font-size: var(--t-base); }
.places .pwhy { font-size: var(--t-sm); color: var(--muted); }
.places .plinks { display: flex; flex-wrap: wrap; gap: var(--s-4); margin-block-start: var(--s-4); }
.maplink {
  display: inline-block; text-decoration: none; font-size: var(--t-sm); font-weight: 600;
  padding: var(--s-3) var(--s-6); border: 1px solid var(--line); border-radius: var(--r-pill);
  color: var(--action-ink); background: var(--card);
}
.maplink:hover { border-color: var(--action-ink); }

/* Confidence is not decoration: reference numbers and amounts were measured
   flapping between runs, so anything under 0.7 is flagged, not stated. */
.low {
  display: inline-block; background: var(--low-bg); color: var(--action-ink);
  font-size: var(--t-xs); padding: 1px 6px; border-radius: var(--r-xs); font-weight: 700;
  margin-left: 6px; white-space: nowrap;
}
.warn { background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-ink);
        border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px; font-size: var(--t-lg); }
.warn strong { display: block; margin-bottom: 3px; }
.lawyer { background: var(--calm-bg); border: 1px solid var(--calm-border); border-radius: var(--radius);
          padding: 14px 16px; margin-bottom: 14px; font-size: var(--t-lg); }
.note { background: var(--calm-bg); border: 1px solid var(--calm-border); border-radius: var(--radius);
        padding: 16px; margin-bottom: 14px; }

/* THE READING WRAPS, WHATEVER WORD THE LETTER USED.
   Everything in a reading card is model output about German bureaucracy
   written in the reader's language, so it routinely carries a word no line can
   hold — a compound, an office name, or a gloss like
   "Fiktionsbescheinigung/zaświadczenie", which measured 288px inside a 231px
   paragraph at 320px and pushed the whole page sideways. It overflowed in
   English, Albanian, German and Polish alike; only Polish went far enough to
   move the page, which is how it survived every earlier check.
   EVERY paragraph and list item in a card, not just the ones bound to a
   model field: the same overflow turned up in a card <p> that carries no
   data-field at all ("Eine Geschwindigkeitsüberschreitung..." at 320px), and
   a rule that has to be extended each time a new card is written is a rule
   that will be missed again. Same treatment the two dozen other model-fed
   fields in this file already get — this prose was simply missed. */
.card p, .card li { overflow-wrap: anywhere; }
ol.steps { margin: 0; padding-left: 22px; }
ol.steps li { margin-bottom: 11px; }
ol.steps li:last-child { margin-bottom: 0; }
.by { color: var(--action-ink); font-size: var(--t-md); font-weight: 600; }

table { width: 100%; border-collapse: collapse; font-size: var(--t-base); }
td { padding: 7px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
tr:last-child td { border-bottom: none; }
/* 34%, not 42%. The key column is a label — "Reference number", "Appointment"
   — and 42% of a capped 920px row is still 386px of mostly empty cell before
   the value it belongs to starts. */
td.k { color: var(--muted); width: 34%; padding-inline-end: 12px; }
.ev { color: var(--muted); font-size: var(--t-sm); font-style: italic; display: block; margin-top: 3px; }
.caveats { font-size: var(--t-base); color: var(--muted); }
.caveats li { margin-bottom: 6px; }
.warn .caveats { color: inherit; padding-inline-start: 20px; }

.orig img { width: 100%; border-radius: var(--r-sm); border: 1px solid var(--line); display: block; }
.orig .pdfnote { color: var(--muted); font-size: var(--t-base); margin-top: 10px; }
/* "Straightened for reading. · Use my photo". A caption, not a control panel:
   the picture above it is the thing being looked at, and this only has to say
   which version it is and offer the other one. Same muted weight as .pdfnote
   so it reads as a note about the image rather than an action on the letter. */
.orig .scannote {
  color: var(--muted); font-size: var(--t-base); margin-top: 10px;
  display: flex; gap: var(--s-4); align-items: baseline; flex-wrap: wrap;
}
.orig .scannote .linkbtn { font-size: var(--t-base); padding: 0; }

/* "Download your photo · Download the straightened page", per sheet.
   A list rather than a sentence: with several pages it IS a list, and one row
   per sheet is the only layout that stays readable at four. */
.orig .downloads { list-style: none; margin: var(--s-5) 0 0; padding: 0; }
.orig .downloads li {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s-4);
  padding: var(--s-3) 0; font-size: var(--t-base);
}
.orig .downloads li + li { border-top: 1px solid var(--line); }
.orig .downloads .pageno { color: var(--muted); min-width: 5.5em; }
/* PDF first-page preview: bounded, paper-like, clickable. */
.orig .thumbwrap { display: inline-block; max-width: 260px; }
.orig .thumb {
  width: 100%; border-radius: var(--r-xs); border: 1px solid var(--line);
  box-shadow: 0 1px 6px rgba(0,0,0,.12); display: block;
}
.orig .filemeta { font-weight: 600; color: var(--ink); }

/* Translation previews: each stored translation shown like a mini document,
   with the same paper-like first-page thumbnail as the original. */
.trgrid { display: flex; flex-wrap: wrap; gap: var(--s-7); margin: 6px 0 14px; }
.trprev { display: flex; gap: var(--s-6); align-items: flex-start;
          border: 1px solid var(--line); border-radius: var(--r-sm); padding: 12px; max-width: 360px; }
.trprev .thumbwrap { display: block; flex: 0 0 96px; }
.trprev .thumb { width: 96px; border-radius: var(--r-xs); border: 1px solid var(--line);
                 box-shadow: 0 1px 5px rgba(0,0,0,.12); display: block; }
.trprev .trmeta { display: flex; flex-direction: column; gap: var(--s-3); min-width: 0; }
.trprev .trmeta strong { font-size: var(--t-lg); }
.trprev .filemeta { font-weight: 600; color: var(--muted); font-size: var(--t-md); }
.trprev .controls { margin-top: 4px; gap: var(--s-7); flex-wrap: wrap; }

/* Ask-about-this-letter: a grounded chat under the explanation. */
.asklog { display: flex; flex-direction: column; gap: var(--s-4); margin-bottom: 12px;
  max-height: 440px; overflow-y: auto; }
.ask-msg { padding: 10px 13px; border-radius: var(--r-md); font-size: var(--t-lg); line-height: 1.5;
  max-width: 85%; white-space: pre-wrap; overflow-wrap: anywhere; }
/* .talk-me / .talk-them ride the same two alignments deliberately: a message
   the reader sent should sit where a message the reader sent always sits.
   align-self on a column flexbox works on the INLINE axis, so both sides
   mirror for an Arabic or Farsi conversation with no [dir=rtl] override
   which is the common case in this feature, not the exotic one. */
/* The whole bubble mirrors in Arabic — side AND squared corner together.
   
   align-self already followed the writing direction, so the bubbles moved:
   your own words sat on the left, which is what every Arabic messaging app
   does. What did NOT move was the squared-off corner, because
   border-bottom-right-radius is a physical side. A bubble on the left with its
   notch pointing right reads as broken rather than as mirrored — the shape was
   still aimed at the speaker's old side.
   
   The logical radii flip with the text direction, so the notch stays against
   the edge the bubble is actually on. */
/* Block so the text inside gets its own direction AND its own alignment.
   <bdi> already carries dir=auto; inline, it isolates the reordering but the
   lines still align to the bubble's side. */
.ask-msg > bdi { display: block; }

.ask-you, .talk-me { align-self: flex-end; background: var(--accent); color: var(--on-accent); border-end-end-radius: 4px; }
.ask-ai, .talk-them { align-self: flex-start; background: var(--chip-bg); color: var(--ink); border-end-start-radius: 4px; }

.ask-pending { opacity: .6; }
.ask-err { align-self: flex-start; background: var(--warn-bg); color: var(--warn-ink);
  border: 1px solid var(--warn-border); }
.ask-flag { display: block; margin-top: 6px; font-size: var(--t-sm); font-weight: 600; color: var(--muted); }

/* A web-sourced answer must never be mistakable for something the letter says.
   Its own tint, a dashed edge, the label ABOVE the text rather than below it,
   and its sources attached, so the eye classifies it before reading it. */
/* Aged-out obligation: visible, dated, but plainly not part of the total. */
.stale-line { opacity: .6; }

.ask-web { border: 1px dashed var(--line); background: transparent; }
.ask-flag.web { margin-top: 0; margin-bottom: 6px; color: var(--ink); opacity: .75; }
.ask-lookup { display: block; margin-top: 10px; font-size: var(--t-md); padding: 6px 12px; min-height: 34px; }
.ask-sources { display: flex; flex-direction: column; gap: var(--s-2); margin-top: 10px; font-size: var(--t-sm); }
.ask-srclabel { color: var(--muted); font-weight: 600; }
.ask-sources a { color: var(--muted); overflow-wrap: anywhere; }
/* Composer: the question gets the full width on its own line, with the model
   picker and Ask beneath it, the shape every chat UI converged on, and the
   reason is mechanical: a select and a text input on one row fight over the same
   space, and the select wins because its width comes from its longest option.
   Putting them on one row made the input a third of the box. */
.askform { display: flex; flex-direction: column; gap: var(--s-4); }
.askform input { width: 100%; min-width: 0; }
.askrow { display: flex; align-items: center; gap: var(--s-4); }
.askrow .go { margin-inline-start: auto; }        /* Ask stays on the far edge */

/* Saved conversations: a row of chat tabs + "New chat". */
.chat-head { display: flex; align-items: center; gap: var(--s-5); margin-bottom: 12px; flex-wrap: wrap; }
/* Tabs take the slack, but must not grow into the model picker beside them
   they were overlapping it, because flex:1 with wrapping children claims the
   full row before the picker gets measured. */
.chat-tabs { display: flex; gap: var(--s-3); flex-wrap: wrap; flex: 1 1 auto; min-width: 0; }
.chat-tab { display: inline-flex; align-items: center; gap: var(--s-2); padding: 5px 6px 5px 12px;
  border: 1px solid var(--line); border-radius: var(--r-pill); font-size: var(--t-md); cursor: pointer;
  background: transparent; max-width: 230px; }
.chat-tab.active { background: var(--chip-bg); border-color: var(--muted); font-weight: 600; }
/* min-width:0 is what makes the ellipsis actually happen: a flex child defaults
   to min-width:auto, so the label refused to shrink below its text and spilled
   out of the pill and over the model picker instead of truncating. */
.chat-tab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }

/* The conversation list, behind the clock rather than across the header.
   
   Every past chat used to be a pill in the header, so the header grew with
   the reader's history: five conversations wrapped the controls onto three
   lines and pushed the conversation itself down the panel. A list that only
   matters when you go looking for it belongs behind one control. */
.chat-head { position: relative; }
/* The open conversation's name. Takes the free space so the two icons stay
   pinned to the end, and truncates rather than wraps — a long first question
   becomes the title, and a header that grows to two lines pushes the
   conversation down the panel. */
.chat-title {
  flex: 1; min-width: 0; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chat-menu {
  position: absolute; inset-inline-end: 0; top: calc(100% + 6px); z-index: 30;
  width: min(320px, 90vw); padding: var(--s-4);
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-sm); box-shadow: 0 10px 30px rgba(0,0,0,.28);
}
.chat-menu-list { max-height: 320px; overflow-y: auto; margin-top: var(--s-4); }
.chat-row {
  display: flex; align-items: center; gap: var(--s-4);
  padding: var(--s-4) var(--s-5); border-radius: var(--r-xs);
  cursor: pointer; color: var(--ink);
}
.chat-row:hover, .chat-row:focus-visible { background: var(--chip-bg); }
.chat-row.active { background: var(--chip-bg); font-weight: 600; }
.chat-row .chat-tab-name { flex: 1; }

/* Drag the left edge to widen the panel.
   
   A fixed width is a guess about a screen the author cannot see: the same
   panel that is comfortable beside a letter on a laptop is cramped next to a
   two-column table on a wide monitor. The handle is thin but its hit area is
   not — 10px of grab for a 2px line, because a resize handle you have to aim
   at is a resize handle nobody uses. */
.chat-resize {
  position: absolute; inset-block: 0; inset-inline-start: -5px; width: 10px;
  cursor: ew-resize; z-index: 40; touch-action: none;
}
.chat-resize::after {
  content: ""; position: absolute; inset-block: 0; inset-inline-start: 4px;
  width: 2px; background: transparent;
}
.chat-resize:hover::after, .chat-panel.resizing .chat-resize::after {
  background: var(--accent);
}
/* No handle where there is no room to drag: on a phone the panel is the whole
   screen and the edge belongs to the browser's back gesture. */
@media (max-width: 720px) { .chat-resize { display: none; } }
.chat-del { display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 18px; height: 18px; border-radius: 50%; color: var(--muted); font-size: var(--t-lg); line-height: 1; }
.chat-del:hover { background: var(--warn-bg); color: var(--warn-ink); }
.chat-new { white-space: nowrap; padding: 8px 12px; min-height: 0; font-size: var(--t-md); }
.chat-empty { color: var(--muted); font-size: var(--t-base); padding: 8px 2px; margin: 0; }
.chat-hint { font-size: var(--t-sm); margin: 0 0 10px; }

/* Icon buttons (header sidebar toggle, drawer minimize). Hidden by default;
   revealed by the rules that own them. */
.icon-btn { display: none; align-items: center; justify-content: center;
  width: 40px; min-height: 40px; padding: 0; font-size: var(--t-xl); line-height: 1; flex: none;
  border: 1px solid var(--control-line); border-radius: var(--r-sm); background: var(--card); color: var(--ink); cursor: pointer; }
/* The theme toggle. `.icon-btn` is hidden by default and revealed by whichever
   rule owns it; this one is owned by nobody, because it belongs on every page.
   It renders as an emoji rather than an SVG so it needs no icon sprite on the
   public pages, and it shows the theme you would switch TO. */
#themetoggle { display: inline-grid; place-items: center; font-size: var(--t-lg); }
/* The legal pages keep their picker and the toggle together at the end of the
   bar, so the two controls read as one group rather than as a control and a
   stray button. */
.legal-bar-tools { display: inline-flex; align-items: center; gap: var(--s-5); }

/* Sidebar collapse handle: a small round button that rides the sidebar's edge,
   vertically centred, with a chevron that points the way it will move, left to
   close, right to open. Hidden on mobile (no sidebar there); positioned in the
   desktop sidebar block below. */
.sidehandle { display: none; }

.psources a { color: var(--accent-ink); text-decoration: none; }
.psources a:hover { text-decoration: underline; }

/* Ask-about-this-letter: a toggleable drawer on the end side (right in LTR,
   mirrored in RTL), with a floating button to open it. */
.chatfab { position: fixed; inset-block-end: calc(20px + var(--sa-bottom));
  inset-inline-end: max(20px, var(--sa-side)); z-index: 40;
  display: inline-flex; align-items: center; gap: var(--s-4); border: 0; border-radius: var(--r-pill);
  background: var(--accent); color: var(--on-accent); padding: 12px 18px; font-size: var(--t-lg); font-weight: 600;
  cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,.22); }
/* Out of the way, not gone: it slides past the bottom edge and comes back on
   the way up (`_fabFollowsScroll` in app-vault.js). `visibility` rather than
   `display` so the slide is animatable, and `pointer-events: none` so a tap
   aimed at whatever it was covering cannot land on it mid-slide. */
.chatfab { transition: transform var(--dur-2) cubic-bezier(.4, 0, .2, 1),
                       opacity var(--dur-1) ease; }
body.fab-away .chatfab {
  transform: translateY(calc(100% + 20px + var(--sa-bottom)));
  opacity: 0; pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { .chatfab { transition: none; } }
body.chat-open .chatfab { display: none; }
.chatfab[hidden] { display: none; }     /* class display must not beat the hidden attr */
@media (max-width: 560px) { .chatfab .fab-label { display: none; } .chatfab { padding: 14px; } }
/* Scroll clearance for the FAB. It now rides along on every vault view, not
   just Home, so it floats over the last row of a long list, a deadline you
   cannot finish reading because a button is parked on it. The FAB is ~52px
   tall and sits 20px from the bottom; 96px puts the final row clear of it.
   Screen only: on paper there is no FAB, and this rule outranks the print
   block's `.wrap { padding: 0 }` on specificity, so it would print as a blank
   strip at the foot of every page. */
@media screen {
  /* The FAB has moved up by the home indicator, so the clearance under the last
     row has to move with it or the row it was protecting is covered again. */
  body:has(.chatfab:not([hidden])) .wrap { padding-block-end: calc(96px + var(--sa-bottom)); }
}

/* Full-height, so both ends are the reader's problem: the drawer header would
   sit under the clock and the message composer under the home indicator. The
   fill still runs edge to edge — only the contents are inset. */
.chatdrawer { position: fixed; inset-block: 0; inset-inline-end: 0; z-index: 45;
  width: min(400px, 92vw); background: var(--card);
  border-inline-start: 1px solid var(--line); box-shadow: -6px 0 24px rgba(0,0,0,.16);
  padding-block: var(--sa-top) var(--sa-bottom);
  display: flex; flex-direction: column;
  transform: translateX(100%); transition: transform var(--dur-2) ease; }
html[dir="rtl"] .chatdrawer { transform: translateX(-100%); }
body.chat-open .chatdrawer { transform: translateX(0); }
.chatdrawer[hidden] { display: none; }     /* class display must not beat the hidden attr */

/* PARKED OFFSCREEN IS NOT GONE — the same lesson .sidebar already records, and
   this drawer never got it. Translated out of view it stayed `visibility:
   visible`, so its controls kept their place in the tab order: six presses of
   Tab, counted on the closed drawer, walked into a panel nobody could see, and
   a screen reader swiped straight through it. Same fix and same shape as the
   rail's — visibility flips only AFTER the slide has finished, so the drawer
   glides out instead of blinking away halfway. */
.chatdrawer { visibility: hidden;
  transition: transform var(--dur-2) ease, visibility var(--dur-1) linear var(--dur-2); }
body.chat-open .chatdrawer { visibility: visible; transition: transform var(--dur-2) ease; }

/* The scrim. Under the drawer (z-index 45) and over everything else. */
.chatscrim { position: fixed; inset: 0; z-index: 44;
  background: rgb(var(--shade-rgb) / .45);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--dur-2) var(--ease), visibility var(--dur-1) linear var(--dur-2); }
body.chat-open .chatscrim { opacity: 1; visibility: visible; pointer-events: auto;
  transition: opacity var(--dur-2) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .chatdrawer, .chatscrim, body.chat-open .chatdrawer, body.chat-open .chatscrim {
    transition: none;
  }
}
.chatdrawer-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-5);
  padding: 13px 16px; border-block-end: 1px solid var(--line); }
.chatdrawer-head strong { font-size: var(--t-xl); }
.chatdrawer-head .icon-btn { display: inline-flex; width: 34px; min-height: 34px; font-size: var(--t-2xl); }
.chatdrawer #chatpanel { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 14px 16px; }
.chatdrawer .asklog { flex: 1; max-height: none; }

/* A ROW OF TOKENS THAT WRAPS BETWEEN THEM, never inside one. The model name and
   the prompt version are emitted with no whitespace between their spans, so
   there was no break opportunity at all and the pair ran off the right edge —
   23px of page scroll on the document screen at every phone width. Flex gives
   the break opportunity the markup does not, and `nowrap` still keeps a single
   version string in one piece. */
.run { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-7);
  font-size: var(--t-sm); color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.run span { white-space: nowrap; }
.err { background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-ink);
       padding: 16px; border-radius: var(--radius); }
.empty { text-align: center; color: var(--muted); padding: 50px 20px; }
/* A screen with nothing on it yet. It is one sentence and one way forward, and
   in `app-space` — which has no width cap, deliberately — it was rendering as
   a 1400px band. Centred rather than start-aligned, because with nothing else
   on the page there is no column for it to line up with. */
.emptystate { max-width: 520px; margin-inline: auto; margin-top: 8vh; text-align: center; }
.emptystate .controls { justify-content: center; margin-top: 14px; }

/* ═══ A DOCUMENT, READ ON A PHONE ═══════════════════════════════════════════
   The reading surfaces are the app's 17px body size, which is the desktop
   size, and nothing on a document page changed between a 1280px window and a
   393px phone — measured, identical at both. On a phone that lands as 34–39
   characters to a line: a paragraph breaking every five or six words, and an
   explanation that reads as a column of fragments rather than as prose.
   One step down the scale — 15px, `--t-lg` — puts it at roughly 41–45, which
   is where a line stops fighting the reader.
   The date keeps its place at the top of the page's hierarchy but not its
   26px: `--t-3xl` was chosen against a desktop card, and at phone width it was
   three times the size of the smallest thing on screen, with "in 8 days" and
   the HARD DEADLINE badge pushed onto a line of their own. At 20px they sit
   together and it is still the largest thing on the page.
   NOT THE HEADINGS AND NOT THE BUTTONS, which are already 15 and 20 and do the
   work of telling the eye where it is.

   ═══ AND IT MUST NOT OUTRANK THE CLASS ON THE ELEMENT (2026-09-06) ═══
   `#view .card p` is (1,1,1). It therefore beat every caption class in the
   app, and the note further down this block says so in as many words — "it
   wins on specificity over anything a class asks for" — treating that as a
   quirk to patch one selector at a time rather than as the defect.

   MEASURED, on Finances at 390px: all eight paragraphs on the screen are
   classed, none is unclassed, and every one of them rendered at 13.07px —
   `.sub` captions, the date range, the rate provenance, the chart title and
   the section hint, all forced to the same size as prose. `.sub` asks for
   --t-md, which is 13px on a DESKTOP: the rule handed a 390px phone a caption
   marginally LARGER than a 1440px laptop shows, so the fluid scale's entire
   reduction was cancelled for every <p> in a card. That is the "the text is so
   big on my phone" report, and it could not be fixed by moving the scale
   because the scale was not what these elements were reading.

   The fix is `:where()`, which contributes ZERO specificity. The declaration
   still beats inheritance — so an unclassed paragraph, which is what this rule
   was written for, comes down from body size exactly as before — and it now
   loses to any class that names a size, which is what a class naming a size is
   for. Nothing needs to be listed, so nothing can drift. */
@media (max-width: 560px) {
  /* ZERO SPECIFICITY, AND THAT IS THE WHOLE POINT — see below.
     `:where()` contributes nothing to specificity, so this declaration beats
     INHERITANCE and loses to every class. A paragraph that says nothing about
     its own size gets the reading step; a paragraph whose class already names
     a size keeps it. */
  :where(#view .card p, #view .steps li, #view .rem, #view .bulkrow) { font-size: var(--t-lg); }
  #view .deadline .d { font-size: var(--t-2xl); }
  /* A FIGURE IS NOT PROSE, and `#view .card p` cannot tell them apart.
     The rule above is about lines of TEXT — 34 characters to a line is too few
     — and it wins on specificity over anything a class asks for. The net-worth
     headline is a <p>, so it declared `--t-4xl` and rendered at 13.56px on a
     phone: SMALLER than the account names under it (15.56px) and smaller than
     every balance in the list. The largest figure on the screen was the
     smallest text on it, on phones only, which is why it never looked wrong on
     a laptop.
     `--t-3xl` and not `--t-4xl`, for the reason the deadline date two lines up
     takes 2xl instead of 3xl: 30px against a 393px screen is a figure shouting
     over everything near it. At 20px it is still, by a wide margin, the largest
     thing on the page.
     Swept before it was written: this is the ONLY paragraph in the seven
     Finances tabs that asks for more than --t-lg, so the exception is one line
     rather than a list that will drift. */
  #view .card p.nwfig { font-size: var(--t-3xl); }
}

/* ---- vault list ---- */
.doc { display: block; text-decoration: none; color: inherit; }
.doc:hover { border-color: var(--accent-ink); }
.doc .row { display: flex; align-items: baseline; gap: var(--s-5); }
/* AND THE TITLE GIVES WAY, rather than the row growing past the screen.
   A flex item's automatic minimum is its longest WORD, and the senders this
   app exists to explain write words like "Versicherungsbescheinigung" — 26
   characters, 244px, wider than the 218px of usable row a 360px phone has. So
   the row measured wider than the card, and the badge that `margin-inline-end:
   auto` parks at the trailing edge was parked 10px off the side of the
   document: measured 370px of scrollWidth in a 360px viewport, on every list
   view a German inbox produces. The whole page scrolled sideways.
   `min-width: 0` lets the title shrink to the space there is, and
   `overflow-wrap: anywhere` makes the long word break instead of running out
   of the box it was allowed to shrink — the same pair `.tile-title` and
   `.ldesc` already carry, for the same reason. */
.doc .row > strong { min-width: 0; overflow-wrap: anywhere; }
/* THE URGENCY MARK, and the two ways it was wrong.
 *
 * ONE: it used the FILL token where it needed the INK token. `--urgent` is a
 * background to put white text on, so it is dark in both palettes by design —
 * #b3261e, unchanged from light. As a 9px dot on the dark card that measures
 * 2.69:1, under the 3:1 that WCAG 2.2 SC 1.4.11 requires of a graphical object
 * carrying meaning. `--urgent-ink` is the same red tuned per palette for
 * exactly this job (#f28b82 in dark) and measures 7.37:1. The dark palette had
 * always had the right colour; this rule was reaching for the wrong one.
 * Same for `--action`, whose `--action-ink` exists for the same reason.
 *
 * TWO: hue was the only difference between the three states. SC 1.4.1 (Level A)
 * is "colour is not used as the ONLY visual means of conveying information",
 * and measured, these were 1.19:1 apart in light — grey against red is a hue
 * change and almost no luminance change, which is precisely the pair a
 * red-blind reader cannot separate. So the three now differ in SHAPE as well:
 * informational is a hollow ring, action-needed is solid, urgent is solid
 * inside a ring. That reads at 9px, and it reads in greyscale.
 *
 * The shape is drawn with border and outline rather than a shadow, and that is
 * deliberate: forced-colors mode discards box-shadow and overrides background,
 * but keeps border and outline and maps them to system colours. So the three
 * states stay distinguishable when the palette is taken away entirely, which
 * is what the block near the end of this file is for. */
/* `inline-block`, and it is load-bearing in a way that hid a bug for a long
   time. WIDTH AND HEIGHT DO NOT APPLY TO A NON-REPLACED INLINE ELEMENT. This
   rule has always declared 9x9, and in the vault list and the calendar that
   worked — because those parents are flex containers, which blockify their
   children and so let the size through. `.tile-title` is not flex, so in the
   documents GRID the same span stayed `display: inline` and the 9px was
   silently dropped: measured 0px wide, an urgency mark that rendered as
   nothing at all on the screen with the most documents on it.
   It stayed invisible precisely because it was a bare background with no
   border — there was nothing left to draw once the box collapsed. Adding the
   ring made it show up as a 4px sliver, which is how it was finally noticed.
   `vertical-align: middle` because an inline-block sits on the text baseline
   otherwise, and a dot hanging below the line reads as a typo. */
.dot { width: 9px; height: 9px; border-radius: 50%; flex: none;
  display: inline-block; vertical-align: middle;
  box-sizing: border-box;
  background: transparent; border: 2px solid var(--info); }
.dot.action-needed { background: var(--action-ink); border-color: var(--action-ink); }
.dot.URGENT { background: var(--urgent-ink); border-color: var(--urgent-ink);
  outline: 1.5px solid var(--urgent-ink); outline-offset: 1.5px; }
.doc .meta { color: var(--muted); font-size: var(--t-md); margin-top: 4px; }

/* Category grouping in the vault */
.cat-head { font-size: var(--t-sm); text-transform: uppercase; letter-spacing: .06em;
  color: var(--muted); margin: 26px 0 8px; display: flex; align-items: baseline; gap: var(--s-4); }
.cat-count { font-weight: 400; opacity: .65; }
.tag-everyday { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
  background: var(--accent); color: var(--on-accent); padding: 2px 7px; border-radius: var(--r-xs); margin-inline-start: auto; }
.doc-holder { font-weight: 600; color: var(--accent-ink); }

/* Profile (identity aggregated from documents) */
/* "…, 11 more" — the tail of a source line, and a control rather than a link:
   it opens the rest of the list in place, it does not go anywhere. Styled as
   the words around it so the line still reads as one sentence, with the accent
   colour and the underline that say the words can be pressed. */
.srcmore { border: 0; background: none; padding: 0; font: inherit; cursor: pointer;
  /* `font: inherit` does NOT bring the line-height with it from a paragraph
     that sets it separately, and a button's own `normal` is taller than the
     1.55 around it: the line holding this control stood apart from the two
     above it, as if the list had ended and something else had begun. */
  line-height: inherit; color: var(--accent-ink); text-decoration: underline; }
.srcmore:hover { text-decoration-thickness: 2px; }

.side-link { display: block; text-decoration: none; color: var(--accent-ink);
  font-weight: 600; font-size: var(--t-base); padding: 8px 0; margin-bottom: 8px; }
.ptable { display: flex; flex-direction: column; }
.prow { display: flex; gap: var(--s-7); padding: 11px 0; border-bottom: 1px solid var(--line); }
.prow:last-child { border-bottom: 0; }
/* The age row carries an eye, and the button is 30px of fixed height in a row
   that is otherwise as tall as its text. Centred, so that a value which has
   wrapped does not leave the control pinned to its first line, reading as
   something that belongs to the line above.

   DOUBLED SPECIFICITY, and measured before it was written this way. `.prow`
   and `.prow-age` tie at (0,1,0), so the winner is whichever comes last — and
   the phone block below sets `.prow { align-items: baseline }`. A single class
   here was therefore silently beaten at exactly the widths where the value
   wraps: at 360 the age ran to two lines, the row grew to 75px, and the eye
   sat 11px from the top with 34px of nothing under it. `.prow.prow-age` wins
   in both blocks and does not depend on the order of two rules 20 lines
   apart. */
.prow.prow-age { align-items: center; }
/* AND THE EYE SITS BESIDE WHAT IT CONTROLS. `.pval` is `flex: 1` on every
   other row, which is right for a value with nothing after it and wrong here:
   it pushed the button to the far trailing edge, half the width of the card
   away from the age it hides. Shrinking the value to its text leaves the
   column start exactly where every other row's value starts — only the
   trailing space moves.
   WIDE SCREENS ONLY. The phone block gives `.pval` `flex: 1 1 55%` and
   `text-align: end` so that every value right-aligns into a column; a value
   shrunk to its text there would abandon that column and hug its label. */
@media (min-width: 561px) { .prow.prow-age .pval { flex: 0 1 auto; } }
.plabel { flex: 0 0 150px; color: var(--muted); font-size: var(--t-base); }
.pval { flex: 1; font-weight: 600; min-width: 0; overflow-wrap: anywhere; }
/* Phones: the label keeps the value's company, and the ROW wraps instead of
   the column stacking.
   Stacking was the first answer and it was too blunt: it spent two lines on
   every row, including "First name / Alex" and "Nationality / British", which
   came to 510px for seven facts on a 360px screen — a quarter of the page to
   say seven short things. What it was protecting against is real, though: a
   long address in a fixed 150px column breaks mid-word.
   So the label sizes to its text, the value takes the rest and sits against
   the trailing edge. Measured at 360: a 68px label leaves the value 214px,
   against the 150px column the old rule was written to avoid.

   WHAT `flex-wrap` ACTUALLY CATCHES, because it is not what you would guess.
   A long VALUE does not move: `.pval` carries `overflow-wrap: anywhere`, so
   its minimum width is one character and it always fits beside its label —
   it wraps inside its own column, over as many lines as it needs. What wraps
   is a long LABEL: German's "Sozialversicherungsnummer des Antragstellers"
   takes the whole row, and the value drops beneath it at the full 294px,
   which is the old stacked layout arriving exactly where it was needed.
   Measured, all four combinations, and none of them scrolls sideways. */
@media (max-width: 560px) {
  .prow { flex-wrap: wrap; align-items: baseline; gap: var(--s-1) var(--s-6); }
  .plabel { flex: 0 1 auto; }
  .pval { flex: 1 1 55%; text-align: end; }
}

/* Money view --------------------------------------------------------------- */
.mcards { display: flex; flex-wrap: wrap; gap: var(--s-6); margin-bottom: 20px; }
/* THE SAME TILE AS HOME. This is the same object as `.stat` — a number the
   reader came to the screen to see, with a label under it — and it was a flat
   beige `--chip-bg` panel while Home's was a white glass card with a shadow.
   Two visual languages for one idea, one click apart. It borrows Home's
   surface; the sizes stay its own, because four of these share a row where
   Home's four share a page. */
/* TWO PER ROW IS THE FLOOR, same rule and same reason as `.doc-grid`.
   A flat `150px` basis needs 312px of row for its second card; this screen's
   card body offers 292 on a 360px phone, so all four metrics stacked one per
   row — four near-empty 90px bands, and the ledger they head pushed a screen
   and a half down. `min()` takes the half-row on a phone and the 150 everywhere
   else, so the wide layout is byte-for-byte what it was. */
.mcard {
  flex: 1 1 min(150px, calc(50% - var(--s-6))); min-width: 0;
  display: flex; flex-direction: column; gap: var(--s-1);
  padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--card); box-shadow: var(--shadow-1);
}
/* Big-number currencies (EGP ≈ E£11,000) must stay inside the card: a
   responsive size, and a hard wrap as the last resort rather than overflow. */
/* ON THE SCALE, RATHER THAN BESIDE IT.
   This was `clamp(17px, 2.6vw, 22px)` — its own ramp, written before the type
   scale had one, and floored at 17 for a stated reason: the app's body size
   was 17px, so a figure below that was outweighed by the ledger row under it.
   Body is a range now and reads 14.59px on a 390px phone, so a 17px floor
   stopped being "one step above the rows" and became a figure holding a size
   nothing else on the screen still uses. --t-2xl is 16 on that phone and 20 on
   a laptop: still comfortably the largest thing in the tile, and it moves with
   everything else the next time this scale is tuned. */
.mcard .mval { font-size: var(--t-2xl); font-weight: 700;
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.mcard .mlabel { font-size: var(--t-md); color: var(--muted); }
.mcard.in .mval { color: var(--ok-ink); }

.saybox { display: flex; gap: var(--s-4); margin: 4px 0 2px; }
.saybox input {
  flex: 1; min-width: 0; padding: 11px 13px; font-size: var(--t-lg);
  border: 1px solid var(--control-line); border-radius: var(--r-sm); background: var(--card); color: var(--ink);
}
.saybox .go { white-space: nowrap; }
/* Voice input: mic button before the say box. Pulses while listening. */
/* padding:0 is load-bearing, not tidiness. The shared control rule above sets
   `padding: 10px 14px`, and with border-box a fixed `width: 44px` leaves a
   content box of 44 - 2 border - 28 padding = 14px for a 20px icon. Grid puts
   an overflowing item at the content-box start, so the mic spilled 3px to the
   right and sat visibly off-centre, measured, after twice being "fixed" by
   adjusting the glyph itself, which was already centred in its viewBox.
   Every other icon button (.eye, .viewbtn, .icon-btn) resets padding; this was
   the one that did not. */
/* `hidden` HAS TO HIDE IT, and a `display` two lines down means it did not.
   `_wireMic` bails before `mic.hidden = false` when the browser has no speech
   recognition — its own comment says "leave the mic hidden" — but the UA rule
   `[hidden]{display:none}` loses to any author rule that sets `display`, so
   the button was on screen in every browser that cannot use it, offering a
   microphone that does nothing. The sixth occurrence of a bug this stylesheet
   has now shipped six times; found by the markup scan added to
   tests/test_hidden_attribute.py, which is the first one able to see an
   element written as a template string. */
.saymic[hidden] { display: none; }
.saymic { flex: 0 0 auto; width: 44px; padding: 0; display: inline-grid; place-items: center;
  border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--card);
  color: var(--accent-ink); cursor: pointer; transition: background var(--dur-1) ease, border-color var(--dur-1) ease; }
.saymic .icon { width: 20px; height: 20px; }
.saymic:hover { background: var(--hover-bg); }
.saymic.listening { color: var(--on-accent); border-color: transparent;
  background: var(--accent); animation: micpulse 1.2s ease-in-out infinite; }
@keyframes micpulse { 0%,100% { box-shadow: 0 0 0 0 rgb(var(--grad-2-rgb) / .5); } 50% { box-shadow: 0 0 0 7px rgb(var(--grad-2-rgb) / 0); } }
@media (prefers-reduced-motion: reduce) { .saymic.listening { animation: none; } }
.saystatus { color: var(--muted); margin: 8px 0 0; }
.saystatus.err { color: var(--urgent-ink); }

/* WHAT FAHIM HEARD, before any of it is written down.
 *
 * A CARD AND NOT A LIST OF ROWS. The ledger underneath it is a list of rows,
 * and these are not rows yet — they are a claim about a sentence, and the
 * reader is being asked to agree with it. Bordered and set apart so that the
 * difference between "this happened" and "shall I write this down?" is visible
 * without reading a word.
 *
 * `[hidden]` FIRST, and it is load-bearing rather than tidiness: this block
 * sets `display`, and an author `display` beats the UA's `[hidden]` rule — the
 * bug this stylesheet has now shipped six times and which the mic button two
 * dozen lines up carries its own paragraph about. */
.saycard[hidden] { display: none; }
.saycard { display: flex; flex-direction: column; gap: var(--s-3);
  /* AIR ON BOTH SIDES. The sweep in
     test_no_block_sits_on_the_one_below_it.js cannot see this block — it only
     exists after somebody has said something — so its spacing is the one on
     this screen that has to be right without being measured. Below it sits
     "Or fill it in yourself", which a 2px gap would have been touching. */
  margin-block: var(--s-5); padding: 12px 13px; background: var(--card);
  border: 1px solid var(--calm-border); border-radius: var(--r-sm); }
.sayheard { margin: 0; font-size: var(--t-sm); color: var(--muted); font-weight: 600; }
.saylines { display: flex; flex-direction: column; gap: var(--s-2); }
/* Each line is its own row with its own way out. A card that can only be taken
   or left whole makes one misheard figure cost the reader the whole sentence. */
.sayline { display: flex; align-items: center; gap: var(--s-3);
  padding: 6px 0; border-bottom: 1px solid var(--line); }
.sayline:last-child { border-bottom: 0; }
.saywhat { flex: 1; min-width: 0; overflow-wrap: anywhere;
  display: flex; flex-wrap: wrap; gap: var(--s-3); align-items: center; }
.sayacts { display: flex; gap: var(--s-3); flex-wrap: wrap; }
/* A LINE THAT CANNOT BE PRESSED YET says so and steps back, rather than
   sitting there looking like the others. Muted rather than hidden: the reader
   said it, and a sentence that silently vanished would be the card editing
   what they told it. */
.sayline.blocked .saywhat { color: var(--muted); }
.saynone { font-size: var(--t-sm); color: var(--urgent-ink); }
.saypick select { max-width: 100%; min-height: 44px; font-size: var(--t-md); }
/* The verb chip carries the weight — "Change" against "Add" is the difference
   between a new row and somebody's existing one being rewritten. */
.chip.verb { font-weight: 700; color: var(--accent-ink); }

/* A checkbox on the spend page, with its sentence.
 *
 * ITS OWN CLASS RATHER THAN `.msetrow.check`, which is the same three
 * properties. That class belongs to the Settings screen, and a spend page
 * borrowing it is a page that moves when somebody tunes the spacing of a
 * screen it has nothing to do with. The 24px box comes from the global rule
 * further down, which every checkbox in this app already relies on. */
.spcheck { display: flex; align-items: center; gap: var(--s-3);
  /* AIR ABOVE AS WELL AS BELOW. `.spfield` carries its own margin at the
     bottom of a card, not between one field and the next — the rows above
     these are spaced by `.spline`'s gap — so a checkbox after a text field sat
     flush against it. Caught by test_no_block_sits_on_the_one_below_it.js,
     which is the sweep this stylesheet keeps for exactly this: a block that
     touches the one above it, on a screen nobody thought to look at. */
  margin-block: var(--s-5) var(--s-4); }
.spcheck input:disabled + span { color: var(--muted); }

/* WHO A SPEND WAS FOR, and whether that money is still out there. Two chips,
   never one: "for Sara" stays true after she pays, and the second has to be
   able to go when it does. */
.chip.who { font-weight: 600; }
.chip.owed { color: var(--ok-ink); background: transparent;
  border: 1px solid var(--calm-border); font-weight: 600; }
/* The tick that clears a debt sits with the row's other actions and is the
   only one of them that is good news, so it is the only one with a colour. */
.lact.paid { color: var(--ok-ink); }

.ledger-h { margin: 22px 0 8px; font-size: var(--t-base); color: var(--muted); font-weight: 600; }
.ledger { display: flex; flex-direction: column; }
.lrow {
  display: flex; align-items: center; gap: var(--s-6);
  padding: 11px 0; border-bottom: 1px solid var(--line);
}
.lrow:last-child { border-bottom: 0; }
.lmain { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--s-2); }
.ldesc { font-weight: 600; overflow-wrap: anywhere; }
/* The title IS the way into the document from here, so it is a target, not a
   word in a sentence: at 15px its box came to 42x19 and WCAG 2.5.8 asks 24. */
.ldesc a { color: inherit; text-decoration: none;
  display: inline-flex; align-items: center; min-height: 24px; }
.ldesc a:hover { color: var(--accent-ink); text-decoration: underline; }
.chip.receipt { color: var(--accent-ink); background: transparent;
  border: 1px solid var(--calm-border); font-weight: 600; }
.lmeta { font-size: var(--t-sm); color: var(--muted); display: flex; flex-wrap: wrap; gap: var(--s-3); align-items: center; }
.lmeta a { color: var(--accent-ink); text-decoration: none; }
.lmeta a:hover { text-decoration: underline; }
.lamt { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* THE SAME ROOM PROBLEM, ONE SCREEN OVER.
 *
 * `.lrow` is one flex line — text, amount, buttons — and on a 360px phone the
 * amount (57px) and the two buttons (62px) took 143px of 294, leaving the text
 * 151. So "Groceries · Receipt · Aug 18, 2026 · 1:53 AM" wrapped onto THREE
 * lines and the average row came to 114px against the desktop's 77; seven
 * spends filled 795px and the screen ran to 3.1 phone-fulls.
 *
 * Below the phone breakpoint the row becomes a small grid instead: what it was
 * and what it cost on the first line, where the eye compares them, and the
 * meta line spanning the full width underneath, where it fits on one.
 * `display: contents` on `.lmain` is what lets that span happen — the two
 * pieces are wrapped in it for the flex layout, and a grid can only place
 * children of the grid itself. The editing state sets `display: block` on the
 * row and replaces its contents wholesale, and `.lrow.editing` outranks this,
 * so the form is untouched. */
@media (max-width: 560px) {
  /* AND THE ORDER OF IMPORTANCE, WHICH A PHONE HAD UPSIDE DOWN.
     `.mval` is `clamp(15px, 2.6vw, 22px)`: 2.6vw on a 393px screen is 10px, so
     the four headline figures pinned to their 15px floor — while a spend row's
     title and amount inherit the app's 17px body size, which does not shrink.
     Measured on the phone: "€27.35 / Running shoes" rendered LARGER than
     "€214.00 Outstanding". One lunch outweighed the balance.
     So the figures take 17 here and the rows take 15, and the screen reads
     figures, then rows, then labels, the way it already does at 1280 where the
     clamp reaches 22.
     17 AND NOT 20 for the figures, measured: the tile's inner width is 118px at
     360 and 135 at 393, and "EGP 11,105.40" is 106px at 15, 120 at 17, 141 at
     20. At 20 every long multi-currency value wraps at both widths; at 17 only
     that value, only at 360. €214.00, €1,234.56 and ≈ €124.04 fit at any of
     the three. */
  /* The figures are handled at the source (`.mcard .mval`'s clamp floor); the
     rows step down one place on the scale so the order reads at a glance. */
  .ldesc, .lamt { font-size: var(--t-lg); }
  /* A COLUMN FOR THE MARK, because this grid places its children BY NUMBER.
     `.lmain` is `display: contents`, so every piece of the row is a child of
     the grid itself and each one names its own cell. A new child that names
     none — the sender's mark — does not go where it looks like it should; it
     auto-flows into the next free cell, which put a 30px disc on the line
     BELOW the title, between the title and the meta. Measured, not guessed:
     that is what the first screenshot of it showed.
     So the mark takes column 1 and everything else shifts one across. The meta
     and the note now start at column 2 as well, which indents them under the
     title rather than under the disc — the shape a row with a mark wants
     anyway, and the one Subscriptions already has. */
  .lrow {
    display: grid; grid-template-columns: auto 1fr auto auto;
    column-gap: var(--s-5); row-gap: var(--s-1); align-items: baseline;
  }
  .lmain { display: contents; }
  /* `center`, not the row's `baseline`: a disc has no baseline worth aligning
     to, and against one it hung below the title it belongs to. */
  .rowmark { grid-column: 1; grid-row: 1; align-self: center; }
  .ldesc { grid-column: 2; grid-row: 1; }
  .lamt { grid-column: 3; grid-row: 1; }
  .lacts { grid-column: 4; grid-row: 1; }
  /* A THIRD ACTION DOES NOT FIT BESIDE A TITLE ON A 320px PHONE. Opening a
     spend into its lines added a button to this column, and `auto` gave the
     column what it asked for — so the `1fr` title beside it was crushed to
     21px and "Seoudi Market" came out one letter per line. Measured by
     tests/test_the_phone_layout.js, which is the only reason it was not
     shipped that way.
     The actions drop to a line of their own, at the end, and ONLY on the rows
     that carry the third one: `:has()` keeps every two-action row exactly as
     it was. `grid-row: auto` puts them after the meta rather than on top of
     it, the way `.lnote` and `.lmeta` already flow. */
  .lrow:has(.lexp) .lacts {
    grid-column: 2 / -1; grid-row: auto; justify-self: end; }
  /* AND SO DOES THE PAID/NOT-PAID PAIR, for the same reason and found the same
     way: unplaced, it auto-flowed into column 1, and because that column is
     `auto` the 200px button pair became the column width for its row — leaving
     the first bill's title indented halfway across the screen while every row
     under it started at the mark. */
  .lnote, .lmeta, .paidpick { grid-column: 2 / -1; }
}
.lrow.in .lamt { color: var(--ok-ink); }
.del {
  border: 0; background: none; color: var(--muted); font-size: var(--t-2xl); line-height: 1;
  cursor: pointer; padding: 0 4px; border-radius: var(--r-xs); flex: 0 0 auto;
}
.del:hover { color: var(--urgent-ink); }
/* NOTHING GOES HERE. There used to be two dark-mode blocks at this point
   restating `.mcard.in .mval` and `.lrow.in .lamt` as `--ok-ink` — which is
   what the light rules twenty lines up already say, and `--ok-ink` is a token
   that swaps itself in dark, so both were no-ops.
   The third line was not a no-op and was a bug: it set `.saystatus` (not
   `.saystatus.err`) to `--urgent-ink`, so in dark mode "Logged 3 spends" was
   rendered in the same red as "Could not hear that". Measured:
   `getComputedStyle(.saystatus).color` came back rgb(242,139,130) in dark for
   both the plain and the error element. The palette does the theming; a
   component does not need to restate it. */

.rem { display: flex; align-items: baseline; gap: var(--s-6); padding: 11px 0;
       border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.rem:last-child { border-bottom: none; }
.rem .when { font-weight: 700; font-variant-numeric: tabular-nums; min-width: 104px; }
.rem .what { flex: 1; min-width: 200px; font-size: var(--t-lg); }
.rem .src { color: var(--muted); font-size: var(--t-sm); display: block; }
.rem.overdue .when { color: var(--urgent-ink); }
.rem.soon .when { color: var(--action-ink); }

/* ---- one link this reader has handed out ----

   NOT `.rem`, which is what it borrowed at first and what broke it twice over.
   That rule aligns on the TEXT BASELINE, so four buttons of different heights
   sat at four different offsets; and `.rowend` beside it is only ever laid out
   inside `.controls`, so the span holding them had no layout at all and the
   labels wrapped through one another — "Copy link" rendered as "Co py link"
   with the icon on top of the word.

   A reminder row and a share row look alike and are not the same thing: one
   is a date and a sentence, the other is a name and four controls. */
.sharerow { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s-5); padding: 12px 0; border-bottom: 1px solid var(--line); }
.sharerow:last-child { border-bottom: none; }
.sharerow .who { flex: 1 1 200px; min-width: 0; font-size: var(--t-lg); }
.sharerow .meta { display: block; color: var(--muted); font-size: var(--t-sm); }
.sharerow .acts { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); }
/* Icon and label on one line, and the label never breaks mid-phrase. Both were
   the visible symptom of the unstyled span above. */
.sharerow .acts button { display: inline-flex; align-items: center;
  gap: var(--s-3); white-space: nowrap; }
.sharerow .acts .icon { width: 15px; height: 15px; }
.sharerow.off .who, .sharerow.off .meta { color: var(--muted); }

/* ---- the landing page is ONE column, like every other page ----
   Its own width because its job is different: it is prose and a single
   action, not a workspace of grids. The app column (1240) is right for a
   vault of tiles and wrong here — the drop zone rendered 1204x143 at that
   width, a dashed rectangle eight times wider than it is tall around one
   centred sentence.

   The first fix capped the hero and the capture block at 720 and left the
   sections below at 1204. That is worse, and measurable: at a 1990px window
   the headline and the upload area ended 502px short of the cards beneath
   them, so the most important half of the page read as a narrow column
   attached to a wide one. One width, one leading edge, one right edge —
   the same law the app pages follow. */
/* The landing page is one column of text and a capture area, and 900px is
   roughly where a paragraph stops being comfortable. It briefly went to 1120
   to carry a two-column hero with a sample verdict beside the upload; that
   card is gone — the proof belongs in "See it on a real letter" further down,
   not competing with the one control the page exists to get you to use — so
   the cap comes back with it. One width, one leading edge, one right edge. */
.home-space .wrap { max-width: 900px; padding-top: 0; }

/* ======================================================================
   THE LANDING PAGE HAS A GROUND, AND ONLY THE LANDING PAGE
   ----------------------------------------------------------------------
   DESIGN.md removed the aurora, the glass and the gradient fills in
   2026-08 because together they were the stock 2025-AI-landing-page look
   and the APP read as generated. That reasoning was about the app, and it
   still holds there: `test_the_ground_stays_flat` is unchanged for every
   screen behind the sign-in. It is now scoped rather than deleted, and the
   landing page — the one screen whose entire job is to be looked at before
   anyone has decided anything — is the exemption.

   Two slow fields of the brand ramp, and nothing else. No dot grid, no
   gradient button fills, no glass on cards: those were the parts that made
   the product itself feel generic, and none of them comes back.

   ---- WHY --muted CHANGES HERE, WHICH IS THE PART THAT IS NOT DECORATION
   Ambient light behind text is a contrast change, and this one was a
   failure. `--muted` is #5f6b7a and measures 4.83:1 on --bg — eight
   hundredths above the AA floor. Composited under even the weakest field
   it drops through it: grad-3 at .10 gives 4.59, grad-2 at .10 gives 4.29,
   grad-1 at .20 gives 3.51. Every "subtle glow" on a light page with this
   palette ships secondary text below WCAG AA, invisibly, because
   tools/check_contrast.py reads tokens and a token cannot see a layer
   behind it.

   So the landing page darkens its own secondary text to #4b5665, which
   holds 4.82:1 against the strongest ground either field can produce and
   6.63:1 where there is no field at all. `--glow-bg` states that strongest
   ground as a colour so the contrast tool can check text against it — it
   is a MEASUREMENT, not a paint; no rule renders it. Change an alpha below
   and that number has to move with it, which is the point of writing it
   down. Dark mode needs neither: light text over a darkened ground has
   5.7:1 of headroom at these alphas, so the dark blocks restore the normal
   token.
   ====================================================================== */
.home-space {
  --muted: #4b5665;
  --glow-bg: #c2d2e3;             /* grad-1 at .20 over --bg, the worst case */
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]).home-space {
    --muted: #9aa7b8;
    --glow-bg: #12273e;           /* grad-2 at .22 over the dark --bg */
  }
}
/* Compound, not descendant: `.home-space` is on <html>, the same element the
   theme attribute is on, so `:root[data-theme="dark"] .home-space` matches
   nothing at all. */
:root[data-theme="dark"].home-space {
  --muted: #9aa7b8;
  --glow-bg: #12273e;
}

/* A sibling of .wrap and OUTSIDE it, at z-index 0 with the content lifted to
   1. The obvious spelling — a `z-index: -1` child of the wrapper — paints
   behind <body>'s own background and is simply never seen; body carries
   --bg, so anything below it is under an opaque sheet. */
.landglow { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
.home-space .wrap, .home-space .sidebar { position: relative; z-index: 1; }
.landglow i {
  position: absolute; display: block; border-radius: 50%;
  /* The radial stop to `transparent` IS the softness. A filter: blur() on top
     of it is a second full-screen raster pass every frame of the drift for a
     difference nobody can point at. */
  animation: landglow-drift 34s ease-in-out infinite;
  will-change: transform;
}
.landglow i:nth-child(1) {
  width: 52vw; height: 52vw; top: -16vw; inset-inline-start: -10vw;
  background: radial-gradient(circle, rgb(var(--grad-2-rgb) / .20), transparent 70%);
}
.landglow i:nth-child(2) {
  width: 44vw; height: 44vw; top: 16vw; inset-inline-end: -12vw;
  background: radial-gradient(circle, rgb(var(--grad-1-rgb) / .16), transparent 70%);
  animation-duration: 46s; animation-delay: -12s;
}
/* 34s and 46s are choreography, not transitions, so they are written here
   rather than taken from --dur-*, exactly as DESIGN.md says animations should
   be — and switched off by hand below for the same reason. */
@keyframes landglow-drift {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  50%      { transform: translate3d(2vw, 3vw, 0) scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
  .landglow i { animation: none; }
}

/* ---- The header floats over the ground ----
   `backdrop-filter` earns its place here and nowhere else: this bar overlaps
   content that is scrolling under it, which is the only situation where a
   blur of what is behind is information rather than an effect. It is why the
   bar is also sticky — a translucent header on a header that never overlaps
   anything is decoration, and that is the version that got removed.
   The negative inline margin lets the bar reach the window edges while its
   contents stay on the page's leading edge. */
.home-space .top {
  position: sticky; top: 0; z-index: 30;
  background: rgb(var(--card-rgb) / .72);
  backdrop-filter: blur(14px) saturate(1.4);
  margin-inline: calc(-1 * max(18px, var(--sa-side)));
  /* THE WRAPPER'S TOP PADDING MOVES ONTO THIS BAR, and that is the whole fix
     for "it is not stuck to the top". `position: sticky; top: 0` pins an
     element once it REACHES the top, and this one starts 18px below it —
     `.wrap` puts that padding above everything. So at rest the bar floated
     16px down and then snapped flush the moment you scrolled, which reads
     exactly like a sticky header that is broken. Taking the padding off the
     wrapper and giving it to the bar means the bar is flush at scroll 0 and
     flush at scroll 600: it never moves, which is what stuck means.
     --sa-top rides along, or an installed PWA puts the logo under the
     status bar. */
  padding: calc(10px + var(--sa-top)) max(18px, var(--sa-side)) 10px;
  margin-bottom: var(--s-6);
  /* Rounded at the bottom, square at the top, because it is pinned to the top
     of the window: it reads as a bar that belongs to the page rather than a
     slab that happens to end.
     DELIBERATELY NOT FULL-BLEED. The usual trick for that is
     `margin-inline: calc(50% - 50vw)`, and 100vw INCLUDES the vertical
     scrollbar — on a page this long that is a guaranteed horizontal scrollbar
     on every desktop browser that reserves gutter space. A contained bar has
     no such failure mode. */
  border-radius: 0 0 var(--r-lg) var(--r-lg);
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-2) var(--ease);
}
/* The hairline appears only once there is something underneath to separate
   from, so the bar has no edge at rest. app-index.js sets .scrolled. */
.home-space .top.scrolled { border-bottom-color: var(--line); }

/* ---- The header fits on ONE row on a phone ----
   It did not, and the item that fell off was the account control: measured at
   390px, the bar wants 402px of track inside 339px of room, so `flex-wrap`
   put "Sign in" alone on a second line under everything else. Making it a
   .ghost button is what tipped it over, but the bar was already within a few
   pixels of wrapping — the badge in dev was hiding how close it was.

   TWO CHANGES, AND THE SECOND ONE IS THE ONE THAT ACTUALLY HOLDS.

   The wordmark goes and the logo stays. 62px, and the mark is the brand on a
   phone; the <h1>'s link keeps its aria-label, so nothing is lost to a screen
   reader, only to the eye that can already see the logo.

   That alone leaves about 4px of slack, which is not a fix, it is a
   coincidence — and it is a coincidence in ENGLISH. The picker shows the
   language's own name: "Nederlands", "Português", "Українська" are all wider
   than "English", so the bar would go straight back to two rows for exactly
   the readers this picker exists for. So the pill is also allowed to shrink,
   with the name truncating rather than the row breaking. It is the one item
   here that can lose a few characters and still be obvious: it carries a globe
   icon, and its value is the word the reader is looking for in their own
   script. */
@media (max-width: 560px) {
  /* ⚠️ nowrap FIRST, and it is the whole fix — the other three rules only
     decide what gives.

     `.spacer` is `flex: 1`. In a wrapping row that is not a spacer, it is a
     guillotine: it grows to eat every pixel left on line one, so everything
     after it is pushed to line two no matter how little room it needed. That
     is why hiding the wordmark did nothing on its own — 62px of new space went
     straight into the spacer and "Sign in" stayed exactly where it was.

     With nowrap the row cannot break, the spacer collapses to nothing, and the
     deficit has to come out of an item that can shrink. Below decides which:
     not the logo, not the theme button (a 40px tap target), not the account
     control — the language pill, which is the one item here that can lose a
     few characters and still be obvious, because it carries a globe icon and
     its value is the reader's own language in their own script. */
  .home-space .top {
    flex-wrap: nowrap;
    /* 10px between six items is 50px of the 339 this bar has — more than the
       wordmark that was removed to make room. At 6 it is still a gap and the
       language name stops truncating. */
    gap: var(--s-3);
  }
  /* Dev-only chrome, and on a phone it was costing 47px of a bar that had
     none to give — enough to turn "English" into "E…". It says nothing a
     developer cannot see in the URL. */
  .home-space .top #envbadge { display: none; }
  /* ⚠️ 14, BECAUSE `.wrap` IS 14 HERE. The bar reaches the window edges by
     pulling out exactly as far as the page's gutter, and the gutter narrows on
     a phone — `.wrap { padding-inline: max(14px, var(--sa-side)) }` a few
     hundred lines up. The base rule pulls out by 18, so below 560px the header
     stuck 4px past the viewport on each side and the whole page scrolled
     sideways by 4. Measured at 360, 430 and 520; gone at 620, where the gutter
     goes back to 18.
     The two numbers must match, and nothing but arithmetic says so — which is
     why test_the_landing_header_reaches_exactly_the_page_gutter reads both out
     of this file and compares them. */
  .home-space .top {
    margin-inline: calc(-1 * max(14px, var(--sa-side)));
    padding-inline: max(14px, var(--sa-side));
  }
  .home-space .top .spacer { flex: 1 1 0; min-width: 0; }
  .home-space .top #brandname { display: none; }
  .home-space .top #langwrap { min-width: 0; flex: 0 1 auto; }
  .home-space .top .langpick { min-width: 0; max-width: 100%; }
  .home-space .top .langpick select {
    min-width: 0; text-overflow: ellipsis;
  }
  /* The account control never shrinks and never wraps: it is the reason
     somebody who already has documents opened this page. */
  .home-space .top #who { flex: 0 0 auto; }
  .home-space .top #who .ghost { white-space: nowrap; }
}


/* ---- The hero block: claim, capture, and what comes back ----
   Only `display: grid` is conditional. The child placement below is inert
   while the wrapper is a plain block, so it can be written once.

   `:has(> #landing:not([hidden]))` IS THE SIGNED-OUT TEST, and it is borrowed
   rather than invented: app-index.js hides #capture and #landing on the same
   two lines, in both places it hides them (a started upload, and the sign-in
   panel), and a signed-in reader gets #landing hidden on load. So "the landing
   is showing" already means "this is the pitch, not the tool", and reusing
   that flag is one fewer thing that can drift out of step with the JS.

   It also fails to the simpler layout: a browser without `:has()` never
   matches, the wrapper stays a block, the proof card stays hidden, and the
   page is exactly what it was before this rule existed. */


/* The feature grid on the landing page. The same visual language as the
   `.space` cards in the vault — icon chip, name, one line — because they are
   the same five things, and somebody who signs up should recognise the screen
   they land on. Not links: there is nowhere to send a signed-out reader except
   back to the top of this page. */
.featgrid {
  display: grid; gap: var(--s-6);
  /* 240 gives three tracks in the 900px column: 3 + 2 across five cards.
     And `min()` gives TWO on a phone, where a flat 240 needs 492px of content
     box against the 365 a 393px screen has: five cards came down as five
     full-width rows, one sentence each, 464px of scrolling to list what the
     app does. Half a row is the smaller number below 492 and the 240 is the
     smaller number above it, so nothing changes on a laptop. */
  grid-template-columns: repeat(auto-fit, minmax(min(240px, calc((100% - var(--s-6)) / 2)), 1fr));
}
/* A QUIET card: surface and hairline, no shadow, no hover theatre. The first
   flat pass removed the box entirely and the section read as text floating on
   nothing, worst in dark where there is no shadow to imply structure. The
   template look was every box carrying the same shadow and lift — not the box
   itself. */
/* TIGHTER THAN IT WAS. Five of these in a 900px column came to a 415px block
   of mostly padding: 171px-tall cards carrying a chip, three words and one
   line. The card was sized for the 1120px layout the page briefly had, and it
   never came back down when the width did. Nothing here is smaller than the
   app uses elsewhere — the title is the step that the `.step` cards above it
   already use — it is the air around them that goes. */
.feat {
  display: flex; flex-direction: column; gap: var(--s-3); padding: 14px 16px;
  background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
}
.feat strong { font-size: var(--t-lg); letter-spacing: -.01em; }
/* TWO TO A ROW ONLY PAYS IF THE TILE GETS TIGHTER TOO. Stacked icon, title and
   sentence in a 177px card ran to five lines and made the section 100px TALLER
   than the single column it replaced — denser to look at, longer to scroll,
   which is not the trade anybody wanted. The icon moves up beside the title
   and the sentence drops one step on the scale; the card then says the same
   thing in the room it has. */
@media (max-width: 560px) {
  /* `align-content: start` as well as `align-items: center`: the first keeps
     the two rows together at the top of a card that is taller than its
     content, the second keeps the icon centred against the title beside it.
     Without the first, two cards in a row with different amounts of text
     started their text at different heights — the shorter card's contents
     drifted to its middle, which reads as a wobble along the row. */
  .feat { display: grid; grid-template-columns: auto 1fr;
          gap: var(--s-2) var(--s-4); align-items: center; align-content: start;
          padding: 12px 13px; }
  .feat .sicon { grid-row: 1; grid-column: 1; }
  .feat strong { grid-row: 1; grid-column: 2; font-size: var(--t-base); }
  .feat p { grid-column: 1 / -1; font-size: var(--t-md); }
}
.feat p { margin: 0; color: var(--muted); font-size: var(--t-base); line-height: 1.45; }

/* ---- the demo: a German letter beside Fahim's reading of it ----
   The one section no template has, because it is the product's actual output.
   The letter is a paper artefact: LTR and German whatever the UI language
   (the markup pins lang/dir), set slightly small and grey the way a scan
   reads. The explanation column reuses the REAL result components — .verdict,
   .deadline, ol.steps — so this stays honest by construction. */
.demo {
  display: grid; gap: var(--s-8);
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  /* STRETCH, not start. The two halves of this comparison are the same
     document seen twice, and they were ending at different heights: the letter
     stopped ~90px above its explanation, and the receipt ran ~100px past its
     rollup. Two ragged bottom edges, in opposite directions, one under the
     other — which is what reads as "not quite finished" even when every
     individual card is right.
     `start` was correct while the right column was the only one that varied.
     Now both columns carry a stack that ends somewhere, so the row decides the
     height and the last thing in each column takes up the slack. */
  align-items: stretch;
}
.demo-col { display: flex; flex-direction: column; }
/* The last element in each column absorbs the difference. In the left column
   that is the paper artefact — a letter with clear space under the signature
   and a slip with a tail past the total are both what those objects look like.
   In the right column it is the final card, which simply gets a little more
   room than its text needs. Neither is a spacer element; there is nothing here
   that exists only to push. */
.demo-col > figure:last-child,
.demo-col > .card:last-of-type { flex: 1 0 auto; }
.demo-label {
  margin: 0 0 10px; font-size: var(--t-md); font-weight: 700; color: var(--muted);
}
.demo-letter {
  margin: 0; padding: 26px 24px;
  background: var(--card); border: 1px solid var(--line); border-radius: var(--r-sm);
  box-shadow: var(--shadow-1);
  font-size: var(--t-md); line-height: 1.6; color: var(--muted);
  /* THE LETTER IS SET FROM ITS OWN EDGE, NOT THE PAGE'S.
     This used to be `text-align: left` twice over — once here and once under
     `[dir="rtl"]`, keyed on the PAGE's direction — because the specimen was a
     German artefact whatever language the interface was in. It is not any
     more: app-index.js paints a specimen in the reader's own language and puts
     `dir` on this figure. `start` reads that attribute, so an Arabic letter
     sets itself from the right inside an English page, which is the one case
     a reader is here to see. Two rules keyed on an ancestor became one keyed
     on the element, and the RTL audit sees a logical property. */
  text-align: start;
}
/* ON A PHONE THE LETTER IS SHOWN, NOT READ.
 *
 * The demo is 1629px on a 393px screen — two full screens for one example,
 * and there are three of them behind the tabs. The obvious cure is to put the
 * letter and the explanation side by side instead of stacked, and the numbers
 * say no: half of this column is 173px, which for the letter's 13px type is
 * TWENTY-EIGHT characters to a line. The details table was fixed today at
 * 34–39 because that read as fragments; 28 is worse, and a facsimile of a
 * German Mahnung squeezed into 173px stops looking like a letter at all,
 * which is the one job it has here.
 *
 * So the letter keeps the full width and gives up its bottom half instead. A
 * reader needs to see THAT it is a dense official letter with a figure and a
 * deadline in it — both of which are in the first paragraphs — not to read to
 * the signature. The fade says there is more rather than cutting mid-sentence
 * with a hard edge. Above 560px nothing is capped: there the two columns are
 * genuinely side by side and the letter is read next to its explanation. */
/* ALL THREE ARTEFACTS, not just the letter: the slider is as tall as its
   tallest slide, so capping one of three changed the section's height by
   nothing at all — measured, 1629px before and 1629px after. The receipt is a
   764px till roll and the pasted thread is a 715px conversation; both are the
   INPUT half of the comparison, and the same reasoning applies to each. What
   is never capped is the output: the explanation, the deadline, the steps —
   that is what the section exists to show. */
@media (max-width: 560px) {
  /* AND NOTHING STRETCHES, because below this width the two columns are not
     side by side — they are one above the other, and there is no facing column
     whose height they should match. `align-items: stretch` and the "last child
     absorbs the slack" rule above are both written for the desktop pairing;
     stacked, they left 250px of empty card under the capped letter. */
  /* `align-items: start` on the SLIDER as well, so each panel is its own
     natural height and markTabs (app-index.js) can measure one to size the
     scroller. Stretched, every panel reported the tallest panel's height and
     the measurement was circular. */
  .demo { align-items: start; align-content: start; }
  .demo-col > figure:last-child, .demo-col > .card:last-of-type { flex: 0 0 auto; }
  /* The paper artefacts fade from the BOTTOM: a letter and a till roll are
     read from the top, and their first inches carry the sender, the figure and
     the deadline. */
  .demo-letter, .demo-receipt {
    position: relative; max-height: 340px; overflow: hidden;
  }
  .demo-letter::after, .demo-receipt::after {
    content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 84px;
    background: linear-gradient(to bottom, transparent, var(--card));
    pointer-events: none;
  }
  /* A CONVERSATION FADES FROM THE TOP INSTEAD, and that is not symmetry for
     its own sake. Capped the other way it hid the last message — "Vielen Dank.
     Am Dienstag, den 14.07." — which is the message the whole slide exists to
     answer. `justify-content: flex-end` on the column pushes the overflow off
     the top, so what survives is the end of the thread; the fade moves with
     it. The thread has no background of its own, so it fades to the page. */
  /* THE CONVERSATION IS NOT CAPPED, and this is the second thing tried here.
     A letter and a till roll fade out believably: they are paper, the page
     under them is the same colour as they are, and everyone has seen a
     document continue past the edge of a photograph. A THREAD does not. Its
     messages are discrete coloured bubbles, so any cut lands in the middle of
     somebody's sentence, and a fade over it — painted or masked — reads as a
     rendering fault rather than as "there is more above". Reported twice, in
     both directions, which is a clear enough answer: three short messages are
     shown in full and the panel is 230px taller than it would have been. The
     slider sizes itself to each panel now, so that cost lands on this panel
     alone. */
}
.demo-letter p { margin: 0 0 12px; }
.demo-letter p:last-child { margin-bottom: 0; }
.demo-letter strong { color: var(--ink); }
.dl-sender { font-weight: 600; color: var(--ink); }
.dl-meta { font-size: var(--t-sm); }
.dl-subject { font-weight: 700; color: var(--ink); font-size: var(--t-lg); margin-top: 18px; }
/* `end`, not `right`: the letter is pinned dir=ltr so they are the same edge,
   and the logical keyword keeps this out of the RTL side-property audit. */
.dl-date { text-align: end; }
/* The realest detail a German dunning letter has: no signature, and a line
   saying so. */
.dl-machine { font-size: var(--t-xs); opacity: .8; margin-top: 16px; }
/* WHAT FAHIM FILED — under a hairline, at the foot of the card that found it.
   The card says what it read; this says what became of it. Two of them, worded
   and coloured the same across two cards, so they read as one repeated fact
   ("and it was remembered for you") rather than two unrelated badges.

   Quiet deliberately. The deadline is what a reader came to this card for, and
   HARD DEADLINE is already shouting beside it; a third loud thing here would
   make the reader step over noise to reach the date. --accent-ink is the same
   colour .obell uses for exactly this marker in the vault.

   The rule is `border-top: --line` and that is correct rather than an oversight:
   --line is the hairline this file reserves for DECORATION, and a separator is
   not a control boundary. Controls use --control-line, and
   test_no_control_draws_its_boundary_with_the_hairline holds that line. */
/* `.demo-col .filed`, not `.filed`: `.card p` sets `margin: 0` and is the more
   specific selector, so a bare `.filed { margin-top }` lost and the hairline
   sat flush against the sentence above it — reading as an underline on that
   sentence rather than as a rule under the card's content. Measured at 0px.

   The TEXT is --ink and only the icon is accent: accent-ink is what links are
   painted in, and a blue phrase that cannot be clicked is a promise the page
   does not keep. The icon carries the accent, which is enough to say a system
   did this, and the words stay a statement. */
.demo-col .filed { display: flex; align-items: center; gap: var(--s-4);
  margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
  font-size: var(--t-sm); font-weight: 600; color: var(--ink); }
.demo-col .filed .icon { width: 15px; height: 15px; flex: 0 0 auto;
  color: var(--accent-ink); }
.demo-col .card:last-child { margin-bottom: 0; }
.demo-when { font-size: var(--t-xl); font-weight: 700; }
.demo-note { margin: 14px 0 0; }

/* ---- the three comparisons, one at a time ----
   Same mechanics as the feature row, different proportions: there the slides
   are cards and several fit at once, here each slide is a whole two-column
   comparison and exactly one should.

   `mandatory`, not `proximity`. A card row can rest between two cards and
   still make sense; a comparison resting half on the letter and half on the
   receipt is two half-panels and no comparison at all.

   FULL WIDTH, AND THE SCROLLBAR IS GONE. An earlier version left 6% of the
   next panel showing and let the scrollbar be the indicator; these are pages,
   and a page peeking out from behind another page reads as a mistake rather
   than an invitation. The tabs above say how many there are, which one you are
   on, and — the part a scrollbar cannot — what the other two contain.
   Swipe still works: the tabs are a second way in, not the only one.

   The slides are flex items in a row, so they all take the height of the
   tallest one. That is what stops the page jumping as you move between them,
   and it is why `.demo-col`'s last child grows: the slack has to land
   somewhere, and inside a card is better than under one. */
.demoslider {
  display: flex;
  gap: var(--s-8);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  padding-block: 4px;
  /* The dots are the indicator. A scrollbar under a paged carousel is a second
     one that disagrees with the first about how far along you are. */
  scrollbar-width: none;
}
.demoslider::-webkit-scrollbar { display: none; }
/* EVERY PANEL GETS ITS OWN HEIGHT, AT EVERY WIDTH.
   A flex row is as tall as its tallest child, so a 615px receipt panel was
   padded out to the 1319px of the replies panel — on a phone that was 622px of
   blank before the next section, and on a desktop it drew the empty half as
   card, which is what a reader sees as broken. `align-items: start` gives each
   panel its own height and markTabs (app-index.js) sizes the scroller to
   whichever one is showing.
   `overflow-y: hidden` because that height is now a number and the taller
   panels are still inside: without it the element had 700px of vertical scroll
   of its own, so a finger dragging down over the demo scrolled the carousel
   instead of the page, revealing nothing. Horizontal stays `auto` — that is
   the swipe this component is for.
   The transition keeps a tab change from snapping the page under the reader's
   thumb; it is built on `--dur-2`, which the reduced-motion block zeroes. */
.demoslider { align-items: start; overflow-y: hidden;
  transition: height var(--dur-2) cubic-bezier(.4, 0, .2, 1); }
.demoslider > .demo {
  flex: 0 0 100%;
  scroll-snap-align: start;
  /* It was a grid before it was a slide, and it still is inside. */
  margin-top: 0;
}

/* The demo's transcript. `.talkline` and `.ask-msg` do all the work — this
   only supplies the column they stack in, which in the app is `.asklog` (a
   scrolling pane with a height cap). Here they are three fixed turns in a
   panel that is already the right height, so a cap and a scrollbar would be
   furniture for a problem this panel does not have. */
/* The channel line above the thread. Same muted register as `.demo-label`
   beside it — this names what KIND of conversation the sample is, it is not a
   heading competing with one. `.talkch` supplies the icon's colour and size,
   so only the row needs describing. */
.demochannel {
  display: flex; align-items: center; gap: var(--s-3);
  /* Five channel names in Ukrainian do not fit one line on a phone, and the
     row is a caption — wrapping it costs nothing. */
  flex-wrap: wrap;
  margin: 0 0 var(--s-5); color: var(--muted);
  font-size: var(--t-md); font-weight: 600;
}
/* THE OTHER FOUR, NAMED BUT NOT OFFERED. The sample is an email and says so;
   these say what else Fahim answers, so the breadth is visible without a
   second toggle nested inside the one above — a tab inside a tab is content
   hidden twice, and on a landing page the inner one does not get clicked.
   Plain text with middots on purpose: chips would look like controls, and a
   control that does nothing is worse than no control. Every name is a shipped
   string, so this costs no copy. */
.demochannel .chrest { font-weight: 400; opacity: .75; }
/* The sender and subject above the thread. Tight to each other and to the
   first bubble, because together they are one header rather than three
   separate things. `.dl-sender` and `.dl-meta` bring the weight and the muted
   tone from the letter panel; only the spacing is different here. */
.demofrom { margin: 0; }
.demosubject { margin: 2px 0 var(--s-5); }
.demothread { display: flex; flex-direction: column; gap: var(--s-4); }
/* A German thread stays left-set inside an Arabic page, like the letter and
   the receipt beside it: the bubbles still mirror to the reader's side,
   because `.talkline.mine` flips on the inline axis, but the German text
   inside them reads the way German reads. */
[dir="rtl"] .demothread { direction: rtl; }
/* The bubbles mirror to the reader's side; the GERMAN INSIDE THEM does not.
   `<bdi dir="auto">` gets the direction right on its own — it computed `ltr`
   in all sixteen — but direction is not alignment: `text-align` still
   inherits, and in Urdu and Farsi it came through as `right`, which ragged the
   German down its left edge. Measured, not assumed; an earlier check here
   looked at the <bdi> BOX, which fills the bubble either way, and so saw
   nothing wrong.
   Deliberately the same value in both rules, like `.demo-letter` above: a
   German message reads left-set on an Arabic page, and stating it twice is how
   the RTL audit sees a decision rather than an omission. */
.demothread .ask-msg, [dir="rtl"] .demothread .ask-msg { text-align: left; }

/* ---- the demo's category rows size to the words in them ----
   `.catbar` is the Shopping screen's row and its name column is a fixed 104px
   with an ellipsis, which is right there: that screen stacks a dozen rows and
   they have to share one left edge no matter how long a category is called.

   It is wrong HERE. Measured across the sixteen languages at 390px, seven of
   them clipped: "Frutas y verduras", "Produits laitiers", "Artykuły domowe",
   "Господарські товари", "Fruta dhe perime". This is the panel whose entire
   argument is that Fahim reads a German receipt back to you in your language,
   and in seven languages the translated word was the thing cut in half.

   `auto` still shares one column across the three rows, so the bars keep their
   common left edge — it is the same alignment, sized to the longest name that
   is actually there instead of to a number chosen for a different screen. */
/* ---- the demo's category rows: a WIDER fixed column, not a shared auto one ----
   Two constraints fight here and only one layout satisfies both.

   The Shopping screen uses `104px 1fr auto`, and at 390px that clipped the
   category name in seven of the sixteen languages — on the panel whose whole
   argument is that Fahim reads a German receipt back to you in your language.

   The obvious fix, one grid across all three rows via `display: contents`,
   worked and then stopped working the moment these rows became real
   disclosures: a `<summary>` with `display: contents` generates no box, so it
   is no longer the thing you click. The bars had to be openable, so that
   approach is out.

   So: the same fixed-column shape the product uses, widened to a number
   measured against the longest translated name in all sixteen languages
   ("Господарські товари", "Fruits et légumes", "Artykuły domowe") rather than
   inherited from a screen with different words on it. Fixed means every bar
   still starts at the same x, which is what a chart needs. */
.demoslider .catbar { grid-template-columns: 152px 1fr auto; }
/* The item rows hang under the bar, so their indent is that column plus the
   gap — the product's own rule says `calc(104px + var(--s-6))` for the same
   reason, and it has to move with the number above. */
.demoslider .catitems { padding-inline-start: calc(152px + var(--s-6)); }

/* ON A PHONE THE INDENT IS THE FIRST THING TO GO. Lining the items up under
   the bar's amount column is a desktop nicety; at 390px it spends 164 of the
   ~300px a row has, and the item name — the translated word this whole panel
   exists to show — is what gets the ellipsis. Measured in French:
   "Pommes de terre" wanted 100px and had 87.
   The items are inside an open <details> and set smaller and muted, so the
   nesting is legible without buying it with a third of the row. */
@media (max-width: 560px) {
  .demoslider .catitems { padding-inline-start: var(--s-8); }
}

/* ---- which panel you are on, and what the other two are ----
   TABS, NOT DOTS, and the difference is the whole point. Dots are compact and
   they are also silent: they say "there are three of these" and nothing about
   what you would get by clicking. The two panels behind them — the receipt and
   the reply — exist because those features had almost no showing on this page,
   so hiding them behind an unlabelled circle puts them back where they were.
   The published numbers on carousels are unkind (roughly one visitor in a
   hundred ever reaches slide two); a label is what buys a click.

   `.segbar` / `.seg`, the app's own segmented control, not a new component:
   the same object appears on the money screen and in admin. All this adds is
   where it sits and a little more height, because a control that is the main
   navigation of a landing section should be easier to hit than a filter on a
   dense screen. */
.demotabs { display: flex; justify-content: center; margin: 0 0 var(--s-7); }
.demotabs .seg { padding-block: 10px; }

/* ---- the drafted reply, written out as you reach it ----
   The one animation on this page that is not decoration: this feature IS
   "Fahim writes it for you", and a block of finished text appearing all at
   once is the only version of that which does not show it.

   Word by word, not letter by letter. A character typewriter on a 27-word
   paragraph takes several seconds and leaves the reader watching a machine
   pretend to be slow; words arrive fast enough to read along with.

   THE TEXT IS ALL IN THE DOM THE WHOLE TIME — every word is a span that starts
   transparent, not a string being appended. A screen reader gets the finished
   draft immediately and never announces a half-written sentence, and the
   paragraph reserves its full height on first paint, so nothing below it moves
   as the words arrive.

   Hung off `[data-typewrite]` rather than a class of its own, because the box
   it writes into is now the PRODUCT's `.replybody` — see the demo markup. The
   animation is a behaviour attached to whatever renders the draft, not a
   second way of drawing one. */
[data-typewrite].animating [data-w] {
  opacity: 0;
  transition: opacity var(--dur-2) var(--ease);
  transition-delay: var(--d, 0ms);
}
[data-typewrite].animating.written [data-w] { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  /* The duration token is already zeroed here, which kills the fade. The DELAY
     is not, and 27 words at 45ms would still stagger over a second — a slower
     version of exactly the effect the preference asks us not to run. */
  [data-typewrite].animating [data-w] { opacity: 1; transition-delay: 0s; }
}

/* ---- the receipt demo ----
   Secondary to the letter above it by SIZE, not by styling it differently: it
   reuses .demo, .demo-col, .demo-label and .card, so it reads as the same kind
   of evidence rather than as a second design. The one thing it does not reuse
   is .demo-letter — a receipt is a column of amounts, not prose, and setting
   it as prose is the fastest way to make a mock look fake. */

.demo-receipt {
  /* THE SAME BOX AS .demo-letter, DOWN TO THE PADDING. It was 20/18 against
     the letter's 26/24, and capped at 22em while the letter filled its column
     — so the section showed two artefacts of the same kind at two different
     widths with two different insets, one above the other, and read as two
     mock-ups pasted together rather than one comparison.

     The realism argument for a narrow slip is real and it loses to this: what
     the reader is comparing is "the thing that arrives" against "what Fahim
     says about it", twice. The two left-hand cards are the same role, so they
     are the same object. The rows inside still read as a till slip — name on
     the leading edge, amount on the trailing one, tabular figures — which is
     what actually makes it look like a receipt, not the width of the card. */
  margin: 0; padding: 26px 24px;
  background: var(--card); border: 1px solid var(--line); border-radius: var(--r-sm);
  box-shadow: var(--shadow-1);
  font-size: var(--t-md); line-height: 1.5; color: var(--muted);
  /* Set left whatever the page does, for the same reason the German letter is:
     it is a paper artefact. */
  text-align: left;
}
[dir="rtl"] .demo-receipt { text-align: left; }
.dr-shop { margin: 0; font-weight: 600; color: var(--ink); }
.dr-meta { margin: 2px 0 14px; font-size: var(--t-sm); }
/* A <dl> because each line is a term and its value, which is what a receipt
   line is; the row wrapper is what lets the pair sit on one line and the
   amounts share a right edge. */
.dr-lines { margin: 0; }
.dr-lines > div, .dr-total, .rollup > div, .rollup-total {
  display: flex; justify-content: space-between; gap: var(--s-6);
}
.dr-lines dt, .dr-lines dd, .rollup dt, .rollup dd { margin: 0; }
/* tabular-nums so the column of amounts lines up on the decimal instead of
   wobbling with the width of each digit. */
.dr-lines dd, .dr-total span:last-child, .rollup dd, .rollup-total strong {
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.dr-lines > div { padding: 2px 0; }
.dr-total {
  margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--line);
  font-weight: 700; color: var(--ink);
}
.rollup { margin: 0; }
.rollup > div { padding: var(--s-3) 0; border-bottom: 1px solid var(--line); }
.rollup > div:last-child { border-bottom: 0; }
.rollup dd { font-weight: 600; }
.rollup-total {
  margin: var(--s-5) 0 0; padding-top: var(--s-5); border-top: 1px solid var(--line);
  font-size: var(--t-xl);
}
.rollup-total strong { font-weight: 700; }
section.land { margin: 44px 0 0; }
/* `.landh` is the same heading at a lower LEVEL. The landing page's sections
   sit directly under the page h1 and are h2s; the settings page is a stack of
   h3 cards, so its account section has to be an h3 too or it outranks
   everything above it — see the note in app-vault.js. Same size either way:
   the level is about structure, not about how loud it looks. */
section.land > h2, section.land > .landh {
  font-size: var(--t-2xl); letter-spacing: -0.01em; margin: 0 0 18px; font-weight: 700; }

/* ---- The landing page's own heading step, and its own rhythm ----
   `section.land` is shared: the landing page's six sections and Settings'
   danger zone are the same component. Settings is a stack of h3 cards and
   keeps --t-2xl — a section head that outshouts the page it is a footnote on
   is the bug the `.landh` comment above is about. The landing page is not a
   stack of cards, it is six arguments in a row, and at 20px its headings sat
   inside the noise of the 13-15px body around them: measured at 1440, the
   type histogram of this page was 24 elements at 13px, 21 at 14px and one at
   30px, which is the distribution of a settings screen.

   The `#landing` scope is what keeps the two apart, and it is also why these
   rules are safe on /new: that page is this same file, and a signed-in reader
   has `#landing` hidden entirely (app-index.js). */
#landing section.land {
  /* 44px separated these when the headings were 20px. At up to 32px the
     sections need proportionally more air, or the heading of the next one
     reads as a subhead of the previous one's last paragraph. */
  margin-top: 72px;
}
#landing section.land > h2 {
  font-size: var(--t-display-2); letter-spacing: -0.02em; margin: 0 0 22px;
}

/* ---- Each section arrives as the reader reaches it ----
   The page is six arguments stacked; scrolling past them all at once is how a
   reader ends up having read the first and the last. A 14px lift over --dur-3
   ("a deliberate reveal") is the whole effect — no stagger, no scale, nothing
   that moves after it has arrived.

   ⚠️ IT FAILS OPEN, AND THAT IS THE ONLY REASON IT IS ALLOWED HERE. The hidden
   state hangs off `#landing.reveal`, and that class is added BY the script
   that also observes the sections. No JavaScript, a thrown exception before
   that line, a browser without IntersectionObserver — every one of them leaves
   the class off and every section fully visible. Writing `opacity: 0` on
   `section.land` directly is the same effect and one broken script away from a
   landing page that is blank for everybody.

   The reduced-motion block does not lean on the zeroed duration tokens: with
   them at 0ms the transition is instant, but the sections still start hidden
   and still snap in on scroll, which is the thing the preference is asking us
   not to do. So the resting state is simply restored. */
#landing.reveal section.land {
  opacity: 0; transform: translateY(14px);
  transition: opacity var(--dur-3) var(--ease), transform var(--dur-3) var(--ease);
}
#landing.reveal section.land.shown { opacity: 1; transform: none; }

/* ---- and the cards inside it arrive one after another ----
   A grid of five feature cards that fades in as one rectangle is a rectangle
   fading in. Eighty milliseconds between them is under the threshold where it
   reads as waiting and over the one where it reads as simultaneous, so the eye
   is walked across the row instead of being handed it.

   Only the three GRIDS. The prose sections (the privacy checks, the FAQ) are
   sentences, and staggering a list of sentences is the page performing at the
   reader rather than showing them something.

   Same `.reveal` scope as the section above, for the same reason, which also
   means the fail-open guard in
   tests/test_the_stylesheet_says_what_it_means.py covers these rules without
   knowing they exist. */
#landing.reveal section.land :is(.grid3, .featgrid, .plans) > * {
  opacity: 0; transform: translateY(10px);
  transition: opacity var(--dur-3) var(--ease), transform var(--dur-3) var(--ease);
}
#landing.reveal section.land.shown :is(.grid3, .featgrid, .plans) > * {
  opacity: 1; transform: none;
}
#landing.reveal section.land :is(.grid3, .featgrid, .plans) > :nth-child(2) { transition-delay: 80ms; }
#landing.reveal section.land :is(.grid3, .featgrid, .plans) > :nth-child(3) { transition-delay: 160ms; }
#landing.reveal section.land :is(.grid3, .featgrid, .plans) > :nth-child(4) { transition-delay: 240ms; }
#landing.reveal section.land :is(.grid3, .featgrid, .plans) > :nth-child(5) { transition-delay: 320ms; }

@media (prefers-reduced-motion: reduce) {
  #landing.reveal section.land,
  #landing.reveal section.land :is(.grid3, .featgrid, .plans) > * {
    opacity: 1; transform: none;
  }
  /* The delays go too, or a reader who asked for no motion still waits 320ms
     for the fifth card to be allowed to exist. */
  #landing.reveal section.land :is(.grid3, .featgrid, .plans) > * {
    transition-delay: 0s;
  }
}
/* TWO TO A ROW ON A PHONE, same `min()` idiom as the vault's document grid: a
   flat `minmax(200px, …)` needs 412px of content box for its second column and
   a 393px phone offers 365, so five cards came down as five full-width rows —
   464px of screen to say what the app does. The `min()` takes half the row
   when that is the smaller number, which is every phone, and leaves every
   width above it exactly as it was. */
.grid3 { display: grid; gap: var(--s-6);
  grid-template-columns: repeat(auto-fit, minmax(min(200px, calc((100% - var(--s-6)) / 2)), 1fr)); }
.step { padding: 16px; background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius); }
.step .n {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; border-radius: 50%;
  background: var(--accent); color: var(--on-accent); font-weight: 700; font-size: var(--t-base);
  margin-bottom: 10px;
}
.step strong { display: block; margin-bottom: 4px; }
.step p { margin: 0; color: var(--muted); font-size: var(--t-base); }

/* ---- Prose stops before the page does ----
   The column went from 900 to 1120 for the hero's sake, and a grid of cards is
   the thing that gains from that. A sentence is not: at 1120 a FAQ answer runs
   past 110 characters a line, which is roughly double a comfortable measure
   and the reason long-line pages feel like work. `--m-prose` is the cap the
   app already uses for exactly this.

   MAX-WIDTH ONLY, no `margin-inline-end: auto` to hold it on the leading edge:
   each of these already carries a `margin` SHORTHAND in its own rule further
   down, and a shorthand resets the logical margins it does not mention. The
   pairing would look correct here and be silently undone there. They start on
   the leading edge anyway, because that is where a block with no auto margin
   starts — in Arabic too. */
details.faq p, .demo-note, .vatnote, ul.checks { max-width: var(--m-prose); }

ul.checks { list-style: none; margin: 0; padding: 0; }
ul.checks li { padding: 5px 0 5px 26px; position: relative; font-size: var(--t-lg); }
ul.checks li::before {
  content: "✓"; position: absolute; left: 2px; color: var(--accent-ink); font-weight: 700;
}
[dir="rtl"] ul.checks li { padding: 5px 26px 5px 0; }
[dir="rtl"] ul.checks li::before { left: auto; right: 2px; }

/* ---- THREE PLANS, SO THREE COLUMNS OR ONE — NEVER TWO ----
   `auto-fit` with a 210px floor gave two columns between 520 and 700px, which
   for three cards means Free and Plus side by side and "Pay per document"
   alone underneath with a hole next to it. Measured at 520, 560, 620 and 700:
   an orphan row every time. A pricing table is a comparison, and a comparison
   that puts one of the options on its own line has already made the choice
   look odd.

   860px is where three fit, and it is a breakpoint this app already has (see
   DESIGN.md) rather than a fourth one invented for this grid.

   `stretch`, not `start`: the cards carry different numbers of bullets — Free
   253px against Plus 566px at one point — and top-aligned that is three
   different bottom edges. They match now, and the buttons line up with them
   because `.plan` is a column with the controls pushed to the end. */
.plans { display: grid; grid-template-columns: 1fr; gap: var(--s-7); align-items: stretch; }
/* THE SENTENCE UNDER THE BUY BUTTON WAS TOUCHING IT. Measured at 393px: zero
   pixels between the button's edge and the paragraph explaining what a
   document is, which reads as one control that has grown a tail rather than a
   button and a note. `.controls` carries no bottom margin — correct where it
   is the last thing in a card — so the space belongs to whatever follows it. */
.plan .controls + .sub { margin-top: var(--s-5); }
@media (min-width: 860px) { .plans { grid-template-columns: repeat(3, 1fr); } }
.plan { display: flex; flex-direction: column; }
.plan { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; }
.plan.popular { border-color: var(--accent); border-width: 2px; }
.plan h3 { color: var(--ink); font-size: var(--t-xl); text-transform: none; letter-spacing: 0; margin-bottom: 2px; }
.plan .price { font-size: var(--t-lg); font-weight: 700; margin: 2px 0 12px; }
/* auto, so the button sits at the foot of the card whatever the card's height
   is — with stretched cards a fixed margin leaves each button at a different
   distance from the bottom, which is the ragged edge moved rather than fixed. */
.plan .controls { margin-top: auto; padding-top: 16px; }
.plan button { width: 100%; }

details.faq { border-bottom: 1px solid var(--line); padding: 2px 0; }
details.faq:first-of-type { border-top: 1px solid var(--line); }
/* ---- The FAQ draws its own disclosure, like the other two ----
   It was the only `<details>` in the app still showing the platform triangle:
   `.catwrap` and `.hiddenwrap` both suppress the UA marker and draw a chevron,
   and this one never got the same treatment — so the last section of the
   landing page ended in four browser-default glyphs sitting under headings set
   in a display cut. Exactly the drift the base-control note near the top of
   this file is about, one element further along.

   `list-style: none` is what actually removes it in Firefox and current
   Chrome (the marker is a ::marker on a list-item box); the -webkit- rule is
   for older WebKit, and both are needed, which is why the other two carry the
   pair as well.

   The chevron is drawn at the START edge and the row is padded to clear it, so
   the question text lines up in Arabic without a second rule — `›` is mirrored
   by the bidi algorithm and `margin-inline` follows the direction. */
details.faq summary {
  cursor: pointer; font-weight: 600; font-size: var(--t-lg); padding: 12px 0;
  list-style: none; padding-inline-start: 22px; position: relative;
}
details.faq summary::-webkit-details-marker { display: none; }
details.faq summary::before {
  content: "›" / ""; position: absolute; inset-inline-start: 4px;
  color: var(--muted); font-weight: 700;
  transition: transform var(--dur-1) var(--ease);
}
details.faq[open] summary::before { transform: rotate(90deg); }

/* ---- The answer opens, it does not appear ----
   A `<details>` toggles `display` on its content, and display is not
   animatable, so the chevron was rotating over 140ms next to a paragraph that
   simply materialised. The half that moved made the half that did not look
   broken.

   `::details-content` is the box the browser already wraps that content in,
   and `interpolate-size: allow-keywords` is what makes `block-size: auto`
   a value you can transition TO — height animation without measuring anything
   in JavaScript or wrapping the markup in a grid-row trick.

   `transition-behavior: allow-discrete` on content-visibility is the piece
   that makes closing work: without it the content is hidden on frame one and
   the height animates on an empty box, so it opens smoothly and shuts
   instantly.

   Set on the element rather than on :root — `interpolate-size` inherits, and
   turning keyword interpolation on for the whole document changes how every
   other `auto` in the app would animate if anything ever transitioned one.

   Browsers without `::details-content` (it is recent) skip the whole thing and
   get the instant open they have now. Nothing to fall back to, nothing to
   break. */
details.faq {
  interpolate-size: allow-keywords;
}
details.faq::details-content {
  block-size: 0;
  overflow: hidden;
  transition: block-size var(--dur-2) var(--ease),
              content-visibility var(--dur-2) var(--ease) allow-discrete;
}
details.faq[open]::details-content { block-size: auto; }

@media (prefers-reduced-motion: reduce) {
  /* The duration tokens already zero here, which handles the movement. The
     block-size floor has to go too, or a reader who asked for no motion gets
     an answer that is 0px tall for one frame instead of no animation. */
  details.faq::details-content { block-size: auto; overflow: visible; }
}
details.faq summary:hover { color: var(--accent-ink); }
details.faq p { margin: 0 0 14px; color: var(--muted); font-size: var(--t-lg); }

footer.site {
  margin-top: 56px; padding: 22px 0 8px; border-top: 1px solid var(--line);
  font-size: var(--t-base); color: var(--muted);
}
footer.site .cols { display: flex; gap: var(--s-9); flex-wrap: wrap; align-items: flex-start; }
footer.site .brandline { display: flex; align-items: center; gap: var(--s-4); color: var(--ink); font-weight: 600; }
footer.site nav { display: flex; gap: var(--s-8); flex-wrap: wrap; }
footer.site a { text-decoration: none; padding: 8px 0; display: inline-block; }
footer.site .disclaimer { margin-top: 14px; font-size: var(--t-md); }

/* These selectors match `.with-sidebar` on EITHER <html> or <body>. theme.js
   puts it on <html> before the first paint from a remembered flag; the sidebar
   code puts it on <body> once /api/me confirms. Without the early one, every
   navigation painted without the rail and then slid it in when the fetch
   landed, a visible flinch on each click, because .sidebar and .wrap both
   carry a var(--dur-2) transition. */
/* ---- sidebar (desktop, signed-in only) ----
   Hidden below 1180px: the phone flow keeps the vault as its own page.
   1180 rather than 1020, measured: introducing a 264px rail plus its
   gutters costs the content column ~320px, so at 1020 the window got ONE
   pixel wider and the reading column went from 1019px to 700px. The rail
   must not arrive until the page can afford it.
   Logical properties so RTL puts it on the right with no extra rules. */
.sidebar { display: none; }
@media (min-width: 1180px) {
  .with-sidebar .sidebar {
    display: block; position: fixed;
    inset-block: 0; inset-inline-start: 0;
    width: 264px; padding: 20px 16px;
    background: var(--card); border-inline-end: 1px solid var(--line);
    overflow-y: auto;
    transform: translateX(0); visibility: visible;
    transition: transform var(--dur-2) cubic-bezier(.4, 0, .2, 1);
  }
  /* Centre the column in the space the sidebar LEAVES, not in the viewport.
     Viewport-centring reads as off-centre once an opaque 264px rail is beside
     it: at 1440 it produced a 96px gutter on the left of the content and 340px
     on the right, which is what "everything hugs the left" actually was. The
     296px floor still keeps it clear of the sidebar on narrower screens. */
  /* `:has(.sidebar)` is load-bearing. theme.js writes `with-sidebar` onto
     <html> from localStorage before the first paint, so the flag is set on
     EVERY page once the reader has seen the vault once — including the admin
     portal, the legal pages, the error page and the offline page, none of
     which contain a rail. Those four were being pushed 288px to the right to
     clear a sidebar that does not exist on them. Ask the document whether
     there is actually a rail rather than trusting a remembered guess. */
  .with-sidebar:has(.sidebar) .wrap {
    margin-inline-start: max(296px, calc(264px + (100% - 264px - 760px) / 2));
    margin-inline-end: 24px;
  }
  /* Same idea for the app column: centre it in what the rail leaves, with a
     floor that keeps it clear of the rail on narrower screens. Pinning it to
     296px was what made the whole page sit hard against the sidebar with all
     the slack piled up on the right. */
  .with-sidebar:has(.sidebar).app-space .wrap {
    margin-inline-start: max(288px, calc(264px + (100% - 264px - var(--m-page)) / 2));
    margin-inline-end: 24px;
  }

  /* One nav, not two. The sidebar already lists Home / Calendar / Profile /
     Documents / Finances, so the pill row underneath the header repeated every
     destination verbatim. The pills stay for phones, where the sidebar is
     hidden, that is the only place they are the nav rather than a duplicate. */
  .with-sidebar #appnav { display: none; }
  /* Collapse: the toggle appears only where the sidebar exists, and hiding it
     re-centers the content. */
  /* The handle: fixed to the sidebar's right edge (264px), vertically centred,
     riding along as the sidebar slides. */
  .with-sidebar #sidetoggle {
    display: grid; place-items: center; position: fixed;
    top: 50%; inset-inline-start: 249px; transform: translateY(-50%);
    width: 30px; height: 30px; padding: 0; border-radius: 50%;
    border: 1px solid var(--line); background: var(--card); color: var(--muted);
    cursor: pointer; z-index: 41; box-shadow: var(--shadow-1);
    transition: inset-inline-start var(--dur-2) cubic-bezier(.4, 0, .2, 1),
                color var(--dur-1) ease, border-color var(--dur-1) ease;
  }
  .with-sidebar #sidetoggle:hover { color: var(--accent-ink); border-color: var(--grad-3); }
  .with-sidebar #sidetoggle .icon { width: 15px; height: 15px; transform: scaleX(-1); }  /* open → point left */
  .with-sidebar.sidebar-collapsed #sidetoggle { inset-inline-start: 12px; }
  .with-sidebar.sidebar-collapsed #sidetoggle .icon { transform: none; }                 /* closed → point right */
  /* RTL mirrors: the sidebar sits on the right, so the arrow points the other way. */
  html[dir="rtl"] .with-sidebar #sidetoggle .icon { transform: none; }
  html[dir="rtl"] .with-sidebar.sidebar-collapsed #sidetoggle .icon { transform: scaleX(-1); }

  /* Collapse: slide the sidebar out and re-center the content, both
     transitioned, so it glides instead of popping. visibility flips only
     AFTER the slide finishes (delay), so it animates yet still leaves the
     keyboard/screen-reader tab order once hidden. */
  .with-sidebar.sidebar-collapsed .sidebar {
    transform: translateX(-100%); pointer-events: none; visibility: hidden;
    transition: transform var(--dur-2) cubic-bezier(.4, 0, .2, 1), visibility var(--dur-1) linear var(--dur-2);
  }
  html[dir="rtl"] .with-sidebar.sidebar-collapsed .sidebar {
    transform: translateX(100%);
  }
  .with-sidebar.sidebar-collapsed .wrap {
    margin-inline-start: auto; margin-inline-end: auto;
  }
}
.wrap { transition: margin var(--dur-2) cubic-bezier(.4, 0, .2, 1); }
.side-new {
  display: block; text-align: center; text-decoration: none;
  background: var(--accent); color: var(--on-accent); font-weight: 600;
  padding: 11px 14px; border-radius: var(--r-sm); margin-bottom: 16px;
  box-shadow: 0 1px 2px rgb(var(--shade-rgb) / .2);
  transition: background-color var(--dur-1) var(--ease);
}
.side-new:hover { background: var(--grad-1); }
.sidebar h3 { margin-top: 4px; }
.side-doc {
  display: flex; align-items: baseline; gap: var(--s-4);
  padding: 9px 8px; border-radius: var(--r-sm);
  text-decoration: none; color: inherit; font-size: var(--t-base);
}
.side-doc:hover { background: var(--hover-bg); }
.side-doc.active { background: var(--calm-bg); font-weight: 600; }
.side-doc .side-title {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.side-empty { color: var(--muted); font-size: var(--t-md); padding: 4px 8px; }
.side-all {
  display: block; font-size: var(--t-md); text-decoration: none;
  padding: 12px 8px 4px; color: var(--accent-ink);
}
/* Next deadlines at the top of the sidebar: for a returning user this is
   the single most valuable thing on the screen, so it comes first. */
.side-rem {
  display: block; text-decoration: none; color: inherit;
  padding: 7px 8px; border-radius: var(--r-sm); font-size: var(--t-md); line-height: 1.35;
}
.side-rem:hover { background: var(--hover-bg); }
.side-rem .when { font-weight: 700; font-variant-numeric: tabular-nums; }
.side-rem.overdue .when { color: var(--urgent-ink); }
.side-rem.soon .when { color: var(--action-ink); }
.side-rem .what {
  display: block; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.side-sep { border: none; border-top: 1px solid var(--line); margin: 14px 0; }

/* Smooth in-page jumps (pricing/FAQ anchors), unless motion is reduced. */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
section.land { scroll-margin-top: 18px; }

/* The plan block moved to Settings; its styles went with it — and so, on that
   day, did one brace too many. The `}` that used to close the block was left
   behind here, closing nothing. It changed no pixel, because an unmatched
   top-level `}` is an error a CSS parser discards; what it did was make every
   brace-counting tool wrong about this file from here down, which is how it
   was eventually noticed. */
/* Bot check: present but not a gate the eye trips over, the reader came here
   in a panic, and the challenge usually resolves without interaction. */
.botcheck { margin-top: 14px; min-height: 0; }

/* What has been attached to a support message, before it is sent.

   A LIST OF NAMES rather than a grid of thumbnails, and that is a decision.
   The reader picked these files seconds ago and knows what is in them; a
   preview would render somebody's residence permit at full width on a screen
   they may be holding in a waiting room, to tell them something they already
   know. The name and the size are what they actually need to check.

   `overflow-wrap` on the name because a phone camera produces filenames like
   IMG_20260816_114233_HDR_edited.jpg, which is wider than the column. */
/* The real <input type="file">. `display:none` on purpose: the BUTTON beside
   it is the control — see support.html — and the input is out of the tab order
   and off the accessibility tree, so there is nothing here to keep visible for
   a keyboard user. That is also what lets the button take the product's single
   :focus-visible ring instead of one hand-drawn on its neighbour. */
.filepick-input { display: none; }
/* Quieter than .go — attaching a file is optional, and the primary action on
   this screen is sending the message. */
.filepick {
  display: inline-flex; align-items: center; gap: var(--s-4);
  padding: var(--s-5) var(--s-7); font: inherit; font-weight: 600;
  color: var(--accent-ink); background: transparent;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  cursor: pointer;
}
.filepick:hover { border-color: var(--accent-ink); }

/* ---- the reader's side of a support conversation --------------------------

   BUBBLES, and leaning on the inline axis rather than the physical one so the
   sides swap in Arabic on their own: "us" sits on the trailing edge in English
   and the leading edge in Arabic, which is what every messaging app the reader
   already uses does. */
/* The list of conversations, when a reader has more than one. Each row is a
   BUTTON rather than a link: there is no URL for a thread — the whole screen is
   one page that swaps sections — and a link with no href is a thing a keyboard
   cannot reach. */
.supthreads { list-style: none; margin: 0; padding: 0; }
.supthreads li { margin-bottom: var(--s-4); }
.supthread-row {
  display: grid; gap: var(--s-2); width: 100%; text-align: start;
  padding: var(--s-6) var(--s-7); font: inherit; color: inherit;
  background: transparent; border: 1px solid var(--line);
  border-radius: var(--r-sm); cursor: pointer;
}
.supthread-row:hover { border-color: var(--accent-ink); }
.supthread-kind { display: flex; align-items: center; gap: var(--s-4);
  font-size: var(--t-xs); font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--accent-ink); }
/* One line of the opening message. `min-width: 0` because a grid child will not
   shrink below its content otherwise, and a long first line would push the row
   wider than the card. */
.supthread-gist { min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.supthread-when { font-size: var(--t-xs); color: var(--muted); }

.supturns { display: flex; flex-direction: column; gap: var(--s-6);
  margin-bottom: var(--s-7); }
.supturn { max-width: 34em; padding: var(--s-6) var(--s-7);
  border: 1px solid var(--line); border-radius: var(--r-sm); }
.supturn-you { align-self: flex-start; }
.supturn-us { align-self: flex-end; background: var(--calm-bg);
  border-color: var(--calm-border); }
/* An answer the reader has not opened before. Marked on the edge that starts
   the text, so it reads as "this one" rather than decorating the whole card. */
.supturn-new { border-inline-start: 3px solid var(--accent-ink); }
.supturn-who { margin: 0 0 var(--s-2); font-size: var(--t-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.supturn-body { white-space: pre-wrap; overflow-wrap: anywhere; }
.supturn-when { margin: var(--s-4) 0 0; font-size: var(--t-xs); color: var(--muted); }
.supturn-waiting { align-self: flex-start; margin: 0; }
.supturn-atts { display: flex; flex-wrap: wrap; gap: var(--s-4);
  margin-top: var(--s-5); }
.supturn-atts img { display: block; width: 96px; height: 96px; object-fit: cover;
  border: 1px solid var(--line); border-radius: var(--r-xs); }
/* A PDF is a download, never rendered inline — the same split the operator's
   portal makes, and for the same reason: an image was re-encoded from its
   pixels on the way in and a PDF was not. */
.supturn-att-file { display: inline-block; max-width: 16em; padding: var(--s-4) var(--s-5);
  font-size: var(--t-sm); border: 1px solid var(--line); border-radius: var(--r-xs);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.supfiles { list-style: none; margin: var(--s-5) 0 0; padding: 0; }
.supfiles li {
  display: flex; align-items: center; gap: var(--s-5);
  padding: var(--s-4) var(--s-6); margin-bottom: var(--s-3);
  border: 1px solid var(--line); border-radius: var(--r-xs);
}
.supfile-name {
  flex: 1; min-width: 0; font-size: var(--t-sm); color: var(--muted);
  overflow-wrap: anywhere;
}

/* The three facts about what happens to an uploaded letter, under the upload
   box. Quieter than the copy around it and deliberately NOT a badge: a green
   shield next to a privacy claim is the visual language of a cookie banner, and
   this is a sentence a reader should be able to check rather than a seal they
   are asked to trust.

   NO NEGATIVE MARGIN. It had -12px to sit closer under #noaccount, which is
   also a .sub and carries a 22px bottom gap — but #noaccount is hidden for a
   signed-in reader, so on the "Add another document" screen the pull-up landed
   on the submit button and this line was drawn straight through it. A margin
   that depends on a sibling being visible breaks on the page where it is not. */
.upload-trust { font-size: var(--t-base); margin: 10px 0 0; }

.side-upgrade { width: 100%; margin-top: 10px; font-size: var(--t-base); padding: 9px 12px; min-height: 40px; }
/* Same footprint as the upgrade button, quieter: on Plus this is the way out
   (change card, cancel), and it should be easy to find without competing with
   the app itself for attention. */
.side-manage { width: 100%; margin-top: 10px; font-size: var(--t-base); padding: 9px 12px; min-height: 40px; }

/* ---- document translation ---- */
.langbtns { display: flex; gap: var(--s-4); flex-wrap: wrap; margin-top: 4px; }
/* Language chips, light, secondary, one per language not yet translated. */
.langchip {
  display: inline-flex; align-items: center; min-height: 0;
  padding: 8px 15px; border: 1px solid var(--line); border-radius: var(--r-pill);
  background: var(--card); color: var(--ink); font-size: var(--t-base); font-weight: 600;
  cursor: pointer; transition: border-color var(--dur-1) ease, color var(--dur-1) ease, background var(--dur-1) ease;
}
.langchip:hover {
  border-color: var(--grad-3); color: var(--accent-ink);
  background: var(--hover-bg);
}

/* ---- staged reveal ----
   The `rise` keyframes live once, in the motion block further down. This used
   to declare a second 9px version of the same name here; the later definition
   won and this one never ran, so the stagger below was always lifting 16px. */
.stage { animation: rise .38s ease both; }
.stage:nth-child(2) { animation-delay: .10s; }
.stage:nth-child(3) { animation-delay: .20s; }
.stage:nth-child(4) { animation-delay: .30s; }
.stage:nth-child(5) { animation-delay: .40s; }
.stage:nth-child(6) { animation-delay: .50s; }
@media (prefers-reduced-motion: reduce) { .stage { animation: none; } }

[dir="rtl"] { text-align: right; }
[dir="rtl"] ol.steps { padding-left: 0; padding-right: 22px; }
[dir="rtl"] .verdict { border-left: none; border-right: 5px solid var(--info); }
[dir="rtl"] .verdict.action-needed { border-right-color: var(--action); }
[dir="rtl"] .verdict.URGENT { border-right-color: var(--urgent-ink); }
[dir="rtl"] .low { margin-left: 0; margin-right: 6px; }

/* ==========================================================================
   PRINT
   Not a nicety for this product. A user takes the explanation to a Bürgeramt,
   a Beratungsstelle, or a lawyer, and hands it to someone who reads German.
   The printed page must stand alone: no navigation, no interface chrome, and
   the source evidence visible so the reader can check it against the letter.
   ========================================================================== */
@media print {
  :root { --line: #999; }

  body { background: #fff; color: #000; font-size: 11pt; line-height: 1.4; }
  .wrap { max-width: none; padding: 0; }

  /* Interface only, meaningless on paper. */
  .top, .controls, .drop, .files, .waiting, button, select, input,
  .navlink, .badge, #langwrap, #who, .doc .dot, .skip, .sidebar,
  section.land, footer.site, .hero, .herosub {
    display: none !important;
  }

  .card, .note, .warn, .lawyer {
    border: none; border-radius: 0; padding: 0; margin: 0 0 10pt;
    background: var(--on-accent); box-shadow: none; color: #000;
  }
  .stage { animation: none !important; }

  /* Keep the urgency signal, the most important thing on the page. */
  .verdict { border-left: 3pt solid #000; padding-left: 8pt; }
  /* AND PUT IT ON THE RIGHT SIDE IN ARABIC, FARSI, URDU AND PASHTO, which the
     rule above did not. `[dir="rtl"] .verdict` in the screen styles is
     specificity (0,2,0) against that rule's (0,1,0) and @media adds none, so on
     a right-to-left page the screen rule won inside the print block. Measured
     in Chromium with print media emulated, not reasoned about:

         before   border-right 5px   padding-LEFT 10.67px
         after    border-right 4px   padding-right 10.67px   (4px = 3pt)

     The padding was the visible half: 8pt of space on the side with no stripe,
     and the stripe pressed against the text on the side that had one.

     WHAT THIS DOES NOT FIX, because it is not an RTL bug at all: the stripe
     prints AMBER, not black, and it does so in both directions. `#000` here is
     specificity (0,1,0) and `.verdict.action-needed { border-left-color:
     var(--action) }` up in the screen styles is (0,2,0), so the colour rule has
     always won — measured the same way, LTR printed rgb(176,111,20) before this
     change and after it. Whether a printed stripe should be black (legible on
     any photocopier) or keep its urgency colour is a product question, so it is
     written down here rather than decided in passing. */
  [dir="rtl"] .verdict {
    border-left: none; padding-left: 0;
    border-right: 3pt solid #000; padding-right: 8pt;
  }
  .utag { font-weight: 700; color: #000; }
  .title { font-size: 15pt; }
  h3 { font-size: 8pt; letter-spacing: .06em; color: #000;
       border-bottom: 0.5pt solid #999; padding-bottom: 2pt; }

  /* A deadline must survive being photocopied in grayscale. */
  .deadline .d { font-size: 16pt; font-weight: 700; }
  .hard { background: none; color: #000; border: 1pt solid #000; padding: 0 3pt; }
  .low { background: none; color: #000; border: 0.5pt solid #000; }

  /* Evidence quotes are the point on paper: the reader can verify each
     extracted date and amount against the original letter in their hand. */
  .ev { font-size: 9pt; color: #333; }

  .card, .rem, ol.steps li, tr { break-inside: avoid; page-break-inside: avoid; }
  h3 { break-after: avoid; page-break-after: avoid; }

  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 8pt; color: #555; }
  a[href^="/"]::after { content: ""; }

  .orig img { max-height: 16cm; width: auto; border: 0.5pt solid #999; }

  .run { font-size: 8pt; color: #555; }
  .run::after {
    display: block; margin-top: 4pt;
    content: "Generated by Fahim. Explanation and information only, not legal advice.";
  }
}

/* ==========================================================================
   App shell v2, one product, several spaces.
   A flat ground, solid cards, and a pill nav that makes Home / Documents /
   Money / Profile peers, so Fahim reads as a life-admin app, not a list of
   letters with extra buttons.
   ========================================================================== */

/* ---- ground ----
   The page IS the ground: var(--bg), flat. The aurora (three drifting blurred
   light fields plus a dot grid) lived here until 2026-08; it was decoration
   that cost a fixed full-screen composited layer on a product whose design
   target is a mid-range phone, and it was the single loudest "AI default
   aesthetic" tell on every screen. */

/* ---- icon helper ---- */
.icon { display: inline-grid; place-items: center; flex: none; width: 18px; height: 18px; }
.icon svg { width: 100%; height: 100%; display: block; }

/* ---- app nav: the spaces, as peers ---- */
.appnav {
  /* Sticky offsets are measured from the VIEWPORT, not from the padded column,
     so the inset has to be repeated here — otherwise the nav scrolls up and
     parks itself under the Dynamic Island, which is worse than the header bug
     because it happens to a control the reader is trying to press. */
  position: sticky; top: calc(10px + var(--sa-top)); z-index: 35;
  display: flex; gap: var(--s-2); padding: 5px; margin: 12px auto 8px;
  /* `auto` inline margins, not 0. The bar is `width: max-content`, so it is
     always narrower than the column it heads — and parked at the leading edge
     it read as pushed into the corner rather than as the header of the page,
     which is exactly how it looks once the window narrows and the gap to its
     right grows. */
  width: max-content; max-width: 100%; overflow-x: auto; scrollbar-width: none;
  border: 1px solid var(--line); border-radius: var(--r-pill);
  background: var(--card);
  box-shadow: var(--shadow-1);
}
.appnav::-webkit-scrollbar { display: none; }
.appnav[hidden] { display: none; }
.appnav a {
  display: inline-flex; align-items: center; gap: var(--s-4); padding: 8px 14px;
  border-radius: var(--r-pill); text-decoration: none; white-space: nowrap;
  color: var(--muted); font-size: var(--t-base); font-weight: 600;
  transition: color var(--dur-1) ease, background var(--dur-1) ease;
}
.appnav a .icon { width: 16px; height: 16px; }
.appnav a:hover { color: var(--ink); background: var(--chip-bg); }
.appnav a[aria-current="page"] {
  color: var(--on-accent); background: var(--accent);
}
/* Splits the nav into the overview Fahim derives (home/calendar/profile) and
   the vaults you fill (documents/passwords/finances). */
.navsep { flex: 0 0 auto; align-self: stretch; width: 1px; margin: 5px 3px;
  background: var(--line); }
/* Phones and small tablets: seven labelled pills don't fit, so the bar goes
   icon-only (labels are kept for screen readers). Every section stays one
   visible tap away instead of two of them hiding behind a silent horizontal
   scroll — `scrollbar-width: none` means the overflow gives no hint at all.
   860px, not 767px: measured, the labelled bar needs 796px of track, so
   between 768px and ~860px it was overflowing invisibly. */
@media (max-width: 860px) {
  .appnav { width: 100%; justify-content: space-around; gap: var(--s-1); }
  /* THE SEVEN SHARE THE BAR RATHER THAN EACH CLAIMING 47px.
     Icon-only fixed the labels but not the arithmetic: 21px of icon inside
     13px of padding either side is a 47px item that never shrinks, and seven
     of them plus the gaps and the separator need 354px of track. A phone has
     the column width minus its margins — 352px at 390px wide, less below that
     — so from the iPhone 15 downwards the last tab was cut in half by the
     viewport edge, and because `scrollbar-width: none` hides the overflow it
     read as a rendering fault rather than as something scrollable. It was the
     SETTINGS tab that went, and on the settings screen that tab is the filled
     one, so the state indicator was the casualty.
     `flex: 1 1 0` with `min-width: 0` lets them divide whatever there is:
     ~44px each at 390px, ~36px at 320px, still a full icon and a bigger tap
     target than the fixed pill left room for. */
  .appnav a { padding: 9px 4px; gap: 0; flex: 1 1 0; min-width: 0;
              justify-content: center; }
  .appnav a .icon { width: 21px; height: 21px; }
  .appnav a span:not(.icon) {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
    white-space: nowrap; border: 0;
  }
}

/* ---- ON A PHONE THE NAV LIVES AT THE BOTTOM. ---------------------------
 *
 * Every app this audience already has puts its sections under the thumb —
 * and the reader this product is designed for is holding the phone one-handed
 * at a mailbox. The bar was pinned to the TOP, which is the corner of a 6.8"
 * screen a thumb cannot reach without regripping, and it also spent ~50px of
 * the first screen before any content: measured on Finances at 390px, the nav
 * pill and the header together came to 114px of a 465px run-up.
 *
 * Fixed rather than sticky, and out of flow, so those pixels go back to the
 * page. The reader gets the sections in reach AND a taller first screen from
 * one change.
 *
 * ICON-ONLY STILL. The block above hides the labels from 860px down and gives
 * its reasons; six tabs with words under them is the shape the reference apps
 * use, but they carry five tabs in one language. "Documents" is "Dokumente",
 * "المستندات", "दस्तावेज़" here, and a label that fits English and truncates in
 * the other fifteen is worse than no label at all. The icons stay; the words
 * stay for screen readers.
 *
 * 560px, not 860: a tablet in portrait has the reach AND the room, so it keeps
 * the pill at the top where it reads as the header of the page.
 */
@media (max-width: 560px) {
  .appnav {
    position: fixed; inset-block-start: auto; inset-block-end: 0;
    inset-inline: 0; margin: 0; width: 100%; max-width: none;
    /* The bar is the floor of the screen now, so it is bounded on one side
       only — a full border and a radius would draw a pill floating over the
       home indicator. */
    border: 0; border-block-start: 1px solid var(--line); border-radius: 0;
    box-shadow: none;
    padding: var(--s-2) var(--s-3);
    /* THE HOME INDICATOR. Without this the last row of tabs sits under it on
       every notched iPhone, which is the one place a fixed bottom bar always
       goes wrong. */
    padding-block-end: calc(var(--s-2) + var(--sa-bottom));
    /* Over the page, under the chat drawer (45) and its scrim. */
    z-index: 42;
  }
  /* A pill inside a square bar reads as a button someone forgot to finish. */
  .appnav a { border-radius: var(--r-md); }
  /* AND THE FAB CLIMBS ON TOP OF IT. It was 20px off the bottom, which is now
     the middle of the nav. */
  .chatfab { inset-block-end: calc(76px + var(--sa-bottom)); }
  /* The room under the last row is set with the phone's other `.wrap` sizing,
     further down this file — NOT here. `.wrap` is the page column and this
     file already narrows its gutter in one phone block;
     test_the_landing_header_reaches_exactly_the_page_gutter reads the FIRST
     `.wrap` rule inside the FIRST `@media (max-width: 560px)` and compares its
     `padding-inline` against the landing header's pull-out. A second phone
     `.wrap` rule declared earlier answers that question with a rule that has no
     gutter in it at all, and the guard reports the gutter as gone. */
}

/* ---- Home ---- */
.hgreet {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: var(--s-7); flex-wrap: wrap; margin: 20px 0 20px;
}
/* The start button, which only exists while the account is empty. `:empty`
   because a flex child with nothing in it still takes its share of the row's
   gap — 16px of stray space on every account that has documents, for a slot
   that is not rendering anything. Same idiom as `.filtercount:empty`. */
.hstart:empty { display: none; }
.hgreet h2 {
  margin: 0; font-size: clamp(27px, 4.6vw, 38px); line-height: 1.14;
  letter-spacing: -.02em; font-weight: 700;
}
.hgreet .sub { margin: 7px 0 0; font-size: var(--t-lg); max-width: 34em; }

/* THREE TILES, AND THREE IS ODD. `auto-fit` was right while the row held four
   and wrong the moment it held three: on a phone it lays two and leaves the
   third at half width beside an empty cell, which reads as a tile that failed
   to load. Columns are counted explicitly instead — two on a phone with the
   odd one out taking the whole row, three side by side from 561px, the width
   the rest of this file already treats as "not a phone". Written to hold for
   any odd count, so a fourth tile returning needs no change here. */
.stats {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-6); margin: 0 0 14px;
}
.stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
@media (min-width: 561px) {
  .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .stats > .stat:last-child:nth-child(odd) { grid-column: auto; }
}
.stat {
  display: flex; flex-direction: column; gap: var(--s-4); padding: 16px;
  min-width: 0;                                   /* let long currency values shrink, not overflow */
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--card); box-shadow: var(--shadow-1);
  text-decoration: none; color: inherit;
  transition: transform var(--dur-2) ease, box-shadow var(--dur-2) ease, border-color var(--dur-2) ease;
}
.stat:hover { transform: translateY(-2px); box-shadow: var(--shadow-2); border-color: var(--grad-3); }
.sicon {
  width: 34px; height: 34px; border-radius: var(--r-sm); display: grid; place-items: center;
  color: var(--accent-ink);
  background: rgb(var(--accent-rgb) / .08);
  border: 1px solid rgb(var(--accent-rgb) / .18);
}
.sicon .icon { width: 18px; height: 18px; }
.statval {
  font-size: clamp(18px, 2.6vw, 30px); font-weight: 800; letter-spacing: -.02em;
  line-height: 1.1; font-variant-numeric: tabular-nums;
  min-width: 0; overflow-wrap: anywhere;          /* "EGP 1,105.40" wraps instead of spilling over the next tile */
}
.statval.txt { font-size: clamp(16px, 2.4vw, 21px); }
/* Currency amounts run longer than a plain count; size them to sit on one
   line in a narrow tile (still wraps, never overflows, on huge values). */
.statval.money { font-size: clamp(16px, 1.9vw, 23px); }
.statlabel {
  font-size: var(--t-md); color: var(--muted); font-weight: 600;
}

/* 200px, not 215. There are three of these and they should sit as three: at
   215 the row needed 681px and the content column between the sidebar arriving
   and the window getting wide is 665px, so Profile dropped onto a line of its
   own next to 450px of nothing. Measured at 1020–1090. */
.spaces {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--s-6); margin: 8px 0 4px;
}
.space {
  position: relative; overflow: hidden;
  display: flex; flex-direction: column; gap: var(--s-4); padding: 18px;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--card); box-shadow: var(--shadow-1);
  text-decoration: none; color: inherit;
  transition: transform var(--dur-2) ease, box-shadow var(--dur-2) ease, border-color var(--dur-2) ease;
}
.space:hover { transform: translateY(-3px); box-shadow: var(--shadow-2); border-color: var(--grad-2); }
.space strong { font-size: var(--t-xl); letter-spacing: -.01em; margin-top: 2px; }
.space .ssub { color: var(--muted); font-size: var(--t-base); line-height: 1.45; }
.space .scount { font-size: var(--t-md); color: var(--accent-ink); font-weight: 700; }
/* The hint slot is pre-rendered empty so a card frame can paint before its
   count arrives (Part B); an empty one must take no room, or a space card with
   no hint would carry a blank line and a flex gap. */
.space .scount:empty { display: none; }
.space .chev {
  position: absolute; inset-inline-end: 14px; inset-block-start: 17px;
  width: 18px; height: 18px; color: var(--muted);
  transition: transform var(--dur-2) ease, color var(--dur-2) ease;
}
.space:hover .chev { color: var(--accent-ink); transform: translateX(3px); }
[dir="rtl"] .space .chev { transform: scaleX(-1); }
[dir="rtl"] .space:hover .chev { transform: scaleX(-1) translateX(3px); }

.homerow { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--s-6); margin-top: 12px; align-items: start; }
.homerow .card { margin-bottom: 0; }
/* Group heading inside the Home mini card. Small and quiet: it exists to stop
   "What's coming up" sitting over a date that has passed, not to add furniture. */
.minisub {
  margin: 12px 0 4px; font-size: var(--t-md); font-weight: 700; color: var(--muted);
}
.minisub:first-child { margin-top: 2px; }

.mini-item {
  display: flex; gap: var(--s-5); align-items: baseline; padding: 9px 0;
  border-bottom: 1px solid var(--line); text-decoration: none; color: inherit; font-size: var(--t-lg);
}
.mini-item:last-of-type { border-bottom: 0; }
.mini-item .mtitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The truncation has to happen inside the isolated run, not on the RTL flex
   child: a German description in an Arabic UI was losing its FIRST words
   ("…nce-permit appointment") instead of its last. */
.mtitle > bdi { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mini-item .mwhen { font-size: var(--t-md); color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mini-item.overdue .mwhen { color: var(--urgent-ink); font-weight: 700; }
.mini-item.soon .mwhen { color: var(--action-ink); font-weight: 600; }
/* Folded into .seeall below — see the note there. Kept as a name because the
   markup uses it and the two links sit side by side on the home page. */
.mini-more { margin-top: 10px; }

/* ---- space page headers (Money, Profile) ---- */
.spacehead { display: flex; gap: var(--s-6); align-items: center; margin: 0 0 4px; }
.spacehead .sicon { width: 40px; height: 40px; border-radius: var(--r-md); }
.spacehead .sicon .icon { width: 20px; height: 20px; }
/* h2 since the space headings were promoted: each view now titles itself at
   level 2 under the shell's one h1, instead of jumping straight to h3 and
   leaving every screen without a heading a screen reader could land on. h3 is
   kept in the selector for any block that has not moved. */
.spacehead h2, .spacehead h3 {
  margin: 0; font-size: var(--t-2xl); letter-spacing: -.01em;
  text-transform: none; color: var(--ink); font-weight: 700;
}
.spacehead-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-6); flex-wrap: wrap; margin: 4px 0 18px;
}

/* ---- sidebar: spaces group ---- */
.side-spaces { display: flex; flex-direction: column; gap: var(--s-1); margin: 0 0 16px; }
.side-spaces a {
  display: flex; align-items: center; gap: var(--s-5); padding: 9px 10px;
  border-radius: var(--r-sm); text-decoration: none; color: var(--ink);
  font-weight: 600; font-size: var(--t-base);
}
.side-spaces a .icon { width: 17px; height: 17px; color: var(--accent-ink); }
.side-spaces a:hover { background: var(--hover-bg); }
/* SELECTED, NOT LIFTED.
   This was a filled accent panel, which in a column of otherwise flat rows read
   as a raised chip sitting on top of the list rather than as one of its
   members — the eye saw a floating object where it should have seen emphasis.
   Weight and colour say "you are here" without giving the row a surface of its
   own, so the rail stays one flat list.
   Not colour alone: the label also goes bolder, so the current section is still
   legible to a reader who cannot separate the accent from the ink. */
.side-spaces a[aria-current="page"] {
  color: var(--accent-ink);
  font-weight: 700;
}
/* Divides the derived views from the vaults you fill. */
.side-navsep { border: none; border-top: 1px solid var(--line); margin: 8px 8px; }

/* ---- the pending state: a real header, and a cue only if the wait is real ----

   The header a space paints before its data arrives is the REAL header, so
   there is nothing here to style. Two details make it feel like one page
   loading rather than two pages swapping.

   `.pending` is a hairline that stays INVISIBLE for its first 220ms. Most
   navigations resolve inside that, and a loading cue that flashes up and
   straight back out is worse than none — it draws the eye to something that
   has already finished. The delay is on the animation rather than on a
   timer, so nothing has to be cancelled when the data wins the race.

   `#view.settled` marks a view whose header was already painted as a pending
   state. Only the FIRST child is silenced -- that, and nothing else, is what
   was pre-painted. `.rise` fades and lifts a card 16px, and replaying it under
   a header already on screen made the page settle a second time: measured, the
   header dropped 16px the moment the data landed. Cards further down the view
   are genuinely arriving and keep their entrance, as does every screen that
   paints no pending header at all. */
.pending {
  height: 2px; margin-top: var(--s-7); border-radius: var(--r-pill);
  background: var(--line); overflow: hidden;
  opacity: 0; animation: pending-in var(--dur-2) var(--ease) 220ms forwards;
}
.pending::after {
  content: ""; display: block; height: 100%; width: 35%; border-radius: inherit;
  background: linear-gradient(90deg, transparent, var(--grad-2), transparent);
  animation: pending-sweep 1.15s var(--ease) infinite;
}
[dir="rtl"] .pending::after { animation-name: pending-sweep-rtl; }

/* THE READING'S OWN BAR, thicker than the hairline everywhere else.
   .pending is 2px because it usually sits over a card that resolves in a few
   hundred milliseconds — a wait you are not meant to look at. The reading is
   the opposite: it is the longest thing this product does, it is the only
   thing on the card, and the reader has just been sent to this screen to watch
   it. At 2px it read as a divider rather than as progress.
   Scoped to that card so nothing else in the app changes weight. */
#readingcard .pending {
  height: 6px;
  margin-top: 0;
}
#readingcard .pending::after {
  /* Wider and slower to match: a 35% sliver sprinting across a 6px bar looks
     frantic, which is the wrong feeling for "this takes 30-90 seconds". */
  width: 45%;
  animation-duration: 1.6s;
}

/* The same hairline, fixed to the top of the viewport: background work that
   belongs to the PAGE rather than to one card — see progress.js. Above
   everything (a dialog can be open while a localisation finishes) and never
   interactive, so it cannot swallow a tap meant for the header beneath it. */
.worktop {
  /* Below the status bar, not under it: a 3px hairline drawn behind the clock
     is a progress indicator nobody can see, which is the whole of its job. */
  position: fixed; inset-block-start: var(--sa-top); inset-inline: 0; z-index: 9999;
  height: 3px; background: var(--line); overflow: hidden; pointer-events: none;
  /* NO ANIMATION-DELAY. There used to be 220ms here, for the reason
     .section-loading has one: work that finishes immediately should never
     flash a cue. That job moved to progress.js's SHOW_AFTER_MS, which does it
     for the reduced-motion reader too — the block at the bottom of this
     section turns the fade off, so the delay built into it protected everybody
     except the person who asked for less motion. Two delays would now stack
     into two-thirds of a second before the reader learns anything. */
  opacity: 0; animation: pending-in var(--dur-2) var(--ease) forwards;
}
.worktop::after {
  content: ""; display: block; height: 100%; width: 35%;
  background: linear-gradient(90deg, transparent, var(--grad-2), transparent);
  animation: pending-sweep 1.15s var(--ease) infinite;
}
[dir="rtl"] .worktop::after { animation-name: pending-sweep-rtl; }

/* What the hairline is FOR, in words — "Loading…", or "Translating to your
   language…" when that is what is actually happening (progress.js picks).
   A sweeping line reports that something is happening and refuses to say what,
   which is not much comfort during the several seconds a fresh language takes.

   Fixed and pointer-events: none for the bar's reasons, and it hangs BELOW the
   hairline (3px, its height) rather than displacing anything: a page must not
   reflow because a background fetch started. Centred, so it reads the same in
   an RTL layout without a mirrored rule. */
.worklabel {
  /* Still hanging 3px below the hairline — which has itself moved down by the
     inset, so the two stay together. */
  position: fixed; inset-block-start: calc(3px + var(--sa-top)); inset-inline: 0; z-index: 9999;
  display: flex; justify-content: center; pointer-events: none;
  /* Delay lives in progress.js now — see the note on .worktop above. */
  opacity: 0; animation: pending-in var(--dur-2) var(--ease) forwards;
}
/* THE FIFTH TIME THIS BUG SHIPPED, and the one that cost the most to find.

   `hidden` hides through the user-agent rule [hidden]{display:none}, and any
   author rule that sets `display` outranks the user-agent sheet whatever its
   order or specificity. The line above sets `display: flex` (it centres the
   pill), so endWork()'s `_label.hidden = true` set the attribute and changed
   nothing on screen.

   The bar above it has no `display` of its own, so it obeyed — which is why
   the reported symptom was a CAPTION with no hairline over it, pinned to the
   top of the vault after a language switch with every request sitting at 200
   in the network panel. Two rounds of timeouts were added to a counter that
   had been reaching zero correctly the whole time. */
.worklabel[hidden] { display: none; }
/* .worktop needs no `display`, and must never acquire one. Guarded anyway: the
   two are toggled by the same two lines of endWork(), and the next person to
   give this bar a `display: block` will have no reason to connect it to a
   caption that stopped going away. */
.worktop[hidden] { display: none; }
.worklabel > span {
  background: var(--card); color: var(--muted);
  border: 1px solid var(--line); border-start-start-radius: 0;
  border-start-end-radius: 0; border-end-start-radius: var(--r-pill);
  border-end-end-radius: var(--r-pill); border-block-start: 0;
  /* --t-md, not --t-xs. At 11px this was the smallest text in the product,
     centred against the top edge where nothing else competes for the eye — and
     it is the only thing on screen explaining a wait that runs for several
     seconds. A reader who cannot read the sentence gets the wait without the
     explanation, which is the state this caption exists to prevent. It is still
     a quiet muted pill; it is just legible now. */
  padding: var(--s-2) var(--s-6); font-size: var(--t-md);
  box-shadow: var(--shadow-1);
}
@media (prefers-reduced-motion: reduce) {
  .worktop { opacity: 1; animation: none; }
  .worktop::after { animation: none; width: 100%;
    background: var(--grad-2); opacity: .45; }
  /* Same bargain as the bar: the cue is information, so it stays; it just
     stops fading in. */
  .worklabel { opacity: 1; animation: none; }
}
@keyframes pending-in { to { opacity: 1; } }
@keyframes pending-sweep { from { transform: translateX(-100%); } to { transform: translateX(320%); } }
@keyframes pending-sweep-rtl { from { transform: translateX(320%); } to { transform: translateX(-100%); } }
#view.settled > .card:first-child,
#view.settled > .spacehead-row:first-child { animation: none; }
/* Reduced motion keeps the cue — it is information, not decoration — and
   stops it moving. */
@media (prefers-reduced-motion: reduce) {
  .pending { opacity: 1; animation: none; }
  .pending::after { animation: none; width: 100%;
    background: var(--grad-2); opacity: .45; }
}

/* A SECTION STILL FETCHING ITS OWN DATA, without holding up the page around it.
   Part B of the instant-navigation work: a page paints every card's FRAME at
   once and each card fills when its OWN read lands, so the slow read (the money
   summary — it aggregates every document and converts currencies) can never
   stall the fast ones. While a card waits, its box is blurred and dimmed with a
   centred spinner on top; renderSection() in app-vault.js adds this class the
   instant it starts a fetch and removes it when the data arrives.

   This is NOT the skeleton the note below rules out. A skeleton is a grey guess
   at content that has not come; this is an overlay on the REAL, already-correct
   frame of a card — its icon, its heading, its label are all known before the
   fetch — so nothing is faked and nothing moves when the value fills in.

   Same ~200ms show-delay as .pending above: the blur and the spinner are
   invisible for their first 200ms, so a section that resolves in 50ms (every
   local read does) never flashes them. The delay lives on the animation, not a
   JS timer, so nothing has to be cancelled when the data wins the race. The
   spinner is a pseudo-element, so `> *` blurs the content beneath it and leaves
   the spinner itself sharp. Theme-aware through the shared vars. */
.section-loading { position: relative; }
.section-loading > * {
  pointer-events: none;
  animation: section-blur var(--dur-2) var(--ease) 200ms forwards;
}
.section-loading::after {
  content: ""; position: absolute; inset: 0; margin: auto;
  width: 26px; height: 26px; border-radius: 50%;
  border: 3px solid var(--line); border-top-color: var(--accent);
  opacity: 0;
  animation: pending-in var(--dur-2) var(--ease) 200ms forwards,
             spin 1s linear 200ms infinite;
}
@keyframes section-blur { to { filter: blur(3px); opacity: .55; } }
/* Reduced motion keeps the cue — a spinner is information — and stops it moving
   and stops the blur, exactly like .pending's fallback. The 200ms delay is kept
   so a fast section still never flashes it; only the spin and the blur go. */
@media (prefers-reduced-motion: reduce) {
  .section-loading > * { animation: none; }
  .section-loading::after {
    animation: pending-in var(--dur-2) var(--ease) 200ms forwards;
  }
}

/* NO SKELETON STYLES HERE ANY MORE, on purpose.
   A first version of the loading state drew pulsing grey blocks. They are a
   placeholder for a placeholder: every space's header — icon, name, one-line
   description — is known before the fetch starts, because none of it comes
   from the server. The pending state renders that REAL header instead (see
   _paintPending in app-vault.js) and leaves the data area empty, so when the
   content lands the header does not change at all.

   The one loading VISUAL that remained is .section-loading directly above: a
   blur-and-spinner OVERLAY on a card whose frame is already real, shown only
   while that one card's data is still in flight. It is not a skeleton for the
   reason in its own note — it fakes no content — which is why it lives here
   rather than being ruled out with the grey blocks. */

/* ---- motion ----
 *
 * ONE `rise`, DEFINED HERE. There were two @keyframes of this name — a 9px lift
 * up in the staged-reveal block and this 16px one — and CSS resolves a repeated
 * keyframes name by taking the LAST, so the 9px version never ran anywhere. The
 * comment describing it was documenting a rule the browser had already thrown
 * away. `.stage` and `.rise` now share this one visibly rather than by accident;
 * see the staged-reveal block, which keeps only its timing and its delays. */
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.rise { animation: rise .55s cubic-bezier(.22, .7, .25, 1) both; animation-delay: var(--d, 0ms); }
/* WHILE A VIEW TRANSITION IS RUNNING, the cross-fade IS the entrance.
   _swap() in app-vault.js sets this for the length of the transition: without
   it an incoming card both fades in (the transition) and climbs 16px (this
   rule), which reads as the screen arriving twice. */
html.vt-running .rise, html.vt-running .stage { animation: none; }
/* Already arrived — the cross-fade was the entrance. Set by _swap() on the way
   out of a transition, so lifting the suppression above cannot restart an
   animation on content the reader is already looking at. Permanent for these
   nodes and these only; the next navigation renders new ones. */
.rise-spent { animation: none; }
.doc:hover { transform: translateY(-2px); box-shadow: var(--shadow-2); }

@media (prefers-reduced-motion: reduce) {
  .rise { animation: none; }
  .card, .stat, .space, .doc, button.go, a.go, .side-new, .drop, .appnav a,
  .sidebar, .wrap { transition: none !important; }
  .stat:hover, .space:hover, .doc:hover, button.go:hover:not(:disabled), a.go:hover,
  .side-new:hover, .drop:hover { transform: none; }
}

@media print { .appnav { display: none !important; } }

/* ==========================================================================
   MEASURE — one column, one leading edge.

   THE LAW: there is one content column (--m-page), it is centred in the space
   available, everything inside it starts at the same leading edge, and nothing
   inside it is individually centred.

   This block used to do the opposite, and it is worth writing down why it was
   wrong, because it looked reasonable. Four components were capped at four
   widths — 920 for rows, 816 for the calendar card, 780 for its grid, 720 for
   the key/value table — and each was centred in its parent. Concentric boxes
   of different widths have different left edges by definition, so the page had
   four of them and no amount of adjusting one could fix it. Reported from the
   live site as "some stuff are centered and some stuff are left to right", and
   that is exactly what it was.

   The second half of the law is the one that is easy to get backwards: a
   ragged RIGHT edge is invisible and a ragged LEFT edge reads as broken,
   because people take left edges as alignment. So when something genuinely
   must be narrower than the column, it is narrower AT THE LEADING EDGE. Never
   centred.

   Which leaves only two things narrower than the column, and both are text
   measures rather than layout:
     --m-prose  a paragraph, at a readable line length
     .kv        the document's label/value table, which is a list of pairs and
                spreads its pairs apart if given a workspace to fill

   Everything else — cards, rows, ledgers, grids, the calendar — spans the
   column and shares both its edges.

   The exceptions, stated once so they cannot multiply:
     .appnav      centred. It is chrome, not content, and the only element
                  whose width comes from its own contents rather than from the
                  column, so at the leading edge it reads as shoved into a
                  corner.
     .emptystate  centred. There is no column of content for it to align to;
                  it IS the page.
   ========================================================================== */
.kv { max-width: 720px; }
/* A table's minimum width is its longest unbreakable word, so one German
   compound in a value cell — Aufenthaltserlaubnisverlängerung — made the table
   wider than the phone and took the row with it. Breaking inside the word is
   right here: these cells are data, not prose. */
.kv th, .kv td { overflow-wrap: anywhere; }
/* ON A PHONE THE LABEL TAKES THE LINE ABOVE, NOT A THIRD OF THE ROW.
 *
 * `td.k` is 34% wide, which is right for the labels that rule was written for
 * — "Language", "Reference number" — and wrong for the ones a document
 * actually produces. A date's label is the model's sentence about it: "The
 * courier tried to deliver the parcel to you in person but no one was
 * available to receive it." At 393px, 34% is 111px, and that sentence came out
 * as EIGHT lines in a thin column beside a value cell holding "Aug 4, 2026"
 * and two thirds of nothing. Reported as a screenshot; it needed no argument.
 *
 * Stacked, the sentence gets the full 327px and takes four lines, the date
 * sits under it with its controls, and a short label costs one extra line
 * rather than saving one. Columns come back at 561px, where 34% is a real
 * label column again and the alignment is worth having. */
@media (max-width: 560px) {
  .kv, .kv tbody, .kv tr, .kv td { display: block; width: auto; }
  .kv tr { padding: var(--s-5) 0; border-bottom: 1px solid var(--line); }
  .kv tr:last-child { border-bottom: 0; }
  .kv td { padding: 0; border-bottom: 0; }
  .kv td.k { width: auto; padding-inline-end: 0; margin-bottom: var(--s-2);
             font-size: var(--t-md); }
}
/* A ROW THAT IS OUT — struck through and dimmed, which reads as "does not
   count" in every language the app speaks.

   Out for ANY reason. This was only ever put on rows the document switch had
   excluded, on the reasoning that a row with its own toggle already showed its
   state by that toggle going off. It did not show it well enough: a 17px icon
   at the end of a line, against a figure or a date printed in exactly the same
   ink as every one that still counts. Now the mark is the same whether the row
   was switched off by itself or by the document, and the same mark the
   reminders card uses for a warning that will not be sent. */
.kv tr.outrow td { color: var(--muted); }
.kv tr.outrow td .dcell > span:first-child { text-decoration: line-through; }
/* Fahim's own prose, wherever it appears. The Replies intro ran the full
   width of the page — about 150 characters on one line.

   `.talkanswer` and `.legal-lead` are deliberately absent: both set their own
   max-width further down the file, both of those rules win on order, and both
   are already right. Listing them here would have looked like a cap and been
   dead code. */
.spacehead-row > .sub, .spacehead + .sub, .talkreads,
.chat-hint { max-width: var(--m-prose); }

/* Receipt items table + monthly shopping breakdown ------------------------- */
.tablewrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.rtable { width: 100%; border-collapse: collapse; font-size: var(--t-lg); }
.rtable th {
  text-align: start; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); font-weight: 700; padding: 0 12px 8px 0; border-bottom: 1px solid var(--line);
}
.rtable td { padding: 9px 12px 9px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
.rtable tr:last-child td { border-bottom: 0; }
.rtable .ro { color: var(--muted); }
.rtable .rt { font-weight: 600; }
.rtable .rq, .rtable th.rq { width: 1%; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
.rtable .rp, .rtable th.rp { text-align: end; white-space: nowrap; font-variant-numeric: tabular-nums; padding-inline-end: 0; }

.catbars { display: flex; flex-direction: column; gap: var(--s-6); }
.catbar { display: grid; grid-template-columns: 104px 1fr auto; align-items: center; gap: var(--s-6); }
.catbar .cn { font-size: var(--t-base); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.catbar .ctrack { height: 8px; border-radius: var(--r-pill); background: var(--chip-bg); overflow: hidden; }
.catbar .cfill { display: block; height: 100%; border-radius: var(--r-pill);
  background: var(--accent); }
.catbar .camt { font-size: var(--t-base); font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.catbar .cc { color: var(--muted); font-weight: 400; }

/* A category opens to show what is inside it. The row keeps its exact grid —
   it is the same bar, now also a disclosure — so nothing shifts when the list
   below it appears. */
.catwrap > summary.catbar { cursor: pointer; list-style: none; }
.catwrap > summary.catbar::-webkit-details-marker { display: none; }
.catwrap > summary.catbar .cn::before {
  content: "›" / ""; display: inline-block; width: 12px; margin-inline-start: -12px;
  color: var(--muted); transition: transform var(--dur-1) var(--ease);
}
.catwrap[open] > summary.catbar .cn::before { transform: rotate(90deg); }
.catwrap > summary.catbar:hover .cn { color: var(--accent-ink); }
/* Aligned under the BAR, not under the label, so the items line up with the
   spending they explain. 104px name column + the grid gap. */
/* Hidden explicitly when the disclosure is shut. A closed <details> hides its
   non-summary children through the UA stylesheet, and setting an explicit
   `display` on one overrides that — measured: the item list rendered at full
   height while the row still read as collapsed, so every category was open all
   the time and clicking did nothing. */
.catwrap:not([open]) > .catitems { display: none; }
.catitems { margin: var(--s-4) 0 var(--s-6); padding-inline-start: calc(104px + var(--s-6));
  display: flex; flex-direction: column; gap: var(--s-3); }
.catitem { display: flex; justify-content: space-between; gap: var(--s-6);
  font-size: var(--t-md); color: var(--muted); }
.ciname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The receipts an item was bought on, under its line. Wraps rather than
   truncating: an item bought three times has three of these and none of them
   is less true than the others. */
/* The custom window's two dates. Wraps onto its own line on a phone rather
   than squeezing two date inputs and a button into one row. */
/* AIR ON BOTH SIDES. It had a top margin and no bottom one, so the two date
   fields and Apply sat ON the month stepper that follows them — two ranks of
   controls with nothing between them, reading as one jumbled rank. */
.rangerow { display: flex; flex-wrap: wrap; align-items: end; gap: var(--s-5);
  margin-block: var(--s-5); }
.rangerow label { display: inline-flex; align-items: center; gap: var(--s-4);
  font-size: var(--t-md); color: var(--muted); }
.rangerow input[type="date"] { font: inherit; font-size: var(--t-base);
  color: var(--ink); background: var(--field, transparent);
  border: 1px solid var(--control-line); border-radius: var(--r-sm);
  padding: var(--s-4) var(--s-5); }
/* Dates read left-to-right even in an Arabic interface: a date input's own
   controls are LTR, and mirroring only the label around them reads worse. */
.rangerow input[type="date"] { direction: ltr; }
/* ON A PHONE THE TWO FIELDS STACK, AND STACKED THEY HAVE TO LINE UP.
 *
 * Each label is its own inline-flex box, so each one sized its text to itself:
 * "From" is 38px and "To" is 22px, which put the two date fields 16px out of
 * step down the screen with the Apply button wedged beside the lower one. It
 * reads as a form that was never looked at.
 *
 * SUBGRID, not a fixed label column, because the labels are translated: this
 * is "From/To" in English and "Başlangıç/Bitiş" in Turkish, 38px against 62px.
 * A hardcoded 3.5em column fits one of those languages and clips the other,
 * which is the bug in a different costume. Subgrid lets both labels share ONE
 * column measured against the longest of them, in whatever language it is.
 * Where subgrid is missing the declaration is dropped, the label falls back to
 * a single-column grid, and the field sits under its own label — taller, still
 * aligned, still usable. */
@media (max-width: 560px) {
  .rangerow { display: grid; grid-template-columns: auto 1fr;
              align-items: center; gap: var(--s-5); }
  .rangerow label { display: grid; grid-column: 1 / -1;
                    grid-template-columns: subgrid; align-items: center; }
  .rangerow input[type="date"] { width: 100%; }
  /* The action goes full width on its own line rather than beside the second
     field, where it made that field 70px narrower than the first. */
  .rangerow button { grid-column: 1 / -1; }
}
.windowline { margin: var(--s-5) 0 0; font-variant-numeric: tabular-nums; }
/* Whether the filter above governs this tile. Quiet on purpose — it answers a
   question the reader only asks once, and it must not compete with the figure. */
.mscope { display: block; font-size: var(--t-sm); color: var(--muted); }

/* Paid / not paid on a bill. A two-state pair rather than one toggle: "Paid"
   alone leaves the reader guessing whether an un-pressed button means "not
   paid" or "not answered", and this is a question they may want to answer
   either way and change later. */
/* Wraps, because this sits in the narrow left column of a money row: at 320px
   that column is 110px and the two pills need 136px, so both were squeezed and
   "E papaguar" (and English "Not paid") broke mid-label inside a pill. Second
   pill under the first is the right answer in a column that is already
   stacked. */
.paidpick { display: inline-flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-3); }
.paidpick .pbtn { font: inherit; font-size: var(--t-sm); cursor: pointer;
  padding: var(--s-3) var(--s-5); min-height: 0; line-height: 1.2;
  color: var(--muted); background: transparent;
  border: 1px solid var(--line); border-radius: var(--r-pill); }
.paidpick .pbtn:hover { color: var(--accent-ink); border-color: var(--accent-ink); }
.paidpick .pbtn.on { color: var(--on-accent); background: var(--accent);
  border-color: var(--accent); }
/* A settled line stays legible — it is a record, not a deletion. Struck through
   would say "this was wrong"; that is what hiding is for. */
.lrow.settled .ldesc, .lrow.settled .lamt { opacity: .62; }

.cisrc { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-5);
  margin: 0 0 var(--s-4); font-size: var(--t-sm); }
.cisrc-a { color: var(--muted); text-decoration: none; }
a.cisrc-a:hover { color: var(--accent-ink); text-decoration: underline; }
.ciamt { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--ink); }

@media (max-width: 560px) {
  /* The name column collapses on a phone, so indenting to it would push every
     item off the screen. */
  .catitems { padding-inline-start: var(--s-6); }
}

.itemchips { display: flex; flex-wrap: wrap; gap: var(--s-4); }
.itemchip { display: inline-flex; align-items: baseline; gap: var(--s-4);
  padding: 7px 13px; border-radius: var(--r-pill); border: 1px solid var(--line);
  background: var(--chip-bg); font-size: var(--t-base); }
.itemchip b { font-weight: 700; }
.itemchip .ic-amt { color: var(--muted); font-variant-numeric: tabular-nums; }
/* A CHIP IS A SHAPE FOR SOMETHING SHORT, AND THESE ARE NOT SHORT.
 *
 * "Uludag sparkling drink 330ml ×5 €3.95" is 312px of pill. On a 1280px screen
 * three of them sit on a line and the whole block is 220px — the shape works.
 * On a 393px phone the container is 327px, so between one and, at best, one
 * fits: measured, twelve items came out as TWELVE rows and 540px, each pill a
 * lozenge of its own with 26px of horizontal padding and a border drawn around
 * a line of text that was going to be alone on that line anyway.
 *
 * So below the phone breakpoint they stop being chips and become what they
 * already were — a list. Same order, same words, same numbers, with the
 * quantity and the money aligned down the trailing edge where they can be
 * compared. The pill comes back at 561px, where more than one fits on a line
 * and the shape earns its padding. */
@media (max-width: 560px) {
  .itemchips { display: block; }
  .itemchip {
    display: flex; width: 100%; gap: var(--s-4); align-items: baseline;
    padding: var(--s-3) 0; border: 0; border-radius: 0; background: none;
    border-bottom: 1px solid var(--line);
  }
  .itemchip:last-child { border-bottom: 0; }
  /* The name is a bare text node — an anonymous flex item, which no selector
     can reach — so the push comes from the first element AFTER it. */
  .itemchip b { margin-inline-start: auto; }
  .itemchip .ic-amt { min-width: 4.5em; text-align: end; }
}

/* Profile: auto-detected extra facts, masked behind an eye toggle ---------- */
.ftable { display: flex; flex-direction: column; }
/* BOTH TRACKS CAN GIVE. `150px 1fr` is a fixed label column: at 320px it took
   150 of the 273 available and the value column was left with less than its
   longest word, so the row — and with it the whole page — ran off the right
   edge in German ("Arbeitsbescheinigung" against a 150px label). `minmax(0, …)`
   keeps 150px whenever there is room for it and lets the track shrink when
   there is not, which is the only difference at any width above 360px. The
   `min-width: 0` is the grid-item half of the same fix: without it a cell
   refuses to go below its own longest word no matter what the track says. */
.frow {
  display: grid; grid-template-columns: minmax(0, 150px) minmax(0, 1fr);
  gap: var(--s-3) 16px;
  padding: 11px 0; border-bottom: 1px solid var(--line); align-items: baseline;
}
.frow > * { min-width: 0; overflow-wrap: anywhere; }
.frow:last-child { border-bottom: 0; }
.flabel { color: var(--muted); font-size: var(--t-base); }
/* Controls pin to the far end, so the eye and the × line up in a column
   instead of landing wherever each value happens to stop. Worth it HERE, and
   only here: this table routinely runs to a dozen rows, which is the case the
   column is for. The dates table on a document has one or two, and pinning
   there put a control 400px from the value it acts on — see .dcell. `inline-flex` was the bug: it shrank to the
   content, so every row put its buttons somewhere different.
   margin-inline-start rather than margin-left, so it flips with the language. */
.fbody { display: flex; align-items: center; gap: var(--s-4); min-width: 0; }
/* The FIRST control, whichever it is, takes the slack, so the eye and the ×
   line up in a column down the far edge instead of landing wherever each value
   happens to stop. `.fbody` was inline-flex, which shrank to its content and
   put every row's buttons somewhere different.
   Same idiom as .foldtools, .askrow .go and .calnew, and margin-INLINE-start
   so it flips with the language rather than needing an [dir="rtl"] override. */
.fbody > button:first-of-type { margin-inline-start: auto; }
.fval { font-weight: 600; overflow-wrap: anywhere; letter-spacing: .04em; }
/* ═══ WHAT A VALUE IS WORTH ON A PHONE ══════════════════════════════════════
   Everything data-shaped on this app's phone screens settled at 15px as each
   screen was worked through — a ledger row's title and amount, a calendar
   entry's description, a document's prose, the rows of its details table.
   These two did not, and stayed at the app's 17px body size in semibold: an
   address came out as four lines of large bold type in a 161px column, which
   is what "the font is sooooo big" meant. They are the same kind of thing as
   the others — a value read off a document — so they take the same size.
   Labels are already 13–14px and are left alone. */
@media (max-width: 560px) {
  .pval, .fval { font-size: var(--t-lg); }
  /* AND THE LABEL COLUMN GIVES WAY. 150px of a 311px row is a label column
     sized for a desktop: it left "ilert GmbH, Bayenstr. 65, 50678 Köln" 161px
     and four lines. Stacked, the value gets the whole row and takes two —
     the same answer the document's details table reached, for the same reason
     and with the same shape of content. */
  .frow { grid-template-columns: 1fr; gap: var(--s-2); }
  /* `.fsrc` names its own column further down the file, and a later rule of
     equal weight wins however this block is written — so the override has to
     name it too. Without this the row kept a second track and the value was
     squeezed into 125px beside its own source list. */
  .frow > *, .frow .fsrc { grid-column: 1; }
  .flabel { font-size: var(--t-md); }
}
.fval[data-shown="0"] { color: var(--muted); letter-spacing: .12em; }
.eye {
  flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px;
  padding: 0; border: 1px solid var(--control-line); border-radius: var(--r-sm);
  background: var(--card); color: var(--muted); cursor: pointer;
  transition: color var(--dur-1) ease, border-color var(--dur-1) ease;
}

/* The "stop showing this" control on an extra fact. Quiet until hovered: it is
   destructive-ish (permanent, across documents) but it is not the action the
   card is for, so it should not compete with the value it sits beside. */
.factx {
  /* A FLOOR, because this button is a glyph and its box was whatever the glyph
     happened to measure. When the type scale became fluid it shrank with it,
     to 26x21 — under the 24px a finger needs, on every width. Padding around a
     character is not a hit area; a minimum is. */
  min-width: 24px; min-height: 24px;
  background: none; border: 0; cursor: pointer; padding: 2px 8px;
  font-size: var(--t-2xl); line-height: 1; color: var(--muted); opacity: .55;
  border-radius: var(--r-sm);
}
.factx:hover, .factx:focus-visible { opacity: 1; color: var(--urgent-ink); }
.factx:disabled { opacity: .3; cursor: default; }

.eye:hover { color: var(--accent-ink); border-color: var(--grad-3); }
/* IN FLIGHT. Most eyes in this app never disable — they swap bullets for text
   in the page and touch nothing. The age eye records an answer on the account,
   so there is a moment where it is waiting, and without this it stays fully
   lit: on a slow connection the reader presses a control that looks live and
   nothing happens, three or four times. */
.eye:disabled { opacity: .45; cursor: default; }
.eye:disabled:hover { color: var(--muted); border-color: var(--control-line); }
.eye .icon { width: 15px; height: 15px; }
.fsrc { grid-column: 2; font-size: var(--t-sm); color: var(--muted); }
.fsrc a { color: var(--accent-ink); text-decoration: none; }
.fsrc a:hover { text-decoration: underline; }

/* Document version history ------------------------------------------------- */
.verbar { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; margin-bottom: 14px; }
.verchips { display: inline-flex; gap: var(--s-2); padding: 4px; border: 1px solid var(--line);
  border-radius: var(--r-pill); background: var(--chip-bg); }
.verchip { padding: 5px 12px; border-radius: var(--r-pill); text-decoration: none; font-size: var(--t-md);
  font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
.verchip:hover { color: var(--ink); }
.verchip.active { color: var(--on-accent); background: var(--accent); }
/* Cases — the documents that are all one matter -----------------------------
   Built on .verbar's metrics so the two bars stack on a document without one
   of them sitting 3px off the other. */
.casebar { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; margin-bottom: 14px; }
/* The same bar, but under the document rather than over it: the margin goes to
   the other side or the button hangs off the end of the page with no air. */
.casebar-foot { margin-bottom: 0; margin-top: var(--s-8); }
/* Shrinkable: this holds "Part of: <the whole case name>", and flex-wrap on the
   bar cannot rescue a single item wider than the screen. */
.casechip { padding: 5px 12px; border-radius: var(--r-pill); text-decoration: none;
  font-size: var(--t-md); font-weight: 700; color: var(--accent-ink);
  border: 1px solid var(--calm-border); background: var(--calm-bg);
  min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.casechip:hover { color: var(--ink); }
.casecount, .caseref { font-size: var(--t-md); color: var(--muted); }
.caseowed { font-size: var(--t-md); font-weight: 700; font-variant-numeric: tabular-nums; }
/* The status is derived, so it is stated rather than offered: no control, no
   dropdown, nothing here suggests the reader maintains it. */
.casestatus { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase;
  letter-spacing: .05em; color: var(--muted); border: 1px solid var(--line);
  padding: 1px 6px; border-radius: var(--r-xs); }
.casestatus[data-status="open"] { color: var(--accent-ink); border-color: var(--calm-border); }
.casesec { border-radius: var(--r-md); }
/* .cat-head is a flex row with NO wrap, and a case head carries six children to
   a folder head's four — icon, name, count, "4 of 6", status, countdown. The
   name is model-written ("Jobcenter Köln — Bescheid über Arbeitslosengeld II")
   rather than reader-typed, so at 360px this scrolled the whole vault
   sideways. Wrap, and let the name be the thing that gives. */
.casehead { flex-wrap: wrap; }
/* A case is one row until the reader opens it. The chevron turns; the name
   beside it is a link to the case page, which is a different intention. */
.casetoggle {
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; background: none; padding: 0; cursor: pointer;
  color: var(--accent); opacity: .85; line-height: 0;
  transition: transform var(--dur-1) ease;
}
.casetoggle svg { width: 15px; height: 15px; }
.casesec.open .casetoggle { transform: rotate(90deg); }
[dir="rtl"] .casetoggle { transform: rotate(180deg); }
[dir="rtl"] .casesec.open .casetoggle { transform: rotate(90deg); }
.casehead .fname { text-transform: none; font-size: var(--t-base); letter-spacing: .01em;
  color: var(--ink); font-weight: 700; text-decoration: none;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.casehead .fname:hover { text-decoration: underline; }
.casehead .fico { display: inline-flex; color: var(--accent); opacity: .85; line-height: 0; }
.casehead .fico svg { width: 15px; height: 15px; }
/* "4 of 6 here" — folders win over cases in this list, so a case can be split
   across the screen and the row has to say so rather than look incomplete. */
.casepart { font-size: var(--t-xs); color: var(--muted); text-transform: none; letter-spacing: 0; }
/* A flex SIBLING of .mtitle, not a child of it: .mtitle is nowrap + ellipsis, so
   nested here a long title ate the one marker saying this document was
   replaced. `flex: none` and nowrap keep it whole when the row is tight — the
   title is the thing that should give. The row's own `gap` does the spacing. */
.casetag { flex: none; white-space: nowrap;
  font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); }
/* Why a document is in this case, stated on its own row. Quiet — it is a
   footnote to the title, not a competing headline. */
.casewhy { flex: none; white-space: nowrap; font-size: var(--t-xs); color: var(--muted); }
/* A timeline row carries a button, so it is a div with a stretched link rather
   than an anchor: interactive content inside an anchor is not allowed, and some
   browsers refuse to open the control at all. */
.caserow { position: relative; }
.caserowx { flex: none; position: relative; z-index: 1; opacity: 0; transition: opacity var(--dur-1) ease; }
.caserow:hover .caserowx, .caserow:focus-within .caserowx { opacity: 1; }
/* No hover on a phone, so the control is simply always there. */
@media (pointer: coarse) { .caserowx { opacity: 1; } }
.casedim { opacity: .55; }
.caseline { margin-bottom: 14px; }
/* Something that happened, stated once. Not an error, so not warn-ink. */
.casenote { margin: 0 0 10px; font-size: var(--t-md); color: var(--accent-ink); }
.caserr { display: block; font-size: var(--t-md); color: var(--warn-ink); }
.caserr:not(:empty) { margin: 0 0 10px; }

.tag-version { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
  color: var(--accent-ink); border: 1px solid var(--calm-border); padding: 1px 6px; border-radius: var(--r-xs); }
.cmpcard { margin-top: 14px; }
.cmpbody ul { margin: 6px 0 0; padding-inline-start: 20px; }
.cmpbody li { margin: 5px 0; font-size: var(--t-lg); }
.cmpbody p { margin: 6px 0; font-size: var(--t-lg); }
.pulse-sm { display: inline-block; width: 15px; height: 15px; vertical-align: -2px; margin-inline-end: 6px;
  border: 2px solid var(--line); border-top-color: var(--accent-ink); border-radius: 50%;
  animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .pulse-sm { animation-duration: 3s; } }

/* Money controls: time filter, currency picker, per-currency breakdown, edit --*/
.mcontrols { display: flex; align-items: center; gap: var(--s-5); flex-wrap: wrap; margin-bottom: 16px; }
/* THE PICKER WAS OFF THE SIDE OF THE PHONE. Measured at 360px: the preset
   strip is five buttons whose content is wider than the card, so it consumed
   the whole flex line, and the currency picker after it was laid out past the
   right edge — not clipped into a scroller, not wrapped, simply unreachable.
   The reader could not change the currency every figure on the page is
   converted into.

   The strip takes a line of its own (`1 1 100%`) and wraps its own buttons
   inside it, which is what its own comment above says it is for; the picker
   then lands on the next line where there is room for it. Scoped to
   `.mcontrols` because `.segbar` is also the admin screens' period control,
   which sits in rows with different neighbours. */
/* `1 1 auto`, NOT `1 1 100%`. A full-width basis takes a whole line at EVERY
   width, so on a desktop the currency picker wrapped under the ranges and the
   spacer threw it to the far edge — a 90px band of nothing across the card,
   with "In EUR" adrift in it. The strip is a scroller (see below), so it does
   not need a line to itself to stay intact: it takes the room it has, and the
   picker sits at the end of the same row. */
.mcontrols > .segbar { flex: 1 1 auto; min-width: 0;
  /* SCROLLS RATHER THAN WRAPS, the same treatment .mtabs gets a few hundred
     lines down and for the same reason: the wrap this element asks for does not
     happen — its buttons paint outside their own box — so the box shrinks, the
     buttons do not, and whatever is laid out after them is drawn underneath.
     A scroller cannot do that. */
  flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.mcontrols > .segbar::-webkit-scrollbar { display: none; }
.mcontrols > .curpick { flex: 0 0 auto; }
/* Stepping a month at a time, inside the window controls it refines.
   The two arrows are .calnav, so the rotation and its RTL mirror come from the
   calendar header's own rules rather than a second copy of them here.
   The label is min-width'd so the row does not jog sideways as the month name
   changes length — "May 2026" and "September 2026" are 8 and 14 characters of
   the same control. */
/* On the window row, under the range label it changes — NOT in the preset
   strip. Those five buttons already overflow a 360px card on their own, and a
   200px control after them landed at x=370 on a 393px screen: on the page, in
   the accessibility tree, and off the side of the display.
   No auto margin here: `.windowrow > * { margin: 0 }` below would win on equal
   specificity, so an alignment written that way would be a comment describing
   something the file does not do. Start-aligned, under the label. */
.monthstep { display: inline-flex; align-items: center; gap: var(--s-2);
  flex: 0 0 auto; }
.monthstepnow { min-width: 8.5em; text-align: center; font-size: var(--t-md);
  font-weight: 600; color: var(--ink); }
/* A month with nothing after it. Kept in the layout rather than hidden, so the
   label does not shift when the reader steps back into a month that has a
   next. */
.monthstep .calnav[disabled] { opacity: .38; cursor: default; }
/* WRAPS RATHER THAN OVERFLOWS. Five ranges — month, 30 days, year, all time,
   custom — are as wide as the language makes them, and `inline-flex` with no
   wrap made the bar a single unbreakable box: in Albanian it came to 325px of
   content in a 323px card and pushed the whole page into a horizontal scroll
   at 360–375px, which is most phones. Wrapping to a second row costs a line of
   height in the widest languages and nothing in the rest; scrolling it instead
   would have hidden "All time" behind a gesture with no affordance. */
/* A BAR THAT WRAPS IS NOT A PILL, and `--r-pill` on a wrapped one is a bug you
   can see: the reader reported it as "the blue thing is overflowing".
   `border-radius: 999px` is clamped by the browser to half the SHORTER side, so
   on one row it draws the 15.5px arc the design wants — but on two rows the box
   is 62px tall and the arc becomes 31px, a quarter-circle that curves right
   through where the first segment sits. The active segment starts 4px in from
   the corner; the arc's centre is at (31,31), so that corner is 38px from the
   centre of a 31px circle and 7px of blue lands OUTSIDE the grey container it
   is supposed to sit in.
   `--r-md` fixes it at every width, and it has to be fixed at every width
   rather than under a phone breakpoint: this control wraps at up to ~600px in
   Romanian, Ukrainian and Russian (the widest, 527px of segments), and the same
   bar is used by Replies, the landing page's demo tabs and three admin screens,
   any of which can be narrow on a wide monitor. On one row the difference
   between a 15.5px and a 12px arc on a 31px-tall bar is a detail; a segment
   hanging out of its own container is not. */
.segbar { display: inline-flex; flex-wrap: wrap; max-width: 100%;
  gap: var(--s-2); padding: 3px; border: 1px solid var(--control-line);
  border-radius: var(--r-md); background: var(--chip-bg); }
.seg { padding: 6px 13px; border: 0; border-radius: var(--r-pill); background: transparent;
  color: var(--muted); font-size: var(--t-md); font-weight: 600; cursor: pointer; min-height: 0; }
/* On a phone the padding is what tips it over — trimming it keeps all five on
   one row down to about 360px, so the wrap above stays a fallback. */
@media (max-width: 430px) { .seg { padding: 6px 10px; } }
.seg:hover { color: var(--ink); }
.seg.active { color: var(--on-accent); background: var(--accent); }
/* NEVER SQUEEZED. The row is a flex line with a spacer between the range
   buttons and this, so at phone width the picker was the thing that gave — its
   label ("In", "بـ") shrank to nothing and hung off the edge of the card, cut
   in half. It is a label and a select; neither has anything to give. */
.curpick { display: inline-flex; align-items: center; gap: var(--s-3);
  flex: 0 0 auto; min-width: 0; font-size: var(--t-md); color: var(--muted); }
.curpick select { min-height: 36px; padding: 6px 10px; font-weight: 600; }
/* Which rate the converted figures above were made from, and when it was
   quoted. Ordinary provenance, so ordinary muted text — it is shown on every
   converted view, not only a broken one. `.ratesoff` follows and repaints it
   for the approximate case; declared after this rule so it wins at equal
   specificity when both classes are on the element. */
.rateline { color: var(--muted); margin: -6px 0 14px; font-size: var(--t-md); }
.ratesoff { color: var(--action-ink); margin: -6px 0 14px; font-size: var(--t-md); }
/* What went out today, under the window's total. Recessive: it is context for
   the figure above it, not a second headline competing with it. */
.mcard .mtoday { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
.mcard .mbreak { font-size: var(--t-sm); color: var(--muted); font-variant-numeric: tabular-nums;
  margin-top: 2px; overflow-wrap: anywhere; }
.lnote { font-size: var(--t-md); color: var(--muted); overflow-wrap: anywhere; }
.lacts { display: inline-flex; gap: var(--s-1); flex: 0 0 auto; align-items: center; }
/* 30px box round a 15px glyph. It was `padding: 4px`, which measured 23×23 —
   these are the edit and delete controls on a money line, they sit next to
   each other, and 23px is a target you miss. The glyph is unchanged; only the
   box it sits in grew. Touch gets more again, further down. */
.lact { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0;
  width: 30px; height: 30px; flex: 0 0 auto;
  border-radius: var(--r-xs); display: inline-grid; place-items: center; min-height: 0; line-height: 1; }
.lact .icon { width: 15px; height: 15px; }
.lact.del { font-size: var(--t-2xl); }
.lact:hover { color: var(--accent-ink); }
.lact.del:hover { color: var(--urgent-ink); }
.lrow.editing { display: block; }
.lrow.editing .rowmark { display: none; }
/* Hidden document money lines: a tucked-away, restorable group. */
.hiddenwrap { margin-top: 14px; }
.hiddenwrap > summary { cursor: pointer; color: var(--muted); font-size: var(--t-md);
  font-weight: 600; list-style: none; padding: 6px 0; user-select: none; }
.hiddenwrap > summary::-webkit-details-marker { display: none; }
.hiddenwrap > summary::before { content: "▸ " / ""; }
.hiddenwrap[open] > summary::before { content: "▾ " / ""; }
.lrow.hidden-line { opacity: .55; }
.lrow.hidden-line .lact { font-size: var(--t-md); font-weight: 600; padding: 4px 8px; }
/* inline currency picker for a line whose currency the model couldn't read */
.lcur { font-size: var(--t-sm); padding: 3px 6px; border-radius: var(--r-xs); border: 1px solid var(--calm-border);
  background: var(--hover-bg); color: var(--accent-ink); font-weight: 600; cursor: pointer; }
.lrow.nocur .lamt { color: var(--muted); }
.editform { display: flex; flex-direction: column; gap: var(--s-4); padding: 4px 0 8px; }
.editline { display: flex; gap: var(--s-4); flex-wrap: wrap; align-items: center; }
.editform input, .editform select { min-height: 40px; font-size: var(--t-base); }
.editform input[type="text"] { flex: 1; min-width: 140px; }

/* ---------- one spend, on its own page ----------
   A row's own screen: what it was, and the two things a row cannot hold —
   somewhere to go back to, and something to look at. */
.spendback { margin-bottom: var(--s-4); }
/* THE FIGURE IS THE HEADLINE, under the name and above everything else. A
   spend page opened months later is opened to answer "what was this and how
   much", and the amount arriving in body text made the reader hunt for it in a
   line of chips. */
.spendamt { font-size: var(--t-3xl); font-weight: 700; margin: var(--s-2) 0 var(--s-1);
  line-height: 1.1; }
.spenddoc { margin: var(--s-2) 0 0; font-size: var(--t-md); }
/* Headings INSIDE a card, so they need the space above that a card's first
   child does not. `:first-child` keeps the top one flush with the padding. */
.spendh { margin: var(--s-6) 0 var(--s-1); font-size: var(--t-lg); }
.spendh:first-child { margin-top: 0; }
/* `--s-6` AND NOT `--s-4` BETWEEN FIELDS. Eight pixels is the floor the block
   sweep treats as "these two are touching", so a form built on it sits exactly
   on the line a guard is drawing — one token change anywhere above and the
   sweep is right. Twelve is also simply the gap a stack of labelled boxes
   wants: at eight, a label reads as belonging to the box above it. */
.spfield { display: flex; flex-direction: column; gap: var(--s-1);
  margin-top: var(--s-6); }
.spfield > label { font-weight: 600; font-size: var(--t-md); color: var(--muted); }
.spfield select { width: 100%; min-height: 44px; font-size: var(--t-base); }
/* The one control the page ends on, and it saves the whole page. */
.spendsave { margin-top: var(--s-5); }
/* The shop's own mark, at the size a page heading takes rather than the size a
   ledger row takes. `.submark` sizes itself from its context, so without this
   the logo arrived at row scale beside a heading twice its height. */
.spendhead .rowmark { width: 40px; height: 40px; flex: 0 0 auto; }
/* FULL WIDTH, both of them. These are a note and a URL — the two fields in
   this app most likely to be longer than the box — and a 240px input that
   scrolls sideways under the reader's cursor is the worst place to check a
   link you are about to trust. */
/* TWO FIELDS THAT BELONG TOGETHER, side by side where there is room and
   stacked where there is not. Amount with its currency, kind with its date:
   each pair is one decision, and on a laptop a column of six full-width boxes
   reads as six. `min-width` on the children is what makes them stack rather
   than squeeze — the failure the item row had, where the flexible track gave
   the name thirty pixels. */
/* The same 12px the stacked fields use. When this pair wraps on a phone the
   two boxes become a stack, and a wrapped pair sitting 8px apart under fields
   sitting 12px apart is one rhythm pretending to be two. */
.spline { display: flex; flex-wrap: wrap; gap: var(--s-6);
  /* THE AIR BELONGS TO THE ROW, not to the fields inside it. `.spfield` carries
     its own top margin, and in a flex container that margin offsets the
     children while leaving the ROW's own box flush against the field above —
     so the title's box and the amount/currency row touched at every width.
     Caught by the block sweep, which is exactly the shape it looks for. */
  margin-top: var(--s-6); }
.spline > .spfield { flex: 1 1 12rem; min-width: 10rem; margin-top: 0; }
.spfield textarea, .spfield input { width: 100%; font-size: var(--t-base);
  min-height: 44px; }
.spfield textarea { min-height: 84px; resize: vertical; }
/* The link as a LINK, under the box that holds its text. The input shows what
   is stored; this is the thing you can press — and it is the only way to see
   that `amazon.de/x` was saved as `https://amazon.de/x`.
   `overflow-wrap` because a URL is one unbroken word and a long one would
   otherwise push the card wider than the phone. */
.spendlink { margin: var(--s-1) 0 0; font-size: var(--t-md);
  overflow-wrap: anywhere; }
/* AS MANY AS FIT, never fewer than two. `auto-fill` with a floor means one
   picture on a phone is not stretched across the whole card, and six on a
   laptop do not become six stamps. */
.spicgrid { list-style: none; margin: var(--s-2) 0 0; padding: 0; display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--s-3); }
.spic { position: relative; margin: 0; }
.spic img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block;
  border-radius: var(--r-sm); border: 1px solid var(--line); background: var(--hover-bg); }
/* THE REMOVE CONTROL SITS ON THE PICTURE, and carries its own background —
   a bare glyph over a photograph is legible against a pale receipt and
   invisible against a dark one. 30px like every other `.lact`, which is
   already above the 24px floor a pointer target may not go under. */
.spic .lact { position: absolute; inset-inline-end: var(--s-1); top: var(--s-1);
  background: var(--card); border: 1px solid var(--line); color: var(--urgent-ink);
  box-shadow: var(--shadow-1); }
.spic .lact:hover { color: var(--urgent-ink); background: var(--hover-bg); }
.spicadd { margin: var(--s-4) 0 0; }
/* The control that adds one is an ordinary button — see the note beside its
   handler for why the file input itself is hidden and untabbable. Nothing to
   style here beyond the spacing above: it is a `.ghost`, and it takes the one
   focus treatment every other control in the app takes. */
/* SOMETHING IS ATTACHED. Not a count and not a second icon — the same glyph in
   the accent, so a ledger of forty rows shows at a glance which three were
   written up. */
.lact.has { color: var(--accent-ink); }
a.lact { text-decoration: none; }

/* A button that reads as a link, used for the delete-translation action. */
/* `min-height: 0` was here to stop the shared 44px control floor applying, and
   it went two pixels too far: on Profile "Not you? Change" measured 118x22.
   24 is the floor a pointer target may not go under (WCAG 2.5.8); the button
   still sits on its text rather than in a box. */
.linkbtn { border: 0; background: none; padding: 10px 6px; font-size: var(--t-base);
  color: var(--accent-ink); cursor: pointer; min-height: 24px; }
/* A destructive control that is quiet but still visibly a CONTROL.
   
   It was borderless muted text, and it read as a caption — "Delete this
   document" floating beside the heading looked like a label describing the
   page rather than something you could press. Quiet was the right instinct
   (a bordered red button top-right is where every app puts its PRIMARY
   action, and destroying a letter is never that) but invisible is not quiet,
   it is broken.
   
   So: a border to say it is pressable, muted until hovered to say it is not
   the thing you came for. */
.linkbtn.danger {
  color: var(--muted);
  border: 1px solid var(--control-line);
  border-radius: var(--r-xs);
  padding: 8px 12px;
}
.linkbtn.danger:hover,
.linkbtn.danger:focus-visible {
  color: var(--urgent-ink);
  border-color: var(--urgent-ink);
}
/* THE SAME BOX, WITHOUT THE WARNING. Share sits beside Delete in the document
   header, and a borderless blue `.linkbtn` next to a bordered one reads as two
   different kinds of thing — one a link, one a button — when they are two
   actions of equal weight. */
.linkbtn.quiet {
  color: var(--muted);
  border: 1px solid var(--control-line);
  border-radius: var(--r-xs);
  padding: 8px 12px;
}
.linkbtn.quiet:hover,
.linkbtn.quiet:focus-visible {
  color: var(--accent-ink);
  border-color: var(--accent);
}
/* WORDS ON A WIDE SCREEN, THE ICON ALONE ON A NARROW ONE.
   The document header is a wrapping row — back to the vault, the category
   picker, then this. Measured, "Delete this document" only fits on the same
   line from about 620px up; below that the row ran to THREE lines on a phone
   and the delete landed on its own, underneath the category picker, which is
   where a destructive control looks least like it belongs.
   Dropping to the icon puts it back beside the picker and takes the row from
   three lines to two. The label is HIDDEN, not removed — the same treatment
   the app nav gives its labels — so the button is still announced as "Delete
   this document" to a screen reader, and `title` gives a pointer user the
   same words on hover.
   760px, NOT the 620 this was first measured at. 620 was where the ENGLISH
   label fits, and a breakpoint tuned to one language is a bug in the other
   fifteen: German's "Dieses Dokument löschen" is 189px beside a 299px picker
   and split the row in two at 625. The threshold has to clear the longest
   label, so it is set past it. */
.delbtn .icon, .sharebtn .icon { display: none; }
@media (max-width: 760px) {
  .delbtn .dellabel, .sharebtn .dellabel {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
    white-space: nowrap; border: 0;
  }
  .delbtn .icon, .sharebtn .icon { display: inline-grid; width: 18px; height: 18px; }
  /* Square, and a real target: the icon is 18px, so 10px a side makes 38 and
     the coarse-pointer block below grows the hit area the rest of the way. */
  .delbtn, .sharebtn { padding: 10px; line-height: 0; }
}

/* ---- the money twin of the calendar toggle, in the details table ---- */
/* .moneytoggle rides on .caltoggle so the two are the same control in every
   respect that matters — size, hit area, the on/off states, the coarse-pointer
   halo — and differ only in the glyph and what they govern. Written as a
   modifier rather than a copy for that reason: two independent rules would
   drift, and a reader looking at one row would learn the wrong thing about the
   row under it. */
.moneytoggle.on { color: var(--accent-ink); }
.moneytoggle.off { color: var(--muted); }
/* "All of them", under the table whose rows they govern. Quiet, because the
   per-row toggles are the ordinary action and these are the rarer, larger one.
   Wrapping, because two of them in a long language do not fit a phone. */
.bulkrow { display: flex; flex-wrap: wrap; gap: var(--s-5); margin-top: var(--s-5);
  padding-top: var(--s-5); border-top: 1px solid var(--line); }
.bulktoggle { display: inline-flex; align-items: center; gap: var(--s-3);
  font-size: var(--t-md); color: var(--muted); }
.bulktoggle .icon { width: 15px; height: 15px; }
.bulktoggle:hover, .bulktoggle:focus-visible { color: var(--accent-ink); }
/* The receipt table's own controls column: as narrow as two icons. */
.rtable .racts { width: 1%; white-space: nowrap; text-align: end; }
.rtable .racts .lact { margin-inline-start: var(--s-2); }

/* A PHONE IS NOT SHOWN A FIVE-COLUMN TABLE.
 *
 * Measured on a twelve-item Kaufland receipt at 360px: the table needs 408px
 * and the card gives it 294, so `.tablewrap` did what it was told and put the
 * last 114px behind a sideways scroll — which is the PRICE column and both
 * buttons. Ten pixels of the price cell were visible. At 393px, the commonest
 * phone width, the edit and delete buttons showed two pixels between them.
 * A receipt whose prices are off-screen is not a slow receipt, it is a broken
 * one, and nothing on the screen says a hidden axis exists.
 *
 * So below the phone breakpoint each line stops being a row of cells and
 * becomes a block: what it is and what it cost on the first line, the till
 * roll's own words underneath, the controls at the end of that second line.
 * The columns come back at 561px, where they fit. The markup does not change —
 * it is still a table, still one <tr> per item, and still what a screen reader
 * and the corrections endpoint see. */
@media (max-width: 560px) {
  /* The header row names columns that no longer exist. Each line now says what
     it is by where the words sit; the quantity is the one cell whose meaning
     could be in doubt, which is what the `×` before it settles. */
  .rtable thead { display: none; }
  .rtable, .rtable tbody { display: block; }
  .rtable tr {
    display: grid; grid-template-columns: 1fr auto auto;
    column-gap: var(--s-5); row-gap: var(--s-1); align-items: baseline;
    padding: var(--s-4) 0; border-bottom: 1px solid var(--line);
  }
  .rtable tr:last-child { border-bottom: 0; }
  /* 14px rather than 15, HERE ONLY. Two of every six item names needed 213 and
     237px against the 212 they are given, so they wrapped and those rows cost
     an extra line each; a point smaller and they fit. It is the size the rest
     of the app's body text uses, on the one screen that is a list of forty
     short strings rather than prose. */
  .rtable { font-size: var(--t-base); }
  /* 26px, not the shared 30. These two buttons ARE the second line's height —
     the words beside them are 18px — so six pixels here is six pixels off
     every item on the receipt. Still above the 24px floor the calendar chips
     are held to, and only on this table: the ledger's own row keeps 30. */
  .rtable .racts .lact { width: 26px; height: 26px; }
  /* `width: 1%` is the table trick for "size to content" and means nothing to
     a grid item except "shrink to a hundredth of the row", so it goes.
     `min-width: 0` and a break of last resort are what stop the row growing
     past the card: a grid item will not shrink below its longest word, and a
     till roll is full of words no dictionary knows — "Spuelmaschinentabs",
     "Rispentomaten". Measured in Safari on an iPhone simulator, where the same
     line that fits in Chrome ran 9px past the card and put the end of it
     behind a sideways scroll: the fonts are not the same width, so a row that
     only just fits in one engine does not fit in the other. */
  .rtable td { display: block; width: auto; min-width: 0; padding: 0; border: 0;
               overflow-wrap: anywhere; }
  /* TWO LINES, AND WHICH TWO THINGS SHARE THE FIRST ONE DECIDES THE HEIGHT.
     Measured, all three arrangements, on the same twelve-item receipt at 360:
       name + qty + price on line one — the name is left ~200px, needs 210, so
         it wraps: 961px, TALLER than the broken version it replaced.
       name alone on line one, controls on a third — 1217px, worse again: the
         buttons are 30px and every item paid for a line of its own.
       name + price on line one, everything else on the second — 876px, and
         the name has 222px, which fits.
     So: what it is and what it cost where the eye compares them, the till
     roll's own words and the controls underneath. */
  .rtable .rt { grid-column: 1 / span 2; grid-row: 1; }
  .rtable .rp { grid-column: 3; grid-row: 1; width: auto; }
  .rtable .ro { grid-column: 1; grid-row: 2; font-size: var(--t-md); }
  .rtable .rq { grid-column: 2; grid-row: 2; width: auto; }
  /* A FLEX ROW, so the gap is the only space between the two buttons.
     They are inline-level (`display: inline-grid`), which makes the newline
     between them in the markup a rendered space, and each carries a 4px
     margin on top of that. Chrome's grid track happened to be wide enough to
     absorb it; Safari's was not — measured on an iPhone simulator, the cell
     needed 72px in a 63px track and put 9px of the row behind a sideways
     scroll that the desktop engines never showed. */
  .rtable .racts { grid-column: 3; grid-row: 2; width: auto; justify-self: end;
                   display: flex; gap: var(--s-2); justify-content: flex-end; }
  /* `overflow: hidden` on the button, not on the row. Measured in Safari on an
     iPhone: each of these buttons is a 26px box whose CONTENT measures 35 —
     nine pixels of anonymous box that never draws ink but does count towards
     the scroll width of everything above it, which is where the receipt's
     9px sideways scroll came from. Clipping it contains the arithmetic to the
     button. An element's own focus ring is painted outside its overflow and is
     not clipped by this, which is the one thing worth checking before
     reaching for it. */
  .rtable .racts .lact { margin-inline-start: 0; overflow: hidden; }
  /* A bare "5" between a name and a price, with the QTY header gone, is a
     number with no job. `:not(:empty)` because the cell is deliberately blank
     when the quantity is one — see qtyCell in render.js. */
  .rtable .rq:not(:empty)::before { content: "\00D7"; }
  /* THE EDITOR IS ONE CELL ACROSS FIVE COLUMNS, and a grid does not read
     `colspan`. `_editReceiptItemInline` replaces the row's cells with a single
     `<td colspan="5">` holding the form.
     Measured before assuming: without this rule the form is NOT crushed into
     the first column — every `<tr>` is its own grid container, so with one
     item the other two tracks are empty and collapse, and the form still gets
     274 of the 294px. What it loses is the two column gaps. So this is worth
     20px and a correct expression of the markup's own colspan, not a rescue. */
  .rtable td[colspan] { grid-column: 1 / -1; }
}

/* ---- password store (zero-knowledge) ---- */
.pwcard { max-width: 560px; }
.pwlabel {
  display: block; font-size: var(--t-md); font-weight: 600; color: var(--muted);
  margin: 14px 0 6px; text-transform: none; letter-spacing: 0;
}
.pwinput {
  width: 100%; font: inherit; font-size: var(--t-lg); padding: 11px 13px; min-height: 44px;
  border: 1px solid var(--control-line); border-radius: var(--r-sm);
  background: var(--card); color: var(--ink); box-sizing: border-box;
}
/* Border tint only. This rule used to open with `outline: none`, which scores
   (0,2,0) against the (0,1,0) of the one `:focus-visible` rule the whole app
   relies on — so every field carrying this class, which is every field in
   Settings, had NO focus outline for keyboard and switch users. The box-shadow
   here is not a substitute: a shadow is clipped by any `overflow: hidden`
   ancestor and vanishes in forced-colours mode, which is exactly the argument
   DESIGN.md makes for never removing the outline. */
.pwinput:focus { border-color: var(--focus-line); }
textarea.pwinput { min-height: 0; resize: vertical; }

/* A masked field that is NOT type="password".
   The PIN boxes are plain text inputs wearing dots, because a password input
   is filled — with the saved sign-in or vault passphrase, which is never the
   PIN — by Chrome, by Safari and by every password manager, no matter what
   `autocomplete` says. The reasoning in full, and the fallback for a browser
   that does not support this property, are in applock.js beside pinAttrs();
   `.shown` is what the eye beside the box toggles. applock.js repeats these
   two rules inline for its own overlay, which is drawn before this file is
   guaranteed to have arrived. */
.masked { -webkit-text-security: disc; }
.masked.shown { -webkit-text-security: none; }

/* Master-password strength meter. */
.pwmeter { height: 6px; border-radius: var(--r-pill); background: var(--chip-bg);
  overflow: hidden; margin: 8px 0 2px; }
.pwbar { display: block; height: 100%; width: 0; border-radius: var(--r-pill);
  transition: width var(--dur-2) ease, background var(--dur-2) ease; }
.pwbar.s0, .pwbar.s1 { background: var(--urgent); }
.pwbar.s2 { background: var(--action); }
.pwbar.s3 { background: var(--grad-2); }
.pwbar.s4 { background: var(--grad-1); }

.pwack { display: flex; align-items: flex-start; gap: var(--s-5); margin: 16px 0 4px;
  font-size: var(--t-base); color: var(--ink); cursor: pointer; }
.pwack input { margin-top: 3px; min-height: 0; }

.pwerr { color: var(--urgent-ink); font-size: var(--t-base); margin-top: 12px; }
.pwok { color: var(--grad-2); font-size: var(--t-base); font-weight: 600; margin: 12px 0 0; }

/* The one warning that matters: there is no reset. */
.pwwarn {
  display: flex; gap: var(--s-6); align-items: flex-start; margin: 16px 0 4px;
  padding: 13px 15px; border-radius: var(--r-md);
  background: var(--warn-bg); border: 1px solid var(--warn-border);
}
.pwwarn .icon { color: var(--warn-ink); flex: 0 0 auto; margin-top: 1px; }
.pwwarn strong { display: block; color: var(--warn-ink); font-size: var(--t-lg); }
.pwwarn span { display: block; color: var(--warn-ink); font-size: var(--t-base); opacity: .92; margin-top: 2px; }

/* List header + actions. */
.pwbar-top { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-6); flex-wrap: wrap; margin-bottom: 14px; }
.pwactions { display: flex; align-items: center; gap: var(--s-4); }
.pwactions .linkbtn { display: inline-flex; align-items: center; gap: var(--s-3); }
.pwactions .linkbtn .icon { width: 15px; height: 15px; }

.pwlist { display: flex; flex-direction: column; gap: var(--s-5); }

/* One stored login. */
.pwentry {
  display: flex; gap: var(--s-7); align-items: flex-start; justify-content: space-between;
  padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--card); box-shadow: var(--shadow-1);
}
.pwentry-main { min-width: 0; display: flex; flex-direction: column; gap: var(--s-3); flex: 1; }
.pwentry-name { font-weight: 700; font-size: var(--t-lg); }
.pwentry-user, .pwentry-pass { display: flex; align-items: center; gap: var(--s-4);
  font-size: var(--t-base); color: var(--muted); min-width: 0; }
.pwentry-user span { overflow-wrap: anywhere; }
.pwdots { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  letter-spacing: 1px; color: var(--muted); overflow-wrap: anywhere; }
.pwdots.revealed { color: var(--ink); letter-spacing: 0; }
.pweye { border: 0; background: none; padding: 2px; min-height: 0; cursor: pointer;
  color: var(--muted); display: inline-flex; }
.pweye:hover { color: var(--accent-ink); }
.pweye .icon { width: 17px; height: 17px; }
.pwcopy { border: 1px solid var(--line); background: var(--card); color: var(--accent-ink);
  font-size: var(--t-md); font-weight: 600; padding: 3px 10px; border-radius: var(--r-pill);
  min-height: 0; cursor: pointer; }
.pwcopy:hover { border-color: var(--grad-3); }
.pwcopy.copied { color: var(--grad-2); border-color: var(--grad-2); }
.pwentry-url { font-size: var(--t-base); color: var(--accent-ink); text-decoration: none;
  overflow-wrap: anywhere; }
.pwentry-url:hover { text-decoration: underline; }
.pwentry-notes { font-size: var(--t-base); color: var(--muted); white-space: pre-wrap;
  overflow-wrap: anywhere; }
.pwentry-tools { display: flex; flex-direction: column; align-items: flex-end; gap: var(--s-1);
  flex: 0 0 auto; }

.pwpassrow { display: flex; gap: var(--s-4); align-items: center; }
.pwpassrow .pwinput { flex: 1; }
.pwpassrow .linkbtn { flex: 0 0 auto; white-space: nowrap; }
.pwchange { margin-top: 12px; max-width: 420px; }

/* "View all", wherever it appears. There were two of these — `.seeall` under
   the deadlines card and `.mini-more` under the recent-documents card, sitting
   in adjacent columns on the home page — with different colours and only one
   of them carrying an arrow, which was typed into the markup. Two names for
   one link is how a page ends up disagreeing with itself in the same glance.
   The arrow comes from CSS now so it cannot be forgotten at the next call
   site, and it points the way the language reads. */
/* `inline-flex` with a 24px floor, not `inline-block`: at 14px the link's box
   came to 67x22, and WCAG 2.5.8 asks 24x24 of anything that is not a link
   inside a sentence. This one sits alone under a card, which is exactly the
   case the inline exception does not cover. The extra two pixels are height
   the link already had around it as margin. */
.seeall, .mini-more { display: inline-flex; align-items: center; min-height: 24px;
  margin-top: 12px; font-size: var(--t-base);
  font-weight: 600; color: var(--accent-ink); text-decoration: none; }
.seeall:hover, .mini-more:hover { text-decoration: underline; }
.seeall::after, .mini-more::after { content: " \2192"; }
[dir="rtl"] .seeall::after, [dir="rtl"] .mini-more::after { content: " \2190"; }

/* "That's not me" identity control on the profile. */
.idnote { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap;
  margin: -6px 0 16px; font-size: var(--t-base); color: var(--muted); }
.idnote .linkbtn { padding: 4px 6px; font-size: var(--t-base); }
.idedit { margin: 4px 0 16px; max-width: 460px; }

/* ---- deadlines timeline (the obligations view) ---- */
.ochips { display: flex; flex-wrap: wrap; gap: var(--s-4); margin-top: 4px; }
.ochip { font-size: var(--t-md); font-weight: 600; padding: 5px 11px; border-radius: var(--r-pill);
  background: var(--chip-bg); color: var(--muted); }
.ochip.overdue { background: var(--warn-bg); color: var(--urgent-ink); border: 1px solid var(--warn-border); }
.ochip.soon { background: var(--low-bg); color: var(--action-ink); }

/* `auto` on the inline axis, not 0. This is a `margin` SHORTHAND and it sits
   300 lines after the measure block, so it wins on order and resets the auto
   margins the cap needs — which left the deadline list hard against the
   leading edge while the calendar above it was centred. The shorthand is the
   trap: `margin-bottom` on a neighbouring rule would have been harmless. */
.ogroup { margin: 18px 0 0; }
.osub { font-size: var(--t-sm); text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
  font-weight: 700; margin: 0 0 8px; }

/* The row is a div with a stretched link (.ostretch) rather than an <a> around
   everything, because it now holds a <select> and interactive content may not
   nest inside an anchor. The link still covers the whole row, so the click
   target did not change; the children are click-through except the control. */
.orow { position: relative; }
.ostretch { position: absolute; inset: 0; z-index: 0; border-radius: inherit; }
.orow > .owhen, .orow > .omain, .orow > .oamount { position: relative; z-index: 1;
  pointer-events: none; }
.orow .ourg { pointer-events: auto; }

/* A LIST, NOT A STACK OF BOXES.
   Every row was its own bordered, rounded, shadowed card with 8px of page
   showing between it and the next — so a month read as a pile of separate
   objects, and the calendar was the only list in this product drawn that way.
   The ledger, the subscriptions and the accounts are all a flat list inside
   one surface, rows told apart by a hairline:

       .lrow   { padding: 11px 0; border-bottom: 1px solid var(--line); }
       .subrow { padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }

   This is that, and the surface is the DAY (see `.oday`) — which the rows were
   already grouped under, so the grouping now has a shape instead of only a
   heading.

   PULLED OUT THROUGH THE DAY'S PADDING, the same trick `.catrow` uses inside
   the spending panel: the hairline and the hover tint reach the card's edges
   while the words still line up with the heading above them, and the urgency
   stripe sits on the card's own edge where it reads as the row's status. */
.orow {
  display: flex; align-items: center; gap: var(--s-7);
  padding: 13px var(--s-7);
  margin-inline: calc(var(--s-7) * -1);
  width: calc(100% + var(--s-7) * 2);
  border: 0; border-block-end: 1px solid var(--line);
  /* The urgency stripe. Logical, not border-left: this is the one component on
     the deadlines screen that kept its red edge on the trailing side in Arabic,
     while .verdict on the letter itself flipped correctly, so the two screens
     disagreed about which edge means "urgent". */
  border-inline-start: 3px solid transparent;
  text-decoration: none; color: inherit;
  transition: background-color var(--dur-1) ease, border-color var(--dur-2) ease;
}
.orow:last-child { border-block-end: 0; }
/* NO LIFT. A row that slid sideways and grew a shadow was a card behaving like
   a card; a row in a list highlights where the pointer is and stays put. */
.orow:hover { background: var(--hover-bg); }
.orow.overdue { border-inline-start-color: var(--urgent-ink); }
.orow.soon { border-inline-start-color: var(--action); }
/* A row with nothing to say about urgency shows no stripe at all now, rather
   than a grey one: inside a card, a grey edge on every row is a second set of
   dividers running the wrong way. */
.owhen { flex: 0 0 auto; min-width: 82px; font-weight: 700; font-size: var(--t-base); color: var(--muted); }
/* The clock time under the date, on the rows that have one. Its own line and
   a step down in size and weight, because the DATE is what orders this list
   and the time is a detail of the row it sits on. */
.owhenat { display: block; font-weight: 500; font-size: var(--t-sm);
  color: var(--muted); font-variant-numeric: tabular-nums; }
.owhen.overdue { color: var(--urgent-ink); }
.owhen.soon { color: var(--action-ink); }
.omain { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--s-2); }
.odesc { font-weight: 600; font-size: var(--t-lg); overflow-wrap: anywhere; }
.ometa { font-size: var(--t-md); color: var(--muted); display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
.ohard { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--urgent-ink); background: var(--warn-bg); border: 1px solid var(--warn-border);
  padding: 1px 7px; border-radius: var(--r-pill); }
.obell { display: inline-flex; color: var(--accent-ink); }
.obell .icon { width: 14px; height: 14px; }
.oamount { flex: 0 0 auto; font-weight: 700; font-variant-numeric: tabular-nums; font-size: var(--t-lg);
  overflow-wrap: anywhere; }
/* Phones: let the countdown sit above the text so a long amount has room. */
@media (max-width: 560px) {
  /* THE DATE ON ONE LINE, WHAT IT IS ON THE NEXT.
     `.owhen` holds a 68px column and `.oamount` takes what it needs, which on
     a 320px screen leaves the description 133px — and a description is the
     model's sentence about the date: "The courier tried to deliver the parcel
     to you in person but no one was available to receive it." came out as six
     lines in that column. Given the row's full width it takes three.
     Same shape as the details table and the receipt line before it: a column
     sized for a label, holding prose. */
  .orow { display: grid; grid-template-columns: auto 1fr;
          align-items: baseline; column-gap: var(--s-5); row-gap: var(--s-2); }
  .owhen { grid-column: 1; grid-row: 1; min-width: 68px; font-size: var(--t-md); }
  .oamount { grid-column: 2; grid-row: 1; justify-self: end; }
  .omain { grid-column: 1 / -1; grid-row: 2; }
  /* NAMED, like every other child of this grid. Left unplaced it auto-flowed
     into the next free cell and took the row past the screen edge.

     AND ALIGNED TO THE START, not the end. Pushed to the trailing edge it sat
     in the one corner of a phone screen that is already spoken for — the chat
     button floats there, and tests/test_the_phone_layout.js walks the page
     scroll stop by scroll stop looking for exactly this: a small control
     resting under it, which a thumb cannot reach at all. Auto-flow had been
     putting these in the leading columns all along; this puts them back. */
  .oacts { grid-column: 1 / -1; grid-row: 3; justify-self: start; }
  .oamount { font-size: var(--t-base); }
}

/* People (family & friends) on the profile. */
.psub { font-size: var(--t-sm); text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
  font-weight: 700; margin: 14px 0 6px; }
.prow-person { display: flex; align-items: center; justify-content: space-between; gap: var(--s-6);
  padding: 9px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.prow-person:last-child { border-bottom: 0; }
.pperson { display: flex; align-items: baseline; gap: var(--s-5); min-width: 0; flex-wrap: wrap; }
.peoplelink { font-weight: 600; font-size: var(--t-lg); color: var(--accent-ink); text-decoration: none;
  overflow-wrap: anywhere; }
.peoplelink:hover { text-decoration: underline; }
.pcount { font-size: var(--t-md); color: var(--muted); }
.prel { display: inline-flex; align-items: center; gap: var(--s-4); }
.rellabel { font-size: var(--t-md); font-weight: 600; color: var(--ink);
  background: var(--chip-bg); padding: 3px 10px; border-radius: var(--r-pill); }
/* ---- finding a letter: the documents toolbar ----
 *
 * Search on its own row, controls underneath. On a phone the controls wrap to
 * as many rows as they need and the search box keeps the full width, because
 * the box is what somebody arriving with a letter in mind reaches for and a
 * 90px input shared with four dropdowns is not a search field.
 *
 * Everything is visible rather than behind a "Filters" button: a vault quietly
 * showing eight of forty documents is the worst thing this screen can do, and
 * a collapsed panel is how that happens. */
.docbar { margin: 4px 0 14px; padding: 12px 14px; border-radius: var(--r-md);
  background: var(--hover-bg); border: 1px solid var(--calm-border); }
/* "8 documents" / "3 results". Was an inline `style="margin:22px 0 10px"` on
   both of the h3s that render it, which is why it is here: an inline style
   cannot be answered by the phone block at the end of this file, and 32px of
   vertical margin is a lot to spend on one grey line when the screen is
   800px tall. */
.countline { margin: 22px 0 10px; }
.searchrow { position: relative; display: flex; align-items: center; }
.searchrow .searchicon { position: absolute; inset-inline-start: 12px; display: inline-flex;
  color: var(--muted); pointer-events: none; }
.searchrow .searchicon svg { width: 18px; height: 18px; }
/* THE GUTTER FOR THE CLEAR BUTTON IS RESERVED ONLY WHEN THERE IS ONE.
   `#dsearchx` is rendered only while a query is typed, so a flat 38px of
   inline-end padding was 24px of dead space in the empty state — the state the
   box is in every time the screen opens. It cost the placeholder its last two
   words on a phone once the filter toggle moved up beside it. The profile
   search keeps its button in the DOM and hides it with a class, so `:has()`
   still matches there and that box is unchanged. */
.searchrow input { width: 100%; padding: 11px 14px; padding-inline-start: 40px;
  padding-inline-end: var(--s-7); border-radius: var(--r-sm); border: 1px solid var(--control-line);
  background: var(--card); color: var(--ink); font-size: var(--t-lg);
  /* The UA's own clear button would sit beside ours, and only in some browsers. */
  appearance: none; -webkit-appearance: none; }
.searchrow:has(.searchx) input { padding-inline-end: 38px; }
.searchrow input::-webkit-search-cancel-button { display: none; }
.searchx { position: absolute; inset-inline-end: 8px; border: 0; background: none;
  color: var(--muted); font-size: var(--t-2xl); line-height: 1; cursor: pointer;
  padding: 2px 8px; border-radius: var(--r-sm); }
.searchx:hover { color: var(--ink); background: var(--card); }
.filterrow { display: flex; align-items: flex-end; gap: var(--s-5); flex-wrap: wrap;
  margin-top: var(--s-5); }
/* The fold-away control for the filter row. It does not exist above the phone
   breakpoint: there the row wraps onto a line or two and folding it would cost
   a click to hide nothing. */
.filtoggle { display: none; }
@media (max-width: 560px) {
  .filtoggle { display: inline-flex; align-items: center; gap: var(--s-3);
    margin-top: var(--s-5); min-height: 44px; padding: 0 var(--s-6);
    border: 1px solid var(--line); border-radius: var(--r-sm);
    background: var(--card); color: var(--ink); font-size: var(--t-md);
    font-weight: 600; cursor: pointer; }
  .filterrow.collapsed { display: none; }
  /* Stacked, the fields are the full width of the screen rather than each one
     sized to its own longest option, which read as a ragged column. */
  .filterrow .ffield { flex: 1 1 100%; }
  /* EXCEPT CATEGORY AND TAG, which share a line. They are the pair a reader
     reaches for together — which pile, which word — and stacked they spend two
     of the seven rows this fold-out already costs on a phone.
     HALF AS A BASIS, NOT AS A WIDTH, and that is the whole rule: a field is
     only offered when there is something to choose (one category, or no word
     on any letter, and the field is not drawn at all), so either of these two
     can turn up alone. Alone it is the only item on its line and `flex-grow`
     takes it back to the full width the fields above it have — no `:has()`
     needed to ask whether it has a partner. Every other field has a 100% basis
     and so keeps a line to itself; these two are the only ones that can meet. */
  .filterrow .fpair { flex: 1 1 calc(50% - var(--s-5) / 2); min-width: 0; }
  /* The tag half carries a select AND, once a word is chosen, a rename pencil,
     so it is an inline-flex box that sizes to its longest word — 122px beside a
     category select filling 167, which read as one field short of the edge
     rather than half of a pair. Stretching it costs nothing above the pair:
     `.filterrow select` still caps at 190px, the width every stacked field
     already has, so a reader with words but only one category sees no change. */
  .filterrow .fpair .tagfilter { display: flex; }
  .filterrow .fpair .tagfilter select { flex: 1 1 auto; }
  /* AND THE 190px CAP COMES OFF, which is what the full-width rule above meant
     all along: the FIELDS were the width of the screen and the dropdowns inside
     them stopped at 190, so four of them ended mid-row and the pair ended at
     the card's edge — one ragged column, the thing that rule set out to avoid.
     The cap exists for a desktop row, where a 60-character category name would
     otherwise push the fields beside it off the line; stacked there is nothing
     beside them to push, and a wider dropdown is a wider place to press.
     `.ffield` is in the selector only to outrank that cap, which is written
     later in this file at the same weight and would otherwise win. */
  .filterrow .ffield select { max-width: none; }
}
.ffield { display: inline-flex; flex-direction: column; gap: var(--s-2); }
.flabel-sm { font-size: var(--t-sm); text-transform: uppercase; letter-spacing: .04em;
  color: var(--muted); font-weight: 700; }
/* `text-overflow` because the category filter now offers the reader's own
   categories beside the built-in ones, and those are 60 characters of whatever
   they typed. Every option here used to be a short translated label, so a hard
   clip at 190px never showed; a name cut mid-letter with no ellipsis reads as a
   rendering fault rather than a name too long for the box. The document's own
   category picker has carried this rule for the same reason since it started
   listing both. */
.filterrow select { padding: 7px 10px; border-radius: var(--r-sm);
  border: 1px solid var(--control-line); background: var(--card); color: var(--ink);
  font-size: var(--t-base); max-width: 190px; text-overflow: ellipsis; }
.filterrow select:disabled { opacity: .55; cursor: not-allowed; }
.filterrow .linkbtn { align-self: center; margin-bottom: 2px; }
.filtercount { margin: 10px 0 0; font-size: var(--t-md); color: var(--muted); }
.filtercount:empty { display: none; }      /* no empty line when nothing is hidden */
/* The profile's search sits on its own, above the cards it filters. */
.profile-search { margin-bottom: var(--s-2); }

/* A row, heading or card the current search excluded. One class for all three,
   because "hidden by a filter" is one state however big the thing is. */
.filtered-out { display: none !important; }

.pfilter { min-width: 150px; }
/* AI-explain toggle on the capture page */
.explainrow { display: flex; align-items: center; gap: var(--s-6); margin: 16px 0 0;
  padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--r-md); cursor: pointer;
  transition: border-color var(--dur-1) ease, background var(--dur-1) ease; }
/* Class display must not beat the hidden attr, the fourth element in this
   file to need saying so explicitly.
   `hidden` is a UA rule ([hidden]{display:none}), and ANY author rule setting
   display outranks the UA sheet no matter the order. So this row rendered for
   signed-out readers even though app-index.js only unhides it when signed in.
   That offered a choice the server then ignored: explain_ai is forced true
   when there is nowhere to store to, so unticking it while signed out changed
   nothing and the model ran anyway. */
.explainrow[hidden] { display: none; }
.explainrow:hover { border-color: var(--calm-border); background: var(--hover-bg); }
.explainrow:has(input:checked) { border-color: var(--calm-border); background: var(--hover-bg); }
/* 24, for the reason the checkbox rule up at `.bigcheck` gives: this is the
   choice between "search the web about my letter" and "never search", and it
   was a 20px target. */
.explainrow input { width: 24px; height: 24px; flex: 0 0 auto; margin: 0; accent-color: var(--accent); cursor: pointer; }
.explainrow > span { display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; }
.explainrow strong { font-size: var(--t-lg); font-weight: 600; line-height: 1.25; }
.explainrow .sub { font-size: var(--t-md); margin: 0; line-height: 1.45; }
/* "stored, not read" marker */
.tag-unexplained { font-size: var(--t-sm); font-weight: 600; letter-spacing: .02em;
  color: var(--muted); background: var(--chip-bg); padding: 2px 9px; border-radius: var(--r-pill); }
/* folded-away spellings of one person, shown under the name */
.pmerged { flex-basis: 100%; font-size: var(--t-sm); color: var(--muted); overflow-wrap: anywhere; }
/* "same person as" merge controls inside the relationship editor */
.mergebox { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.mergedlist { display: flex; flex-wrap: wrap; gap: var(--s-3); margin: 6px 0 8px; }
.mergedtag { display: inline-flex; align-items: center; gap: var(--s-3); font-size: var(--t-md);
  background: var(--chip-bg); color: var(--ink); padding: 3px 6px 3px 10px; border-radius: var(--r-pill);
  overflow-wrap: anywhere; }
.mtx { border: 0; background: transparent; color: var(--muted); cursor: pointer;
  font-size: var(--t-lg); line-height: 1; padding: 0 2px; }
.mtx:hover { color: var(--urgent-ink); }

/* Model picker in the chat head, a real choice (how carefully the letter is
   read, against speed and cost), so it sits beside the tabs rather than hidden
   behind a dev flag as it used to be. */
/* Model picker lives in the composer, beside the Ask button, the place every
   chat UI puts it, and where it is answering the question "which model will
   answer THIS?" rather than decorating the tab bar. */
.chat-model { display: inline-flex; align-items: center; flex: 0 1 auto; min-width: 0; }
.chat-model select { font-size: var(--t-md); padding: 6px 8px; min-height: 40px; max-width: 100%;
  color: var(--muted); text-overflow: ellipsis; }
/* Visually hidden but read aloud, the select has a title and aria-label, and a
   visible "Model" caption next to the Ask button is just noise. */
.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* Signature state. Three colours because the three states mean different work:
   nothing to do, something to decide, or somebody still owes a signature. */
.card.sig h3 { margin: 0; }
.card.sig .sigmeta { margin: 8px 0 0; font-size: var(--t-md); color: var(--muted); }
.card.sig .sigmeta.ev { font-style: italic; }
.sig-done { border-inline-start: 3px solid var(--ok); }
.sig-none { border-inline-start: 3px solid var(--accent); }
.sig-part { border-inline-start: 3px solid var(--action); }

/* ---- month calendar ----
   The grid answers "what does my month look like"; the list under it keeps the
   detail and the actions. Seven equal columns, so it works the same in RTL
   (logical properties everywhere, no left/right). */
.card.cal { padding-bottom: 14px; }
.calhead { display: flex; align-items: center; gap: var(--s-4); margin-bottom: 12px; }
/* THE ONE ELEMENT HERE THAT TAKES THE SLACK, because it is the one whose
   content nobody can measure in advance. `flex-grow` fixes its BOX at whatever
   the line has left over, so a long month and a short one occupy the same
   width and every control after it keeps its place all year. `min-width: 0`
   because a flex item will not shrink below its own longest word otherwise,
   which is how a heading pushes a button off the end of a narrow card. */
.calmonth { font-size: var(--t-xl); font-weight: 700; flex: 1 1 auto; min-width: 0; }
/* Centred like every other icon-only button (.eye, .lact, .viewbtn, .saymic).
   This one relied on the default button line box instead: with min-height the
   box grew downwards while the glyph stayed on the baseline, so the chevrons
   sat visibly high in their boxes. */
.calnav { min-height: 34px; padding: 4px 10px; display: inline-grid; place-items: center; }
/* The base chev points RIGHT (it is the same glyph the list rows use), so prev
   is a 180° flip and next is untouched, rotating by 90° gave up/down arrows. */
.calnav .icon { width: 16px; height: 16px; transform: rotate(180deg); }
.calnav.next .icon { transform: none; }
html[dir="rtl"] .calnav .icon { transform: none; }          /* mirrored: prev points right */
html[dir="rtl"] .calnav.next .icon { transform: rotate(180deg); }
/* TODAY IS NAVIGATION, SO IT TRAVELS WITH THE ARROWS.
   The auto margin used to sit here, which put all the bar's slack between the
   next-month arrow and Today — 440px of nothing at desktop width — and glued
   Today to Add event, two controls with nothing to do with each other. Today is
   the third way to move the month, not a thing you do to it. */
/* Add event. In the head rather than at the foot of the screen, because it is
   an action on the MONTH — the thing the reader is looking at when they think
   of the date they want to keep. It needs no auto margin to sit at the end:
   the heading above absorbs the slack, so this and the three controls beside
   it are already pushed against the far edge, and pinned there. */
.calnew { min-height: 34px; padding: 6px 12px; font-size: var(--t-base); }
/* The form itself: one line for what and when, one for a note, one for the
   buttons. Hidden until asked for — an empty form permanently open on a
   calendar reads as something half-finished. */
.calform { display: flex; flex-direction: column; gap: var(--s-5);
  margin-bottom: var(--s-7); }
/* `hidden` is an attribute the UA styles as display:none at the lowest
   specificity there is, and the rule above is a class — so it won, and the
   form sat permanently open under the month with nothing in it. Any component
   that sets its own display has to say this itself. Measured in Chrome. */
.calform[hidden] { display: none; }
.calform-line { display: flex; gap: var(--s-5); }
.calform-title { flex: 1 1 auto; min-width: 0; }
.calform-date { flex: 0 0 auto; }
.calform-controls { margin: 0; }
.calform-err { margin: 0; color: var(--urgent-ink); font-size: var(--t-base); }
@media (max-width: 560px) {
  /* A date box and a title box side by side leave neither enough room to read
     at 390px; stacked, both keep their full width. */
  .calform-line { flex-direction: column; }
  /* And the head wraps rather than squeezing: measured at 390px, five controls
     on one line broke "August 2026" and "Add event" across two lines EACH, so
     the row was three lines tall and read as four separate things. On its own
     line the button is one line and the month is one line. */
  .calhead { flex-wrap: wrap; }
  .calnew { flex: 1 0 100%; }
}
/* Two lines, not three. At 320px the month, its two chevrons and "Today"
   measure 266px against 246px of card — so "Today" wrapped onto a line of its
   own above the button, and a three-line header on the smallest phone pushed
   the month itself below the fold. A smaller month label and a tighter gap
   bring the row to 244px, which fits: nav on one line, the button under it. */
@media (max-width: 400px) {
  .calhead { gap: var(--s-3); }
  /* THE LABEL TAKES THE WHOLE LINE HERE, rather than sharing it.
     Beside Today and the two chevrons there are 102px left inside a 239px
     head, and the widest month of the year fits that in five of the sixteen
     languages — "septiembre de 2026" alone is 141px. In the other eleven the
     head became three lines for the long months and two for the short ones,
     so the whole header changed shape as the reader paged through the year.

     `flex-basis: 100%`, not a wrap left to chance: the basis IS the line, so
     the label claims it in every month rather than in the months that happen
     not to fit. Three rows on the smallest phones, the same three all year —
     the heading, then the controls, then the button. */
  .calmonth { font-size: var(--t-lg); flex: 1 0 100%; }
  /* TWO RULES USED TO LIVE HERE AND BOTH ARE GONE, for the same reason: each
     was propping up a layout where a button absorbed the slack.
     `.caltoday { margin-inline-start: 0 }` cancelled an auto margin the base
     rule once gave Today; nothing has an auto margin now, because the heading
     takes the slack instead. And `.calnew { flex: 1 1 auto }` let the button
     share a line whenever the month name happened to be short, which moved it
     209px between March and September in German — the `flex: 1 0 100%` from
     the 560px block stands instead, and the button takes its own line whatever
     the month is called. */
}
/* A SHEET OF DAYS, not seven columns of floating boxes.
   The grid draws its own hairlines the way every month view worth copying
   does: one background colour showing through a 1px gap, so the rules are
   crisp at any zoom and there are no doubled borders where cells meet. The
   cells paint over it, which is also what makes an out-of-month day readable
   as "not this month" — it keeps the lines and drops the fill. */
.calgrid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
  border: 1px solid var(--line); border-width: 0 1px 1px 0;
  border-radius: var(--r-sm); overflow: hidden; }
/* Each cell draws only its leading and top edge and the grid closes the other
   two, so every rule is exactly one pixel with nothing doubled where cells
   meet. Drawn as borders rather than as a 1px grid gap because the spacing
   scale has no 1px step, and a rule between days is a border, not a gap. */
.calgrid > * { border: 1px solid var(--line); border-width: 1px 0 0 1px; }
.cdow { font-size: var(--t-xs); font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
  color: var(--muted); text-align: center; padding: var(--s-3) var(--s-1);
  background: var(--chip-bg); }
/* Wider than tall, deliberately. `aspect-ratio: 7/5` was tried, to stop the
   cell being "a landscape box holding a single digit" — and a square cell at
   the column's full width makes the month 700px of empty boxes, which is worse
   than the thing it fixed. Every month view worth copying is wider than it is
   tall; the original complaint measured a real proportion and drew the wrong
   conclusion from it. 60px is a row you can scan, and it is a floor rather
   than a cap, so a day that gains content can still grow. */
/* Tall enough for the day and three chips under it. The old cell was 60px
   and centred, which is the right shape for a box holding one digit and the
   wrong one for a box holding content: a chip reads along the line the number
   starts on, so both align to the leading edge now. */
/* NO `border: 0` HERE, and that is the whole of a bug worth naming.
   `.calgrid > *` twenty lines up gives every cell its leading and top edge —
   that is where the month's grid lines come from, and its comment says so.
   `.cday` has the same specificity and comes later, so a leftover `border: 0`
   from before the grid had lines silently won: every DAY cell lost all four
   edges while the weekday headers, which set no border of their own, kept
   theirs. What was left on screen was the grid's own right and bottom edge and
   nothing else — a month with a rule down its right side, none down its left,
   and no lines between any of the days. Reported exactly that way: "calendar
   has a right border but no left border". */
.cday { position: relative; display: flex; flex-direction: column; align-items: stretch;
  justify-content: flex-start; gap: var(--s-1); min-height: 72px;
  padding: var(--s-3) var(--s-2) var(--s-2); text-align: start;
  background: var(--card);
  font: inherit; color: var(--ink); overflow: hidden; }
/* Drawn, not hidden — see the note in _monthCard. The DAY is dimmed, not the
   cell: a filled block of grey put the heaviest thing on the grid in the one
   corner that matters least, and three of them in a row read as a merged cell
   rather than as last month.
   BY COLOUR, NOT BY OPACITY, and that is a correction. `opacity: .4` measured
   2.22:1 against the cell in dark mode — a number a person is meant to read,
   at half the contrast WCAG asks for. Opacity cannot be the tool here: in
   light mode --muted is only 5.43:1 at FULL strength, so any fading at all
   falls under 4.5. Two solid colours instead: this month in --ink, the months
   either side in --muted, which measures 5.43 light and 7.20 dark and is how
   the calendars worth copying draw the same distinction.
   A date on a neighbouring day keeps its chip at full strength: it is a real
   date, and the number already says which month it belongs to. */
.cday.out .cnum { color: var(--muted); }
.cnum { font-size: var(--t-md); line-height: 1.6; color: var(--ink);
  align-self: flex-start; padding-inline: var(--s-1); }
.cday.has { cursor: pointer; }
/* INSIDE the cell, because .calgrid clips. The grid hides its overflow so its
   rounded corners cut the cells cleanly, and an outline drawn 2px outside a
   cell in the first or last column was clipped by exactly that — the keyboard
   ring disappeared on the edges of the month. A negative offset draws the same
   ring just within the cell, where nothing can clip it. */
.cday-hit:focus-visible { outline-offset: -3px; }
/* THE CELL'S OWN CLICK, BEHIND ITS CHIPS.
   The cell used to be one <button>, so a day carrying three letters offered
   one destination and every chip in it was decoration. Each chip is a button
   now — and a button inside a button is invalid markup whose inner one stops
   firing, so the cell's own target is a stretched sibling UNDER them instead.
   Same construction as .ostretch on a deadline row, for the same reason. */
.cday-hit { position: absolute; inset: 0; z-index: 0; min-height: 0;
  padding: 0; border: 0; background: none; border-radius: inherit; cursor: pointer; }
/* The number and the chip column ride above it, and let clicks through
   everywhere except on a chip — otherwise the gaps between chips would be
   dead and the number would swallow the day's own click. */
.cnum, .cevents { position: relative; z-index: 1; pointer-events: none; }
.cev, .cmore { pointer-events: auto; }
.cday.has .cnum { font-weight: 600; }
.cday.has:hover { background: var(--hover-bg); }
/* Today is a filled disc on the NUMBER, not a ring around the cell. The ring
   version competed with the cell borders now that the grid has them, and read
   as a selection rather than as "you are here". */
.cday.today .cnum { color: var(--on-accent); font-weight: 700; background: var(--accent);
  border-radius: var(--r-pill); min-width: 1.55rem; text-align: center;
  padding-inline: 0; }
.cevents { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
/* One date. The dot carries the consequence, the words carry who it is with,
   and the whole thing truncates rather than wrapping — a chip that wraps to
   three lines pushes the rest of the day out of the cell. */
.cev { display: flex; align-items: center; gap: var(--s-2); min-width: 0; width: 100%;
  padding: var(--s-1) var(--s-2); border-radius: var(--r-xs); background: var(--chip-bg);
  font-size: var(--t-xs); line-height: 1.35; color: var(--ink);
  /* A button, so the base control rule reaches it: a 44px floor and a 15px
     type size would each on their own break a cell holding three of these. */
  border: 0; min-height: 0; text-align: start; cursor: pointer; }
/* The RING, not a repaint. A chip carries its consequence in its colour, and
   swapping the background on hover turned a red overdue chip grey under the
   pointer — the one chip whose colour is the message. An inset line says
   "pressable" without spending the fill. */
.cev:hover { box-shadow: inset 0 0 0 1px var(--control-line); }
/* Inside its own cell, which clips: .cday is overflow:hidden and a chip sits
   4px from the cell wall, so the shared 3px ring at +2px offset lost a pixel
   on both sides. Drawn just inside the chip instead — the same correction
   .cday-hit above makes against .calgrid, for the same reason. */
.cev:focus-visible, .cmore:focus-visible { outline-offset: -2px; }
/* A FINGER ON A TABLET. Below 620px a chip is a dot and the cell takes the tap
   (see the narrow block); above it the chip carries words and is its own
   button — measured at 98x19 on an 820px iPad, which is a real target for a
   real finger and four pixels under the 24px floor. The words do not need more
   room, so the box grows and the text stays where it is. */
@media (pointer: coarse) and (min-width: 621px) {
  .cev { min-height: 24px; }
}
.cev-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
.cev-txt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* SPENDING IS THE SAME COLOUR IN THE GRID AS IT IS IN THE LIST.
   The rows under the calendar were given the indigo this app draws spending
   with; the grid above them was not, because it builds its chips from
   `level()`, which files a spend under "ctx" — a date that is a FACT — and
   `ctx` has no colour rule at all. So one screen said "this is money" and the
   other left it the same grey as a passport's issue date.

   `--spend-ink` rather than the row's `--series-1`: this dot is 6px, and 5px
   below 620px where the chip's text is hidden and the dot is the ONLY thing
   telling two dates apart. The ink variant is the one that carries at that
   size — 5.55:1 on white against `--series-1`'s 3.72:1. */
.cev.spend .cev-dot { background: var(--spend-ink); }
/* Money arriving, in the green this app already gives it in the ledger. Its
   own colour rather than spending's, because "when did the salary land" and
   "when did the rent go" are the two halves of a month and a calendar that
   drew them alike would answer neither. */
.cev.moneyin .cev-dot { background: var(--ok-ink); }
.cev.soon .cev-dot { background: var(--grad-3); }
/* --urgent-ink, not --urgent: see the note on `.dot`. These are marks on a
   card, not fills carrying white text, and the fill token is dark in both
   palettes by design. */
.cev.hard .cev-dot, .cev.overdue .cev-dot { background: var(--urgent-ink); }
/* The two that carry consequence get a tinted body as well, so a hard date is
   findable in a month at a glance rather than by reading every chip. */
.cev.hard, .cev.overdue { background: var(--warn-bg); color: var(--urgent-ink); }
.cdot { width: 5px; height: 5px; border-radius: 50%; background: var(--muted); display: block; }
.cdot.soon { background: var(--grad-3); }
.cdot.hard { background: var(--urgent-ink); }
.cdot.overdue { background: var(--urgent-ink); }
.cmore { font-size: var(--t-xs); color: var(--muted); line-height: 1.35; padding-inline: var(--s-2);
  /* "+2 more" opens the day, so it is the same button the cell is — and the
     same reset, for the same base rule. */
  border: 0; background: none; min-height: 0; padding-block: 0; text-align: start;
  cursor: pointer; }
/* Two forms of the same count. The words are right when there is a line to
   put them on; in dots mode the cell is 33px wide at 320px and "2 more" was
   being cut mid-word by the cell's own clip. "+2" says the same thing in the
   space that exists, and neither form needs the other to be measured. */
.cmore-short { display: none; }
/* THE ROW A LINK BROUGHT THE READER TO, and it stays until they use the page.
   Louder than the flash below on purpose: that one answers "your eye was
   already here", this one has to be findable by somebody who has just changed
   screen and is scanning for the row they pressed. A ground as well as a ring,
   because a 2px outline on one row of forty is easy to miss and was. */
/* `--accent-ink`, NOT `--accent`. The fill colour is one value in both
   themes — a dark navy — so on a dark card the ring came out at 1.8:1 and
   simply was not there, on the theme this is most often read in. The ink
   variant is the one that flips: 7.31:1 on the light row, 8.22:1 on the dark
   one. The tint alone cannot carry this (1.19:1 against the card by design —
   it must not look like an error state), so the ring has to. */
.lrow.landed { background: var(--chip-bg); border-radius: var(--r-sm);
  box-shadow: 0 0 0 2px var(--accent-ink); }

/* ---- A ROW'S CONTROLS, GATHERED. --------------------------------------
   A wrapper and nothing more. The tick and the cross ALREADY faded in on
   hover, each with its own rule and its own `hover: none` exemption a few
   lines down — a second reveal on the cluster around them was duplicated
   behaviour, and worse, an unplaced child: `.orow` is a two-column GRID on a
   phone whose children are placed BY NAME, so this one auto-flowed into a
   cell of its own and pushed the row to 385px on a 320px screen. Caught by
   tests/test_the_phone_layout.js, which is the only reason it is not in the
   diff.
   `min-width: 0` so the urgency picker inside can shrink rather than set the
   row's width from its longest option. */
.oacts { display: inline-flex; align-items: center; gap: var(--s-3);
  flex: 0 1 auto; min-width: 0; max-width: 100%; }

/* Flash the row the grid jumped to, so the eye lands on it. A day heading
   flashes the same way: the grid now lands on one when the square, rather
   than one date in it, was clicked. */
.orow.flash, .oday.flash { box-shadow: 0 0 0 2px var(--grad-3);
  transition: box-shadow var(--dur-2) ease; }
.oday.flash { border-radius: var(--r-md); }

/* A DAY IS A HEADING, NOT A COLUMN OF DATES IN SMALL GREY TYPE.
   Every row already carried its own date beside the sender, which is the wrong
   place for the one thing that orders the list: four dates in a row read as
   one block and the reader had to check each line to find where a day ended.
   It is also what the month grid scrolls to, so clicking a square lands on the
   day rather than on whichever of its rows happened to be first. */
/* THE DAY IS THE CARD. The rows were already grouped under it and it drew
   nothing at all — a heading with loose boxes beneath. Given the surface the
   rows gave up, a day becomes one object: its date at the top, its dates
   inside it, hairlines between them. */
.oday { margin-bottom: var(--s-7); background: var(--card);
  border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--s-6) var(--s-7); box-shadow: var(--shadow-1); }
.odayhead { margin: 0 0 var(--s-4); font-size: var(--t-base); font-weight: 700;
  color: var(--ink); display: flex; align-items: baseline; gap: var(--s-4); }

/* Off the calendar, FROM the calendar.
   The control existed only on the document's own page — the one place the
   reader is not standing when a date bothers them. Quiet until the row is
   hovered or the button focused, because it is not what a row is for. */
.ox { position: relative; z-index: 1; pointer-events: auto; flex: 0 0 auto;
  display: inline-grid; place-items: center; width: 30px; height: 30px;
  min-height: 0; padding: 0; border: 0; background: none; border-radius: var(--r-sm);
  color: var(--muted); cursor: pointer;
  opacity: 0; transition: opacity var(--dur-1) ease, color var(--dur-1) ease; }
.ox .icon { width: 16px; height: 16px; }
.orow:hover .ox, .ox:focus-visible { opacity: 1; }
.ox:hover { color: var(--urgent-ink); }
/* A finger has no hover. Nothing on a touch screen would ever have revealed
   it, which is the whole feature missing on the device most of this audience
   reads on. */
@media (hover: none) { .ox { opacity: 1; } }

/* THE TICK BESIDE THE CROSS. Same geometry and the same reveal, because they
   are the same kind of control on the same row — but it does not vanish once
   it is ON, or a reader would have no way to see what they had already marked
   and no way back. Colour is --ok, the status token that already means "this
   is settled" elsewhere; it is never a series colour, and it always ships with
   the tick glyph and a label beside it, never colour alone. */
.odone-btn { position: relative; z-index: 1; pointer-events: auto; flex: 0 0 auto;
  display: inline-grid; place-items: center; width: 30px; height: 30px;
  min-height: 0; padding: 0; border: 0; background: none; border-radius: var(--r-sm);
  color: var(--muted); cursor: pointer;
  opacity: 0; transition: opacity var(--dur-1) ease, color var(--dur-1) ease; }
.odone-btn .icon { width: 16px; height: 16px; }
.orow:hover .odone-btn, .odone-btn:focus-visible, .odone-btn.on { opacity: 1; }
/* THE URGENCY PICKER BEHAVES LIKE ITS NEIGHBOURS. It used to live inside the
   meta line, always on, a native <select> in the middle of a sentence. Now it
   sits with the tick and the cross, so it fades in with them — and stays put
   on a touch screen, where nothing ever hovers. */
.ourg { opacity: 0; max-width: 100%;
  transition: opacity var(--dur-1) ease; }
.orow:hover .ourg, .ourg:focus-visible, .ourg.set { opacity: 1; }
@media (hover: none) { .ourg { opacity: 1; } }
.odone-btn:hover { color: var(--ok-ink); }
.odone-btn.on { color: var(--ok-ink); }
@media (hover: none) { .odone-btn { opacity: 1; } }

/* A handled row recedes. It stays on the timeline — that is the difference
   between "done" and "hidden" — so it must read as settled rather than as
   pending, without being so faint that the reader cannot check what they did.
   No strikethrough: the date is still true, it is the duty that is finished. */
.orow.odone .owhen, .orow.odone .odesc { color: var(--muted); }
.orow.odone .oamount { color: var(--muted); }
.odonetag { display: inline-flex; align-items: center; gap: var(--s-2);
  color: var(--ok-ink); font-size: var(--t-sm); }
.odonetag .icon { width: 13px; height: 13px; }

/* ---- Charts. -----------------------------------------------------------
   The ground stays flat: solid fills from the validated series ramp, no
   gradients, no glass, no shadow. Grid and axis are recessive; the mark is the
   only thing that carries data, and text always wears a text token so nothing
   is ever identified by colour alone.

   Geometry lives here rather than in charts.js so a chart obeys the same
   spacing scale as everything else on the page. */
.chart { margin: 0 0 var(--s-8); }
.chart-title { margin: 0 0 var(--s-5); font-size: var(--t-md); color: var(--muted); }
.chart-cur { color: var(--muted); font-variant-numeric: tabular-nums; }
.chart-empty { margin: 0; color: var(--muted); font-size: var(--t-md); }

/* Horizontal bars: a category name gets a whole line to be read on, which is
   what fifteen languages and a phone width between them require. */
.cbars { display: flex; flex-direction: column; gap: var(--s-3); }
.cbar-row { display: grid; grid-template-columns: minmax(0, 8.5rem) 1fr auto;
  align-items: center; gap: var(--s-5); }
.cbar-label { min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; font-size: var(--t-md); color: var(--ink); }
.cbar-folded { color: var(--muted); font-variant-numeric: tabular-nums; }
.cbar-track { position: relative; height: 14px; border-radius: var(--r-sm);
  background: var(--chip-bg); overflow: hidden; }
/* 4px rounded data-end, anchored to the baseline: the bar grows from the axis
   and only the far end is rounded, so a short bar still reads as starting at
   zero rather than floating. */
.cbar-fill { position: absolute; inset-block: 0; inset-inline-start: 0;
  border-start-end-radius: var(--r-mark);
  border-end-end-radius: var(--r-mark); }
.cbar-value { font-size: var(--t-md); color: var(--ink);
  font-variant-numeric: tabular-nums; white-space: nowrap; }

/* Stacked columns over time. */
/* The plot: gridlines behind, columns in front, the whole thing indented from
   the left so the axis figures have somewhere to sit. */
.cplot { position: relative; padding-inline-start: 4.5rem;
  margin-block-end: var(--s-7); }
/* The rule starts AFTER the gutter, not at the edge of the box.
   `inset-inline: 0` on an absolutely-positioned child resolves against the
   parent's PADDING box, so the padding reserved for the figures did not push
   the rule inwards at all — the rule ran the full width and the labels, placed
   to its left, hung outside the card and were clipped. Offsetting the rule
   itself is what makes the gutter real. */
.cgrid { position: absolute; inset-inline-start: 4.5rem; inset-inline-end: 0;
  height: 0; border-block-start: 1px solid var(--line); pointer-events: none; }
.cgridlabel { position: absolute; inset-inline-end: calc(100% + var(--s-4));
  transform: translateY(-50%); color: var(--muted); font-size: var(--t-sm);
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.ccols { position: relative; display: flex; align-items: flex-end; gap: var(--s-3);
  height: 150px; overflow-x: auto; padding-block-end: var(--s-2); }
.cscale { margin: var(--s-3) 0 0; color: var(--muted); font-size: var(--t-sm);
  text-align: end; font-variant-numeric: tabular-nums; }
.ccol { display: flex; flex-direction: column; align-items: center;
  gap: var(--s-2); flex: 1 1 0; min-width: 34px; height: 100%;
  justify-content: flex-end; }
.ccol-stack { display: flex; flex-direction: column-reverse; justify-content: flex-start;
  width: 100%; max-width: 46px; height: 100%; gap: var(--s-1); }
.ccol-seg { display: block; width: 100%; border-radius: 0; }
/* Only the topmost segment is rounded — the stack reads as one column standing
   on the axis, not as tiles. */
.ccol-stack > .ccol-seg:last-child { border-start-start-radius: var(--r-mark);
  border-start-end-radius: var(--r-mark); }
.ccol-label { font-size: var(--t-sm); color: var(--muted); white-space: nowrap; }

/* A legend appears for two or more series, and always pairs the colour with a
   word: the swatch is identity, the word is what makes it readable to somebody
   who cannot tell the two swatches apart. */
.clegend { display: flex; flex-wrap: wrap; gap: var(--s-5);
  margin: 0 0 var(--s-5); font-size: var(--t-sm); color: var(--muted); }
.cleg { display: inline-flex; align-items: center; gap: var(--s-3); }
.cleg i { width: 12px; height: 12px; border-radius: var(--r-xs); flex: none; }

/* ---- The overview at a glance: a figure, a drawing, a destination. ------
   Two across on a phone and three on anything wider, because there are three
   of them and a lone tile on a second row reads as one that failed to load.
   auto-fit rather than a media query: the grid is inside a card whose width
   the reader changes with the sidebar, not just with the device. */
/* TWO KINDS OF TILE, ONE GRID, TWO WIDTHS.
   A headline figure is a number and two words and reads perfectly two-up on a
   phone — which is how the four of them have always been laid out. A graphic
   tile carries a ring or a pair of bars beside its figure and needs the row.
   One `auto-fit` track cannot be right for both: sized for the graphic tiles it
   put the figures one per row and doubled the height of the first screen; sized
   for the figures it would squeeze the rings.
   So the columns are fixed and the wide tiles span them. */
.mtiles { display: grid; gap: var(--s-6); margin: 0 0 var(--s-8);
  grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mtiles > .mtile { grid-column: 1 / -1; }
/* THE TILE IN POSITION ONE, DRAWN AS THE ONE THE EYE LANDS ON.
   Twelve tiles at one weight is twelve tiles with no entry point. This is a
   wrapper, not a variant of the tile: whatever the reader put first — a figure
   card or a graphic one — gets the full row and one step up the type scale, and
   the tile inside keeps its own markup. Which tile that is belongs to the
   reader; see the comment at the call site. */
.mtiles > .mtilehero { grid-column: 1 / -1; display: grid; }
/* TWO FIGURES ACROSS THE HERO, and the gap is the grid's own so the pair reads
   as two tiles rather than one box with a line in it. Only ever two, and only
   ever figures — see the call site. */
.mtiles > .mtilehero--pair { grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-6); }
.mtilehero > .mcard, .mtilehero > .mtile { margin: 0; width: auto; flex: none;
  padding: var(--s-7); }
/* One step, not three. --t-3xl against a 393px screen is 20px, which is where
   the net-worth headline sits on the Accounts tab and is comfortably the
   largest thing on this screen without shouting over the rest of it. */
.mtilehero .mval, .mtilehero .mtilefig { font-size: var(--t-3xl); }
.mtilehero .mlabel, .mtilehero .mtilelabel { font-size: var(--t-lg); }
/* Wider than a phone: four figures across, and the graphic tiles take half a
   row each rather than stretching a ring across the whole card. */
@media (min-width: 46rem) {
  .mtiles { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .mtiles > .mtile { grid-column: span 2; }
  /* THE HERO SHARES ITS ROW ON A WIDE SCREEN.
     `1 / -1` is right on a phone, where the row is the width of a thumb and a
     promoted figure has nowhere else to be. On a 1440px window it gave ONE
     number a 1054px-wide box: measured, 100px tall and 85% empty, and it also
     pushed the three remaining figures onto a row of their own where they
     filled three of four columns and left the fourth blank. Two spans instead
     of four puts the hero and two figures on one full row, and the whole band
     loses a row of furniture without any tile getting smaller than it was. */
  .mtiles > .mtilehero { grid-column: span 2; }
}
/* `.mcard` was built as a flex child of `.mcards` — `flex: 1 1 min(150px, ...)`
   means nothing to a grid item, and without a reset it keeps a basis the grid
   track has already decided. Its box styling is what it brings; the sizing
   belongs to the grid. */
.mtiles > .mcard { flex: none; width: auto; margin: 0; }
/* A whole tile is the tap target, not a link inside it. Colour comes from the
   card tokens and never from --accent: the tile is a surface that happens to
   be a link, and painting it as one would put three coloured slabs on the
   first screen of Finances. */
.mtile { display: flex; gap: var(--s-5);
  padding: var(--s-6); border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card); color: var(--ink); text-decoration: none;
  transition: border-color var(--dur-2) ease; }
/* A RING SITS BESIDE THE FIGURE; BARS SIT UNDER IT. Stacking the ring put a
   120px disc under every tile and made the three of them 1300px of scrolling
   on a phone — the overview is the tab that opens by default and it has to fit
   on a screen. Bars are the opposite: they measure along their length, so they
   take the width and go below. Which one a tile is comes from the caller, not
   from :has(), which is newer than the browsers this file still writes
   color-mix fallbacks for. */
.mtile--side { flex-direction: row; align-items: center;
  justify-content: space-between; }
.mtile--stack { flex-direction: column; }
.mtile:hover { border-color: var(--control-line); }
.mtilehead { display: grid; gap: var(--s-2); }
/* The glyph sits on the label's baseline row, sized to the text rather than to
   a fixed box, so it tracks the fluid type scale instead of drifting out of
   line with it at the wide end. */
.mtilelabel { display: flex; align-items: center; gap: var(--s-3);
  font-size: var(--t-md); color: var(--muted); }
.mtilelabel .icon { flex: none; width: 1.15em; height: 1.15em; }
.mtilelabel .icon svg { width: 100%; height: 100%; display: block; }
.mtilefig { font-size: var(--t-2xl); font-weight: 700; letter-spacing: -.02em;
  font-variant-numeric: tabular-nums; }
.mtilecur { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
.mtileart .chart { margin: 0; }
/* The head takes the slack so the ring stays hard against the far edge however
   long the label wraps; min-width:0 lets a long category name ellipsise
   instead of shoving the ring off the tile. */
.mtile--side .mtilehead { flex: 1 1 auto; min-width: 0; }
.mtile--side .mtileart { flex: 0 0 auto; }
/* Smaller than a full-width ring, and the figure inside it tracks down with
   it: --t-2xl in a 5rem hole overflows at three digits and a percent sign. */
.mtile--side .cringsvg { max-width: 5rem; }
.mtile--side .cringpct { height: min(100%, 5rem); font-size: var(--t-lg); }
.mtile--side .cringlabel, .mtile--side .cringcap { font-size: var(--t-sm); }
/* The bars take the whole tile width, under the figure. */
.mtile--stack .mtileart { margin-block-start: auto; }

/* ---- Choosing what is on the overview. ----------------------------------
   Up/down buttons rather than dragging: a drag needs a pointer that can hover,
   a steady hand and a fallback for everyone who has neither. Two buttons work
   with a keyboard, a screen reader and a thumb on a moving bus. */
.widbar { margin: var(--s-6) 0 0; }
.widbar .linkbtn { border: 1px solid var(--control-line);
  border-radius: var(--r-md); padding: 9px 14px; color: var(--ink);
  font-weight: 600; font-size: var(--t-md); }
.widbar .linkbtn:hover { border-color: var(--accent-ink); }
.widedit { margin: var(--s-5) 0 0; padding: var(--s-5);
  border: 1px solid var(--line); border-radius: var(--r-md); background: var(--card); }
.widrow { display: grid; grid-template-columns: 1fr auto auto auto;
  align-items: center; gap: var(--s-3); padding: var(--s-3) 0;
  border-block-end: 1px solid var(--line); }
.widrow:last-of-type { border-block-end: 0; }
.widname { min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; font-size: var(--t-md); font-weight: 600; }
/* Up is the chevron rotated a quarter turn back, down a quarter turn on. Not
   mirrored in RTL: vertical order does not flip with reading direction, and the
   .calnav rules next door only mirror the horizontal pair. */
.widrow [data-widup] .icon { transform: rotate(-90deg); }
.widrow [data-widdown] .icon { transform: rotate(90deg); }
.widrow .acctx[disabled] { opacity: .35; cursor: default; }
.widadd { display: flex; flex-wrap: wrap; gap: var(--s-5);
  margin-block-start: var(--s-5); }

/* ---- What you are saving for. -------------------------------------------
   A card per goal: how far along, what is still needed a month, and what is
   already counting toward it. The bar is progress along a distance the reader
   named — a ring is for a proportion of a limit, which is a different
   question. Capped at the target server-side, so a beaten goal reads as
   finished rather than as a bar that has overrun its track. */
.goalgroup .inswhy { margin-block-end: var(--s-5); }
.goalcard { padding: var(--s-6) 0; border-block-end: 1px solid var(--line); }
.goalcard:last-of-type { border-block-end: 0; }
.goalhead { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-4); }
.goalname { font-weight: 600; min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.goalcard.done .goalname { color: var(--ok-ink); }
.goalfig { margin: var(--s-2) 0 var(--s-3); font-size: var(--t-lg);
  font-weight: 700; font-variant-numeric: tabular-nums; }
.goaltrack { display: block; height: 10px; border-radius: var(--r-sm);
  background: var(--chip-bg); overflow: hidden; }
.goalfill { display: block; height: 100%; background: var(--accent);
  border-start-end-radius: var(--r-mark); border-end-end-radius: var(--r-mark); }
.goalcard.done .goalfill { background: var(--ok); }
.goalpct { margin: var(--s-2) 0 0; font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
/* What is still needed each month. NOT a status colour, and the first attempt
   at that only got half way: it avoided --urgent and took --action-ink, which
   is the token every "soon", "near" and "action-needed" in this stylesheet
   wears, plus the rate caveat. So a plain division — what is left over the
   months the reader themselves chose — arrived in the colour the app uses for
   a deadline closing in, on EVERY goal, whether or not anything is wrong.
   Prominence belongs to it; it is the answer the card exists to give. It gets
   that from weight and size, which mean the same thing to every reader, rather
   than from a colour that means caution three screens away. */
.goalneeds { margin: var(--s-3) 0 0; font-size: var(--t-md);
  color: var(--ink); font-weight: 600; }
.goalbacklabel { margin: var(--s-5) 0 var(--s-2); font-size: var(--t-sm);
  color: var(--muted); text-transform: none; }
.goalbacks { display: flex; flex-direction: column; gap: var(--s-2); }
.goalback { display: grid; grid-template-columns: auto 1fr auto auto;
  align-items: center; gap: var(--s-3); }
.goalback .icon { width: 16px; height: 16px; color: var(--muted); }
.goalback .icon svg { width: 100%; height: 100%; display: block; }
.goalbackname { min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; font-size: var(--t-md); }
.goalbackval { font-size: var(--t-md); font-variant-numeric: tabular-nums;
  white-space: nowrap; }
/* The goal's own actions. `.linkbtn` made three verbs look like three links in
   a paragraph; they are the only things on the card a reader can DO. */
.goalacts { display: flex; flex-wrap: wrap; gap: var(--s-3);
  margin-block-start: var(--s-5); }
.goalacts .linkbtn {
  border: 1px solid var(--control-line); border-radius: var(--r-md);
  padding: 9px 14px; color: var(--ink); font-weight: 600;
  font-size: var(--t-md); }
.goalacts .linkbtn:hover { border-color: var(--accent-ink); }
/* Putting money in is the primary act on a savings goal, and the one the
   reader came to the card for. */
.goalacts .linkbtn.primary { background: var(--accent); color: var(--on-accent);
  border-color: transparent; }
.goalacts .linkbtn.primary:hover { background: var(--grad-1); }

/* ---- What it all adds up to. --------------------------------------------
   The figure, what it leaves out, and the line behind it. The captions under
   the number are not footnotes: "spendable" excluding gold is the difference
   between a figure a reader can act on and one they cannot, so it is said at
   the same size as everything else they read here. */
.nwbox { margin: 0 0 var(--s-8); padding-block-end: var(--s-6);
  border-block-end: 1px solid var(--line); }
.nwtoggle, .nwranges { display: flex; flex-wrap: wrap; gap: var(--s-2);
  padding: 3px; border: 1px solid var(--control-line); border-radius: var(--r-md);
  background: var(--chip-bg); width: fit-content; max-width: 100%; }
.nwtoggle { margin-block-end: var(--s-5); }
.nwranges { margin-block-start: var(--s-5); }
.nwfig { margin: 0; font-size: var(--t-4xl); font-weight: 700;
  letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.nwcur { font-size: var(--t-md); color: var(--muted); font-weight: 600; }
.nwnote { margin: var(--s-2) 0 0; font-size: var(--t-md); color: var(--muted); }
.nwbox .chart { margin-block-start: var(--s-6); }
/* The plot needs a height of its own: the line is drawn in a stretched viewBox
   and would otherwise collapse to nothing. */
.nwbox .cplot { height: 8.5rem; }

/* ---- Instalments: what is being paid off, and when the charge drops. -----
   The drop line is the figure no statement shows, so it sits directly under
   the monthly total it is about, in accent text rather than a status colour —
   nothing has gone wrong, a charge is simply going to get smaller. */
.plandrop { margin: calc(-1 * var(--s-5)) 0 var(--s-7); color: var(--accent-ink);
  font-size: var(--t-md); font-weight: 600; }
.planlist { display: flex; flex-direction: column; margin: 0 0 var(--s-6); }
.planrow { display: grid; align-items: center; gap: var(--s-1) var(--s-4);
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }
.planrow:last-child { border-block-end: 0; }
/* A plan with nothing left stays on the list — the reader's own record of a
   thing they paid off — and recedes rather than disappearing. */
.planrow.done .planname, .planrow.done .planval { color: var(--muted); }
.planmain { display: grid; gap: var(--s-1); min-width: 0; }
/* Wraps: see .acctmeta, .incmeta, .planmeta below. */
.planamt { display: grid; gap: var(--s-1); justify-items: end; }
.planval { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.planleft { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap; }
/* PAYMENTS MADE AGAINST PAYMENTS OWED IS A COUNT, and a count reads along a
   length. The ring elsewhere is for a proportion of a limit the reader set,
   which is a different question and wears a different shape. */
.plantrack { grid-column: 1 / -1; height: 6px; border-radius: var(--r-sm);
  background: var(--chip-bg); overflow: hidden; margin-block-start: var(--s-3); }
.planfill { display: block; height: 100%; background: var(--accent);
  border-start-end-radius: var(--r-mark); border-end-end-radius: var(--r-mark); }
.planpay { grid-column: 1 / -1; justify-self: start; margin-block-start: var(--s-4);
  cursor: pointer; border: 1px solid var(--control-line);
  border-radius: var(--r-md); background: transparent; color: var(--ink);
  padding: 8px 14px; font-size: var(--t-md); font-weight: 600; }
.planpay:hover { border-color: var(--accent-ink); }
/* THE HISTORY TOGGLE, beside "mark one paid" and deliberately quieter than it.
   Recording today's payment is the everyday act; opening five months of backfill
   is the one somebody does once, on the day they arrive. */
.planhist { grid-column: 1 / -1; justify-self: start; margin-block-start: var(--s-3);
  cursor: pointer; border: 0; background: none; padding: 4px 0;
  color: var(--muted); font-size: var(--t-sm); font-weight: 600;
  text-decoration: underline; text-underline-offset: 3px; }
.planhist:hover { color: var(--ink); }

/* ---- What was already paid ----------------------------------------------
   Set apart from the plan row with the chip background the other "here is the
   detail behind that figure" panels use, because that is what it is: the row
   above says a plan is 5 of 12 paid, and this says which five. */
.payhist { border: 1px solid var(--line); border-radius: var(--r-md);
  padding: var(--s-5) var(--s-6); margin-block-end: var(--s-6);
  background: var(--chip-bg); }
.payhist h4 { font-size: var(--t-sm); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); margin: 0 0 var(--s-4); }
.payrow { display: grid; align-items: center; gap: var(--s-1) var(--s-4);
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  padding-block: var(--s-3); }
/* TABULAR AND NON-WRAPPING, so a column of dates reads as a column. A backfilled
   plan is the one place in this app where a reader scans twelve dates in a row. */
.paywhen { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.paymain { display: grid; gap: var(--s-1); min-width: 0; }
.payamt { font-variant-numeric: tabular-nums; }
/* A deposit is not one of the payments and the row has to say so where the eye
   already is — beside the figure, not in a legend at the bottom. */
.paykind { font-size: var(--t-sm); color: var(--muted); }
.paynote { font-size: var(--t-sm); color: var(--muted); overflow-wrap: anywhere; }
.payform { display: grid; gap: var(--s-3) var(--s-4);
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  padding-block-start: var(--s-4); border-block-start: 1px solid var(--line); }
/* The note gets the whole width: it is a sentence, and a sentence in a
   9rem column is four words a line. */
.payfull { grid-column: 1 / -1; }
/* Money OUT of a pot is a row, not a gap — `add_goal_money` records it as a
   negative contribution so the month the reader needed it stays in the history.
   Marked, never hidden, and never in a status colour: --urgent means "this
   needs you", and taking your own savings out is a decision, not a problem. */
.payamt.out { color: var(--muted); }

/* ---- The money sections read as panels, not as run-on prose. -------------
   Each of these — what comes in, what you hold, what is being paid off, what
   you are saving for — is a separate account of the reader's money, and they
   were stacked as headings and rows directly on the page with nothing between
   them. Three groups of text in a column look like one long document; the
   reader has to read to find the boundary instead of seeing it.

   A hairline box and a radius, not a second white fill: the page is already a
   white card on a grey ground, so filling these again would be white on white
   and would only add shadow noise. The border does the containment and the
   padding does the breathing. */
.insgroup.incgroup, .insgroup.goalgroup, .insgroup.catbreak,
.acctlist, .planlist {
  border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--s-7); }
.acctlist, .planlist { margin: 0 0 var(--s-7); }
/* AND THE THREE PANELS ABOVE THEM GET THE SAME AIR. `.acctlist`/`.planlist`
   have carried a margin since they were written; the `.insgroup` variants that
   share their box carried none, so "Where it went" met the filter row above it
   and the first ledger row below it with two borders touching. A box that
   draws an edge has to keep its distance from what it is not part of. */
.insgroup.incgroup, .insgroup.goalgroup, .insgroup.catbreak {
  margin-block: var(--s-7); }
/* `.inclist` gets NO box of its own: it lives inside `.incgroup`, which already
   has one, and a box inside a box is two boundaries drawn for one grouping —
   it also ate 2×padding of row width and truncated "CIB 3-Year Certificate" to
   "CIB 3-Year…" for no reason. `.acctlist` and `.planlist` keep theirs, because
   on their own tabs they are top-level and nothing else contains them. */
.inclist { margin: 0 0 var(--s-6); }
/* A panel's own heading sits tight to its top edge rather than floating. */
.insgroup.incgroup > h3, .insgroup.goalgroup > h3, .insgroup.catbreak > h3 {
  margin-block-start: 0; }

/* ---- THE BUDGETS TAB, WHOSE WRAPPER HAD NO RULE AT ALL. -----------------
   `_loadBudget` has always wrapped its sections in `<div class="budgetcard">`,
   and that class was not defined anywhere in this file — a hook the markup
   offered and the stylesheet never took. It cost the tab its grouping.

   The box above is opt-in by variant: `.insgroup` alone draws nothing, and
   only `.incgroup`, `.goalgroup` and `.catbreak` name themselves into it. Of
   the four sections on this tab exactly one — "What comes in" — carries such a
   variant, so it rendered as a bordered panel while "In and out", "Your
   budgets" and "Your own kinds" ran bare and flush to the card's padding
   underneath it. Measured on a 390px phone: one section inset inside a
   hairline and three sitting outside it, which reads as content that has
   fallen out of the panel rather than as three more sections.

   Named through the wrapper rather than by adding three more variant classes
   to the list above: the rule is "every section on this tab is a panel", and
   the wrapper is where that is true. `.incgroup` matches both rules and gets
   the same values from each, so the one section that was already right does
   not move. */
.budgetcard > .insgroup {
  border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--s-7); margin: 0 0 var(--s-7); }
.budgetcard > .insgroup > h3 { margin-block-start: 0; }
/* The form that adds a line belongs to the budgets it adds to, so it keeps the
   panels' company rather than floating between them at a different inset. */
.budgetcard > .budform { margin-block: var(--s-7); }

/* ---- What comes in. -----------------------------------------------------
   The figure is big and the sentence under it is not decoration: it is the
   difference between this number and a bank balance. A reader who takes it for
   money already received will plan around money that has not turned up, so the
   caption sits under the figure rather than behind a tooltip. */
.incexpected { display: grid; gap: var(--s-1); margin: 0 0 var(--s-2); }
.incbig { font-size: var(--t-3xl); font-weight: 700; letter-spacing: -.02em;
  font-variant-numeric: tabular-nums; color: var(--ok-ink); }
.inclabel { font-size: var(--t-md); color: var(--muted); }
.incgroup .inswhy { margin-block-end: var(--s-6); }
.inclist { display: flex; flex-direction: column; margin: 0 0 var(--s-6); }
.incrow { display: grid; align-items: center; gap: var(--s-1) var(--s-4);
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }
.incrow:last-child { border-block-end: 0; }
.incmain { display: grid; gap: var(--s-1); min-width: 0; }
/* ONE LINE. "today · Goes into CIB Debit · Record it for me" wrapped onto a
   second line and dragged the row's height with it, so a list of three sources
   was three different heights. The detail is context for the name above it;
   when there is not room it ends in an ellipsis rather than reflowing the row. */
/* Wraps: see .acctmeta, .incmeta, .planmeta below. */
.incamt { display: grid; gap: var(--s-1); justify-items: end; }
/* Money arriving wears --ok-ink, the token the Refunds tile already uses for
   the same idea. Not a status: nothing has gone right or wrong, it is the
   direction of travel, and it is paired with a "+" so the sign is not carried
   by colour alone. */
.incval { font-weight: 700; font-variant-numeric: tabular-nums;
  white-space: nowrap; color: var(--ok-ink); }
.incyear { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap; }

/* ---- Accounts: where the money sits, and what is owed. ------------------
   Three totals as the same .mcard the rest of Finances uses, then a row per
   account. Nothing here paints a class with a status colour: owing money on a
   card is not a fault, and --urgent is reserved for a thing that has gone
   wrong. The class is said in words on the total above it. */
.acctlist { display: flex; flex-direction: column; margin: 0 0 var(--s-8); }
/* THE CLASS TOTAL, DOING ITS SECOND JOB AS THE HEADING OF ITS OWN ACCOUNTS.
   It was a card floating above a list that mixed all three classes together, so
   nothing on the page said which rows made which figure. As a heading it says
   both. The figure sits at the end of the line, on the same baseline as the
   name, because that is where every account row below it puts its own — three
   headings and their rows then share one column of money. */
/* The correction form, opened inside the list at the row it belongs to. It is
   a row of the list rather than a panel floating over one, so the account being
   corrected stays visible directly above the boxes that change it. */
.acctlist > .acctadd, .planlist > .acctadd { border-block-end: 1px solid var(--line); }
.planlist > .acctadd:last-child { border-block-end: 0; }
.acctlist > .acctadd:last-child { border-block-end: 0; }
.acctformacts { display: flex; flex-wrap: wrap; gap: var(--s-4); align-items: center; }
/* ---- what was in a spend, when the reader has said ----------------------
   A row of boxes per line, and the name takes whatever the numbers do not:
   "Nappies" is the part worth reading and the quantity is two characters. The
   grid collapses to two rows on the narrowest phones rather than scrolling
   sideways, because a form the reader has to pan across to finish is a form
   they abandon halfway. */
/* NO `.spenditems` DISCLOSURE ANY MORE. These boxes lived in a `<details>`
   inside an editor that opened over the ledger row; they are on the spend's
   own page now, under a heading, so there is nothing to fold away and no
   platform triangle to hide. */
.itemrows { display: flex; flex-direction: column; gap: var(--s-3);
  margin-block-start: var(--s-4); }
.itemrow { display: grid; gap: var(--s-2);
  grid-template-columns: minmax(0, 1fr) 4.5rem 5.5rem auto auto; align-items: center; }
/* STACKED UNTIL THE ROW ACTUALLY FITS, which is a good deal wider than a
   phone. At 26rem the five tracks came back on one line the moment the screen
   passed 416px, and the four fixed ones — quantity, price, the kind picker and
   the remove button — take about 330px between them: on a 420px screen that
   left THIRTY pixels for the item's name, the one field the whole row is
   about. Measured at 420px before this: 30px wide. 35rem is where the name
   still has ~140px, and it is the breakpoint this stylesheet already changes
   its mind at elsewhere. */
@media (max-width: 35rem) {
  /* QUANTITY IS ONE OR TWO CHARACTERS AND THE PRICE IS FIVE. The flexible
     track was on the quantity, so on a 560px screen "2" sat in a 383px box
     next to a 72px price — the two numbers sized in inverse proportion to
     what goes in them. */
  .itemrow { grid-template-columns: 4.5rem minmax(0, 1fr) auto;
    grid-template-areas: "name name name" "qty amt del" "cat cat cat"; }
  .itemrow .i_name { grid-area: name; }
  .itemrow .i_qty { grid-area: qty; }
  .itemrow .i_amt { grid-area: amt; }
  .itemrow .i_cat { grid-area: cat; }
  .itemrow .i_del { grid-area: del; }
}
.itemrow input, .itemrow select { min-width: 0; }
/* AND THE KIND PICKER CANNOT EAT THE ROW. It is an `auto` track, so its width
   is its widest option — a word this product translates into sixteen
   languages, and the longest of them would take the space back off the name
   the breakpoint above just gave it. */
.itemrow .i_cat { max-width: 10rem; }
/* AIR ABOVE THE FOOT, not only between the rows. Inside the ledger's editor
   this row sat under a `.itemrows` whose own gap ended at its last child, so
   "Add a line" and the running total touched the last line's boxes — invisible
   at a glance and caught by the block sweep the moment the same fields were
   put on a card of their own, where nothing else supplies the gap. */
.itemfoot { justify-content: space-between; align-items: center;
  margin-block-start: var(--s-4); }
/* What the lines come to, beside what was typed at the till. A statement of
   two figures and nothing about the gap between them — see the route. */
.itemsum { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }

/* ---- the rail's one nested space -----------------------------------------
   Finances is seven screens, and the rail has the room to say so. The link and
   its disclosure share a row; the sections sit under it, indented to the depth
   of the icon above so they read as belonging to it rather than as six more
   spaces. */
/* ---- the kinds a reader invented -------------------------------------------
   A name and two small controls, on the Budgets tab beside their other typed
   lines. The name takes the room; the pencil and the cross are the same pair an
   account row and a plan row already carry, so a reader meets one shape. */
.kindlist { display: flex; flex-direction: column; margin-block: var(--s-4); }
.kindrow { display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center; gap: var(--s-2);
  padding: var(--s-4) 0; border-block-end: 1px solid var(--line); }
.kindrow:last-child { border-block-end: 0; }
.kindname { font-weight: 600; overflow-wrap: anywhere; }
.kindadd { display: flex; flex-wrap: wrap; gap: var(--s-3); align-items: center; }
.kindadd input { flex: 1 1 10rem; min-width: 0; }

/* ONE ROW, ONE HIGHLIGHT. The link and the arrow are two press targets in a
   single row, and each used to paint its own hover — so pointing at the arrow
   lit a small square beside a "Finances" that stayed cold, and the pair read as
   two unrelated controls that happened to be adjacent rather than as one row
   with a second control on it. The ROW takes the highlight now, from either
   half; what each half DOES when pressed is unchanged. */
.side-nestrow { display: flex; align-items: center; gap: var(--s-1);
  border-radius: var(--r-sm); }
.side-nestrow:hover { background: var(--hover-bg); }
/* And neither child paints over it. Both backgrounds are opaque today, so this
   costs nothing visually — but a hover colour that is ever given alpha would
   otherwise stack on the half the pointer is actually over, and the row would
   go two-tone for the reason it was just fixed. */
.side-nestrow:hover > a, .side-nestrow:hover .side-more { background: none; }
.side-nestrow > a { flex: 1 1 auto; min-width: 0; }
.side-more { display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 1.75rem; height: 1.75rem; padding: 0;
  border: 0; border-radius: var(--r-sm); background: none; color: var(--muted);
  cursor: pointer; transition: transform var(--dur-2) ease, color var(--dur-2) ease; }
/* Ink only — the surface belongs to the row above. The arrow still darkens
   under the pointer, so it is still visibly its own target. */
.side-more:hover { color: var(--ink); }
.side-more .icon { width: 1rem; height: 1rem; transform: rotate(90deg); }
/* Pointing down when the list is open, along the reading direction when shut.
   The shut state is mirrored for RTL by the same rule .calnav uses. */
.side-nest.open .side-more .icon { transform: rotate(-90deg); }
html[dir="rtl"] .side-more .icon { transform: rotate(90deg); }
html[dir="rtl"] .side-nest.open .side-more .icon { transform: rotate(-90deg); }
.side-sub { display: flex; flex-direction: column;
  margin-inline-start: var(--s-8); }
/* `[hidden]` IS ONLY A DEFAULT, and any explicit `display` beats it. Without
   this line the six links stayed on screen and in the tab order while the
   disclosure said aria-expanded="false" — measured: seven of them still had a
   layout box with the list shut. A control that reports one state and shows
   another is worse than no control, and it is worst for the reader who is
   tabbing rather than looking. */
.side-sub[hidden] { display: none; }
.side-subitem { display: block; padding: var(--s-2) var(--s-4);
  font-size: var(--t-md); color: var(--muted); text-decoration: none;
  border-radius: var(--r-sm); }
.side-subitem:hover { color: var(--ink); background: var(--chip-bg); }
/* WHICH OF THE SEVEN YOU ARE IN.
   The list styled :hover and nothing else, so the parent "Finances" row lit for
   every section and no line said which one — the rail could tell you that you
   were in Finances and not that you were in Budgets. The same two properties
   the space rows use above, so the two levels of the rail mark "here" the same
   way rather than inventing a second vocabulary for it. */
.side-subitem[aria-current="page"] {
  color: var(--accent-ink);
  font-weight: 700;
}
.acctgroup { margin-block-end: var(--s-8); }
.acctgroup:last-of-type { margin-block-end: var(--s-7); }
.acctgroup .acctlist { margin: 0; }
.acctgrouphead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s-4); margin: 0 0 var(--s-3); padding: 0 var(--s-7);
  font-size: var(--t-sm); font-weight: 600; }
.acctgroupname { color: var(--muted); text-transform: none; }
.acctgrouptotal { font-variant-numeric: tabular-nums; color: var(--ink);
  font-size: var(--t-md); }
/* NO COLOUR ON THE DEBT TOTAL, which is what the "You owe" tile did too: only
   `.mcard.in` ever took a token, and `.out` was plain ink. Owing money is not
   an error state, the heading beside it already says "You owe", and a figure
   whose meaning is carried by its colour is one a colourblind reader has to
   guess at. The hook is kept so the decision is visible rather than absent. */
.acctgroup.out .acctgrouptotal { color: var(--ink); }
.acctrow { display: grid; align-items: center; gap: var(--s-1) var(--s-4);
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }
.acctrow:last-child { border-block-end: 0; }
/* A TINTED TILE, the way `.submark` already does it — ink on a 16% tint of a
   series colour with a 38% border. That pairing is not a preference: white on
   the eight fills fails AA in four of them in light and seven in dark, and
   tools/check_contrast.py measures all sixteen pairs of this exact recipe. A
   flat grey chip for every kind said the five kinds were one thing.

   `--mk` is set inline per row from the account's kind, and defaulted here so
   a mark rendered without it has a colour rather than none. */
.acctmark { --mk: var(--series-1);
  display: grid; place-items: center; width: 34px; height: 34px;
  border-radius: var(--r-sm); color: var(--ink);
  background: color-mix(in srgb, var(--mk) 16%, var(--card));
  border: 1px solid color-mix(in srgb, var(--mk) 38%, var(--card)); }
.acctmark .icon { width: 18px; height: 18px; }
.acctmark .icon svg { width: 100%; height: 100%; display: block; }
.acctmain { display: grid; gap: var(--s-1); min-width: 0; }
/* THE NAME WRAPS RATHER THAN VANISHES, the same fix `.acctmeta` / `.incmeta` /
   `.planmeta` already got below — this is the line ABOVE those, on the same
   rows, and it was left truncating.
   
   The phone suite's rule is that below 560px nothing inside #view may be
   trimmed at all, ellipsis or not: an ellipsis on a desktop rail is a
   considered choice, and on a phone it is the app eating the reader's own
   words. These are the reader's own words in the strongest sense — they typed
   them: "Sparkasse current" lost its last word at 412px, "Gold (one ounce)"
   at 393, and "CIB Cairo" at 360. In Arabic and in dark, and in WebKit too.
   
   Not reserved to two lines the way the sub-line is. Every row HAS a sub-line,
   so reserving there keeps a list of them level; a name that needs two lines is
   the exception, and holding a blank line under every short name to tidy the
   rare long one costs every row to flatter one.
   
   Found by pointing the phone suite at Accounts and Instalments for the first
   time — screens it had never visited, which is what its own rule about
   selectors that never appear is for. */
.acctname, .incname, .planname { font-weight: 600; min-width: 0;
  overflow-wrap: anywhere; }
/* TWO LINES, ALWAYS — the sub-line under an instalment, an income source and an
   account, which are the same row in three sections and were three copies of
   the same rule.
   
   It was one line with an ellipsis, and that was a real decision with a reason
   still worth honouring: a line that wrapped only when it was long dragged its
   row's height with it, so a list of three sources came out three different
   heights. What the reason missed is what the ellipsis was eating. At 393px an
   income line lost 151px of itself, and what went was "Record it for me" — the
   app writing ledger rows on the reader's behalf, which is the one thing on
   that line worth a second look. The gold holding lost "from 4 Sep 2026", the
   date that is the whole reason a reader may price their own gold, and an
   instalment lost the card it charges.
   
   So: wrap, and reserve the second line whether or not it is used. Nothing is
   cut, and every row in a list is the same height — which is what the ellipsis
   was there for. Two lines is enough for the longest of these at 320px; a third
   would be a sentence, and none of them is one. */
.acctmeta, .incmeta, .planmeta { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; min-width: 0;
  overflow-wrap: anywhere; min-block-size: 2.6em; }
.acctval { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.acctcur { font-size: var(--t-sm); color: var(--muted); font-weight: 600; }
/* The utilisation ring, for the one account kind that HAS a ceiling to draw a
   proportion against. Small: it is a glance beside a figure, not the subject
   of the row. */
/* Full width and LAST in source order, so the close button stays on the row
   with the figure instead of being pushed under the ring. */
.acctart { grid-column: 1 / -1; grid-row: 2; justify-self: start;
  width: min(100%, 11rem); margin-block-start: var(--s-3); }
.acctart .cringsvg { max-width: 4.25rem; }
.acctart .cringpct { height: min(100%, 4.25rem); font-size: var(--t-md); }
.acctart .cringcap { font-size: var(--t-sm); }
.acctart .chart { margin: 0; }
.acctx { background: none; border: 0; padding: var(--s-2); cursor: pointer;
  color: var(--muted); display: grid; place-items: center; }
.acctx:hover { color: var(--urgent-ink); }
.acctx .icon { width: 16px; height: 16px; }
.acctx .icon svg { width: 100%; height: 100%; display: block; }

/* Adding one. The KIND is asked first because it decides which of the fields
   below mean anything — a credit limit on a cash tin is a box with no answer. */
/* AN ACTION LOOKS LIKE AN ACTION.
   These were `<summary>` elements wearing link colour, so "Add a source" read
   as a heading in the wrong colour — floating orange text with no edge, no
   press target and nothing to say it could be pressed. A disclosure is still
   the right MECHANISM (the form belongs under the thing that opens it), but the
   affordance has to be a button, so it borrows `.ghost`'s box: the same border,
   radius, weight and padding every other secondary action in this app uses. */
/* AIR ABOVE THE BUTTON, EVERYWHERE IT IS A BUTTON. This disclosure ends five
   different cards — income, goals, accounts, plans, subscriptions — and it had
   no top margin at any of them, so "Add a subscription" sat flush against the
   panel above it and read as part of that panel rather than as the card's own
   action. Set on the control itself rather than fixed once on the one screen
   the report came from: the same button ends four other cards, and a margin
   added to one of them is the fix that reaches a fifth of the places with the
   bug. The two lists below opt back out, because inside a list this control is
   a ROW — the last one — and a gap there would break the run of hairlines. */
/* BOTH SIDES, because a control with air above it and none below it is the
   same bug seen from the other end: on Accounts this disclosure sits ON the
   rates line under it, and the line reads as part of the button. */
.acctadd { margin-block: var(--s-7); }
.acctlist > .acctadd, .planlist > .acctadd { margin-block: 0; }
.acctadd > summary {
  display: inline-flex; align-items: center; gap: var(--s-3);
  cursor: pointer; list-style: none;
  border: 1px solid var(--control-line); border-radius: var(--r-md);
  background: transparent; color: var(--ink); font-weight: 600;
  padding: 11px 18px; font-size: var(--t-base);
  transition: border-color var(--dur-1) var(--ease),
              background-color var(--dur-1) var(--ease); }
.acctadd > summary:hover { border-color: var(--accent-ink); }
.acctadd > summary::marker { content: ""; }
.acctadd > summary::-webkit-details-marker { display: none; }
/* The + turns into a − when the form is open, so the control says which way it
   goes. Drawn with the icon set's own stroke rather than a glyph character. */
.acctadd > summary::before {
  content: ""; width: 14px; height: 14px; flex: none;
  background: currentColor;
  -webkit-mask: var(--plus-mask) center / contain no-repeat;
  mask: var(--plus-mask) center / contain no-repeat; }
.acctadd[open] > summary { margin-block-end: var(--s-5); }
.acctadd[open] > summary::before { --plus-mask: var(--minus-svg); }
.acctadd > summary::marker { content: ""; }
.acctform { display: grid; gap: var(--s-5); }
.acctkinds { display: grid; gap: var(--s-3); }
.acctkind { display: grid; grid-template-columns: auto auto 1fr; align-items: center;
  gap: var(--s-4); padding: var(--s-4); cursor: pointer;
  border: 1px solid var(--control-line); border-radius: var(--r-md); }
/* :has() would be tidier and is newer than the browsers this file still writes
   color-mix fallbacks for, so the checked state is drawn on the input's own
   siblings via :checked + * instead. */
.acctkind input:checked ~ .acctkindmark { color: var(--accent-ink); }
.acctkind input:checked ~ .acctkindmain .acctkindname { color: var(--accent-ink); }
.acctkindmark { display: grid; place-items: center; width: 26px; height: 26px;
  color: var(--muted); }
.acctkindmark .icon { width: 20px; height: 20px; }
.acctkindmark .icon svg { width: 100%; height: 100%; display: block; }
.acctkindmain { display: grid; gap: var(--s-1); min-width: 0; }
.acctkindname { font-weight: 600; }
.acctkinddesc { font-size: var(--t-sm); color: var(--muted); }
/* MIN-WIDTH ZERO, ON EVERY LEVEL OF THIS FORM.
   A grid item's min-width is `auto`, which means "at least my content's
   intrinsic width" — and an <input>'s intrinsic width comes from its `size`
   attribute, not from the column it sits in. So every field here laid itself
   out at 591px inside a 332px card: the labels fitted, the inputs ran off the
   side of the phone, and nothing scrolled because the card clipped them. The
   page-level overflow test could not see it either, for the same reason.
   Measured at 360px, the narrowest width this stylesheet targets. */
.acctform, .acctfields { min-width: 0; }
.acctfields { display: grid; gap: var(--s-5); }
.acctfield { display: grid; gap: var(--s-2); font-size: var(--t-md);
  color: var(--muted); min-width: 0; }
.acctfield input, .acctfield select { font-size: var(--t-base);
  width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; }
/* The account chip on a ledger row, told apart from the category chip beside
   it by weight rather than by a colour — the row already carries a status
   vocabulary and a third hue in it would compete with the amount. */
.chip.acct { font-weight: 600; }

/* ---- Where the window's money went, category by category. --------------
   A row per category: swatch, glyph, name, count and share, amount, and a bar
   under all of it. The bar is the picture; every value is written beside it, so
   nothing here is carried by colour alone.

   Grid rather than flex, so the amounts form a column that lines up down the
   list — the comparison the reader is actually making — instead of each row
   ending wherever its own name stopped. */
.catrows { display: flex; flex-direction: column; }
.catcur + .catcur { margin-block-start: var(--s-7); }
.catcurhead { margin: 0 0 var(--s-2); font-size: var(--t-md);
  color: var(--muted); font-weight: 600; }
.catrow { border-block-end: 1px solid var(--line); }
.catrow:last-child { border-block-end: 0; }
/* A row is a button now: it narrows the ledger below to its kind. Full width,
   left aligned and transparent, because it is a list row that happens to be
   pressable — not a control competing with the real buttons on the page. */
/* THE ROW REACHES THE PANEL'S EDGES, and its tint reaches them with it. The
   row was exactly as wide as its own text, so the chosen kind's highlight
   stopped short of both inner edges with a strip of panel showing past each
   end — a band that reads as clipped rather than as a row that is selected.
   Pulled out through the panel's padding and given the same padding back, so
   every word still lines up with the heading above while the ground behind it
   runs the full width. The hairlines go with it, which is what a divider in a
   panel is meant to do. */
button.catrow, .catrow.plain {
  display: grid; align-items: center; gap: var(--s-1) var(--s-4);
  grid-template-columns: auto auto minmax(0, 1fr) auto;
  padding: var(--s-5) var(--s-7); cursor: pointer;
  margin-inline: calc(var(--s-7) * -1);
  width: calc(100% + var(--s-7) * 2);
  background: none; border: 0; border-block-end: 1px solid var(--line);
  text-align: start; font: inherit; color: inherit; }
.catrow.plain { cursor: default; }
/* The chosen kind, said twice: the row is marked and the Kind picker above
   shows the same value, because they are one piece of state. */
button.catrow.on .catname { color: var(--accent-ink); }
/* NO RADIUS NOW THAT IT IS FULL-BLEED: rounded corners against the panel's
   own straight inner edge leave four slivers of the panel showing through,
   which is the "cut out" look this rule was fixing. */
button.catrow.on { background: var(--chip-bg); }
/* The platform triangle is suppressed the way the rest of the app's
   disclosures do it; the row's own affordance is that it is a big tap target
   with a bar in it. */
button.catrow:hover .catname { color: var(--accent-ink); }
/* The empty cell on the folded row is the width of a glyph, so the columns
   line up whether or not the row has one. */
.caticon { display: inline-grid; place-items: center; width: 18px; height: 18px; }
.catmark { width: 10px; height: 10px; border-radius: var(--r-xs); }
/* The category glyph takes the row's own series colour rather than sitting
   grey beside a coloured dot that means the same thing. */
.catrow .icon { width: 18px; height: 18px; }
.catrow .icon svg { width: 100%; height: 100%; display: block; }
.catname { min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; font-weight: 600; }
.catval { font-variant-numeric: tabular-nums; font-weight: 600;
  white-space: nowrap; }
/* Count and share sit under the name on a phone rather than squeezing the
   name into nothing: "18 spends · 28%" is fixed width and a category name is
   not. */
.catmeta { grid-column: 3 / -1; font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
.cattrack { grid-column: 1 / -1; height: 6px; border-radius: var(--r-sm);
  background: var(--chip-bg); overflow: hidden; }
.catfill { display: block; height: 100%;
  border-start-end-radius: var(--r-mark); border-end-end-radius: var(--r-mark); }
/* THE LEDGER, SITTING UNDER THE KIND THAT ASKED FOR IT. Pressing a row moves
   the list of that kind's spends up here, so the marked row and its rows are
   one block. Inset and hairline-marked on the start edge, because a flat list
   of spends dropped between two category rows reads as more categories. */
.catrows .ledger.catledger {
  margin: 0 0 var(--s-4); padding-inline-start: var(--s-4);
  border-inline-start: 2px solid var(--line); }
/* The spends behind one category. Indented under the row that opened them and
   set smaller, so the list reads as belonging to it. */
.catdrill { padding: 0 0 var(--s-5) calc(10px + var(--s-4)); }
.drow { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s-5); padding: var(--s-3) 0;
  border-block-start: 1px solid var(--line); }
.dmain { display: grid; gap: var(--s-1); min-width: 0; }
.ddesc { font-size: var(--t-md); overflow: hidden; text-overflow: ellipsis; }
.ddesc a { color: var(--accent-ink); }
.damt { font-size: var(--t-md); font-variant-numeric: tabular-nums;
  white-space: nowrap; }

/* ---- One bounded proportion, as a ring. --------------------------------
   Spent against the reader's own budget line; owed against their own credit
   limit. NOT the category pie charts.js refuses — there is one fraction here
   and no second angle to compare it against.

   The tones are the SAME THREE .budfill already uses, and deliberately so: the
   ring on the overview tile and the bar on the budgets tab are two drawings of
   one figure, and a reader who sees amber in one place and blue in the other
   reads that as two different states. --accent, --action-ink, --urgent. */
.cring { position: relative; display: grid; justify-items: center;
  gap: var(--s-2); }
.cringsvg { width: 100%; max-width: 7.5rem; aspect-ratio: 1; display: block;
  /* Twelve o'clock, clockwise. Not flipped for RTL — see the note in
     charts.js: a clock does not run backwards in Arabic. */
  transform: rotate(-90deg); }
.cringtrack { fill: none; stroke: var(--chip-bg); stroke-width: 9; }
/* Round cap, so a 3% sweep is a visible mark rather than a hairline. The
   stroke is set inline by charts.js because the tone is state, not style. */
.cringfill { fill: none; stroke-width: 9; stroke-linecap: round;
  transition: stroke-dasharray var(--dur-2) ease; }
/* The figure sits in the hole, on the ring's own centre line. Absolute rather
   than a grid cell so the ring keeps its square aspect and the number does not
   push the label down when it grows a digit. */
.cringpct { position: absolute; inset-block-start: 0; height: min(100%, 7.5rem);
  display: grid; place-items: center; font-size: var(--t-2xl); font-weight: 700;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em; color: var(--ink); }
.cringpct.near { color: var(--action-ink); }
.cringpct.over { color: var(--urgent-ink); }
.cringlabel { font-size: var(--t-md); color: var(--muted); }
.cringcap { font-size: var(--t-md); color: var(--muted); text-align: center; }
.cringcap.near { color: var(--action-ink); }
.cringcap.over { color: var(--urgent-ink); }
@media (prefers-reduced-motion: reduce) { .cringfill { transition: none; } }

/* ---- In against out, two bars on one axis. -----------------------------
   Both scaled to the larger figure, so the shorter bar is short by the ratio it
   actually is. --ok and --urgent are the reserved status tokens and this is not
   a status: money going out is not a fault. The two wear series colours. */
.cflows { display: flex; flex-direction: column; gap: var(--s-3); }
.cflow-row { display: grid; grid-template-columns: minmax(0, 3.5rem) 1fr auto;
  align-items: center; gap: var(--s-4); }
.cflow-label { font-size: var(--t-sm); color: var(--muted); white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis; }
.cflow-row .cbar-fill.in { background: var(--series-6); }
.cflow-row .cbar-fill.out { background: var(--series-7); }

/* ---- Change over time, as a line. --------------------------------------
   Stretched by preserveAspectRatio="none", which is why the stroke carries
   vector-effect="non-scaling-stroke" in the markup: without it a wide plot
   draws a hairline and a narrow one draws a slab. */
/* Inset by the same gutter `.cgrid` uses, so the line cannot run through the
   axis figures. See the note in charts.js. */
.clinearea { position: absolute; inset-block: 0; inset-inline: 4.5rem 0; }
.clinesvg { position: absolute; inset: 0; width: 100%; height: 100%;
  overflow: visible; }
.cline { fill: none; stroke: var(--series-8); stroke-width: 2;
  stroke-linejoin: round; stroke-linecap: round; }
.clinedot { position: absolute; width: 9px; height: 9px; border-radius: 50%;
  background: var(--series-8); transform: translate(-50%, 50%); }
.clineends { display: flex; justify-content: space-between; gap: var(--s-5);
  margin: var(--s-3) 0 0; padding-inline-start: 4.5rem;
  font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }

/* The table behind every chart. Not a fallback — the accessible rendering, the
   printed one, and the one that answers "what exactly was that figure". */
.charttable { width: 100%; border-collapse: collapse; font-size: var(--t-md); }
.charttable th, .charttable td { text-align: start; padding: var(--s-3) var(--s-4);
  border-block-end: 1px solid var(--line); }
.charttable thead th { color: var(--muted); font-weight: 600; }
.charttable td { font-variant-numeric: tabular-nums; }
.chartswap { background: none; border: 0; padding: 0; color: var(--action-ink);
  font-size: var(--t-sm); cursor: pointer; }

/* ---- What the vault noticed. -------------------------------------------
   A finding is a question, so it reads as one: the sentence first, the reason
   under it in muted text, and both letters linked so the reader can look. No
   status colour anywhere in here — nothing has been decided, and painting a
   finding urgent would be the code claiming something it cannot know. */
.insights .insgroup { margin: 0 0 var(--s-9); }
.insights .insgroup:last-child { margin-bottom: 0; }
.insights h3 { margin: 0 0 var(--s-5); font-size: var(--t-lg); }
.insrow { padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }
.insrow:last-child { border-block-end: 0; }
.insmain { margin: 0; color: var(--ink); }
.inswhy { margin: var(--s-2) 0 0; color: var(--muted); font-size: var(--t-md); }
.insdocs { margin: var(--s-3) 0 0; font-size: var(--t-md); }
.insdocs a { color: var(--action-ink); }
.chartwrap { margin: 0 0 var(--s-8); }
.charttablewrap { margin-block-start: var(--s-5); overflow-x: auto; }

/* ---- Budgets, income against outgoings. --------------------------------
   Status here is --ok and --urgent, the reserved tokens, and it NEVER travels
   alone: every bar has its figures above it and a sentence below saying what
   the colour says. A reader who cannot tell the two colours apart loses
   nothing. */
.netgroup { margin-block-end: var(--s-6); }
.netgroup:last-of-type { margin-block-end: 0; }
.netcur { margin: 0 0 var(--s-2); font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
/* THE SAME SHAPE IN EVERY CURRENCY. As a wrapping flex row, how many figures
   fit on a line depended on how wide the figures were — so "EGP 15,000.12"
   pushed Difference onto a second line while the euro and sterling blocks
   directly under it stayed on one, and three sibling blocks in one section came
   out 137px, 74px and 74px tall. The reader whose money is in a three-letter
   code got the ragged one. A track that is sized by the column and not by the
   content wraps all three at the same width or none of them. */
.netrow { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--s-5) var(--s-8); }
.netcell { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
.netlabel { font-size: var(--t-sm); color: var(--muted); }
.netval { font-size: var(--t-xl); font-weight: 600; color: var(--ink);
  font-variant-numeric: tabular-nums; }
.netval.in { color: var(--ok-ink); }

.budrow { position: relative; padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }
.budrow:last-child { border-block-end: 0; }
.budhead { display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--s-5); margin-block-end: var(--s-3); }
.budcat { color: var(--ink); }
.budfig { color: var(--muted); font-size: var(--t-md); font-variant-numeric: tabular-nums; }
.budtrack { display: block; height: 10px; border-radius: var(--r-sm);
  background: var(--chip-bg); overflow: hidden; }
.budfill { display: block; height: 100%; background: var(--accent);
  border-start-end-radius: var(--r-mark); border-end-end-radius: var(--r-mark); }
.budfill.near { background: var(--action-ink); }
.budfill.over { background: var(--urgent); }
.budstate { margin: var(--s-3) 0 0; font-size: var(--t-md); color: var(--muted); }
.budstate.near { color: var(--action-ink); }
.budstate.over { color: var(--urgent-ink); }
/* The limit, edited where it is read. Sized to its content and unadorned until
   it is touched, so the row still reads as a sentence rather than as a form. */
.budlimit { width: 6.5em; padding: 2px var(--s-3); border: 1px solid transparent;
  border-radius: var(--r-xs); background: none; color: var(--ink);
  font: inherit; font-variant-numeric: tabular-nums; text-align: end;
  min-height: 0; }
.budlimit:hover { border-color: var(--line); }
.budlimit:focus { border-color: var(--accent); background: var(--card); }

/* Logging a spend without the model. Folded away, so it costs nothing on a
   phone until the reader wants it. */
/* BOTH SIDES, for the reason `.acctadd` gets both: on the Overview this
   disclosure had a margin above and none below, so the tile grid started on
   its bottom edge. Every screen that follows it with a block of its own has
   its own top margin, and adjacent margins collapse — the ones that were
   right do not move. */
.addown { margin-block: var(--s-5); }
/* BOTH properties, and our own glyph — the rule every other disclosure in this
   file follows. `list-style: none` is what actually removes the marker in
   Firefox and current Chrome, where it is a `::marker` on a list-item box;
   `::-webkit-details-marker` alone was half a fix for half the app. The
   triangle is then drawn here, so it is a glyph this project chose rather than
   the platform's. `/ ""` keeps it out of the accessibility tree: the open state
   is already announced by the element itself. */
/* THE LINK COLOUR, NOT THE URGENT ONE. `--action-ink` is this app's amber for
   things that need attention — "in 2 days", "action needed", and the caution
   under this very control saying the figures were converted at approximate
   rates. A disclosure that opens a form is not urgent, and wearing the same
   amber made a warning and a link indistinguishable on one screen. */
.addown > summary { cursor: pointer; color: var(--accent-ink);
  font-size: var(--t-md); list-style: none; user-select: none; }
.addown > summary::-webkit-details-marker { display: none; }
.addown > summary::before { content: "▸ " / ""; }
.addown[open] > summary::before { content: "▾ " / ""; }
.addrow { display: flex; flex-wrap: wrap; gap: var(--s-5);
  margin-block-start: var(--s-5); }
.addrow input[type="number"] { max-width: 7rem; }
.addrow input[type="date"] { max-width: 10rem; }
.addrow input[type="text"] { flex: 1 1 9rem; min-width: 0; }

.budx { position: absolute; inset-block-start: var(--s-4); inset-inline-end: 0;
  display: inline-grid; place-items: center; width: 30px; height: 30px;
  min-height: 0; padding: 0; border: 0; background: none; border-radius: var(--r-sm);
  color: var(--muted); cursor: pointer; opacity: 0;
  transition: opacity var(--dur-1) ease; }
.budx .icon { width: 15px; height: 15px; }
.budrow:hover .budx, .budx:focus-visible { opacity: 1; }
@media (hover: none) { .budx { opacity: 1; } }
.budform { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5);
  margin-block-start: var(--s-7); }
.budform input { max-width: 8rem; }

/* ---- The Finances tab strip. ---------------------------------------------
   Six screens where there was one three-thousand-pixel scroll. Real links to
   real addresses, so Back, a bookmark and a middle-click all work; the active
   one is marked by weight and a rule under it, never by colour alone. Scrolls
   sideways on a phone rather than wrapping into two rows that push the content
   down the screen. */
.mtabs { display: flex; gap: var(--s-3); margin: 0 0 var(--s-7);
  border-block-end: 1px solid var(--line); overflow-x: auto;
  scrollbar-width: none; }
.mtabs::-webkit-scrollbar { display: none; }
.mtab { flex: 0 0 auto; padding: var(--s-4) var(--s-5); color: var(--muted);
  text-decoration: none; white-space: nowrap; font-size: var(--t-lg);
  border-block-end: 2px solid transparent; margin-block-end: -1px; }
.mtab:hover { color: var(--ink); }
.mtab.on { color: var(--ink); font-weight: 600; border-block-end-color: var(--accent); }
.msechint { margin: var(--s-8) 0 0; color: var(--muted); font-size: var(--t-md); }
/* ---- THE WINDOW PILL. ----------------------------------------------------
   Hidden above the phone breakpoint: a wide screen has room for the controls
   themselves, and a disclosure over a row that already fits is a tap asking to
   be told what it already shows. `_syncWindowPick` keeps the <details> open
   there, so hiding the summary hides nothing else. */
.windowsum { display: none; }
.windowpick { margin: 0; }
/* THE BROWSER'S OWN TRIANGLE, GONE — at every width, and named through
   `.windowpick summary` because that is the pairing
   test_every_disclosure_hides_the_platform_triangle reads: it collects the
   class off every <details> in the app and looks for both spellings against
   it. Written against `.windowsum` alone (the summary's own class) the marker
   was in fact hidden and the guard could not tell, which is the guard doing
   its job — a disclosure that styles its marker away under a class the details
   does not carry is one rename from showing a triangle again.
   Unconditional, not inside the phone block: above 560px the summary is
   `display: none` so nothing shows either way, and a marker rule that only
   exists at one width is a marker rule waiting for the width to change. */
.windowpick > summary { list-style: none; }
.windowpick > summary::-webkit-details-marker { display: none; }
@media (max-width: 560px) {
  .windowsum {
    display: inline-flex; align-items: center; gap: var(--s-3);
    /* 44, not the 24px a link needs: this is the control that stands for the
       whole window, it is pressed with a thumb, and it is the only way back to
       the presets once they are folded away. */
    min-height: 44px; padding: var(--s-4) var(--s-6);
    margin-block-end: var(--s-5);
    border: 1px solid var(--control-line); border-radius: var(--r-pill);
    background: var(--card); color: var(--ink);
    font-size: var(--t-lg); font-weight: 600;
    cursor: pointer;
  }
  /* `chev` points along the inline axis; a disclosure points at what it opens.
     Rotated rather than mirrored, so it means the same thing in Arabic — down
     is down in every direction. */
  .windowsumchev { transform: rotate(90deg); transition: transform var(--dur-1) ease; }
  .windowpick[open] .windowsumchev { transform: rotate(-90deg); }
  /* The controls sit under the pill rather than beside it, and the pill keeps
     its own bottom margin so the two never touch. */
  .windowpick[open] .windowbody { margin-block-end: var(--s-5); }
}

/* THE WINDOW, IN ONE ROW: the stepper, then the two facts about it.
   `align-items: center` rather than `baseline` — the caption is now a stacked
   pair rather than a single line, and a baseline would hang the 38px control
   off the first of its two rows. */
.windowrow { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s-3) var(--s-6); margin-block-end: var(--s-7); }
.windowrow > * { margin: 0; }
/* The dates and the rate they were converted at, stacked tight beside the
   control they describe. `min-width: 0` so a long localised date range
   ellipsises inside the row instead of pushing the stepper off the phone.
   The margins are zeroed HERE as well as on `.windowrow > *`, because these
   two are grandchildren now — `.rateline` carries a -6px top margin from the
   days when it sat under a block of its own. */
.windowfacts { display: grid; gap: var(--s-1); min-width: 0; }
/* `.windowrow` in front of it on purpose: `.card p` is (0,1,1) and a bare
   `.windowfacts > *` is (0,1,0), so the reset lost to the card's paragraph
   margin and left 10px under the date line — the row measured 91px when the
   two captions in it are 36. Two classes beat a class and a type. */
.windowrow .windowfacts > * { margin: 0; }
.windowfacts .windowline, .windowfacts .rateline { font-size: var(--t-sm); }

/* ---- WHAT A PHONE SPENDS BEFORE IT SHOWS ANYTHING. -----------------------
   Measured on a 390x844 phone: 511 to 618px of this page went on furniture
   before the first figure — 60 to 73% of the screen, on every visit. The
   reader scrolled to reach the thing they opened the app for.

   Three cuts, none of which removes a fact:

   - the page subtitle is prose explaining a screen whose heading and five tabs
     now explain it better. Seventy pixels, read once, in the way forever.
   - the range buttons scroll in ONE row instead of wrapping into two. Five
     ranges in sixteen languages will not fit a phone on one line and never
     could; wrapping made that everyone's problem instead of the problem of the
     reader who wants "All time".
   - the dates and the rate share a line with the currency, because they are
     one sentence about one window.

   Measured after: 511 -> 355px, and the first screenful holds 489px of content
   instead of 333. */
@media (max-width: 560px) {
  .pagesub { display: none; }
  /* THE WINDOW, ON A PHONE: the control across the width, the facts under it.
     MEASURED, after trying the other way first. The card offers 324px of
     content at 390. Two 44px arrows and a label that must hold "September
     2026" make the stepper 189 of that, and the two captions want 279 — so
     side by side they wrapped anyway (189 + 128 + gap > 324) and cost 95px in
     two ragged rows. Squeezing the captions into the 123px left over would
     have broken each of them across two lines in a column narrower than the
     control beside it.
     So the stepper takes the row it was already taking and SPREADS into it,
     which puts the two arrows under the two thumbs instead of side by side in
     the middle, and the captions get the full width beneath at label size —
     one line each rather than two. 116px -> ~74, and nothing is dropped. */
  /* TWO COLUMNS, AND THE HERO IS WHY THEY ADD UP NOW.
     The orphan this block used to fix was arithmetic: two columns plus a hero
     taking the whole row is five slots for four figures, so the fourth stood
     alone beside 168px of empty card. `auto-fit` with a 92px track was the
     first fix and it was the wrong one — `.mtile` spans `1 / -1`, so every
     track always has an item in it, and auto-fit only collapses tracks that are
     EMPTY. Nothing ever collapsed; a row of two figures simply sat in three
     tracks with the third blank, which is the same defect one column over.
     The hero holds two figures now, so four tiles are 2 + 2 and the row closes
     on its own. */
  .mtiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* THREE FIGURES TO A ROW WANT LESS BOX AROUND THEM THAN TWO DID.
     Scoped to the phone on purpose: the rule above this block promises the
     wide layout is byte-for-byte what it was, and 14/16 is the pair that makes
     that true. --s-5/--s-6 are the steps either side of it. */
  .mcard { padding: var(--s-5) var(--s-6); }
  .windowrow { gap: var(--s-3); }
  .windowrow .monthstep { flex: 1 1 100%; justify-content: space-between; }
  .monthstepnow { font-size: var(--t-sm); }
  .windowfacts { flex: 1 1 100%; }
  .mcontrols { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
    margin-block-end: var(--s-5); }
  .mcontrols::-webkit-scrollbar { display: none; }
  .segbar { flex-wrap: nowrap; }
  /* Each range keeps its own line and the ROW scrolls. Without this the buttons
     shrank instead of overflowing, so "This month" and "30 days" each broke
     across two lines and the bar was as tall as two rows anyway — the wrap
     removed, the height kept. */
  .seg { flex: 0 0 auto; white-space: nowrap; }
  /* The spacer would eat the scroll width; on a phone the currency simply
     follows the ranges. */
  .mcontrols .spacer { display: none; }
}

/* The blocks each tab fills in after its own fetch — the forecast, the trend,
   the standing charges. They arrive UNDER content that is already on screen, so
   they need the separation a sibling would have had; without it the heading of
   the block sat flush against the last ledger row above it and read as part of
   it. Empty until their fetch lands, and an empty div must not push anything
   down, so the margin is on the block rather than on the host. */
#overviewflow > *, #spendtrend > *, #subs > * { margin-block-start: var(--s-9); }
.msecempty { margin: var(--s-7) 0; color: var(--muted); }

/* ---- The Finances settings tab. ------------------------------------------
   Every control here changes a figure the reader can see. Saved on change,
   one field at a time, so there is nothing to lose by leaving the screen and
   a group can never reset another. */
/* PREFIXED, every one of them. `.setrow` was already taken by the main
   Settings screen, defined LATER in this file, so its rules won on the cascade
   — including a narrow-width `flex-direction: column` — and the Finances
   settings rows silently stacked, putting every checkbox on a line above its
   own label. A generic class name in a stylesheet this size is a collision
   waiting for a screen to be built. */
.msettings { display: grid; gap: var(--s-9); }
@media (min-width: 900px) {
  /* The width finally used: four groups in two columns on a desktop, one on a
     phone. The page was a single narrow column on a 1440px screen, with the
     right half empty. */
  .msettings { grid-template-columns: 1fr 1fr; gap: var(--s-9); }
}
.msetgroup h3 { margin: 0 0 var(--s-5); font-size: var(--t-lg); }
.msetrow { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-5); margin-block-end: var(--s-4); }
.msetrow > span { color: var(--ink); }
.msetrow input[type="number"] { max-width: 7rem; }
.msetrow.check { justify-content: flex-start; }
.msetcols { border: 1px solid var(--line); border-radius: var(--r-sm);
  padding: var(--s-5); margin: var(--s-5) 0 0; }
.msetcols legend { padding: 0 var(--s-3); color: var(--muted); font-size: var(--t-sm); }
.msetreq { color: var(--muted); font-size: var(--t-sm); font-style: normal; }

/* The ledger's own search and category picker, above the rows they narrow. */
.mfilter { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5);
  /* Both sides: with a top margin only, the search box's own border met the
     top border of the breakdown panel under it on Spending, and the first
     ledger row on Bills. */
  margin-block: var(--s-5); }
.mfilter input { flex: 1 1 12rem; min-width: 0; }

/* ---- WHAT WAS IN A SPEND, under the row that says what it cost. ---------
   Indented to the width of the row's own mark, so the table reads as belonging
   to the row above it rather than as a new section. `[hidden]` is spelled out
   because this block sets a display of its own, and a display beats the
   browser's own hidden rule. */
.lrowitems[hidden] { display: none; }
/* THE AIR IS MARGIN, NOT PADDING, and the difference is not cosmetic. As
   padding the block's own box still began on the ledger row's bottom edge and
   ended on the next row's top one — which is the defect this app keeps
   shipping, and tests/test_no_block_sits_on_the_one_below_it.js caught it here
   before a reader did. Nothing paints a ground behind this block, so the space
   looks identical and now belongs where it means something: between the row
   and the table, not inside the table. */
.lrowitems { display: block; overflow-x: auto;
  /* Less air above than below, because the table belongs to the row that
     opened it and more to that row than to the one under it — but not so
     little that the two run together: 6px read as a crowded row rather than
     as a row with something under it. */
  margin: var(--s-5) 0 var(--s-7);
  padding-inline-start: calc(var(--s-8) + var(--s-7)); }
.linetable { width: 100%; border-collapse: collapse; font-size: var(--t-md); }
.linetable th { text-align: start; font-weight: 600; color: var(--muted);
  font-size: var(--t-sm); padding-block: var(--s-3); white-space: nowrap; }
.linetable td { padding-block: var(--s-3);
  border-block-start: 1px solid var(--line); }
/* Figures line up in a column and never wrap; `text-align: end` rather than
   `right` so an Arabic ledger mirrors with the rest of the page. */
.linetable .num { text-align: end; white-space: nowrap;
  font-variant-numeric: tabular-nums; }
.linetable td .chip { margin-inline-start: var(--s-3); }
.linesum { margin: var(--s-4) 0 0; text-align: end; color: var(--muted);
  font-size: var(--t-sm); font-variant-numeric: tabular-nums; }
/* IT POINTS DOWN, NOT ALONG THE LINE — `.windowsumchev`'s exact idiom, and
   for its reason: `chev` points along the inline axis, and nothing in this
   stylesheet mirrors it, so left as it comes it points RIGHT in an Arabic
   ledger — where right is backwards, and a disclosure would be wearing the
   glyph for "previous". Rotated it points at what it opens, and down is down
   in every writing direction. */
.lexp .icon { transform: rotate(90deg);
  transition: transform var(--dur-1) var(--ease); }
.lexp[aria-expanded="true"] .icon { transform: rotate(-90deg); }

/* A standing charge, with the yearly figure beside the monthly one. */
.subrow { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5);
  padding: var(--s-5) 0; border-block-end: 1px solid var(--line); }
.subrow:last-child { border-block-end: 0; }
/* THE SENDER'S MARK. A tint of one of the eight categorical series colours,
   with the letters in --ink. Not white on the colour itself: measured across
   the ramp, white clears AA on four of the eight in light and one in dark, and
   there is no single letter colour that clears all sixteen. Ink on a 16% tint
   does, everywhere, and tools/check_contrast.py holds it there.

   `background: var(--chip-bg)` first so a browser without color-mix gets a
   plain disc rather than a transparent one — the mark loses its colour, not
   its shape. */
.submark {
  /* The row supplies the real one inline — which of the eight this sender
     hashes to. Declared here as well because a var() that nothing defines is
     an invalid declaration, so a mark rendered without the inline value would
     have no background at all rather than a wrong-coloured one. */
  --mk: var(--series-1);
  flex: 0 0 auto; display: grid; place-items: center;
  /* 36, NOT 30. The disc was a fixed size beside type that is a range, and the
     bigger the surrounding text the more it read as an afterthought — reported
     on the instalments list, where the plan's name is one of the largest
     strings on the screen and the sender's mark beside it was half its height.
     IT COSTS 6px A ROW, measured on Bills at 390px: 68/93/129 became 74/99/135.
     The disc is the tallest thing on a ledger row's first line, so the row
     grows with it — worth saying rather than assuming, because the first draft
     of this note claimed the opposite and the measurement disagreed. */
  width: 36px; height: 36px; border-radius: 50%;
  /* The letters grow with the disc; --t-sm in a 36px circle is a mark with a
     hole in it. */
  font-size: var(--t-md); font-weight: 700; letter-spacing: .01em;
  color: var(--ink); background: var(--chip-bg);
  background: color-mix(in srgb, var(--mk) 16%, var(--card));
  border: 1px solid color-mix(in srgb, var(--mk) 38%, var(--card));
}
/* HALF THE DISC, IN CSS RATHER THAN IN THE MARKUP.
   `_brandMark` writes width="15" height="15" on the svg, chosen when the disc
   was 30 — half of it, so that Netflix's N (9.4 x 17.0 measured, the one
   common mark whose long side is vertical) clears the top and bottom instead
   of pressing against them. A percentage keeps that ratio when the disc
   changes size, so the attribute is now the fallback and this is the rule.
   `preserveAspectRatio` stays at its default: a mark fills its long side and
   is never stretched. */
.submark svg { width: 50%; height: 50%; }

/* A REAL MARK SITS ON A PLAIN GROUND, not on the tinted one.
 *
 * The tint exists to give two initials enough contrast to read against. A logo
 * brings its own colour and its own silhouette, and putting it on a coloured
 * disc fights both — the brand's blue over a hashed orange wash is how a mark
 * ends up looking broken rather than branded. So the circle keeps its size and
 * its hairline, and gives up the fill. */
.submark.hasmark {
  background: var(--card);
  border-color: var(--line);
}
.submark.hasmark svg { display: block; }
/* A stopped charge recedes here too, the same way its name and figure do. */
.subrow.ended .submark { opacity: .55; }
/* `flex-basis: 0`, not `auto`, and the row wraps now so this matters. With an
   auto basis the name column's hypothetical size is the whole title, and a
   wrapping flex line breaks BEFORE it shrinks anything — so a long sender name
   pushed the monthly and yearly figures onto a line of their own. A zero basis
   lets the column take what is left instead of demanding what it wants. */
/* THE NAME KEEPS A WIDTH IT CAN BE READ AT, and the row wraps instead.
 *
 * `flex: 1 1 0` means "take what is left", and what was left shrank the moment
 * this row grew a fourth column — the day it is taken. Measured at 393px: the
 * figure took 112, the day 58, the mark and the stop button the rest, and the
 * title was handed NINETEEN PIXELS. It did not clip, so nothing said so; it
 * wrapped to one character per line, and the link became a 19px-wide target
 * under the 24px floor, which is what the phone suite caught.
 *
 * A real basis rather than a min-width: `.subrow` already wraps, so the columns
 * that do not fit beside a readable title move to a second line, which is the
 * behaviour a narrow phone wants anyway. `min-width: 0` stays for the long
 * unbroken word — a sender name with no spaces still has to be allowed to
 * shrink rather than push the row sideways. */
.submain { flex: 1 1 11rem; min-width: 0; display: flex; flex-direction: column;
  gap: var(--s-1); }
/* A TARGET THAT DOES NOT DEPEND ON THE TYPE SCALE.
   This link is the row's whole subject — the subscription's name, standing on
   its own line, not a link inside a sentence — so WCAG 2.5.8 asks it to be at
   least 24px tall and tests/test_the_phone_layout.js measures it on every
   phone width in both themes and both directions.
   It had no box of its own. Its height was body line-height and nothing else:
   15.56px of type at 1.55 came to 24.1px on a 393px phone, which cleared the
   floor by a tenth of a pixel and only because prose happened to be that size.
   Lowering the body floor by one point took it to 22.6 and the guard fired —
   correctly, and on a screen nobody had touched.
   So the target is stated rather than inherited. A one-line name is 24px
   whatever the scale does next; a two-line one is already taller and the
   min-height does nothing. */
/* `align-self`, AND `inline-flex` WOULD NOT HAVE DONE IT.
   This link is a direct child of `.submain`, which is a flex COLUMN, so it is
   a flex item — and a flex item's display is blockified: `inline-flex`
   computes to `flex` and the box still fills the row. `.ldesc a` gets away
   with `inline-flex` only because it sits inside `.ldesc` rather than directly
   inside the flex container.
   Stretched full width, the name then sat at the start of its own INLINE
   direction, which a Latin title resolves LTR — so on an Arabic page the row's
   name was laid out 249px away from the row's own mark, measured at 900px,
   while every other part of the row had mirrored correctly.
   `align-self: flex-start` takes the item off the stretch and back to its
   content width, at the CROSS-axis start — which for a column follows the
   page's direction, so the name sits against the mark in Arabic and in
   English, and the 24px touch target survives both. */
.submain a { color: var(--ink); min-height: 24px;
  display: flex; align-items: center; align-self: flex-start; max-width: 100%; }
.submeta { font-size: var(--t-sm); color: var(--muted); }
/* What this charge used to cost.
   Muted and not a status colour: --urgent and --action mean "this needs you",
   and a price that moved is a fact, not a demand. The letter that announced it
   is a link, because that is the whole difference between this row and a line
   on a bank statement.
   ITS OWN LINE UNDER THE ROW, not a fourth thing competing for the row's
   width — inside the name column it squeezed the title onto three lines on a
   393px phone with the em dash orphaned on one of them. */
.subprice { flex: 1 0 100%; margin: 0; font-size: var(--t-sm); color: var(--muted); }
.subprice a { color: var(--muted); text-decoration: underline; }
.subprice a:hover, .subprice a:focus-visible { color: var(--ink); }
.subrow.ended .subprice { opacity: .55; }
.subfig { display: flex; flex-direction: column; align-items: flex-end;
  gap: var(--s-1); flex: 0 0 auto; }
.submonth { color: var(--ink); font-variant-numeric: tabular-nums; }
.subyear { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
/* A stopped charge recedes but stays: the document it came from is still in the
   vault, and a row that vanished would be the only record going with it. */
.subrow.ended .submain a, .subrow.ended .submonth { color: var(--muted); }
/* A TYPED CHARGE HAS NO LETTER TO LINK TO, so its name is a span. It still has
   to read as the same weight of thing as the one beside it that does — a name
   that looked lighter would say "this one matters less", and the ones the
   reader typed are precisely the subscriptions nobody sends post about. */
.subname { color: var(--ink); }
/* AGED OUT OF THE TOTAL, NOT OFF THE LIST. The same recession a stopped charge
   gets, for the same reason: the row is here to be seen, and it is not money
   going out this month. Never a status colour — --urgent and --action mean
   "this needs you", and an old document is a fact about the vault, not a demand. */
.subrow.dormant .submain a, .subrow.dormant .subname,
.subrow.dormant .submonth { color: var(--muted); }
.dormantnote { font-style: italic; }

/* ---- Repeating charges, as questions -----------------------------------
   Set apart from the list above with the same chip background "Coming up"
   uses, because nothing in this panel is a row yet: it is what the ledger
   suggests and what the reader has not answered. A card that drew these among
   the real ones would be asserting a commitment nobody agreed to. */
.subfound { border: 1px solid var(--line); border-radius: var(--r-md);
  padding: var(--s-5) var(--s-6); margin-block-start: var(--s-7);
  background: var(--chip-bg); }
.subfound h3 { font-size: var(--t-sm); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); margin: 0 0 var(--s-4); }
.subfoundrow { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s-4); padding-block: var(--s-4); }
/* The two answers are equally reachable. "No" is a ghost rather than a smaller
   or hidden control: a reader who cannot decline easily learns to ignore the
   whole panel, and a dismissal is as useful an answer as an acceptance. */
.subfoundacts { display: flex; gap: var(--s-3); flex: 0 0 auto;
  margin-inline-start: auto; }
.subfoundacts button { min-height: 36px; padding-inline: var(--s-5); }
/* ---- Subscriptions: the figure the screen leads with -------------------
   The monthly total was a sentence in a paragraph inside another tab. The
   yearly figure is the one that actually gets a subscription cancelled, and it
   read like a footnote. Fluid like every other headline on this page — see the
   type-scale block; never a fixed px. */
.subhero { font-size: var(--t-4xl); font-weight: 700; margin: var(--s-6) 0 0;
  font-variant-numeric: tabular-nums; line-height: 1.1; }
.subherosub { font-size: var(--t-sm); color: var(--muted); margin: var(--s-1) 0 0; }
/* Three figures on one row, wrapping rather than shrinking: at 320px they
   stack, and a stat squeezed to two characters is not a stat. */
.substats { display: flex; flex-wrap: wrap; gap: var(--s-4) var(--s-7);
  margin-block: var(--s-6); }
.substat { display: flex; flex-direction: column; font-size: var(--t-sm);
  color: var(--muted); }
.substat b { font-size: var(--t-xl); color: var(--ink); font-weight: 700;
  font-variant-numeric: tabular-nums; }
/* WHAT IS ABOUT TO BE TAKEN, above the full list. The whole reason this screen
   is worth opening before the money leaves rather than after. */
.subsoon { border: 1px solid var(--line); border-radius: var(--r-md);
  padding: var(--s-5) var(--s-6); margin-block-end: var(--s-6);
  background: var(--chip-bg); }
.subsoon h3 { font-size: var(--t-sm); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); margin: 0 0 var(--s-4); }
.subsoonrow { display: flex; align-items: center; gap: var(--s-4);
  padding-block: var(--s-3); }
.subdue { font-size: var(--t-sm); color: var(--muted); white-space: nowrap;
  font-variant-numeric: tabular-nums; }
/* Due today or tomorrow is the one state on this screen worth a colour. */
.subdue.now { color: var(--warn-ink); font-weight: 700; }
/* The crossed figure, under the one the charge is actually in. "$19.99" with
   "≈ EGP 958.00" beneath is the row a reader holding two currencies can read
   without doing arithmetic — which is most of this product's readers. */
.subalt { font-size: var(--t-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; }
.subwhen { display: flex; align-items: center; gap: var(--s-3); flex: 0 0 auto; }
.subdaypick { font-size: var(--t-sm); padding: 2px var(--s-2); }
.subx { display: inline-grid; place-items: center; width: 30px; height: 30px;
  min-height: 0; padding: 0; border: 0; background: none; border-radius: var(--r-sm);
  color: var(--muted); cursor: pointer; flex: 0 0 auto; opacity: 0;
  transition: opacity var(--dur-1) ease, color var(--dur-1) ease; }
.subx .icon { width: 16px; height: 16px; }
.subrow:hover .subx, .subx:focus-visible, .subx.on { opacity: 1; }
.subx:hover, .subx.on { color: var(--ok-ink); }
@media (hover: none) { .subx { opacity: 1; } }

/* A date the reader added themselves says so — otherwise a row with no sender
   and no letter behind it reads as one Fahim failed to attribute. */
.oown { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--accent-ink); }
.onote { color: var(--muted); overflow-wrap: anywhere; }

/* ---- multi-line question box ---- */
.askform textarea {
  width: 100%; min-height: 44px; max-height: 160px; resize: none; overflow-y: auto;
  font: inherit; line-height: 1.45; padding: 11px 13px;
}
/* NARROW SCREENS KEEP THE DOTS. A cell is a seventh of the width, so at 620px
   a chip has about 40px for its words — enough for "Ausl…", which tells the
   reader nothing and costs the height of three lines to say it. Below that the
   chip drops its text and becomes the dot it used to be: same colour, same
   meaning, and the day is still tappable for the detail underneath.
   620px, because that is the width where a chip stops fitting a short sender
   name, not a device size. */
@media (max-width: 620px) {
  .cday { min-height: 58px; gap: var(--s-1); padding: var(--s-2) var(--s-1) var(--s-1); }
  .cnum { font-size: var(--t-xs); line-height: 1.5; }
  .cday.today .cnum { min-width: 1.35rem; }
  /* The chips become a row of dots that wraps, so a busy day still reads as
     busy without the cell growing. */
  .cevents { flex-direction: row; flex-wrap: wrap; gap: var(--s-1); align-items: center;
             padding-inline: var(--s-1); }
  /* A DOT IS NOT A TAP TARGET, so it does not take taps.
     At 390px a chip is 5px tall — the cell owns the touch, exactly as it did
     before chips became buttons, and a tap anywhere in the square opens that
     day. Nothing is lost: the day's rows are what a tap reaches either way,
     and the two land within a line of each other. Keyboard activation is not
     a pointer event, so a chip is still reachable by Tab and Enter where
     somebody is driving a narrow window from a keyboard. */
  .cev { padding: 0; background: none; gap: 0; pointer-events: none; }
  .cev:hover { box-shadow: none; }
  .cev.hard, .cev.overdue { background: none; }
  .cev-txt { display: none; }
  .cev-dot { width: 5px; height: 5px; }
  .cmore { padding-inline: var(--s-1); font-size: var(--t-xs); }
  .cmore-long { display: none; }
  .cmore-short { display: inline; }
}

/* ---- calendar: three levels of consequence, and a per-date toggle ----
   Every extracted date now reaches the Calendar, so colour is what stops the
   view becoming a list. Red is reserved for what costs you something, overdue,
   a hard legal deadline, or a letter already flagged URGENT, amber for
   actionable and within a fortnight, and muted for the facts a letter records
   (issued on, valid from) which the reader asked to see but must not have
   competing with a deadline for attention. */
.orow.lv-urgent { border-inline-start-color: var(--urgent-ink); background: var(--warn-bg); }
/* A TINTED ROW STILL ANSWERS THE POINTER. `.orow:hover` sets a background and
   this rule sets one too, at equal weight and later in the file — so the one
   row that most wants to be clicked was the only one that stopped lighting up
   under the cursor. */
/* AN INSET EDGE, NOT A SECOND TINT. The first attempt reached for a
   `--warn-bg-hover` that does not exist and hid it behind a fallback — which
   tests/test_design_system.py fails on sight, and rightly: a token that only
   exists inside one `var()` fallback is a colour nobody can find or change.
   `--warn-border` is real, already the edge on this row's own tag, and reads
   in both themes. */
.orow.lv-urgent:hover { box-shadow: inset 0 0 0 1px var(--warn-border); }
.orow.lv-urgent .odesc { font-weight: 700; }
.orow.lv-soon { border-inline-start-color: var(--action); }
/* NO STRIPE ON A FACT. It was `--line` — the same grey as the hairlines now
   separating the rows, so every fact drew a short grey edge at right angles to
   them, reading as a divider that had come loose. A fact says what it is by
   being quiet; it does not need an edge to say "no urgency". */
.orow.lv-context { border-inline-start-color: transparent; opacity: .72; }
.orow.lv-context .odesc { font-weight: 500; color: var(--muted); }

/* ---- THE LEGEND, WHICH IS ALSO THE FILTER. -----------------------------
   A row of switches under the month grid: what is on this calendar, and one
   press to take a kind off it. Pills rather than checkboxes because they sit
   in a line under a grid and read as a legend — which is the other half of
   their job. */
.calkinds { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s-3); margin-block: var(--s-6); }
.calkinds-lead { font-size: var(--t-sm); color: var(--muted); font-weight: 600;
  margin-inline-end: var(--s-2); }
.calkind { display: inline-flex; align-items: center; gap: var(--s-3);
  min-height: 32px; padding: var(--s-2) var(--s-5); cursor: pointer;
  /* `--r-pill`, not a literal 999px. The scale has a token for exactly this
     shape and tests/test_design_system.py fails a rule that writes its own
     size — which it did, and which is the whole point of having the scale. */
  border: 1px solid var(--control-line); border-radius: var(--r-pill);
  background: transparent; color: var(--muted); font: inherit;
  font-size: var(--t-sm); font-weight: 600;
  transition: border-color var(--dur-1) var(--ease),
              color var(--dur-1) var(--ease); }
.calkind.on { color: var(--ink); border-color: var(--ink); }
.calkind:hover { border-color: var(--accent-ink); }
/* SWITCHED OFF IS STRUCK THROUGH, not merely paler. Two greys — "off" and
   "this kind is quiet at the moment" — are the same grey to anybody who is not
   comparing them side by side, and the state has to be readable on its own. */
.calkind:not(.on) span { text-decoration: line-through; }
.calkind-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.calkind-dot.spend { background: var(--spend-ink); }
.calkind-dot.in { background: var(--ok-ink); }
/* The count is a detail of the label, so it steps back from it. */
.calkind-n { font-weight: 500; opacity: .7; font-variant-numeric: tabular-nums; }

/* ---- WHAT THE READER SPENT, ON A TIMELINE OF THINGS TO DO. -------------
   A spend is not a duty, so it takes `lv-context` — the treatment for a date
   that is a fact — and that was the whole of its appearance: the same grey
   stripe and the same 28% fade as a passport's issue date, under a bright
   blue tag. Two things wrong with it. It read as DEGRADED rather than as a
   different kind of row, which is what "looks different somehow" was seeing;
   and the one coloured thing on it, the tag, was the blue this app spends on
   links and on "Added by you".

   `--series-1` is the answer because it is not a new colour: it is the first
   band of the chart vocabulary Finances draws spending with, and the mark an
   account already wears (`.acctmark`). The calendar and the money screens now
   say the same thing the same way, and no urgency colour is spent on
   something nobody can act on.

   THE FADE COMES OFF. Content the reader asked to see is not content to be
   dimmed; the row steps back through weight and colour, which it already did,
   and not by being made translucent over whatever is behind it. */
/* ONE COLOUR FOR SPENDING ON THIS SCREEN, and this is it. The stripe was
   `--series-1` — the chart band Finances draws spending with — while the tag
   beside it and the dot up in the grid were `--spend-ink`, the variant that
   stays legible small. Three markers for one idea in two shades of indigo,
   which is the thing that gets noticed and asked about. The chart keeps its
   own band; a calendar should agree with itself first, and the ink is also
   the stronger line (5.55:1 against 3.72:1). */
.orow.ospend { border-inline-start-color: var(--spend-ink); opacity: 1; }
.orow.ospend .odesc { color: var(--ink); font-weight: 600; }
/* The tag agrees with the stripe, so blue goes on meaning "link" and
   "you added this" everywhere else on this screen. */
.orow.ospend .oown { color: var(--spend-ink); }
/* The figure is the point of the row — it is why a spend is worth a line on a
   calendar at all — so it keeps full weight where a fact's would be muted. */
.orow.ospend .oamount { color: var(--ink); }

/* MONEY ARRIVING IS NOT MONEY LEAVING, and the ledger has always said so in
   this exact green (`.lrow.in .lamt`). The calendar borrows it rather than
   inventing a second vocabulary for one idea: same colour, same meaning, two
   screens. */
.orow.ospend.oin { border-inline-start-color: var(--ok-ink); }
.orow.ospend.oin .oown { color: var(--ok-ink); }
.orow.ospend.oin .oamount { color: var(--ok-ink); }

/* The toggle sits at the far end of its row, aligned down the column, so the
   dates stay readable as a list and the controls form their own quiet edge.
   No fill and no border: this annotates the value, it does not compete with it.
   State is carried by colour and opacity alone, accent when the date is on the
   calendar, muted when it is not. */
/* THE TOGGLE SITS BESIDE ITS DATE, not at the far edge of the cell.
   It used to carry `margin-inline-start: auto`, on the reasoning that the
   controls should line up in a quiet column down the trailing edge. That reads
   well in a mock-up and badly in use: the cell is half a capped table, so at
   1440 the date ended at 655 and the control that puts it on the calendar sat
   at 1038 — nearly 400px away, with nothing in between to connect them. A
   column of controls is worth having when there are eight rows to scan; this
   card usually has one or two, and the reader's question is "what does this
   button do", which is answered by what it is standing next to. */
.dcell { display: flex; align-items: center; gap: var(--s-5); }

.caltoggle {
  display: inline-flex; align-items: center; justify-content: center;
  /* 30px of hit area around a 17px glyph: the icon stays quiet, the target
     stays thumb-sized. */
  width: 30px; height: 30px; padding: 0; flex: 0 0 auto;
  border: 0; background: none; border-radius: var(--r-sm);
  cursor: pointer; transition: color var(--dur-1) ease, opacity var(--dur-1) ease;
}
.caltoggle .icon { width: 17px; height: 17px; }
.caltoggle.on { color: var(--accent-ink); opacity: 1; }
/* OFF IS A COLOUR, NOT A FADE.
   `opacity: .45` put the off state at 1.90:1 against the card in light and
   2.48:1 in dark — measured — where SC 1.4.11 asks 3:1 of the visual
   information that identifies a control AND ITS STATE. It is the same failure
   the --*-ink variants further up this file were introduced to fix, and it bit
   hardest on the reminder bell, where the off state is the only way back to a
   warning the reader has switched off.
   Full-strength --muted instead: 5.43:1 and 7.20:1, and the on/off difference
   is still plain — accent blue against grey, two hues and two lightnesses
   rather than one glyph fading out. The row it sits on is struck through as
   well, so the state never rests on colour alone (SC 1.4.1). */
.caltoggle.off { color: var(--muted); opacity: 1; }
.caltoggle.off:hover { color: var(--accent-ink); }
.caltoggle.on:hover { opacity: .7; }
.caltoggle:disabled { cursor: progress; opacity: .3; }


/* ---- documents: three ways to look at the same vault ----
   A vault is scanned three different ways. List is densest; grid shows the
   letter's own photograph, which is how people actually recognise their post;
   details lines the facts up for comparing rather than finding. */
/* THE TOOLS STAY ON THE TRAILING EDGE AT EVERY WIDTH.
   `.spacehead-row` is `space-between`, which puts this group hard against the
   right on a wide screen. On a phone the row wraps, the group lands alone on
   the second line, and `space-between` has nothing to push it against — so a
   content-sized box sat at the LEADING edge and stopped 58px short of the
   trailing one, leaving "+ New document" floating in the middle of a line with
   ragged space on both sides. That is what it looked like, and it was not a
   size problem: the button was the right size in the wrong place.
   `flex: 1 1 auto` lets the group claim the whole line once it is alone on
   one, and `flex-end` keeps its children together at the trailing edge. On a
   wide screen the box grows into space that was empty anyway and the children
   render exactly where they already did, so the desktop layout is unchanged —
   verified by measuring the button's edges before and after at 1280px. */
.spacehead-tools { display: flex; align-items: center; gap: var(--s-5);
  flex: 1 1 auto; justify-content: flex-end; flex-wrap: wrap; }
/* THE ROW GIVES WAY BEFORE THE BUTTON DOES. A flex item's default minimum is
   its longest word, so the CTA was the thing that shrank when the header ran
   out of room: at 320px in German "+ Neues Dokument" was squeezed from the
   160px it needs to 151px and broke onto two lines — a 69px-tall button beside
   a 30px toggle. It should keep its size and let the row wrap instead, which
   is what the pair of rules here says: don't shrink, and if you truly cannot
   fit, take your own line. `max-width` is the last resort, for a language long
   enough that the button alone is wider than the column — there the label
   wraps rather than running off the edge. */
.spacehead-tools > .go { flex: 0 0 auto; max-width: 100%; }
.viewbar { display: inline-flex; gap: var(--s-1); padding: 3px;
  border: 1px solid var(--control-line); border-radius: var(--r-sm); background: var(--card); }
.viewbtn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 30px; padding: 0; border: 0; border-radius: var(--r-xs);
  background: none; color: var(--muted); cursor: pointer;
  transition: background var(--dur-1) ease, color var(--dur-1) ease;
}
.viewbtn .icon { width: 17px; height: 17px; }
.viewbtn:hover { color: var(--accent-ink); }
.viewbtn.on { background: var(--accent); color: var(--on-accent); }


/* Grid. auto-fill rather than a fixed column count so it reflows from a phone
   to a wide desktop without a breakpoint per size.

   TWO COLUMNS ARE THE FLOOR, and that is what the `min()` buys. A flat
   `minmax(168px, 1fr)` needs 352px of content box for its second column, and a
   360px phone offers 324 — so the whole vault rendered as ONE tile per row,
   each one 324px wide with a 3:4 photograph inside it. Twelve documents was
   four screens of scrolling to reach the one you came for, which is the
   complaint this rule answers. The `min()` says: 168px per column, OR half the
   row minus the gap, whichever is smaller. On a phone that is the half, so two
   fit at any width down to 320; from 388px up the 168 is the smaller number
   again and nothing about the desktop layout changes — measured at 1280, five
   tracks of 173.594px before and after.
   THE GAP STAYS 16px AT EVERY WIDTH, and that is not an oversight. Trimming it
   to 12 on a phone bought each tile 4px and cost a discontinuity: at 560px the
   narrower gap let a THIRD 168px track fit, so the grid went three columns at
   560 and two at 561 — columns going down as the screen gets wider, which is
   what dragging a window past the breakpoint would have shown. Measured both
   ways; 4px per tile is not worth a grid that reflows backwards. */
.doc-grid { display: grid; gap: var(--s-7); margin-bottom: 8px; align-items: start;
  grid-template-columns: repeat(auto-fill, minmax(min(168px, calc((100% - var(--s-7)) / 2)), 1fr)); }
/* A category heading INSIDE the grid, spanning every column, so the automatic
   categories share one grid instead of opening a new one each (see
   _autoSections in app-vault.js for the measurements that forced this). The
   first one loses its top margin: it follows the toolbar, not another
   section. */
.doc-grid > .grid-head { grid-column: 1 / -1; margin: 18px 0 0; }
.doc-grid > .grid-head:first-child { margin-top: 2px; }
.doc-tile {
  display: flex; flex-direction: column; text-decoration: none; color: inherit;
  border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden;
  background: var(--card); transition: transform var(--dur-1) ease, box-shadow var(--dur-1) ease;
}
.doc-tile:hover { transform: translateY(-2px); box-shadow: var(--shadow-2); }
/* 3:4 is only the starting shape, used until the image reports its own; JS
   then sets aspect-ratio per tile so a landscape page is not cropped to a
   portrait box. align-items:start on the grid lets each tile keep its height
   instead of every row stretching to the tallest. */
.tile-shot { position: relative; display: block; aspect-ratio: 3 / 4;
  background: var(--bg); overflow: hidden; }
.tile-shot img { width: 100%; height: 100%; object-fit: cover; object-position: top center;
  display: block; }
.tile-fallback { position: absolute; inset: 0; display: none;
  align-items: center; justify-content: center; color: var(--muted); }
.tile-fallback .icon { width: 30px; height: 30px; }
/* A document with no stored preview: hide the broken image, show the glyph.
   It keeps the 3:4 box. Giving it a shorter one was tried and was worse: a
   real vault has some scans and not others, so two aspect ratios in one grid
   turned every row into a ragged edge, which is a bigger cost than a tile that
   is quiet. It gets the chip tint instead, so "no preview stored" reads as a
   deliberate blank rather than as a page that failed to load. */
.tile-shot.no-shot { background: var(--chip-bg); }
.tile-shot.no-shot img { display: none; }
.tile-shot.no-shot .tile-fallback { display: flex; }

/* THE READER DELETED THE SCAN, and that is not the same as "no preview stored".
   The generic grey glyph read as a document that failed to load — on the one
   tile where nothing failed and the blank is exactly what they asked for. This
   state is drawn on purpose: a tick rather than a page, a line saying so, and a
   faint tint so a vault half-forgotten still reads as orderly rather than
   broken. Same 3:4 box as every other tile, for the reason the comment above
   gives — two aspect ratios in one grid is a ragged edge. */
.tile-shot.shot-forgotten { background: var(--chip-bg); }
.tile-forgotten { display: flex; flex-direction: column; gap: var(--s-3);
  align-items: center; justify-content: center; text-align: center; padding: 8px; }
/* Bigger than the other fallbacks and less faded: this tile is the ONLY
   confirmation a reader gets that a deletion they asked for actually happened,
   so the glyph carries the message rather than decorating it. */
.tile-forgotten .icon { width: 40px; height: 40px; opacity: .75; }
.tile-forgotten-label { font-size: var(--t-sm, .74rem); line-height: 1.3;
  color: var(--muted); opacity: .85; max-width: 90%; }
.tile-body { display: flex; flex-direction: column; gap: var(--s-2); padding: 10px 11px 12px; }
/* THE GAP HERE HAS NEVER WORKED, and nobody could see it until the mark it was
   spacing started rendering. `display: flex` and `gap` are declared on the next
   line and then overridden three lines below by `-webkit-box`, which is what
   clamps the title to two lines — and `gap` applies to flex and grid only. So
   the urgency mark sat directly against the first letter of every title.
   It went unnoticed because the mark was 0px wide in this view until the
   `display: inline-block` fix on `.dot`; there was nothing to space.
   The margin is on the mark rather than restored to the parent because the
   parent cannot be flex — the line clamp needs -webkit-box. In the vault list
   the row IS flex and its own gap does the work, so the margin is scoped here
   rather than put on `.dot` globally, where it would double the spacing. */
.tile-title .dot { margin-inline-end: var(--s-4); }
.tile-title { display: flex; align-items: baseline; gap: var(--s-4); font-weight: 600;
  font-size: var(--t-base); line-height: 1.35;
  /* Two lines then ellipsis: a tile that grows with its title breaks the grid. */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  /* AND A WORD LONGER THAN THE TILE BREAKS INSTEAD OF ESCAPING IT.
     The clamp only limits LINES; a single unbreakable word still runs past the
     right edge and is cut mid-letter with no ellipsis to say so. This is not
     an edge case in a German inbox — "Versicherungsbescheinigung" is 26
     characters against a 168px tile, and the sender names this app exists to
     explain are built that way on purpose. */
  overflow-wrap: anywhere;
  /* AND THE URGENT DOT'S RING NEEDS ROOM INSIDE THAT CLIP.
     `.dot.URGENT` draws its outer ring with `outline` + `outline-offset`, which
     reaches 3px beyond the 9px box and is NOT part of it — so `overflow: hidden`
     above cut the left third of the ring off every urgent tile. Measured before
     this: the ring's outer edge sat exactly 3px past the clip edge, and a red
     dot that should read as "solid, ringed" read as a broken crescent on the
     one screen that shows the most documents at once.
     Padding lets it in, because the clip is the PADDING box; the equal negative
     margin puts the text back where it was, so nothing moves. 4 rather than 3
     leaves a pixel of slack for sub-pixel rounding. */
  padding-inline-start: 4px; margin-inline-start: -4px; }
.tile-meta { font-size: var(--t-md); color: var(--muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* When it was added, under the sender. Quieter than the sender on purpose: it
   is the line you scan when the sender did not tell you what you wanted. */
.tile-added { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap;
  font-size: var(--t-sm); color: var(--muted); opacity: .9; }
/* The shelf a result came from, on the flat views that have no headings. A
   chip rather than more grey text, so it does not read as part of the date. */
.tile-group, .doc-group { display: inline-block; max-width: 100%; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
  font-size: var(--t-sm); font-weight: 600; color: var(--ink);
  background: var(--chip-bg); padding: 1px 8px; border-radius: var(--r-pill); }
/* WHICH KIND of grouping a flattened row belongs to.
   A section heading says it without words — the reader's own category carries
   a tag and its tools, a case a chevron and a status chip. The search results,
   the grid tiles and the "Where" column have no heading to sit under, so the
   name arrives bare and a case reads exactly like a category. The icon is the
   section's own, at the size of the text it sits in. */
.doc-kind { display: inline-flex; align-items: center; gap: var(--s-2); }
.doc-kind .icon svg { width: 13px; height: 13px; opacity: .75; }
.doc-kind.case .icon svg { opacity: .9; }
.doc-added { white-space: nowrap; }
/* On the document's own screen: when it was added, with the time. */
.doc-addedline { margin: -4px 0 14px; font-size: var(--t-md); }

/* Details. Wrapped in its own scroller so a narrow screen scrolls the table
   rather than the page. */
.table-scroll { overflow-x: auto; margin-bottom: 10px; }
.doc-table { width: 100%; border-collapse: collapse; font-size: var(--t-lg);
  table-layout: fixed; }
/* Each category is its own table, so the widths have to be declared rather
   than measured: an auto-layout table sizes columns from ITS OWN rows, which
   put five different column grids down one page. */
.doc-table th:nth-child(1), .doc-table td:nth-child(1) { width: 34%; }
.doc-table th:nth-child(2), .doc-table td:nth-child(2) { width: 28%; }
.doc-table th:nth-child(3), .doc-table td:nth-child(3) { width: 14%; }
.doc-table th:nth-child(4), .doc-table td:nth-child(4) { width: 12%; }
.doc-table th:nth-child(5), .doc-table td:nth-child(5) { width: 12%; }
/* SIX COLUMNS when the rows carry their group — a search result, or the vault
   with grouping off, where the heading that used to say it is gone. The widths
   above already total 100%, so without their own set the sixth column would be
   squeezed to nothing and the five before it would keep their old share. */
.doc-table.with-group th:nth-child(1), .doc-table.with-group td:nth-child(1) { width: 28%; }
.doc-table.with-group th:nth-child(2), .doc-table.with-group td:nth-child(2) { width: 22%; }
.doc-table.with-group th:nth-child(3), .doc-table.with-group td:nth-child(3) { width: 12%; }
.doc-table.with-group th:nth-child(4), .doc-table.with-group td:nth-child(4) { width: 16%; }
.doc-table.with-group th:nth-child(5), .doc-table.with-group td:nth-child(5) { width: 11%; }
.doc-table.with-group th:nth-child(6), .doc-table.with-group td:nth-child(6) { width: 11%; }
/* THE WORDS COLUMN, when the reader has written any. The widths above total
   100%, so each shape needs its own set or the new column is squeezed to
   nothing and the ones before it keep their old share — the same reason
   `.with-group` has its own.

   `:not(.with-group)` ON EVERY ONE OF THEM, because a table can carry both
   classes and these rules are positional. Without it, the rule for the
   six-column shape — where column 4 is the words — also matched the
   seven-column one, where column 4 is the SHELF: on a phone that hid the shelf
   along with the words, and a search result lost the one piece of context it is
   built on. Found by measuring the real table at 414px, not by reading it. */
.doc-table.with-tags:not(.with-group) th:nth-child(1), .doc-table.with-tags:not(.with-group) td:nth-child(1) { width: 30%; }
.doc-table.with-tags:not(.with-group) th:nth-child(2), .doc-table.with-tags:not(.with-group) td:nth-child(2) { width: 23%; }
.doc-table.with-tags:not(.with-group) th:nth-child(3), .doc-table.with-tags:not(.with-group) td:nth-child(3) { width: 12%; }
.doc-table.with-tags:not(.with-group) th:nth-child(4), .doc-table.with-tags:not(.with-group) td:nth-child(4) { width: 17%; }
.doc-table.with-tags:not(.with-group) th:nth-child(5), .doc-table.with-tags:not(.with-group) td:nth-child(5) { width: 9%; }
.doc-table.with-tags:not(.with-group) th:nth-child(6), .doc-table.with-tags:not(.with-group) td:nth-child(6) { width: 9%; }
/* SEVEN: the rows carry their shelf AND their words. */
.doc-table.with-group.with-tags th:nth-child(1),
.doc-table.with-group.with-tags td:nth-child(1) { width: 25%; }
.doc-table.with-group.with-tags th:nth-child(2),
.doc-table.with-group.with-tags td:nth-child(2) { width: 19%; }
.doc-table.with-group.with-tags th:nth-child(3),
.doc-table.with-group.with-tags td:nth-child(3) { width: 10%; }
.doc-table.with-group.with-tags th:nth-child(4),
.doc-table.with-group.with-tags td:nth-child(4) { width: 14%; }
.doc-table.with-group.with-tags th:nth-child(5),
.doc-table.with-group.with-tags td:nth-child(5) { width: 14%; }
.doc-table.with-group.with-tags th:nth-child(6),
.doc-table.with-group.with-tags td:nth-child(6) { width: 9%; }
.doc-table.with-group.with-tags th:nth-child(7),
.doc-table.with-group.with-tags td:nth-child(7) { width: 9%; }

/* A PAINTED WORD.
 *
 * Built from one hue per colour rather than three stored shades: a wash of it
 * behind the text, a stronger line around it, and the app's own ink on top. So
 * a painted chip is the same object as an unpainted one — same size, same
 * weight, same corner — wearing a colour, which is what a reader means by
 * "make this one red". Both themes get it for free: the hue changes in the
 * palette, the mixes follow.
 *
 * The percentages are the whole design. The wash is low enough that the text
 * on it keeps the contrast the plain chip has — the ink does not change — and
 * the border is where the colour actually reads at chip size.
 */
.tagchip[data-tagcolour="blue"],   .rowtag[data-tagcolour="blue"],
.swatch[data-tagcolour="blue"]   { --tag-hue: var(--tag-blue); }
.tagchip[data-tagcolour="green"],  .rowtag[data-tagcolour="green"],
.swatch[data-tagcolour="green"]  { --tag-hue: var(--tag-green); }
.tagchip[data-tagcolour="amber"],  .rowtag[data-tagcolour="amber"],
.swatch[data-tagcolour="amber"]  { --tag-hue: var(--tag-amber); }
.tagchip[data-tagcolour="red"],    .rowtag[data-tagcolour="red"],
.swatch[data-tagcolour="red"]    { --tag-hue: var(--tag-red); }
.tagchip[data-tagcolour="purple"], .rowtag[data-tagcolour="purple"],
.swatch[data-tagcolour="purple"] { --tag-hue: var(--tag-purple); }
.tagchip[data-tagcolour="teal"],   .rowtag[data-tagcolour="teal"],
.swatch[data-tagcolour="teal"]   { --tag-hue: var(--tag-teal); }

.tagchip[data-tagcolour]:not([data-tagcolour=""]),
.rowtag[data-tagcolour]:not([data-tagcolour=""]) {
  background: color-mix(in srgb, var(--tag-hue) 14%, var(--chip-bg));
  border-color: color-mix(in srgb, var(--tag-hue) 55%, var(--control-line));
}

/* THE SWATCHES. Round, so they are read as paint rather than as another
   control, and the chosen one is ringed rather than ticked: a tick inside a
   14px circle is a smudge. */
.swatches { display: inline-flex; flex-wrap: wrap; gap: var(--s-3); }
.swatch {
  width: 18px; height: 18px; min-height: 0; padding: 0; flex: 0 0 auto;
  border-radius: var(--r-pill); cursor: pointer;
  border: 1px solid color-mix(in srgb, var(--tag-hue) 55%, var(--control-line));
  background: color-mix(in srgb, var(--tag-hue) 60%, var(--chip-bg));
}
/* "No colour" is the plain chip's own surface, so it looks like what it does. */
.swatch[data-tagcolour=""] { background: var(--chip-bg); }
.swatch.on { outline: 2px solid var(--focus-line); outline-offset: 2px; }
/* BIGGER UNDER A THUMB. 18px is a dot a mouse hits and a finger does not, and
   the six of them sit side by side — the miss lands on the wrong colour rather
   than on nothing, which is the worse kind of miss. Grown only where the
   pointer is coarse, so the row stays a row of dots on a desktop. */
@media (pointer: coarse) {
  .swatch { width: 26px; height: 26px; }
}

/* One word per row: the chip, how many letters wear it, the paint, and the two
   things you can do to it. Wraps rather than scrolls — a row of a settings list
   is not a table. */
.taglist { list-style: none; margin: 0; padding: 0; display: flex;
  flex-direction: column; gap: var(--s-4); }
.tagrow { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-4);
  padding: var(--s-4) 0; border-bottom: 1px solid var(--line); }
.tagrow:last-child { border-bottom: 0; }
/* The count and the paint sit together after the word, and the two buttons go
   to the far end, where every other row in settings puts its controls. */
.tagused { flex: 0 0 auto; }
.tagrow .tagacts { margin-inline-start: auto; display: inline-flex; gap: var(--s-3); }
@media (max-width: 560px) {
  /* On a phone the swatches take the line under the word rather than squeezing
     the count and the buttons off the end of it. */
  .tagrow .swatches { flex-basis: 100%; }
  .tagrow .tagacts { margin-inline-start: 0; }
}

/* NARROW SCREENS DROP THE WORDS COLUMN, and only that one.
   The details view is seven columns wide with a shelf and words in it; on a
   phone the table already scrolls sideways inside its own box, and measured at
   375px the words got 56 of those pixels — an ellipsis where a word should be,
   costing a column of width to say nothing. Everything the view exists for
   (which letter, from whom, when, what is due) stays, and the words are one tap
   away on the document itself. The remaining widths are re-declared rather than
   left to the browser to redistribute: this table is `table-layout: fixed`, and
   the shares above no longer add up once a column is out. */
@media (max-width: 700px) {
  .doc-table.with-tags:not(.with-group) th:nth-child(4),
  .doc-table.with-tags:not(.with-group) td:nth-child(4),
  .doc-table.with-group.with-tags th:nth-child(5),
  .doc-table.with-group.with-tags td:nth-child(5) { display: none; }

  .doc-table.with-tags:not(.with-group) th:nth-child(1), .doc-table.with-tags:not(.with-group) td:nth-child(1) { width: 34%; }
  .doc-table.with-tags:not(.with-group) th:nth-child(2), .doc-table.with-tags:not(.with-group) td:nth-child(2) { width: 28%; }
  .doc-table.with-tags:not(.with-group) th:nth-child(3), .doc-table.with-tags:not(.with-group) td:nth-child(3) { width: 14%; }
  .doc-table.with-tags:not(.with-group) th:nth-child(5), .doc-table.with-tags:not(.with-group) td:nth-child(5) { width: 12%; }
  .doc-table.with-tags:not(.with-group) th:nth-child(6), .doc-table.with-tags:not(.with-group) td:nth-child(6) { width: 12%; }

  .doc-table.with-group.with-tags th:nth-child(1),
  .doc-table.with-group.with-tags td:nth-child(1) { width: 28%; }
  .doc-table.with-group.with-tags th:nth-child(2),
  .doc-table.with-group.with-tags td:nth-child(2) { width: 22%; }
  .doc-table.with-group.with-tags th:nth-child(3),
  .doc-table.with-group.with-tags td:nth-child(3) { width: 12%; }
  .doc-table.with-group.with-tags th:nth-child(4),
  .doc-table.with-group.with-tags td:nth-child(4) { width: 16%; }
  .doc-table.with-group.with-tags th:nth-child(6),
  .doc-table.with-group.with-tags td:nth-child(6) { width: 11%; }
  .doc-table.with-group.with-tags th:nth-child(7),
  .doc-table.with-group.with-tags td:nth-child(7) { width: 11%; }
}

/* The words themselves: quieter and smaller than the chips on the document's
   own screen. There they are controls with an × on each; here they are a fact
   about the row, read at a glance down a column. */
.rowtags { display: flex; flex-wrap: wrap; gap: var(--s-2); min-width: 0; }
.rowtag {
  display: inline-block; max-width: 100%; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--t-sm); color: var(--muted);
  background: var(--chip-bg); border: 1px solid var(--control-line);
  border-radius: var(--r-pill); padding: 1px 8px;
}
/* "+4" is a count, not a word: no border, so it does not read as one more. */
.rowtag.more { border-color: transparent; background: none; padding-inline: 2px; }

/* `break-word`, NOT `anywhere`. They wrap the same, and they differ in the one
   way that matters here: `anywhere` also shrinks the element's intrinsic
   MINIMUM width, so the flexed first cell collapsed to its longest word and
   broke a 50-character title across three lines in a 500px column. */
.doc-table td { overflow-wrap: break-word; }
.doc-table th { text-align: start; font-size: var(--t-sm); text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); font-weight: 700;
  padding: 8px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
.doc-table td { padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; }
/* NOT `display: flex`. Setting display on a <td> replaces `table-cell`, which
   takes the cell out of the table's layout entirely — the column width stops
   applying to it and it sizes itself from its own content, which is why a
   50-character title broke across three lines beside 300px of empty column.
   The dot is aligned inline instead, and the cell stays a cell. */
.doc-table td:first-child > .dot { display: inline-block; vertical-align: middle;
  margin-inline-end: var(--s-4); }
.doc-table a { color: inherit; text-decoration: none; font-weight: 600; }
.doc-tr { cursor: pointer; }
.doc-tr:hover { background: var(--card); }
.doc-tr:focus-visible { outline-offset: -2px; }
.doc-table .overdue { color: var(--urgent-ink); font-weight: 700; }
.doc-table .soon { color: var(--action-ink); font-weight: 700; }


/* The original, centred in its card.
   It is the one thing on the page that is not text, it is the reader's own
   letter, and it is what confirms the explanation below belongs to the document
   in their hand. Left-aligned against a full-width card it read as an
   attachment; centred it reads as the subject. Capped so a landscape scan does
   not stretch to a width at which the print stops being legible. */
.card.orig { text-align: center; }
.card.orig h3 { text-align: start; }
.card.orig img { max-width: min(100%, 560px); max-height: 70vh; width: auto;
  height: auto; margin-inline: auto; border-radius: var(--r-sm); }
/* The 260px cap made sense when the original sat at the foot of the page as a
   reference. Now it is the second thing the reader sees and its job is to let
   them recognise the letter, so it gets room, capped by height rather than
   width, so a landscape scan and a portrait one both fill the same vertical
   space instead of one of them shrinking to a stamp. */
.card.orig .thumbwrap { display: inline-block; max-width: min(100%, 560px); }
.card.orig .thumb { max-width: 100%; max-height: 60vh; width: auto; height: auto; }
.card.orig .pdfnote { text-align: start; }

/* "How Fahim read this", two or three facts the reader can act on, not the
   run record. Key/value pairs rather than a monospace line, because this is
   information about their document, not telemetry. */
.readmeta { display: grid; grid-template-columns: auto 1fr; gap: var(--s-3) 14px;
  align-items: baseline; margin-top: 4px; }
.readmeta .rk { font-size: var(--t-md); color: var(--muted); }
.readmeta .rv { font-weight: 600; }
.readmeta .rv.low-conf { color: var(--action-ink); }

/* "Delete the original scan" is a `<details>` whose summary is a .linkbtn, and
   it was the fourth disclosure in the app and the only other one still
   carrying the platform triangle — a browser-default glyph immediately to the
   left of something drawn to look like a link. `.catwrap` and `.hiddenwrap`
   suppress the marker; this one and the FAQ never did, which is the same fix
   landing on some of a set and not the rest.
   Both properties: `list-style` is what removes it in Firefox and current
   Chrome, the -webkit- pseudo is for older WebKit. */
.forgetblock > summary { list-style: none; }
.forgetblock > summary::-webkit-details-marker { display: none; }

/* The FIFTH disclosure, and the guard found it rather than a person did.
   Settings is a stack of `<details class="setsec">` whose summary is a whole
   designed row — icon chip, title, the value you came to check — with the
   browser's triangle sitting in front of all of it. Nobody had reported it
   because the row looks deliberate enough that the marker reads as part of the
   design until you notice no other row in the app has one.
   This is exactly what test_every_disclosure_hides_the_platform_triangle is
   for: it is driven off the markup, so a disclosure added later fails until it
   is styled like the other four. */
.setsec > summary { list-style: none; }
.setsec > summary::-webkit-details-marker { display: none; }

/* The re-read affordance. Quiet by default, it is an admission that the answer
   may be wrong, and a loud button saying so on every document would undermine
   the ones that are right. */
.rereadrow { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.rereadrow .ghost { font-size: var(--t-base); }
#rereadpanel .modelsel { min-width: 180px; padding: 9px 10px; border-radius: var(--r-sm);
  border: 1px solid var(--control-line); background: var(--card); color: var(--ink); }
.warnline { color: var(--urgent-ink); }

/* A day that carries only recorded facts. Same neutral grey as the rest of the
   context styling, the red dot is reserved for something that was missed, and
   spending it on "passport issued 2024" is how a reader stops trusting it. */
.cdot.ctx { background: var(--muted); opacity: .6; }

/* Whose deadline this is. A vault holds a family's papers, and "ID card expires
   Jan 2028" with no name reads as the reader's own, which is the one misreading
   that makes someone act on another person's date. Quiet, but always present. */
/* The name WRAPS. It was `white-space: nowrap`, which is right for a two-word
   name and wrong for "për Shereen Hassan Shafik Elsayed": 214px of unbreakable
   pill in a 345px row put the deadlines list into a horizontal scroll on a
   360px phone. Truncating with an ellipsis was the other option and is worse
   here — this pill exists to say WHOSE date this is, and half a name is how
   someone acts on a date that belongs to their spouse. */
.owho { display: inline-block; max-width: 100%; margin-inline-start: 8px; padding: 1px 8px;
  border-radius: var(--r-pill); font-size: var(--t-sm); font-weight: 600;
  color: var(--accent-ink); background: var(--card); border: 1px solid var(--line);
  vertical-align: middle; overflow-wrap: anywhere; }

/* Settings rows: a label and its control on one line, stacking on a phone. */
/* ---- SETTINGS IS SEVEN SECTIONS, NOT ELEVEN CARDS IN A COLUMN ----
   The page was a flat stack about 4,000px tall on a phone, in no order anybody
   could name, and the three screens that link here all landed at the top of
   it. Each section is a disclosure now, closed by default and carrying the
   value a reader would have opened it to check — so the closed page answers
   "is it on?" without being opened, and a link can name the section it means.

   The row is the target, and it is a big one: a summary line under a title, at
   full width, which is what makes this usable with a thumb. */
/* The way in from the screen a setting is about. Quiet, at the foot, and the
   same on every screen that has one. */
.setlink { margin: 22px 0 0; text-align: center; }
.setsecs { display: flex; flex-direction: column; gap: var(--s-6); margin-top: 16px; }
/* ROOM FOR THE BAR THAT IS ALREADY THERE. A link into a section scrolls it to
   the top of the viewport — where the sticky app nav is sitting, so the row a
   reader was sent to arrived underneath it and the first thing they saw was
   the card below its title. Measured on a 390px phone: the nav is pinned at
   10px + the safe-area inset and stands about 54px tall, so the section stops
   just clear of it. `section.land` above does the same thing for the same
   reason, in smaller print. */
.setsec { scroll-margin-top: calc(76px + var(--sa-top)); }
.setsec { border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card); box-shadow: var(--shadow-1); overflow: hidden; }
/* The chevron is drawn here, like every other disclosure in this file:
   `list-style` removes the marker in Firefox and current Chrome, the -webkit-
   rule covers older WebKit, and both are needed. */
/* A GRID, because the title is an <h2> and the summary a <span>, and both are
   direct children of the <summary> — the only nesting the spec allows there.
   The icon and the chevron each span both rows; the summary row collapses to
   nothing when there is none, or when the section is open. */
.setsec-head { display: grid; align-items: center;
  grid-template-columns: auto minmax(0, 1fr) auto;
  grid-template-areas: "icon title chev" "icon sum chev";
  column-gap: var(--s-6); row-gap: var(--s-1);
  padding: 14px 16px; cursor: pointer; list-style: none; position: relative; }
/* ONE ROW WHEN THE SECOND ONE IS EMPTY.
   A two-row grid keeps its row-gap between the tracks and splits the icon's
   spare height equally across both — so on a row with no summary (and on any
   open section, where the summary is hidden) the title sat 4.8px above the
   middle of its own row while the icon beside it was dead centre. Measured;
   visible precisely because the icon is there to compare it against. */
.setsec-head:has(> .setsec-sum:empty),
.setsec[open] > .setsec-head { grid-template-areas: "icon title chev"; }
.setsec-head::-webkit-details-marker { display: none; }
.setsec-head::after {
  /* `/ ""` is the ALT TEXT of generated content, and it is empty on purpose.
     Chrome puts a pseudo-element's text into the accessible name, so the row
     announced itself as "General, English · Light · Europe/Berlin, ›" — the
     glyph read out as punctuation at the end of every section. The disclosure
     state is already announced by the element's own role. */
  content: "›" / ""; grid-area: chev; color: var(--muted); font-weight: 700;
  font-size: var(--t-xl); line-height: 1;
  transition: transform var(--dur-1) var(--ease); }
.setsec[open] > .setsec-head::after { transform: rotate(90deg); }
.setsec-head:hover { background: var(--hover-bg); }
/* Inside the card, which clips: an outline at +2px offset would lose its top
   and bottom edge against the section's own rounded corner. Same correction,
   and the same reason, as the day cells on the calendar. */
.setsec-head:focus-visible { outline-offset: -3px; }
.setsec-head .sicon { grid-area: icon; width: 36px; height: 36px; border-radius: var(--r-md);
  display: inline-grid; place-items: center; background: var(--chip-bg); color: var(--accent-ink); }
.setsec-head .sicon .icon { width: 18px; height: 18px; }
/* The heading keeps the row's type size and gives up the browser's block
   margins: it labels a row, and an h2's default margin would push the summary
   out of its own line. */
.setsec-title { grid-area: title; margin: 0;
  font-size: var(--t-xl); font-weight: 700; color: var(--ink); }
/* The value, not a description. One line, ellipsised rather than wrapped: a
   summary that grows to three lines is the stack this replaced. */
/* Up to two lines, then clipped. One line ellipsised lost the END of the
   summary, which is where the answer is — at 320px "Keep forever · Explain
   shared documents · On" was cut before the "On", measured. Two lines fit it
   on the narrowest phone this app supports, and the clamp still stops a long
   value from turning a row into a paragraph. */
.setsec-sum { grid-area: sum; font-size: var(--t-md); color: var(--muted); overflow: hidden;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  line-clamp: 2; }
.setsec-sum:empty { display: none; }
/* AND IT GOES WHEN THE SECTION OPENS. The summary exists to answer "what is
   this set to" without opening anything; with the section open the answer is
   on screen, and the row was repeating the card's own first line sixty pixels
   above it — "Free — 3 of 3 documents used this month", twice. */
.setsec[open] > .setsec-head .setsec-sum { display: none; }
/* A closed <details> hides its non-summary children through the UA stylesheet,
   and an explicit `display` on one overrides that — the bug .catitems records
   two hundred lines up. Stated here for the same reason. */
.setsec:not([open]) > .setsec-body { display: none; }
.setsec-body { padding: 0 16px 16px; }
/* THE DISCLOSURE IS THE ENTRANCE. `.rise` is the animation a screen ARRIVES
   with; inside a section it played every time one was opened — measured, the
   card was at 10% opacity when it appeared and took 550ms to arrive, on a row
   a reader had just tapped to see. Same suppression, and the same reasoning,
   as `html.vt-running .rise`: something else is already doing the reveal. */
.setsec-body .rise { animation: none; }
/* The cards inside have already been announced by the row above them, so they
   drop the border and shadow that made them read as separate objects. */
.setsec-body > .card { border: 0; box-shadow: none; padding: 0; background: none;
  /* …and no corner either. A card keeps its radius when its border is removed,
     so the rule below drew the divider between two cards as the top of a
     rounded box — a stray curve floating between two settings. */
  border-radius: 0; }
.setsec-body > .card + .card { margin-top: var(--s-8); padding-top: var(--s-8);
  border-top: 1px solid var(--line); }
/* Landed on by a link, so the eye knows which of seven it is. */
.setsec.flash { box-shadow: 0 0 0 2px var(--grad-3); transition: box-shadow var(--dur-2) ease; }
@media (max-width: 560px) {
  .setsec-head { padding: 12px var(--s-6); gap: var(--s-5); }
  .setsec-title { font-size: var(--t-lg); }
  .setsec-body { padding: 0 var(--s-6) var(--s-6); }
}

.setrow { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-7); padding: 12px 0; border-bottom: 1px solid var(--line); }
.setrow:last-child { border-bottom: 0; }
.setrow label { font-weight: 600; flex: 1 1 auto; min-width: 0; }
/* A row whose label is read but not shown still puts its control where every
   other row on the page puts one. `justify-content: space-between` with
   nothing on the left slid the select to the left margin, and one select out
   of line in a column of five is the kind of thing you see before you can say
   what it is. */
.setrow > label.vh + * { margin-inline-start: auto; }
.setrow .pwinput { max-width: 220px; }
@media (max-width: 560px) {
  .setrow { flex-direction: column; align-items: stretch; }
  .setrow .pwinput { max-width: none; }
}

/* A schedule field and the sentence that says what it means. The sentence sits
   under the input rather than beside it: it is the same width as the value it
   describes, and reading down from "56, 28, 14, 7" to "8 weeks, 4 weeks…" is
   what makes the numbers legible at all. */
/* A FIXED 220px, matching the selects in the rows above, and `flex: 0 0 auto`
   is what it was — which sizes to content, so the four schedule fields came
   out 220, 220, 218 and 188 wide depending on how long the numbers inside
   them were. Four inputs in one stack with a ragged right edge, each moving as
   the reader types. */
.remfield { display: flex; flex-direction: column; gap: var(--s-3); align-items: stretch;
  width: 220px; max-width: 220px; flex: 0 0 220px; }
.remfield .pwinput { max-width: none; }
.remecho { font-size: var(--t-md); color: var(--muted); line-height: 1.35; }
.remecho:empty { display: none; }
@media (max-width: 560px) { .remfield { width: auto; max-width: none; flex: 1 1 auto; } }

/* The acknowledgement beside the passphrase form. A checkbox on its own line
   with the sentence next to it, because the sentence is the point: this is the
   one action in the app that cannot be undone by anyone, including us. */
.dkack { display: flex; align-items: flex-start; gap: var(--s-5); margin-top: 14px;
  font-size: var(--t-lg); line-height: 1.45; cursor: pointer; }
.dkack .bigcheck { margin-top: 1px; }

/* A checkbox at a size a thumb can hit. The UA default is ~13px, which on a
   phone is a target you miss, and this one governs whether the reader is
   warned about a residence permit at all. */
/* 24, NOT 22, AND EVERY CHECKBOX, NOT JUST THIS ONE.
   22px was under the 24×24 minimum that WCAG 2.5.8 sets for a pointer target,
   by two pixels, and the settings toggles that never got this class were the
   browser's own 20px. Neither is a target a thumb hits reliably, and a phone
   audit found them on Reminders and on Privacy — the two screens where the
   checkbox IS the setting. The radios on Privacy — how much Fahim may look up,
   and how much of your profile it may use — were the same 20px, and those are
   the most consequential choices in the app, so they are here too.
   `:where()` keeps the selector's weight at zero, so anything that wants a
   different size still only needs one class.

   AND IT MAY NOT SHRINK. `width` sets what a box asks for; inside a flex row
   — which is what `.setrow` and `.msetrow` are — the default `flex-shrink: 1`
   lets the browser take it back when the label beside it is long. Measured:
   the Calendar's "Show my spending on this calendar" squeezed its box to
   22.9px at 561, and a longer Finances label did the same at 320, both under
   the minimum this rule exists to hold. The size was right and it was being
   overruled two properties later. */
:where(input[type="checkbox"], input[type="radio"]) {
  width: 24px; height: 24px; flex-shrink: 0; }
.bigcheck { width: 24px; height: 24px; accent-color: var(--grad-3);
  flex: 0 0 auto; cursor: pointer; }
@media (max-width: 560px) { .bigcheck { align-self: flex-start; } }

/* The urgency a reader chose for one date, on its Calendar row. A <select>
   rather than a button cycle: three named choices where the wording IS the
   explanation, and cycling would make the reader click twice to find out what
   the options were. Stops looking like a form until touched. */
/* THIS ONE KEEPS NO ARROW, ON PURPOSE. Its whole design is that it reads as
   part of the sentence until the reader touches it, which is why it has
   `appearance: none` and a transparent background. The global drawn-arrow rule
   further down would give it a chevron and — worse — reserve 38px for that
   chevron inside the control. `background-image: none` here overrides it; the
   rule below restores the compact padding, and both are stated AFTER the
   global rule in the file so they win on order.

   max-width was 148px, and the control's own default option is
   "Warn me 14, 7, 3, 1 days before" — 188px of text. It had been truncating
   its own label to "Warn me 14, 7, 3," since it was written, silently, because
   a <select> clips rather than overflowing. 240px fits it in English with room
   for the longer German and Arabic strings, and the row has the space. */
/* 240px IS A CEILING, NOT A WIDTH, and on a small phone it was neither.
 *
 * The note above raised this cap from 148px to 240px so the English and German
 * labels would stop truncating — measured against the text, which was the right
 * instinct, but against the TEXT only. 240px plus the row's own content is wider
 * than a 320px screen, and a <select> will not shrink below its widest option on
 * its own, so the whole page scrolled sideways.
 *
 * Swept at 320px across all sixteen languages: ELEVEN overflowed here, and this
 * one control was the entire cause of every one of them. English by 4px, German
 * by 9, and Russian by 57. Only the RTL four and Turkish escaped, and only
 * because their strings are shorter — not because anything protected them.
 *
 * min() is the fix in one word: keep 240px where there is room for it, and give
 * way to the column when there is not. `min-width: 0` is what lets it give way
 * at all — a form control's automatic minimum is its content, which is exactly
 * the 240px of label we are trying to cap. The label truncates on a narrow
 * screen, which it already did at every width before the cap was raised, and
 * the full sentence is on the `title` and the `aria-label` either way. */
.ourg { appearance: none; -webkit-appearance: none; border: 1px solid transparent;
  background: transparent; color: var(--muted); font: inherit; font-size: var(--t-sm);
  font-weight: 600; padding: 1px 6px; border-radius: var(--r-pill); cursor: pointer;
  margin-inline-start: 8px; max-width: min(240px, 100%); min-width: 0; }
/* No `outline: none` here. It used to be, and it applied to :focus — which
   includes keyboard focus — so the one control on the Calendar row that a
   keyboard user has to reach was the one that never showed where they were. */
.ourg:hover, .ourg:focus { border-color: var(--line); background: var(--card);
  color: var(--accent-ink); }
.ourg.set { color: var(--accent-ink); border-color: var(--line); background: var(--card); }

/* --- reader corrections -----------------------------------------------
   The model misreads a date or a reference number; the reader fixes it in
   place. Deliberately quiet: a pencil that only gains contrast on hover, so
   the reading stays the thing being read and does not look like a form. */
.editbtn {
  /* THE SAME FLOOR .factx NEEDED, and for the same reason: this is a glyph in
     a button, so its box was whatever the glyph measured, and when the type
     scale became fluid it shrank with it — to 26x22, under the 24px a finger
     needs, at every width. The coarse-pointer block further down grows it to
     44px for touch; this is the floor everywhere else, including a mouse. */
  min-width: 24px; min-height: 24px;
  background: none; border: 0; padding: 4px; margin-inline-start: 6px;
  color: var(--muted); opacity: .45; cursor: pointer; border-radius: var(--r-xs);
  vertical-align: middle; transition: opacity var(--dur-1) ease, color var(--dur-1) ease;
}
.editbtn:hover, .editbtn:focus-visible { opacity: 1; color: var(--ink); background: var(--hover-bg); }
.editbtn .icon svg { width: 15px; height: 15px; }
h3 .editbtn { vertical-align: baseline; }
/* The verdict card's title is --t-2xl, so a pencil on `vertical-align: middle`
   sits noticeably high against it and reads as a superscript rather than as a
   control belonging to the line. Baseline for both, and the icon is nudged up
   in .from because a --t-base line has no room to spare. */
.verdict .title .editbtn { vertical-align: baseline; }
/* Tighter padding here, never a smaller box: a --t-base line has no room to
   spare, but the floor above is what the finger meets and it stays. */
.verdict .from .editbtn { vertical-align: baseline; padding: 2px 4px; }
.verdict .from .editbtn .icon svg { width: 13px; height: 13px; }

/* PRINT: a pencil is a control, and a printed reading has nothing to click.
   The rest of the print sheet already drops its buttons; these are new. */
@media print { .editbtn { display: none; } }

/* Support: does this person have an account?
   It decides what the operator can do before replying — look the reader up,
   check their uploads, see their plan — or none of that, with a reply address
   that is whatever they typed into the form. Coloured because the anonymous
   ones are the ones that go unanswered. */
.sup-reg  { color: var(--ok-ink, var(--ink)); }
.sup-anon { color: var(--muted); border-color: var(--muted); }

/* The open reading needs a way out, and it has to stay reachable: the
   explanation runs several screens, so a close button only at the bottom means
   scrolling through somebody's residence permit to put it away. */
/* The card-level eye sits with the heading, not in the table.
   `margin-inline-start: auto` rather than a float or a fixed position: the
   heading row is already a flex line, and this keeps the control at the end of
   it in both directions — an RTL reader gets it on the left, where the end of
   their line is. */
.spacehead .factsall {
  margin-inline-start: auto;
  flex: 0 0 auto;
}

/* The panel a held upload's reading opens into. It used to be raw output
   dropped under the table — the reading itself, or the words "Opening…", or
   "Could not open it.", each as an unstyled paragraph belonging to nothing.
   A card gives it an edge, so it reads as a thing that opened rather than as
   text that appeared. */
.pendingslot { margin-top: 16px; }
.pendingacts { display: flex; gap: var(--s-5); justify-content: center;
  flex-wrap: wrap; margin: var(--s-3) 0 0; }

.pendinghead {
  position: sticky; top: 0; z-index: 2;
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-6); padding: 10px 0; margin-top: 12px;
  background: var(--card); border-bottom: 1px solid var(--calm-border);
}

/* The heading editor takes the value's place, so it has to look like the thing
   it replaced: same size and weight, full width of the line. `.editinput`
   otherwise pins a single size, which on a --t-2xl title made the input read as
   a caption sitting under nothing. */
.editbox-block { display: flex; width: 100%; margin-inline-start: 0; flex-wrap: wrap; }
.editbox-block .editinput {
  flex: 1 1 260px; min-width: 0;
  font-size: inherit; font-weight: inherit; letter-spacing: inherit;
}
/* A title wraps to two lines on a phone; the buttons must not be pushed off it. */
@media (max-width: 560px) {
  .editbox-block .editinput { flex-basis: 100%; }
}

.editbox { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--s-4);
  margin-inline-start: 6px; vertical-align: middle; }
/* Long-form sections (the explanation) get a block editor: three sentences in
   a single-line input is how a correction becomes a worse sentence. */
.editbox:has(textarea) { display: flex; margin: 8px 0 0; }
.editinput {
  font: inherit; font-size: var(--t-lg); color: var(--ink); background: var(--chip-bg);
  border: 1px solid var(--calm-border); border-radius: var(--r-sm); padding: 7px 10px;
  min-width: 180px;
}
textarea.editinput { width: 100%; min-height: 96px; resize: vertical; line-height: 1.5; }
/* The urgency dropdown sits inside `.utag`, which is a caps-and-tracking badge.
   Inheriting that turned three ordinary translated phrases ("Needs action",
   "Handeln nötig") into spaced-out shouting inside a menu — right for a badge,
   wrong for a control you read to choose from. The badge itself keeps its
   caps; only the editor that replaces it reads as words.

   Specificity is deliberate: `.editbox-block .editinput` above sets
   `letter-spacing: inherit` at (0,2,0), so a bare `select.editselect` at
   (0,1,1) would lose and the menu would keep the badge's tracking. Qualifying
   with `.editbox` takes it to (0,2,1), and covers the beside-the-value case
   where `.editbox-block` is not added at all.

   It is sized by its longest option too, not stretched across the line. Those
   same replace-in-place rules exist for a --t-2xl TITLE, where a full-width box
   is the point; inherited by a three-word badge they produced a 460px dropdown
   standing where a small caps label had been. `.editbox-block .editinput` sets
   `flex: 1 1 260px`, so the same (0,2,1) beats it. */
.editbox select.editselect {
  text-transform: none; letter-spacing: normal;
  flex: 0 0 auto; width: auto; min-width: 0;
}

.editsave, .editcancel { font-size: var(--t-base); padding: 7px 14px; }
.editerr { font-size: var(--t-md); color: var(--urgent-ink); flex-basis: 100%; }

/* Shown INSTEAD of a confidence score, never beside one: a model confidence
   next to a value the model never produced is a small lie. */
.corrected {
  font-size: var(--t-sm); font-weight: 600; letter-spacing: .02em;
  color: var(--accent); background: var(--chip-bg);
  padding: 2px 8px; border-radius: var(--r-pill); margin-inline-start: 8px;
}

/* "You said the deadline is the 20th, shall I fix it?" One button per change,
   each naming what it does. No "apply all": a single button covering several
   fields is how someone approves a change they did not read. */
.fixoffer { margin-top: 10px; }
.fixlist { display: flex; flex-wrap: wrap; gap: var(--s-4); margin-top: 8px; }
.fixbtn { font-size: var(--t-base); padding: 8px 14px; }
.fixbtn.done { opacity: .65; cursor: default; }

/* An offer card that has been answered stops looking like a pending question.
   The heading swaps in JS ("Shall Fahim do this?" -> "Done."); this drops the
   tint with it, so a settled card RECEDES instead of competing with the answer
   below it. Undo puts both back.

   `--line`, not a token invented at the keyboard: the first version of this
   rule said `var(--hairline)`, which does not exist in this file, so the
   border fell back to currentColor and a settled card came out with a heavy
   black outline — louder than the live one it was meant to quieten. */
/* An action and the press that reverses it travel together — see the comment
   in _renderActionOffer for the wrap that made them travel apart. */
.fixpair { display: inline-flex; align-items: center; gap: var(--s-4); }

.actoffer.settled { background: transparent; border-color: var(--line); }
.actoffer.settled > strong { color: var(--muted); font-weight: 600; }

/* --- folders: shelves the reader names themselves ---------------------
 *
 * The first version boxed every folder heading in a permanent dashed border
 * and parked an "Unfiled" chip in the toolbar. It made a shelf of letters look
 * like a form to fill in. Folders now share the vault's own section rhythm
 * the same small uppercase .cat-head as an automatic category, and drop
 * targets are drawn ONLY while something is being dragged.
 */

.folderbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-5); margin: 2px 0 6px; }
/* An icon button, sized and shaped like the view toggles it sits under, so
   the two read as the same kind of control rather than a button and a link. */
.foldnew {
  display: inline-flex; align-items: center; justify-content: center;
  /* 44 square. It was 34×32, which is under the thumb target on the page most
     likely to be used standing at a letterbox — and it is the only control in
     the documents grid that is icon-only. */
  width: 44px; height: 44px; padding: 0; border-radius: var(--r-sm);
  border: 1px solid var(--line); background: none; color: var(--muted);
  cursor: pointer; transition: background var(--dur-1) ease, color var(--dur-1) ease, border-color var(--dur-1) ease;
}
.foldnew .icon svg { width: 17px; height: 17px; }
.foldnew:hover { color: var(--accent-ink); border-color: var(--calm-border); background: var(--hover-bg); }

.foldnew[hidden] { display: none; }
.foldinput {
  font: inherit; font-size: var(--t-base); color: var(--ink); background: var(--chip-bg);
  border: 1px solid var(--control-line); border-radius: var(--r-sm); padding: 7px 11px;
}
/* Sized to the button it replaces, not to the longest folder name someone
   might type. A field that jumps the layout every time you open it reads as a
   glitch. */
.foldnew-input { width: 15rem; max-width: 100%; }
.foldnew-input[hidden] { display: none; }

.folderr { font-size: var(--t-md); color: var(--urgent-ink); }

/* The heading keeps .cat-head's type so folders and categories read as one
   list; the icon and controls are what mark it as a place you can put things. */
.foldhead { align-items: center; }
.foldhead .fico { display: inline-flex; color: var(--accent); opacity: .85; line-height: 0; }
.foldhead .fico svg { width: 15px; height: 15px; }
.foldhead .fname { text-transform: none; font-size: var(--t-base); letter-spacing: .01em; color: var(--ink); font-weight: 700; }
.foldtools { display: inline-flex; gap: var(--s-1); margin-inline-start: auto; }

/* Muted but ALWAYS present. Hover-only controls do not exist on a touch
   screen, which is where most of these readers are. */
.foldbtn {
  background: none; border: 0; padding: 5px; color: var(--muted);
  opacity: .5; cursor: pointer; border-radius: var(--r-xs); line-height: 0;
  transition: opacity var(--dur-1) ease, color var(--dur-1) ease, background var(--dur-1) ease;
}
.foldbtn .icon svg { width: 15px; height: 15px; }
.foldbtn:hover, .foldbtn:focus-visible { opacity: 1; color: var(--ink); background: var(--hover-bg); }

.foldconfirm { display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--s-4); margin-inline-start: auto; }
.foldconfirm-text { text-transform: none; letter-spacing: 0; font-size: var(--t-md); color: var(--muted); }
.foldconfirm .small { font-size: var(--t-md); padding: 6px 12px; }

.foldsec { border-radius: var(--r-md); transition: background var(--dur-1) ease, box-shadow var(--dur-1) ease; }
.foldempty {
  margin: 0 0 4px; padding: 16px; text-align: center; font-size: var(--t-base); color: var(--muted);
  border: 1px dashed var(--line); border-radius: var(--r-md);
}

/* Drop affordances: invisible until a drag starts. */
.unfilestrip {
  display: none; align-items: center; justify-content: center; gap: var(--s-4);
  padding: 12px; margin: 4px 0 10px; font-size: var(--t-base); color: var(--muted);
  border: 1px dashed var(--line); border-radius: var(--r-md);
}
.unfilestrip .icon svg { width: 15px; height: 15px; }
body.dragging-doc .unfilestrip { display: flex; }
body.dragging-doc .foldsec { box-shadow: inset 0 0 0 1px var(--line); }

.foldsec.dropover, .unfilestrip.dropover {
  background: var(--hover-bg);
  box-shadow: inset 0 0 0 2px var(--accent);
  color: var(--ink);
}
.dragging { opacity: .4; }

/* The two pickers in the document toolbar (category, folder).
 *
 * The label used to be <span class="sub">, and .sub is a PARAGRAPH style
 * carrying `margin: 0 0 22px`. Inside this inline-flex row that bottom margin
 * pushed the word up off the select's centre line, the control looked
 * misaligned for a reason that had nothing to do with alignment properties.
 * A label here gets its own class and no margin.
 *
 * Height is pinned to 34px so the picker, the "All documents" link (14px text
 * + 10px padding) and the delete button are one row of equal-height controls
 * rather than three things that happen to be near each other. */
/* THE PICKER LIVES IN THE METADATA BLOCK, not in the toolbar.
   It sat up there beside "All documents" and "Delete this document" — a filing
   control in a row of navigation and destruction, two lines above the other two
   filing controls. The rules that kept it from pushing the delete button onto
   a line of its own went with it; so did the one that hid its caption at 320px
   to buy that row back its width. Both were solving a problem that only existed
   while it was up there. */
.foldpick { display: inline-flex; align-items: center; min-width: 0; }
/* A long category name truncates rather than stretching the row. */
.foldpick select { min-width: 0; max-width: 20rem; text-overflow: ellipsis; }
/* 34px, WHICH IS WHAT THIS PICKER HAS ALWAYS BEEN. The base field rule sets a
   44px minimum — a touch target for a form field, aimed at once in a column of
   others its size. This one sits in a line of 34px chips and words; at 44 it
   was the tallest thing in a row of metadata and pushed everything beside it
   onto lines of its own. */
.foldpick select, .taginput { min-height: 0; height: 34px; padding-block: 0; }
/* WITH THE PICKER GONE the wrapper holds a spacer and the delete button, and
   `display: contents` is what lets the spacer still push that button to the far
   end of the row it does not itself lay out. Below 760px it becomes a real flex
   item so the button travels with the back-link rather than taking a line of
   its own. */
.controls .rowend { display: contents; }
@media (max-width: 760px) {
  .controls .rowend {
    display: flex; align-items: center; gap: var(--s-5);
    flex: 1 1 auto; min-width: 0; justify-content: flex-end;
  }
}
.controls .navlink { flex: 0 0 auto; }
.controls .danger { flex: 0 0 auto; }
.picklabel {
  margin: 0; line-height: 1; white-space: nowrap;
  font-size: var(--t-sm); letter-spacing: .04em; text-transform: uppercase;
  color: var(--muted);
}
.foldpick select {
  font: inherit; font-size: var(--t-base); line-height: 1; color: var(--ink);
  background: var(--chip-bg); border: 1px solid var(--control-line);
  border-radius: var(--r-sm); padding: 0 30px 0 11px; height: 34px; margin: 0;
  /* The native arrow sits at a different inset per platform and made the two
     pickers look unequal on macOS. One drawn arrow, one position. */
  appearance: none; -webkit-appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 16px) calc(50% + 1px), calc(100% - 11px) calc(50% + 1px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  cursor: pointer;
}
.foldpick select:hover { border-color: var(--calm-border); }

/* Arabic and the other RTL languages mirror the whole row, so the arrow has to
   move to the other side with it. */
[dir="rtl"] .foldpick select {
  padding: 0 11px 0 30px;
  /* 45deg half first, at the SMALLER offset, see the note on the shared rule
     near the end of this file. This was inside-out here too, and had been since
     the category picker was written; nobody had opened it in Arabic. */
  background-position: 11px calc(50% + 1px), 16px calc(50% + 1px);
}

/* The tag filter and its rename pencil travel together, so the button cannot
   wrap onto a line of its own away from the word it renames. */
.tagfilter { display: inline-flex; align-items: center; gap: var(--s-3); min-width: 0; }
.tagfilter select { min-width: 0; }
/* The filter row's own selects are the app's fields; only the pencil beside one
   of them is new, and it is the same icon button a category header carries. */
.tagfilter .foldbtn { flex: 0 0 auto; }

/* WHAT A DOCUMENT IS IN, AND WHAT IT IS CALLED.
 *
 * One block under the "Added …" line and above the reading, holding the
 * category boxes and the words. It sets ONE gap to whatever follows: these were
 * two rows each with a top margin and no bottom, and the next card came to rest
 * against the chips — a red "some of this could not be read" panel touching the
 * word "passport".
 *
 * A two-column grid rather than two flex rows, so the labels line up: they are
 * short in English and not in German, and the column takes the width of the
 * widest one rather than a number guessed from one language. `align-items:
 * center` on each row keeps a one-line label centred against chips that wrap
 * onto three.
 */
/* ONE LINE, NOT THREE.
 *
 * This was a two-column grid, a row per control, and on a wide screen it spent
 * three rows and most of a screen's width saying very little: the reader came
 * to this page to read a letter and had to scroll past their own filing to
 * reach it. Now the groups flow along one line and wrap only when they run out
 * of room, so the empty half of the row is doing the work.
 *
 * Each caption travels with its controls (`.metagroup`), or a wrap orphans a
 * label at the end of a line above the things it names.
 */
.docmeta {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--s-4) var(--s-6);
  margin: 0 0 16px;
}
.metagroup { display: flex; align-items: center; gap: var(--s-4); min-width: 0; }
.metalabel { flex: 0 0 auto; }
.metarow { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-3) var(--s-4); min-width: 0; }
/* PHONES STACK, caption above its controls: side by side, the labels eat 60px
   of a 320px screen and the chips get what is left. */
@media (max-width: 560px) {
  .docmeta { flex-direction: column; align-items: stretch; gap: var(--s-5); }
  .metagroup { flex-direction: column; align-items: flex-start; gap: var(--s-3); }
}

/* Both controls are QUIET. They are metadata beside a letter, not a form: at
   full chip weight they out-shouted the reading they sit above. */
/* THE SAME SHAPE AS A TAG, because they sit in the same block and a reader
   should not have to learn two visual languages one line apart. A bare
   checkbox in a row of pills reads as a stray form control — in dark mode a
   blank grey square beside words that look like objects.

   The box itself STAYS. It is what says "several of these can be on at once",
   which a pill that merely darkens does not, and it keeps the keyboard and
   screen-reader behaviour the browser already gets right. */
.alsochk {
  display: inline-flex; align-items: center; gap: var(--s-3);
  font-size: var(--t-base); color: var(--ink);
  background: var(--chip-bg); border: 1px solid var(--control-line);
  border-radius: var(--r-pill); padding: 0 12px 0 9px; cursor: pointer;
  /* The name is the reader's own and can be a sentence. Truncate rather than
     let one category stretch the row past the column. */
  max-width: 18rem; min-width: 0; height: 34px;
}
.alsochk > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.alsochk:hover { border-color: var(--calm-border); }
/* Ticked reads at a glance, without the reader inspecting a 15px box: the pill
   the word sits in carries the state as well. */
.alsochk:has(input:checked) { border-color: var(--accent-ink); color: var(--accent-ink); }
/* The focus ring lands on the PILL, because the box inside it is 15px and a
   ring drawn on that is invisible next to the word it belongs to. */
.alsochk:has(input:focus-visible) {
  outline: 3px solid var(--focus-line); outline-offset: 2px;
}
/* AND SOMEWHERE TO LOOK IN A BROWSER WITHOUT `:has()`. This file leans on it
   nineteen times and says elsewhere that a browser without it falls back to a
   simpler layout — a fair trade for layout, not for a focus ring, which is the
   only thing telling a keyboard user where they are. Drawn on the 15px box
   instead of the pill there: smaller, and present. */
@supports not selector(:has(*)) {
  .alsochk input:focus-visible { outline: 2px solid var(--focus-line); outline-offset: 1px; }
}
.alsochk input {
  margin: 0; cursor: pointer; width: 15px; height: 15px; flex: 0 0 auto;
  /* Native, tinted. A hand-drawn checkbox is one more control to keep correct
     in two themes, two directions and every browser; this is the same control
     the platform already draws, wearing the app's colour. */
  accent-color: var(--accent-ink);
}
/* An EMPTY box is quieter than a ticked one. Unchecked, the platform draws a
   pale square that in dark mode is the brightest thing in the block — louder
   than the word beside it and louder than the categories that are actually
   on. */
.alsochk:not(:has(input:checked)) input { opacity: .5; }
/* Mid-request. The box is disabled for the round trip, and a control that
   looks identical while it cannot be pressed reads as a dead one. */
.alsochk:has(input:disabled) { opacity: .55; cursor: default; }
.alsoerr { font-size: var(--t-md); color: var(--urgent-ink); }

.tagchip {
  display: inline-flex; align-items: center; gap: var(--s-2);
  font-size: var(--t-base); color: var(--ink);
  background: var(--chip-bg); border: 1px solid var(--control-line);
  /* SYMMETRIC BY DEFAULT, and narrower on one side only when there is a × to
     make room for. The end padding was 4px on every chip so the × would sit
     tight to the edge — but the settings list draws chips with no × on them,
     and there the word sat 12px from one edge and 5px from the other, reading
     as text that missed its box.
     `padding-inline`, not left/right: it flips with the language on its own,
     which is a whole RTL override this rule used to need. */
  border-radius: var(--r-pill); padding-inline: 11px; height: 34px;
  max-width: 18rem; min-width: 0;
}
.tagchip:has(.tagx) { padding-inline-end: 4px; }
.tagchip .tagname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The × is a 22px target in a 28px chip: smaller reads as decoration and is
   unhittable with a thumb, larger and the chip stops looking like a word. */
.tagchip .tagx {
  border: 0; background: none; color: var(--muted); cursor: pointer;
  font-size: var(--t-base); line-height: 1; padding: 0; width: 24px; height: 24px;
  border-radius: var(--r-pill); flex: 0 0 auto;
}
.tagchip .tagx:hover { color: var(--ink); background: var(--control-line); }
.tagchip .tagx:disabled { opacity: .5; cursor: default; }
/* THE SAME FIELD AS THE CATEGORY PICKER FOUR LINES ABOVE IT.
 *
 * This was 32px, dashed and transparent — sized to the chips beside it rather
 * than to the control it is a sibling of. On a document with no words yet the
 * chips are not there at all, so the box stood alone under a 44px solid picker
 * looking like a smaller, faded copy of it: two fields, one screen, four
 * differences (height, border style, background, type size) and no reason for
 * any of them.
 *
 * So it takes the app's field primitive — 44px, solid, recessed — and sets only
 * what makes it THIS field: a width that holds one word, and the picker's
 * corner radius rather than the default, because the two are read together.
 * The placeholder is what says it takes a word; the dashes were saying it in a
 * way that only read as "unfinished".
 */
.taginput {
  font-size: var(--t-base); border-radius: var(--r-sm); padding-inline: 11px;
  /* The picker's surface, not the default field's. `.foldpick select` opts out
     of the recessed field colours for exactly this row, and a box beside it
     wearing the other pair is the difference a reader sees without being able
     to name it. */
  background: var(--chip-bg); border-color: var(--control-line);
  /* Narrow on purpose. It holds one word, and a full-width box invites a
     sentence — which is a note in the wrong control. */
  width: 11rem; max-width: 100%; min-width: 0;
}
.taginput:disabled { opacity: .55; }

/* A hint, not a target: nothing can be dropped here, so it must not light up
   like the real drop zones do. */
.unfilestrip.hintonly { border-style: dashed; opacity: .8; }
/* A folder heading is draggable to reorder the shelves. The grab cursor is
   the only thing that says so, so it is not optional. */
.foldhead { cursor: grab; }
.foldhead:active { cursor: grabbing; }
/* While reordering, only the OTHER headings are meaningful targets, light
   them rather than the whole section, which would suggest dropping a letter. */
body.dragging-folder .foldsec.dropover { box-shadow: inset 0 0 0 2px var(--accent); }
body.dragging-folder .unfilestrip { display: none; }

/* --- folder toolbar: create, confirm, reset ---------------------------- */

/* The field and its confirm are one control. */
.foldnew-wrap { display: inline-flex; align-items: center; gap: var(--s-3); }
.foldnew-wrap[hidden] { display: none; }

/* Grows out of the button it replaced rather than appearing on top of it.
   The class is added on the frame AFTER unhiding, a transition on an element
   that is still display:none never runs at all. */
.foldnew-wrap .foldnew-input {
  width: 0; opacity: 0; padding-inline: 0; border-color: transparent;
  transition: width var(--dur-2) cubic-bezier(.2, .8, .3, 1), opacity var(--dur-2) ease,
              padding var(--dur-2) cubic-bezier(.2, .8, .3, 1), border-color var(--dur-2) ease;
}
.foldnew-wrap.open .foldnew-input {
  width: 15rem; max-width: 60vw; opacity: 1; padding-inline: 11px;
  border-color: var(--line);
}
.foldnew-wrap .foldok { transform: scale(.7); opacity: 0; transition: transform var(--dur-2) cubic-bezier(.2,.8,.3,1) var(--dur-1), opacity var(--dur-1) ease var(--dur-1); }
.foldnew-wrap.open .foldok { transform: scale(1); opacity: 1; }

.foldok {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; padding: 0; border: 0; border-radius: var(--r-sm);
  background: var(--accent); color: var(--on-accent); cursor: pointer;
}
.foldok .icon svg { width: 17px; height: 17px; }
.foldok:hover { filter: brightness(1.08); }
/* The discard, beside the confirm and quieter than it: same size so the pair
   reads as one control, but the accent belongs to the action that creates
   something, not to the one that throws a word away. */
.foldcancel { background: var(--chip-bg); color: var(--muted); border: 1px solid var(--line); }
.foldcancel:hover { color: var(--ink); background: var(--hover-bg); filter: none; }


.foldreset { color: var(--muted); }
.foldreset:hover { color: var(--accent-ink); }

/* Sections arriving after a folder change: a short rise, staggered just
   enough to read as "these moved" rather than "the page reloaded". */
@keyframes foldrise {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
.foldsec { animation: foldrise .26s cubic-bezier(.2, .8, .3, 1) both; }
.foldsec:nth-of-type(2) { animation-delay: .03s; }
.foldsec:nth-of-type(3) { animation-delay: .06s; }
.foldsec:nth-of-type(4) { animation-delay: .09s; }

/* A dropped card should look like it landed, not like the page changed. */
@keyframes dropland { 0% { transform: scale(.97); } 60% { transform: scale(1.012); } 100% { transform: none; } }
.foldsec .doc-tile, .foldsec .card.doc { animation: dropland .3s cubic-bezier(.2, .8, .3, 1) both; }

/* Motion is decoration here; the vault must work identically without it.
   Someone who has asked their system for less movement, often because it
   makes them ill, gets none. */
@media (prefers-reduced-motion: reduce) {
  .foldnew-wrap .foldnew-input,
  .foldnew-wrap .foldok { transition: none; }
  .foldsec, .foldsec .doc-tile, .foldsec .card.doc { animation: none; }
}
/* A renewal is not a due date. It reads as something to START, not to meet, so
   it gets its own marker rather than borrowing the hard-deadline one, a permit
   expiry and a payment deadline demand different behaviour on different
   timescales. */
.orenew {
  font-size: var(--t-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--accent-ink); background: var(--chip-bg);
  padding: 2px 7px; border-radius: var(--r-xs); margin-inline-start: 8px; white-space: nowrap;
}


/* --- support: viewing someone else's account ---------------------------
   Loud on purpose. An operator inside a reader's vault sees a screen otherwise
   indistinguishable from their own, and the likeliest support mistake is acting
   on the wrong person's letter believing it is yours. Fixed to the top so it
   survives scrolling, and carrying the way out so leaving never means hunting
   for the admin page again. */
/* The bar keeps its own fill behind the status bar — a strip of page showing
   above a red warning band would read as a rendering fault — and pads its text
   down clear of the clock. */
#impbar { position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
  display: flex; align-items: center; justify-content: center; gap: var(--s-7);
  flex-wrap: wrap; padding: calc(9px + var(--sa-top)) 16px 9px;
  font-size: var(--t-base); font-weight: 600;
  color: var(--on-accent); background: var(--urgent); box-shadow: 0 2px 10px rgba(0,0,0,.28); }
#impbar .linkbtn { color: var(--on-accent); text-decoration: underline; font-weight: 700; }
/* Push the app down rather than letting the bar cover the header. The bar is
   now taller by the inset, so this grows with it — and .wrap gives its own
   inset back, because the bar is already holding that space and counting it
   twice would leave a band of empty page under a bar that is not covering
   anything. */
body.has-impbar { padding-top: calc(42px + var(--sa-top)); }
body.has-impbar .wrap { padding-block-start: 18px; }
@media (max-width: 560px) { body.has-impbar { padding-top: calc(62px + var(--sa-top)); } }
/* THE RAIL NEEDS THE SAME OFFSET, and does not get it from the padding above.
   `.sidebar` is position: fixed with `inset-block: 0`, so it starts at the top
   of the VIEWPORT no matter what the body is padded by — the bar covered its
   first row, and "Home" was not merely hidden but unclickable for the whole
   support window (elementFromPoint returned the bar). Same figure as the body
   padding, so the two move together if either is ever retuned. */
body.has-impbar .sidebar { inset-block-start: calc(42px + var(--sa-top)); }

/* Everything in the reader's vault, while an operator is inside it. Greyed and
   unclickable, because a live-looking toggle that silently var(--dur-3) is
   indistinguishable from a guard that does not work, and language and theme are
   not server-backed at all, so clicking those would change the OPERATOR's
   browser rather than doing nothing. */
body.viewing-as #view input:disabled,
body.viewing-as #view select:disabled,
body.viewing-as #view textarea:disabled,
body.viewing-as #view button:disabled {
  opacity: .5; cursor: not-allowed; filter: grayscale(.4);
}
body.viewing-as #view label { cursor: not-allowed; }

/* The operator screen's command block. */
pre.cmd { background: var(--card); border: 1px solid var(--line); border-radius: var(--r-sm);
  padding: 10px 12px; font-size: var(--t-md); overflow-x: auto; white-space: pre; }
table .num { text-align: end; font-variant-numeric: tabular-nums; }


/* --- the admin portal ---------------------------------------------------
   Its own small vocabulary rather than reusing the reader-facing cards: this
   page is a dashboard read at a glance by one person, not a document read
   carefully by someone anxious about a deadline. It shares the SYSTEM with the
   rest of the product — the ground, the surfaces, the tokens, the type — and
   differs only where the job differs. */
.adminhead { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-7); flex-wrap: wrap; margin: 4px 0 14px; }
.adminhead h2 { margin: 0; font-size: var(--t-2xl); letter-spacing: -.01em;
  display: inline-flex; align-items: center; gap: var(--s-5); }
/* Who is signed in, and the two ways out of here. The operator's own address
   used to be the only thing in this corner, which meant the portal had no
   route back to the vault and no way to reload one screen without reloading
   the document. */
.adminwho { display: flex; align-items: center; gap: var(--s-5); flex-wrap: wrap; }
.adminwho .ghost { padding: 6px 12px; font-size: var(--t-md); }

/* ---- the tab bar --------------------------------------------------------
   Six screens instead of one column three and a half thousand pixels long.
   The accounts table — the thing this page is actually opened to use — was at
   the bottom of it, under the business figures, the money, twelve statistics,
   the reader messages and the anonymous uploads. */
.admintabs { display: flex; gap: var(--s-2); flex-wrap: wrap;
  border-bottom: 1px solid var(--line); margin-bottom: 18px;
  /* The bar follows the screen down. These tables are long by nature and the
     way back to another section was a scroll to the top. */
  position: sticky; top: 0; z-index: 5;
  background: var(--bg); padding-top: var(--s-3); }
@media (max-width: 720px) {
  /* One row that scrolls, rather than three that wrap. Six tabs wrapped to
     three rows on a phone, and because the bar is sticky those three rows
     then followed the operator down the page taking a quarter of it. */
  .admintabs { flex-wrap: nowrap; overflow-x: auto;
    scrollbar-width: none; -webkit-overflow-scrolling: touch; }
  .admintabs::-webkit-scrollbar { display: none; }
  .admintab { flex: none; }
  /* THE RING IS DRAWN INSIDE. `overflow-x: auto` makes this a scroll
     container, and a scroll container clips on BOTH axes — so the global
     focus ring, which sits 2px outside the element, was cut off on the
     first and last tab and along the bottom of every one of them. Same
     treatment `.cday` and `.doc-tr` already use for the same reason. */
  .admintab:focus-visible { outline-offset: -3px; }
}
.admintab {
  display: inline-flex; align-items: center; gap: var(--s-4);
  padding: 9px 14px; border-radius: var(--r-sm) var(--r-sm) 0 0;
  font-size: var(--t-base); font-weight: 600; color: var(--muted);
  text-decoration: none; white-space: nowrap;
  border: 1px solid transparent; border-bottom: 0;
  /* The underline is drawn on this element rather than by moving the bar's
     own border, so a tab changing state cannot shift the row by a pixel. */
  box-shadow: inset 0 -2px 0 0 transparent;
  transition: color var(--dur-1) ease, background var(--dur-1) ease,
              box-shadow var(--dur-1) ease; }
.admintab:hover { color: var(--ink); background: var(--hover-bg); }
.admintab.on { color: var(--accent-ink); background: var(--card);
  border-color: var(--line); box-shadow: inset 0 -2px 0 0 var(--accent); }
.atabicon { display: inline-grid; place-items: center; }
.atabicon .icon { width: 15px; height: 15px; }
/* A count that means "there is work here". Only drawn when it is not zero: a
   grey 0 on every tab is five pieces of furniture saying nothing. */
.atabbadge { display: inline-grid; place-items: center; min-width: 19px;
  height: 19px; padding: 0 6px; border-radius: var(--r-pill);
  background: var(--accent); color: var(--on-accent);
  font-size: var(--t-xs); font-weight: 700; font-variant-numeric: tabular-nums; }

/* ---- a card's own header row ----
   Title on the left, its controls on the right, on one baseline. The money
   card used to put four period buttons and two links into `.controls` rows
   that wrapped into each other at anything under a full desktop width. */
.cardhead { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-6); flex-wrap: wrap; margin-bottom: var(--s-6); }
.cardhead h3 { margin: 0; }

/* The period and filter choosers are `.segbar` + `.seg` + `.seg.active`, the
   segmented control the reader-facing Money view already owns — not a second
   one under a new name. They were bare link-buttons in a row here, which is
   the same visual weight as the "Download CSV" link sitting beside them, so
   nothing said the four were one control with one of them chosen.

   Its rules are up at `.segbar`, near the money controls it was written for.
   The FIRST draft of this block redefined `.segbar` here with a different
   padding and gap, which is two rules for one component with the later one
   silently winning — test_design_system caught it, which is what it is for. */

/* ---- the period, above everything it governs ----
   The four range buttons used to sit inside the Money card and moved only the
   Money card, so setting both dates to today changed the revenue and left the
   account and document counts at all-time beside it. The control is now its
   own card, and every band under it repeats the window it is showing. */
.periodcard { margin-top: 16px; }
.periodnote { margin: var(--s-6) 0 0; }
/* ---- whose figures, on the same card as which dates ----
   The second scope control. It began inside the Money card's header, which
   made it look like it governed that one card while it visibly changed the
   four bands below — the same mistake the range buttons made, one card later.
   Both controls scope the whole screen, so both live here.

   Its own row rather than crowded beside the range buttons: on a narrow
   screen two segbars on one line wrap into each other and read as one control
   with seven options. The label is what keeps them apart when they do stack. */
.scoperow {
  display: flex; align-items: center; gap: var(--s-8);
  flex-wrap: wrap; margin-top: var(--s-8);
  /* Aligned to the right edge so it sits directly under the range buttons.
     The two are one pair — both scope every card below — and left-aligning
     this one put a stray control adrift under the heading instead. */
  justify-content: flex-end;
}
.scopelabel { flex: 0 0 auto; }
/* Checkboxes rather than a segmented control, because the three audiences are
   independent rather than exclusive — any one, any pair, or all three. Styled
   as pills so the row still reads as one control, with the native input kept
   (not hidden) so it stays keyboard-reachable and announces its own state. */
.checkbar { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.chk {
  display: inline-flex; align-items: center; gap: var(--s-3);
  padding: var(--s-3) var(--s-5); border-radius: var(--r-pill);
  background: var(--chip-bg); color: var(--muted);
  cursor: pointer; user-select: none; font-size: var(--t-md); font-weight: 600;
}
.chk.on { background: var(--accent); color: #fff; }
.chk input { accent-color: var(--accent); margin: 0; }
.chk.on input { accent-color: #fff; }
/* Below the card's own breakpoint the head stacks and its segbar goes left;
   this follows it, or the pair comes apart exactly where space is tightest. */
@media (max-width: 720px) {
  .scoperow { justify-content: flex-start; }
}
/* The window itself, beside the word "Period" — so the heading says which
   dates rather than just promising that dates exist. */
.periodnow { font-weight: 600; font-size: var(--t-md); color: var(--accent-ink);
  background: var(--calm-bg); border: 1px solid var(--calm-border);
  padding: 2px 9px; border-radius: var(--r-pill); margin-inline-start: var(--s-5);
  white-space: nowrap; }
/* A card whose header is only a title. `.cardhead` is a flex row built for a
   title plus controls, and with nothing on the right it stretches the title. */
.cardonlyhead { margin: 0 0 var(--s-6); display: flex; align-items: center;
  flex-wrap: wrap; gap: var(--s-3); }
/* The period repeated on each band of figures, quietly. Pushed to the far end
   so it sits with the hairline rather than crowding the band's name. */
.agperiod { flex: none; font-size: var(--t-sm); color: var(--muted);
  font-weight: 600; white-space: nowrap; order: 3; margin-inline-start: var(--s-5); }
/* `.astat-group::after` is the hairline and must stay BETWEEN the name and the
   period rather than after both. */
.astat-group::after { order: 2; }
/* What a small table is counting, when it is not the same window as the band
   above it — an all-account breakdown sitting beside a windowed one needs to
   say which it is, or the two read as the same question. */
.tblnote { margin: -4px 0 8px; font-size: var(--t-sm); }

/* The money card's second row: a date range on the left, the two things that
   leave the page on the right. */
.moneyrow { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-6); flex-wrap: wrap; margin-bottom: var(--s-7); }
/* The range and its Show button are ONE control and wrap as one. Left to size
   itself the group took only the width of the two date fields, so "Show" fell
   to a line of its own with the button it belongs to sitting above it. */
.dater { display: inline-flex; align-items: center; gap: var(--s-5);
  flex-wrap: wrap; flex: 1 1 30em; }
.dater .pwinput { max-width: 11.5em; }
.moneyacts { display: inline-flex; align-items: center; gap: var(--s-5); flex-wrap: wrap; }
.moneyacts .ghost { padding: 6px 12px; font-size: var(--t-md); }

/* ---- search ----
   A field with the magnifier inside it rather than a bare box captioned
   "search by email", which was also the only hint that an account id or a
   Paddle reference works here too. */
.searchrow { display: flex; gap: var(--s-5); align-items: center;
  flex-wrap: wrap; margin-bottom: var(--s-7); }
.searchbox { position: relative; flex: 1 1 22em; min-width: 0; display: block; }
.searchbox .pwinput { width: 100%; padding-inline-start: 36px; }
.searchicon { position: absolute; inset-inline-start: 11px; top: 50%;
  transform: translateY(-50%); display: grid; place-items: center;
  color: var(--muted); pointer-events: none; }
.searchicon .icon { width: 16px; height: 16px; }

/* ---- the pager, on every list ----
   Position first, total second. The old footer counted the rows in front of
   it — "Showing 25 of 25" on page three of three hundred. */
.pagerline { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-6); flex-wrap: wrap; margin: var(--s-7) 0 0;
  font-size: var(--t-base); color: var(--muted); }
.pagerbtns { display: inline-flex; gap: var(--s-4); }
.pagerbtns .ghost { padding: 6px 12px; font-size: var(--t-md); }
.pagerbtns .ghost[disabled] { opacity: .4; cursor: default; }
.filternote { margin: var(--s-6) 0 0; }

/* ---- nothing here ----
   Every list on this screen used to render "" when it was empty, so an
   operator with no messages waiting saw no card at all — which reads as a
   broken feature rather than as an empty queue. */
.aempty { display: flex; flex-direction: column; align-items: center;
  text-align: center; gap: var(--s-3); padding: 30px 16px; }
.aemptyicon { display: grid; place-items: center; width: 40px; height: 40px;
  border-radius: var(--r-pill); color: var(--muted); background: var(--chip-bg); }
.aemptyicon .icon { width: 19px; height: 19px; }
/* "Could not load this" is not the same state as "there is nothing here", so
   it does not get the same grey circle. An operator scanning past a quiet
   queue must not scan past an unreachable one. */
.aemptyicon.warn { color: var(--warn-ink); background: var(--warn-bg);
  border: 1px solid var(--warn-border); }
.aemptytitle { margin: 4px 0 0; font-weight: 600; }
.aempty .sub { margin: 0; max-width: 46ch; }

.aloading { display: flex; align-items: center; justify-content: center;
  gap: var(--s-5); padding: 30px 16px; }
.aspin { width: 15px; height: 15px; border-radius: 50%; flex: none;
  border: 2px solid var(--line); border-top-color: var(--accent);
  animation: aspin 700ms linear infinite; }
@keyframes aspin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .aspin { animation-duration: 2.4s; } }

/* ---- the receipt ----
   The operator's actions answer with the SAME toast the reader's side uses —
   `.toast` + a `<span>`, defined once, far below. It is not a second component
   under an admin name.

   The first draft here WAS a second component, and it collided: a `.toast` of
   its own, positioned bottom-centre with `inset-inline-start: 50%` and a
   translate. The existing rule comes later in the file and sets
   `inset-inline: 0` on a flex container, so the later one won for the shared
   properties and the earlier one's translate survived — which is why the box
   landed off to one side instead of centred. Exactly the `.stat`/`.astat`
   collision this stylesheet already documents, in the other direction: there
   the fix was a distinct name, here the fix is to stop duplicating a component
   that already existed.

   `.toast-ok` below is the only addition: the reader's side is failures-only by
   design (a changed value is its own receipt), while a grant on this screen has
   no such visible receipt. */

/* A label over each band of figures. Twelve numbers used to arrive as three
   identical unlabelled grids, so finding one meant reading all of them. Same
   small-caps eyebrow the reader-facing screens use for a group of rows, so the
   two halves of the product agree about what a group heading looks like. */
/* The band heading, with the same icon chip the reader-facing space headers
   use, so the two halves of the product agree about what a section looks
   like. The hairline carries the eye across to the tiles it labels. */
.astat-group {
  display: flex; align-items: center; gap: var(--s-5);
  margin: 26px 0 10px;
}
.astat-group:first-of-type { margin-top: 6px; }
.astat-group::after {
  content: ""; flex: 1; height: 1px; background: var(--line);
}
.agicon {
  display: inline-grid; place-items: center; flex: none;
  width: 22px; height: 22px; border-radius: var(--r-xs);
  color: var(--accent-ink);
  background: rgb(var(--accent-rgb) / .08);
  border: 1px solid rgb(var(--accent-rgb) / .18);
}
.agicon .icon { width: 13px; height: 13px; }

/* "Admin portal" set as a chip beside the mark, rather than "— support" run
   into the wordmark. It names what the screen is instead of describing who it
   is for, and it stops the header reading as a sentence about the product. */
.adminmark {
  font-size: var(--t-sm); font-weight: 700; text-transform: uppercase;
  letter-spacing: .07em; color: var(--accent-ink);
  background: var(--calm-bg); border: 1px solid var(--calm-border);
  padding: 3px 10px; border-radius: var(--r-pill); vertical-align: middle;
}

/* CELLS NEED HORIZONTAL PADDING. The base `td` rule is `padding: 7px 0` — no
   inline padding at all, which is right for the reader's two-column key/value
   table where `td.k` adds its own, and wrong for every data table on this
   screen. Measured: the accounts table put the document count hard against the
   joined date and rendered "9" and "2026-07-31" as "92026-07-31".
   Scoped to this page's tables rather than changed on the base rule, because
   the base rule is load-bearing for the document view. */
body.admin table td, body.admin table th { padding-inline-end: var(--s-7); }
body.admin table td:last-child, body.admin table th:last-child { padding-inline-end: 0; }
/* A bare <th> is CENTRED by the browser, and every cell under it is start
   aligned — so "Joined" sat 108px to the right of the dates beneath it and the
   table read as though its columns had come apart. Nothing in this stylesheet
   styled a plain `th`; `.rtable th` and `.doc-table th` both do, which is why
   the reader-facing tables looked right and this one did not. Same treatment
   as those two, so all three agree. */
body.admin table th {
  text-align: start; font-size: var(--t-xs); text-transform: uppercase;
  letter-spacing: .05em; color: var(--muted); font-weight: 700;
  padding-block-end: var(--s-4); border-bottom: 1px solid var(--line);
}
/* …except the numeric ones, which stay over their digits. */
body.admin table th.num { text-align: end; }

/* The encryption marker in the accounts table. It used to borrow `.ohard`,
   which is the red HARD DEADLINE chip from the reader's Calendar — so a vault
   being encrypted, which is a good and ordinary state, was flagged in the
   colour this product reserves for "this will cost you something". */
/* ---- the anonymous-upload action cell ----
   A LABEL MAY NOT BREAK IN THE MIDDLE OF ITSELF.

   This column is last in a six-column table and the browser gave it whatever
   was left, which at 1440px was 88px. "Open file" is 66px of text in an inline
   link with no `nowrap`, so it broke across two lines and read as two separate
   controls, "open" and "file" — sitting under "Reading", which had wrapped off
   its own line too. Three words stacked in a column where there are two
   buttons.

   So: the items wrap as WHOLE UNITS or not at all, and the column is given a
   floor wide enough for the two it usually holds. A five-page upload still
   wraps its page links onto a second line, which is the right thing to do with
   five of them — it just never splits one. */
.upact { min-width: 11em; }
.upact .linkbtn { white-space: nowrap; display: inline-block; }

/* A handled message stays visible but stops competing. Removing it would
   make "did I already answer this?" unanswerable without a database client. */
.sup-done { opacity: .5; }
/* The CELL is normal whitespace and only what a person TYPED preserves its line
   breaks. Both properties used to sit on the cell, so every newline and every
   space of indentation in the markup built inside it rendered as blank space —
   about 150px under each turn once the cell held a conversation rather than one
   paragraph. */
.sup-body { max-width: 460px; }
.sup-said { white-space: pre-wrap; overflow-wrap: anywhere; }

/* A kind somebody else's clock is running on — a data request, a payment gone
   wrong — wears the EXISTING `.atag.warn`, which is defined for exactly this
   ("a tag that has gone from a fact to a problem"). It had its own `.sup-clock`
   rule for a while, and that rule did nothing: same specificity as `.atag` and
   written earlier in this file, so `.atag`'s own colour won and the badge
   rendered identical to PROBLEM and PRAISE. Caught by looking at the screen. */
.sup-clockwhy { display: inline-block; margin-top: 3px; color: var(--warn-ink); }

/* Attachments under the message body. Small, uniform, and clickable through to
   the full file — big enough to tell a screenshot of the vault from a photo of
   a letter, which is all the decision the operator is making here. */
.sup-atts { display: flex; flex-wrap: wrap; gap: var(--s-4); margin-top: var(--s-5); }
.sup-att { display: inline-flex; align-items: center; gap: var(--s-3);
  border: 1px solid var(--line); border-radius: var(--r-xs); overflow: hidden; }
.sup-att img { display: block; width: 88px; height: 88px; object-fit: cover; }
/* A PDF never renders here — see _attachments() in app-admin.js for why — so
   it is a labelled download instead of a picture. */
.sup-att-file { padding: 8px 10px; font-size: var(--t-sm); color: var(--accent-ink);
  max-width: 220px; }
.sup-att-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* What was written back, under what it answers. Indented rather than boxed:
   it is the same conversation, not a separate object.

   LOGICAL properties, not padding-left / border-left. The quote bar belongs on
   the side the text starts from, and the operator portal is read in Arabic by
   the person who runs this — a physical `left` would put the bar on the far
   edge of the paragraph it marks. */
.sup-reply { margin-top: var(--s-5); padding-inline-start: var(--s-5);
  border-inline-start: 3px solid var(--calm-border); }
/* Ours vs theirs, on the quote bar rather than the background: the operator is
   reading down a table column, and two alternating fills turn it into stripes.
   Accent = we said it. */
.sup-reply-us { border-inline-start-color: var(--accent-ink); }

/* The queue row's way in. A LINK on the opening line, so the whole sentence is
   the target rather than a small "open" at the far edge of a wide table. */
.sup-open { display: block; color: inherit; text-decoration: none; }
.sup-open:hover { color: var(--accent-ink); text-decoration: underline; }
.sup-size { display: block; margin-top: var(--s-2); font-size: var(--t-xs); }

/* ---- one conversation, on its own page ----------------------------------- */

/* What is held ABOUT the conversation. A definition list because that is what
   it is; two columns so the labels form a readable edge instead of alternating
   with the values down a single one. */
.sup-headtags { display: inline-flex; align-items: center; gap: var(--s-4);
  flex-wrap: wrap; }

.sup-facts { display: grid; grid-template-columns: max-content 1fr;
  gap: var(--s-3) var(--s-7); margin: var(--s-7) 0; }
.sup-facts dt { font-size: var(--t-xs); font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted); }
.sup-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }

/* The conversation itself. Bounded so a long line stays readable — prose past
   about 70 characters is measurably harder to follow, and the operator is
   reading somebody's account of a problem, not scanning a column. */
.sup-thread { max-width: 46em; }
.sup-thread .sup-reply { margin-top: var(--s-7); }
/* THEIRS is the opening message and every reader turn; ours is accented. Same
   vocabulary as the queue used, so an operator who learned it there does not
   have to learn it again here. */
.sup-reply-them { border-inline-start-color: var(--calm-border); }
.sup-replybox-page { margin-top: var(--s-7); max-width: 46em; min-width: 0; }

.sup-actions { min-width: 9em; }
.sup-actions .linkbtn { white-space: nowrap; display: inline-block; }
.sup-replybox { margin-top: var(--s-4); min-width: 260px; }
.sup-replybox textarea { width: 100%; font: inherit;
  padding: var(--s-4) var(--s-5);
  border: 1px solid var(--control-line); border-radius: var(--r-xs);
  background: transparent; color: inherit; }

.atag { display: inline-block; font-size: var(--t-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
  color: var(--accent-ink); background: var(--calm-bg);
  border: 1px solid var(--calm-border); padding: 1px 7px; border-radius: var(--r-pill); }

/* An operator tag that has gone from "a fact" to "a problem": a message
   waiting two days, an account past due, a blocked reader. Same shape as a
   normal tag so the table still scans, different colour so the eye stops. */
.atag.warn {
  background: var(--warn-bg); border-color: var(--warn-border); color: var(--warn-ink);
}
/* A super user in the accounts table. Filled rather than outlined, because the
   operator is scanning a column where nearly every row says "free" or "plus" —
   this one is rare and is the row they came looking for. Not `.warn` red:
   nothing is wrong with the account, it is just above the paywall. */
/* An account that is OURS. Deliberately quiet — grey, not a warning colour:
   nothing is wrong with it, it is simply not part of the market the figures
   above describe. It still has to be visible at a glance, because a row that
   is missing from every number on the screen has to say so. */
.atag.ours { background: var(--chip-bg); border-color: var(--line);
  color: var(--muted); }

.atag.super {
  background: var(--accent); border-color: transparent; color: var(--on-accent);
}

/* ---- the account table, and the panel that opens inside it -------------
   The last cell used to hold four link-buttons — view, give, block, delete —
   at the same weight as each other, so "delete this person's account" and
   "look at their vault" were the same click in the same place. One control
   now, opening the account underneath its own row. */
.acctable td { vertical-align: top; }
/* The address is the way in, so it is the thing that looks pressable. */
.rowmail { border: 0; background: none; padding: 0; cursor: pointer;
  font: inherit; font-weight: 600; color: var(--accent-ink); text-align: start;
  overflow-wrap: anywhere; }
.rowmail:hover { text-decoration: underline; }
.rowid { display: block; font-size: var(--t-xs); }
/* The read/stored split under a row's document count. Block, so the total
   stays the number the eye lands on and the breakdown reads as its footnote
   rather than as a second figure competing with it. */
.docsval { display: block; }
.docsplit { display: block; font-size: var(--t-xs); }
.docsplit > span { display: block; white-space: nowrap; }
.rowact { white-space: nowrap; }
.tagnote { display: block; font-size: var(--t-xs); margin-top: 2px; }

/* ---- the accounts table on a phone ----
   Nine columns do not fit in 390px, and a table that scrolls sideways inside
   the page is a table whose last four columns nobody ever sees — including
   State, which is the one that says an account needs attention.

   So below the break each row becomes a card: the address as its heading, and
   every other cell as a labelled line. `data-label` carries the header text
   down to the cell, because a stacked cell has no header above it any more. */
@media (max-width: 720px) {
  .acctable, .acctable tbody, .acctable tr, .acctable td { display: block; width: 100%; }
  .acctable thead { display: none; }
  .acctable tr[data-row] {
    border: 1px solid var(--line); border-radius: var(--r-md);
    background: var(--card); padding: var(--s-6); margin-bottom: var(--s-5); }
  .acctable td { padding: 3px 0 !important; text-align: start !important; }
  /* The address heading spans the card rather than sitting in a column. */
  .acctable td.acell { padding-bottom: var(--s-5) !important;
    margin-bottom: var(--s-4); border-bottom: 1px solid var(--line); }
  .acctable td.acell .rowmail { font-size: var(--t-lg); }
  .acctable td[data-label] { display: flex; align-items: baseline;
    justify-content: space-between; gap: var(--s-6); }
  .acctable td[data-label]::before {
    content: attr(data-label); flex: none;
    font-size: var(--t-xs); text-transform: uppercase; letter-spacing: .05em;
    color: var(--muted); font-weight: 700; }
  /* Value and its breakdown right-aligned together, under one another,
     against the label at the start of the row. */
  .acctable td[data-label] .docsval { text-align: end; }
  .acctable td.rowact { padding-top: var(--s-5) !important; }
  .acctable td.rowact .linkbtn { padding-inline: 0; }
  /* The detail row is already full width; it just loses the card's chrome so
     it reads as belonging to the card above rather than as another one. */
  .acctable tr.detailrow { margin-top: calc(var(--s-5) * -1); margin-bottom: var(--s-5); }
  .acctdetail { padding: var(--s-7) var(--s-6); border-radius: 0 0 var(--r-md) var(--r-md); }
  /* An empty state is not a row of data and must not be labelled like one. */
  .acctable td[colspan] { padding: 0 !important; }
  .acctable td[colspan]::before { content: none; }
}

/* The row the panel lives in. A cell spanning the whole table, so the panel is
   the width of the screen rather than the width of the last column. */
.detailrow > td { padding: 0 !important; background: var(--hover-bg); }
.detailrow[hidden] { display: none; }
.acctdetail { padding: var(--s-8) var(--s-7) var(--s-7);
  border-top: 2px solid var(--accent); }
.detailtop { display: flex; align-items: flex-start; justify-content: space-between;
  gap: var(--s-7); flex-wrap: wrap; margin-bottom: var(--s-7); }
.detailmail { margin: 0; font-size: var(--t-xl); font-weight: 700;
  overflow-wrap: anywhere; }
.detailtop .sub { margin: 2px 0 0; }
.detailtags { display: flex; align-items: center; gap: var(--s-4); flex-wrap: wrap; }
.detailfacts { margin-bottom: var(--s-8); }
/* A heading INSIDE the panel, so "give somebody a plan" and "delete their
   account" are visibly two different kinds of thing rather than six controls
   in a row. */
.detailgroup { margin: var(--s-8) 0 var(--s-3); font-size: var(--t-md);
  text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.detailnote { margin: 0 0 var(--s-3); }
.detailacts { display: flex; align-items: center; gap: var(--s-5);
  flex-wrap: wrap; padding-top: var(--s-6); }
.detailacts .spacer { flex: 1; }
/* The destructive one is pushed to the far end by that spacer and outlined in
   red, so it cannot be reached by muscle memory aimed at the button beside it. */
.detailacts .ghost.danger { border-color: var(--warn-border); color: var(--urgent-ink); }
.detailacts .ghost.danger:hover { background: var(--warn-bg); }
@media (max-width: 560px) {
  /* In a column, a flexed spacer is a tall empty gap. */
  .detailacts { flex-direction: column; align-items: stretch; }
  .detailacts .spacer { display: none; }
}

/* ---- the "give" panel ----
   Three gifts of very different weight — a month of Plus, five letters, and
   unlimited everything — which used to be three ragged rows of
   label-input-text-button that lined up with nothing. It borrows `.setrow`
   from the reader's settings page, so the admin is not a second design system
   maintained by nobody, and the alignment comes for free. */
.givecard { margin-top: 16px; }
/* The explanation under a row's label: same shape the settings rows use, as a
   class rather than the inline style that was being repeated at each one. */
.rowhint { display: block; font-weight: 400; font-size: var(--t-md);
  color: var(--muted); margin-top: 2px; max-width: 44ch; }
/* THREE COLUMNS: the amount, THE PRIMARY ACTION, then anything else.
 *
 * It was two — amount, then all the buttons right-aligned in one cell — and
 * with a different number of buttons per row the right edge lined up while the
 * BUTTONS did not. Measured at 1440px: "Grant" sat at x=1202 on the Plus and
 * Extra-letters rows and at x=986 on the Super row, because two more buttons
 * had pushed it left.
 *
 * That is not only ragged, it is dangerous. What ended up at x=1190 on the
 * super row — where the eye and the cursor had just found "Grant" twice — was
 * "Revoke". A column of primary actions that moves is a mis-click waiting for
 * somebody in a hurry.
 *
 * So the primary button gets a column of its own and cannot drift. The extra
 * buttons flow into a third column after it, and the ragged edge moves to the
 * right where nothing is aimed at it. */
/* THE FIRST COLUMN IS FIXED, not sized to its contents.
   `max-content` was the first version and it made the primary column depend on
   what happened to be in the amount column — so a row holding a select with no
   unit label after it computed narrower than "30 days", and its Apply button
   sat 62px left of the three Grant buttons above it. Measured: 865 against 927.
   That is the same drifting-primary bug this grid was introduced to fix,
   reintroduced by the first row that did not look like the other three.
   12em is wider than "6.5em input + gap + 4.2em unit", so every row's amount
   fits and every row's primary starts at the same x whatever it holds. */
/* AND ITS WIDTH DOES NOT DEPEND ON ITS LABEL.
   `width: min(38em, 100%)` inside a space-between row means the control keeps
   whatever the label left it — so "Plus" and "Counted in the numbers" gave
   their controls different widths, the fixed first column started at a
   different x in each, and at 900px the four primaries landed at three
   different places even with the column fixed. A flex basis the label cannot
   push on makes every row's control identical, so the columns inside them line
   up whatever the labels say. */
.givectl { display: grid; grid-template-columns: 12em max-content 1fr;
  align-items: center; gap: var(--s-6);
  flex: 0 0 min(38em, 62%); }
/* The select fills that column rather than sitting narrow inside it. */
.giveunit select.pwinput { width: 100%; }
.giveprimary { display: flex; }
.giveprimary .ghost { width: 100%; justify-content: center; }
.givemore { display: flex; align-items: center; gap: var(--s-5); flex-wrap: wrap; }
/* The destructive one goes to the FAR end of its column — as far from the
   primary action as the row allows, rather than adjacent to it. */
.givemore .danger { margin-inline-start: auto; }
/* A number and its unit read as one control. The unit was a separate grey
   sentence floating after the box, which is why "30" and "days" could end up
   on different lines. */
.giveunit { display: inline-flex; align-items: center; gap: var(--s-4); }
.giveunit .pwinput { width: 6.5em; text-align: end; font-variant-numeric: tabular-nums; }
/* The unit is a FIXED column, wide enough for the longest of them. Left to
   size itself, "letters" is half a word wider than "days" and shoves its input
   left — so the three number boxes, which should read as one column down the
   card, each sat at a different x. */
.giveunit em { font-style: normal; font-size: var(--t-md); color: var(--muted);
  min-width: 4.2em; }
/* The reason wants the whole width, not the right-hand column. `.setrow
   .pwinput` caps every field at 220px, which is right for a settings dropdown
   and wrong for a sentence. */
.giverow-wide { align-items: flex-start; }
.giverow-wide .pwinput { flex: 1 1 22em; min-width: 0; max-width: none; }

/* STACK EARLIER THAN THE READER'S SETTINGS PAGE DOES.
   Between about 600 and 740px the label and a 62%-wide control both fit, but
   only just — so the label wraps to a different height in each row and the
   primaries drift apart again (measured at 640px: 436 and 391). The reader's
   settings rows stack at 560 and are fine there because their labels are
   short; these carry a sentence of explanation each. Scoped to the panel so
   the settings page keeps its own breakpoint. */
@media (max-width: 740px) {
  .acctdetail .setrow { flex-direction: column; align-items: stretch; }
  .acctdetail .givectl { flex: none; width: 100%; grid-template-columns: 1fr; }
  .acctdetail .giveprimary .ghost { width: auto; }
  .acctdetail .givemore { justify-content: flex-start; }
  .acctdetail .givemore .danger { margin-inline-start: 0; }
}

@media (max-width: 560px) {
  /* `.setrow` turns into a column here, and in a column `flex-basis` is a
     HEIGHT — so `flex: 1 1 22em` on the reason field rendered a text input
     three hundred and fifty pixels tall. Full width, natural height. */
  .giverow-wide .pwinput { flex: none; width: 100%; }
  /* Stack the amount over its buttons rather than squeezing two columns into
     a phone, and let the actions read left-to-right from the same edge as
     everything else in the row. */
  .givectl { grid-template-columns: 1fr; width: 100%; gap: var(--s-5); }
  .giveunit .pwinput { flex: 1 1 auto; width: auto; }
  /* Stacked, the three columns become three rows; the extras get their own
     line and the destructive one stops being pinned to a right edge that no
     longer exists. */
  .givemore { justify-content: flex-start; }
  .givemore .danger { margin-inline-start: 0; }
}
/* The answer, where the eye already is rather than dangling under the card.
   Hidden until there IS one — an empty grey line under three buttons reads as
   something having failed silently. */
.giveresult { margin: 14px 0 0; padding: 10px 12px; border-radius: var(--r-md);
  background: var(--chip-bg); font-size: var(--t-base); color: var(--ink);
  /* A rule down the leading edge, so the answer reads as a callout rather than
     as one more paragraph in a card that already has four. */
  border-inline-start: 3px solid var(--accent); }
.giveresult[hidden] { display: none; }
.giveresult.bad { color: var(--urgent-ink); border-inline-start-color: var(--urgent-ink); }

/* Numbers first, labels under. The value is what the eye is looking for, so it
   gets the size and the label gets out of the way.

   ⚠️ `.astat`, NOT `.stat`. This block used to call itself `.stat`, which is
   also the name of the Home tile 1100 lines above it — a different component
   with a different surface. Two rules, one class, and the LAST one wins: the
   Home tiles were rendering with this padding (14px 16px, not 16px) and this
   opaque background, which silently overrode the (then-translucent) surface
   the `@supports` block gave `.stat, .space`. Measured, not guessed: `.stat`
   computed `rgb(255,255,255)` while `.space` beside it computed
   `rgba(255,255,255,.58)`. The support screen's vocabulary is deliberately its
   own; it needs its own names too. */
.statgrid { display: grid; gap: var(--s-6); margin-bottom: 12px;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.astat { display: flex; flex-direction: column; gap: var(--s-2); padding: 14px 16px;
  border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--card);
  box-shadow: var(--shadow-1);
  transition: transform var(--dur-2) ease, box-shadow var(--dur-2) ease,
              border-color var(--dur-2) ease; }
.astat:hover { transform: translateY(-2px); box-shadow: var(--shadow-2);
  border-color: var(--grad-3); }
.astatv { font-size: var(--t-3xl); font-weight: 700; line-height: 1.1;
  font-variant-numeric: tabular-nums; }
.astatl { font-size: var(--t-md); color: var(--muted); font-weight: 600; }
.astatn { font-size: var(--t-sm); color: var(--muted); opacity: .85; }

.twocol { display: grid; gap: var(--s-9);
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.twocol h3 { margin: 0 0 8px; font-size: var(--t-base); }
.twocol table { width: 100%; }
.twocol td { padding: 3px 0; font-size: var(--t-base); }


/* --- one arrow, one position, on every dropdown ------------------------
   The native select arrow sits wherever the platform puts it, on Chrome that
   is hard against the right edge, far from the text it belongs to, and it
   cannot be moved. So it is replaced by a drawn one, which is what .foldpick
   select already did; this just extends the same treatment to the rest so the
   pickers in Settings stop looking different from the picker in the vault.

   Two gradients rather than an SVG data URI because they inherit currentColor,
   so the arrow follows the light and dark themes with no second asset and no
   hardcoded colour that would be wrong in one of them.

   ⚠️ EVERY `select`, NOT A LIST OF THEM. This used to name seven selectors,
   including three by id, and an allowlist of ids is a promise that whoever
   adds the eighth dropdown will remember to come back here. Nobody did:
   `#talkch` on the Replies page and `#askmodel` in Settings both rendered the
   platform arrow, `#askmodel` doing it directly beside five selects that had
   the drawn one. Verified by computed style, `appearance: auto` and
   `background-image: none` on those two while their neighbours had ours.

   The exclusions are the cases where the arrow is not what the control looks
   like at all: a list box has no arrow to replace, and `.native` is the escape
   hatch for anything that genuinely wants the system widget.

   `.bare` is the second hatch, for a select that is deliberately not a box at
   all: `.ourg` on a Calendar row reads as part of the sentence until you touch
   it, and the arrow rule was giving it a chevron plus 38px of reserved space
   inside a 148px control — which truncated its own default label,
   "Warn me 14, 7, 3, 1 days before", to "Warn me 14, 7, 3,".

   The exclusions are deliberately NOT wrapped in `:where()`. That was tried:
   dropping this rule to (0,0,1) lets every component rule win its own padding
   back, and `.pwinput` sets `background:` as a shorthand — which resets
   `background-image` — so all five Settings dropdowns lost the arrow outright
   while `#askmodel` and `#curpick` kept it with 8px of clearance and ran their
   text underneath it. Measured on every select on every screen. This rule has
   to outrank the components it is correcting; the way out is the hatch, not
   the specificity. */
select:not([multiple]):not([size]):not(.native):not(.bare) {
  appearance: none; -webkit-appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  /* 22px in from the edge, not the ~12px the browser uses: the arrow reads as
     part of the control rather than as something stuck to its border. */
  background-position: calc(100% - 22px) calc(50% + 1px),
                       calc(100% - 17px) calc(50% + 1px);
  padding-inline-end: 38px;
}
/* RTL moves the arrow to the other side, and the two halves must SWAP with it.
   background-position is not mirrored by direction: it is measured from the
   left edge whatever the writing mode. The gradient list is fixed (45deg draws
   the left half of the triangle, 135deg the right), so keeping the same order
   of offsets puts the right half on the left and the left half on the right.
   The triangle comes apart into two wedges pointing away from each other
   which is exactly what Arabic showed. */
[dir="rtl"] select:not([multiple]):not([size]):not(.native):not(.bare) {
  background-position: 17px calc(50% + 1px), 22px calc(50% + 1px);
}

/* The category picker moves in by the same amount, so the two never disagree. */
.foldpick select {
  background-position: calc(100% - 22px) calc(50% + 1px),
                       calc(100% - 17px) calc(50% + 1px);
  padding-inline-end: 38px;
}
[dir="rtl"] .foldpick select {
  /* Same swap as above: the 45deg half must stay to the LEFT of the 135deg one. */
  background-position: 17px calc(50% + 1px), 22px calc(50% + 1px);
}


/* --- "what does this actually do?" --------------------------------------
   A quiet (i) next to an action. Quiet on purpose: it must not compete with the
   button it explains, and it must not read as a warning, it is there for the
   reader who wants to know before pressing, not to make everyone anxious. */
.infowrap { position: relative; }
.card.infowrap .spacehead { flex-wrap: wrap; }
.infobtn { display: inline-flex; align-items: center; justify-content: center;
  /* min-* as well as width/height: inside a flex row the default align-items
     stretch was pulling the circle into an oval, and a 26px height on a
     stretched item is a suggestion rather than a size. */
  width: 26px; height: 26px; min-width: 26px; min-height: 26px;
  align-self: center; padding: 0; border-radius: 50%;
  border: 1px solid var(--line); background: transparent; color: var(--muted);
  cursor: pointer; flex: 0 0 auto; margin-inline-start: 8px; }
.infobtn svg { width: 15px; height: 15px; }
/* A 26px circle is the right SIZE and the wrong TARGET: on a phone it is half
   the 44px a thumb needs, and this one sits directly beside Save — a miss
   presses the button that writes the reminder schedule. The circle stays 26px
   and the hit area grows around it, which costs nothing visually. */
.infobtn::after { content: ""; position: absolute; inset: 50% auto auto 50%;
  width: 44px; height: 44px; transform: translate(-50%, -50%); }
.infobtn { position: relative; }
.infobtn:hover, .infobtn.on { color: var(--accent-ink); border-color: var(--calm-border); }

/* Full width under the row, so a paragraph does not squeeze the buttons. */
.infobox { flex: 1 0 100%; margin: 10px 0 0; font-size: var(--t-base); line-height: 1.55;
  /* Newlines in the copy become line breaks. The text is a short list of
     consequences, and four sentences run together is the shape people skip. */
  white-space: pre-line;
  color: var(--muted); padding: 10px 12px; border-radius: var(--r-sm);
  background: var(--chip-bg); border: 1px solid var(--line); }


/* --- the public pages: about, privacy, security, terms ------------------
   Long-form reading, unlike the rest of the app. Wider measure, more air
   between sections, and a table that can scroll on a phone rather than
   squeezing four columns into 360px. */
.legal-nav { display: flex; flex-wrap: wrap; gap: var(--s-4) 16px; margin-top: 22px;
  font-size: var(--t-base); }
/* Padded, not bare. These sat at 22px tall — under the 24px floor a target is
   meant to clear, on Privacy, Terms, Security and About, which are the pages a
   reviewer reads first. They are block-level links in a wrapping row, so the
   fix is the box, not a hit-area halo: a halo on a wrapped flex row overlaps
   its neighbours, which is how the ledger's delete became easier to hit by
   accident. Padding grows the target and the spacing together. */
.legal-nav a { color: var(--muted); text-decoration: none;
  padding-block: var(--s-3); }
.legal-nav a:hover { color: var(--accent-ink); text-decoration: underline; }
/* THE SAME PADDING AS ITS NEIGHBOURS, and that is the whole rule.
   The current page is a <span>, not an <a>, so it never picked up the
   padding-block above — and in a stretched flex row every padded link's text
   sits lower in its box while this one sits at the top. The selected tab
   therefore floated visibly above the row it belongs to, which reads as a
   layout bug rather than as emphasis. Colour and weight say "you are here";
   the box must stay identical. */
.legal-here { color: var(--accent-ink); font-weight: 700;
  padding-block: var(--s-3); }

/* `start`, not the inherited alignment — the same trap `.talktext` documents.
   `[dir="rtl"] { text-align: right }` is a global rule and text-align INHERITS,
   so when this card renders English inside an Urdu shell it kept the shell's
   right alignment even with its own dir set to ltr. `start` resolves against
   the block's OWN direction, which is the one thing that is always correct
   here. */
.card.legal { line-height: 1.65; text-align: start; }
/* A German noun in the middle of a sentence is longer than a 320px column, and
   these pages name them: "Fiktionsbescheinigung", "beglaubigte Übersetzung",
   "Datenschutz-Grundverordnung". Measured at 320px, one list item on the
   privacy page needed 228px inside a 211px box and painted over the edge. */
.legal p, .legal li { overflow-wrap: anywhere; }
.legal-lead { font-size: var(--t-xl); color: var(--muted); margin: 0 0 6px; max-width: 60ch; }
.legal p { max-width: 68ch; }
.legal-h { margin: 26px 0 6px; font-size: var(--t-xl); letter-spacing: .01em; }
.legal-list { max-width: 68ch; padding-inline-start: 20px; margin: 6px 0; }
.legal-list li { margin: 5px 0; }
/* A callout for the sentences that qualify a claim rather than making one.
   These are the paragraphs a careful reader is looking for. */
.legal-note { max-width: 68ch; margin: 12px 0; padding: 11px 13px; font-size: var(--t-lg);
  color: var(--muted); background: var(--chip-bg); border: 1px solid var(--line);
  border-radius: var(--r-sm); }
.legal-tablewrap { overflow-x: auto; margin: 10px 0; }
.legal-table { width: 100%; border-collapse: collapse; font-size: var(--t-lg);
  min-width: 460px; }
.legal-table th, .legal-table td { text-align: start; padding: 8px 12px 8px 0;
  border-bottom: 1px solid var(--line); vertical-align: top; }
.legal-table th { color: var(--muted); font-size: var(--t-md); text-transform: uppercase;
  letter-spacing: .06em; }
.legal-foot { margin: 26px 0 40px; font-size: var(--t-base); }
/* The only thing in this footer is this link, so it is a target rather than a
   word in a sentence: it measured 38x18, under the 24px floor. */
.legal-foot a { color: var(--muted); text-decoration: none;
  display: inline-flex; align-items: center; min-height: 24px; }
.legal-foot a:hover { color: var(--accent-ink); text-decoration: underline; }

/* THE LEGAL PAGES ARE PROSE, SO THE COLUMN IS A READING COLUMN.
   Every block on them is already capped at a measure — 68ch for the body and
   the lists, 60ch for the lead — but the CARD around them took the full 1240
   page column, so it rendered 1204px wide around 728px of text and left 480px
   of empty card down the right-hand side. A card twice as wide as anything in
   it reads as a bug, which is exactly how it was reported.

   The cap goes on the WRAP rather than on the card, so the tab row, the
   language picker, the card and the footer all share one width and one
   trailing edge — the same law the app column follows. 800px is the 68ch
   measure plus the card's padding and the wrap's. */
.legal-space .wrap { max-width: 800px; }

/* Shown only when these pages have no translation for the reader's language
   and fall back to English. Quiet — it explains a limitation, it is not a
   warning about their data. */
.legal-fallback {
  margin: 14px 0 0; padding: 9px 13px; font-size: var(--t-md);
  color: var(--muted); background: var(--calm-bg);
  border: 1px solid var(--calm-border); border-radius: var(--r-md);
}
.legal-fallback[hidden] { display: none; }


/* The public-page links at the foot of the rail. Deliberately the quietest
   thing in the sidebar, always reachable, never competing with the sections
   above it. They were previously only on the signed-out front page, which is
   backwards: the person most likely to want to know what happens to their
   residence permit is the one who has already uploaded it. */
.side-legal { display: flex; flex-wrap: wrap; gap: var(--s-2) 12px; margin: 18px 4px 6px;
  padding-top: 12px; border-top: 1px solid var(--line); }
/* Padded for the same reason as .legal-nav: bare, these were 20px tall, and
   the sidebar is on every signed-in page, so it was the most-shown target in
   the app and the smallest. */
.side-legal a { font-size: var(--t-md); color: var(--muted); text-decoration: none;
  padding-block: var(--s-2); }
.side-legal a:hover { color: var(--accent-ink); text-decoration: underline; }
/* An unread reply on the support link. Filled accent rather than the warning
   colour: an answer to your question is good news, and the row it sits on is a
   quiet footer that a red pip would turn into an alarm. */
.side-newdot {
  display: inline-block; min-width: 1.25em; margin-inline-start: var(--s-3);
  padding: 0 var(--s-2); font-size: var(--t-xs); font-weight: 700;
  line-height: 1.5; text-align: center;
  color: var(--on-accent); background: var(--accent);
  border-radius: var(--r-pill);
}

.legal-bar { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-7); flex-wrap: wrap; }
.legal-bar #langpick { max-width: 170px; height: 34px; font-size: var(--t-base); }

/* ---- confirm/notice dialog: replaces window.confirm()/alert() ----
 * Same reasoning as the folder editor and account-deletion form (see
 * app-vault.js): no grey OS dialog, ever. This is the shared overlay for
 * every other call site, highest z-index in the app, above the chat
 * drawer, so it is never accidentally hidden behind other fixed chrome. */
.dlg-overlay {
  position: fixed; inset: 0; z-index: 200;
  background: rgb(var(--shade-rgb) / .45);
  display: flex; align-items: center; justify-content: center;
  /* A centred card only reaches the insets once it is tall enough to fill the
     screen — which the document picker and the deletion confirmation both are
     on a phone. max(), not calc(): 20px is clearance from the edge, and the
     inset is the same clearance measured by the hardware. */
  padding: max(20px, var(--sa-top)) max(20px, var(--sa-side)) max(20px, var(--sa-bottom));
}
/* ONE PLACE DECIDES THE SPACING BETWEEN A DIALOG'S PARTS.
   Every child used to carry its own margin-bottom and .dlg-controls zeroed its
   own margin-top, trusting whatever sat above it to have left a gap. That held
   until a child arrived without one: the e-mail field on the one-document
   purchase had no rule of its own, so it sat flush against Cancel — and, being
   an unstyled input, at the browser's default 190px inside a 420px card. A
   column gap cannot be forgotten by the next child added here, and `stretch`
   gives that child the card's full width without being asked. */
.dlg-card {
  background: var(--card); border: 1px solid var(--control-line);
  border-radius: var(--radius); box-shadow: var(--shadow-1);
  padding: 20px; max-width: 420px; width: 100%;
  display: flex; flex-direction: column; align-items: stretch; gap: var(--s-7);
}

/* THE ONLY THING IN THE APP THAT APPEARED ON ONE FRAME.
 *
 * Every other surface here fades — the toast, the drawer, the rail, and now
 * the navigation between screens. This one was appended and removed outright,
 * which on a phone reads less like a question being asked than like the screen
 * glitching. Two parts, because they are two things: the shade darkens, and
 * the card comes up 8px underneath it — the same distance and the same easing
 * `toast-in` already uses, so a question and a failure enter alike.
 *
 * `.dlg-out` is set by _closeDialog(), which then waits on getAnimations()
 * before removing the node. The durations therefore live HERE and nowhere
 * else; there is no matching number in the JS to keep in step.
 *
 * Exit is --dur-1 against --dur-2 in: dismissing is a decision already made,
 * and making the reader watch it leave at the same speed it arrived is the
 * most common way this kind of dialog starts to feel slow. */
/* FOUR KEYFRAMES, NOT TWO PLAYED BACKWARDS. The exit was first written as the
   entrance with `reverse`, which does not restart anything: an animation is
   identified by its name, so re-declaring the same name only updates the
   running one, and the card was gone from the DOM 30ms after Escape with no
   exit drawn at all — measured. Separate names are separate animations, and
   they actually play. */
@keyframes dlg-shade-in  { from { opacity: 0; } }
@keyframes dlg-card-in   { from { opacity: 0; transform: translateY(8px); } }
@keyframes dlg-shade-out { to { opacity: 0; } }
@keyframes dlg-card-out  { to { opacity: 0; transform: translateY(8px); } }
.dlg-overlay { animation: dlg-shade-in var(--dur-1) var(--ease); }
.dlg-card    { animation: dlg-card-in var(--dur-2) var(--ease); }
.dlg-overlay.dlg-out { animation: dlg-shade-out var(--dur-1) var(--ease) forwards; }
.dlg-overlay.dlg-out .dlg-card { animation: dlg-card-out var(--dur-1) var(--ease) forwards; }
/* The card is INFORMATION, so it still arrives and still leaves — it just
   stops moving. Same rule the toast follows. _closeDialog reads the animation
   list off the element, so with no animation it removes the node immediately
   rather than sitting through a timeout nobody can see. */
@media (prefers-reduced-motion: reduce) {
  .dlg-overlay, .dlg-card,
  .dlg-overlay.dlg-out, .dlg-overlay.dlg-out .dlg-card { animation: none; }
}

/* THE SCROLLER IS <html>, NOT <body>, and this cost a round of being wrong.
   `body { overflow: hidden }` looked like a scroll lock and measured like one
   in the styles, but the page still moved 31px under an open dialog: the
   viewport scrolls the root element, and body's overflow has nothing to say
   about it. Locking here is what actually holds. */
html.dlg-open { overflow: hidden; }
.dlg-msg { margin: 0; font-size: var(--t-lg); line-height: 1.5; }
.dlg-controls { margin-top: 0; justify-content: flex-end; }
/* A LABELLED FIELD IN A DIALOG, for the record-what-already-happened form.
   `.dlg-input` alone was one unlabelled box, which is right for a prompt that
   asks one question in its own sentence and wrong the moment there are three:
   an amount, a date and a note are indistinguishable from each other without
   labels, and a placeholder is not one — it disappears the moment anything is
   typed and several screen readers never announce it. */
.dlg-field { display: grid; gap: var(--s-2); font-size: var(--t-sm);
  color: var(--muted); }
.dlg-field .dlg-input { color: var(--ink); font-size: var(--t-md); }

/* Picking one document out of a vault. Scrolls inside the card rather than
   growing it, so forty documents and two behave the same. Each row is two
   lines because "Vertrag" and "Vertrag" are the same option twice until you
   can see who sent each one. */
.dlg-picks { display: flex; flex-direction: column; gap: var(--s-2);
  max-height: min(46vh, 380px); overflow-y: auto; }
/* The search over that list, offered once it is longer than a glance. Sits
   between the question and the rows, where a reader already looks for one, and
   borrows the vault search's own icon-inside-the-field shape so the two read as
   the same control in two places. */
.dlg-search { position: relative; display: block; }
.dlg-search .searchicon { position: absolute; inset-inline-start: var(--s-5);
  top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.dlg-search .searchicon svg { width: 16px; height: 16px; }
.dlg-search input { width: 100%; padding-inline: calc(var(--s-5) + 24px) var(--s-5);
  padding-block: var(--s-5); border: 1px solid var(--control-line); border-radius: var(--r-sm);
  background: var(--card); color: var(--ink); font-size: var(--t-base); }
/* Said, not shown as an empty box: a list that goes blank with no sentence
   reads as a dialog that broke rather than a search that found nothing. */
.dlg-none { margin: 0; }
.dlg-none[hidden] { display: none; }
/* flex:0 0 auto is load-bearing, and the reason is not the obvious one.
   A flex item normally cannot be squashed below its own content, because
   min-height defaults to auto. But .dlg-pick is a BUTTON, and the shared
   control rule at the top of this file gives every button `min-height: 44px`
   for the touch target — an explicit min-height replaces that automatic
   content-based floor with 44. So once this list is taller than .dlg-picks'
   max-height, every row is free to shrink to 44px, a wrapped two-line title
   plus its subtitle spills out, and the rows are drawn over each other.
   A document picker is the one dialog whose rows routinely wrap: real vault
   titles run to "<person> — Tenant Self-Disclosure Form (Mieterselbstauskunft)".
   Fixing it here rather than dropping the 44px keeps the touch target. */
.dlg-pick { display: flex; align-items: center; gap: var(--s-5);
  width: 100%; text-align: start; cursor: pointer; flex: 0 0 auto;
  border: 1px solid var(--control-line); background: none; color: inherit;
  border-radius: var(--r-sm); padding: var(--s-5) var(--s-6); font: inherit; }
/* WHAT `hidden` MEANS ON A ROW THAT SETS ITS OWN DISPLAY.
   The browser's own `[hidden] { display: none }` is a UA rule, and the author
   `display: flex` above beats it — so the search filtering these rows set the
   property on every non-match and NOTHING disappeared. Same override
   `.foldnew-wrap[hidden]` has carried for the same reason. */
.dlg-pick[hidden] { display: none; }


/* min-width:0 is what lets a long title WRAP instead of pushing the row wider
   than the dialog: a flex child refuses to shrink past its longest word
   otherwise, and vault titles are long German compounds. */
.dlg-pick-text { flex: 1 1 auto; min-width: 0; }
.dlg-pick-shot { flex: 0 0 auto; width: 40px; height: 52px; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--control-line); border-radius: var(--r-xs);
  background: var(--field); color: var(--muted); }
/* contain, NOT cover, for the reason the vault grid already records: a passport
   data page is landscape, and cropping it into this portrait chip throws away
   half the page — measured, 50% — including the photograph, which is the part
   that makes it recognisable at a glance. Letterboxing is the cheaper loss. */
.dlg-pick-shot img { width: 100%; height: 100%; object-fit: contain; display: block; }
.dlg-pick-fallback { display: none; }
.dlg-pick-shot.no-shot img { display: none; }
.dlg-pick-shot.no-shot .dlg-pick-fallback { display: block; }
.dlg-pick:hover { background: var(--chip-bg); }

.dlg-pick-label { display: block; font-weight: 600; }
/* margin-bottom is reset because .sub is a PROSE class — it carries 22px of
   paragraph spacing, which inside a row means 33px of dead air under the
   subtitle against 11px over the title. Nobody saw it while the rows were
   being squashed; the moment they stopped shrinking, every row was half again
   as tall as it needed to be and two fewer options fitted on the screen. */
.dlg-pick-sub { display: block; margin-top: 2px; margin-bottom: 0;
  font-size: var(--t-md); }

/* WHAT THE MODEL CAN SEE, above the box the question is typed in. Anywhere
   else and the reader asks "compare these two" with nothing attached and gets
   a confident answer about one of them. */
.attachrow { display: flex; flex-wrap: wrap; gap: var(--s-3);
  margin-bottom: var(--s-4); }
.attachchip { display: inline-flex; align-items: center; gap: var(--s-2);
  max-width: 100%; background: var(--chip-bg); color: var(--ink);
  border-radius: var(--r-pill); padding: var(--s-2) var(--s-3) var(--s-2) var(--s-5);
  font-size: var(--t-md); }
.attachchip .icon { width: 14px; height: 14px; flex: none; }
/* The title is the part that has to give way: a chip that wraps to two lines
   pushes the composer down every time one is added. */
.attachname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attachchip button { border: 0; background: none; color: var(--muted);
  cursor: pointer; font-size: var(--t-lg); line-height: 1; padding: 0 var(--s-2);
  border-radius: var(--r-xs); }
.attachchip button:hover { color: var(--ink); }

/* A DOCUMENT THAT IS NOT IN THE VAULT.
   Uploaded into the conversation to be asked about and not kept: it is not in
   their documents, not on their calendar, and nothing will remind them. The
   dashed edge says "provisional" at a glance and the word says it outright,
   because the state of somebody's paperwork must not depend on telling two
   greys apart. */
.attachchip.unkept { background: transparent; border: 1px dashed var(--line); }
.unkeptflag { flex: none; font-size: var(--t-sm); color: var(--muted);
  text-transform: lowercase; white-space: nowrap; }
/* The chip that stands in for a letter still being read. Reading takes tens of
   seconds, and a "+" that does nothing visible for a minute is a "+" the
   reader presses again. */
.attachchip.pending { color: var(--muted); font-style: italic; }

/* A file being dragged over the conversation. An outline rather than an
   overlay: the reader is aiming at a panel they can still read, and a curtain
   dropped over the answer they are holding a letter up against is the one
   thing they cannot afford to lose sight of. */
.chatdrawer.dropping, #chatpanel.dropping {
  outline: 2px dashed var(--accent); outline-offset: -6px; }


/* ---- reply drafts ----
   The draft is the one thing a reader SENDS, under their own name, to an
   authority. So the "this is not finished" parts are styled to be as loud as
   the German, and the German itself is boxed as a letter rather than flowing
   into the page like the explanation does. */
.replyrow { display: flex; gap: var(--s-4); flex-wrap: wrap; align-items: center; }
.replyrow #replyintent { flex: 0 0 auto; max-width: 240px; }
.replyrow #replynote { flex: 1 1 200px; min-width: 0; }

.replydraft { margin-top: 14px; }
.replybadge {
  display: inline-block; margin: 0 0 10px;
  padding: 4px 10px; border-radius: var(--r-pill);
  background: var(--low-bg); color: var(--action-ink);
  font-size: .82rem; font-weight: 600;
}
.replyletter {
  margin-top: 12px; padding: 14px 16px;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius);
}
.replysubject { margin-bottom: 10px; }
/* pre, so the paragraph breaks the model produced survive; wrapped, because a
   German official sentence is long and nobody should scroll sideways. */
.replybody {
  margin: 0; white-space: pre-wrap; word-wrap: break-word;
  font: inherit; color: var(--ink);
}

/* Translate control: a dropdown plus a button, not one chip per language.
   Fourteen chips was a wall that pushed the document off the screen and got
   worse with every language added; a select stays one row at any count. */
.translaterow { display: flex; gap: var(--s-4); align-items: center; flex-wrap: wrap; }
.translaterow #trlang { flex: 1 1 180px; max-width: 260px; min-width: 0; }

/* Talk, "what do I say back?" ------------------------------------------- */
/* Two panes, like every messaging app: the conversations on the left, the one
   you are in on the right. The transcript reuses .asklog and .ask-msg (see the
   note up there); everything here is what surrounds it. */
/* The list is the index, the thread is the thing, and it should not look like
   an equal split. It was 260px against a 724px content column, which reads as
   half the screen.
   Fixed rather than a percentage: .wrap caps the content at 760px, so a
   percentage resolves to the same number at every viewport and only adds a
   floor that then wraps "+ New conversation" onto two lines. */
.talkwrap { display: grid; grid-template-columns: 210px minmax(0, 1fr);
  gap: var(--s-8); align-items: start; margin-top: var(--s-8); }

.talkside { display: flex; flex-direction: column; gap: var(--s-5); position: sticky; top: 84px; }
.talknewbtn { width: 100%; }
.talksidehead { margin: 6px 2px 0; font-size: var(--t-sm); font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.talklist { display: flex; flex-direction: column; gap: var(--s-2); max-height: 55vh;
  overflow-y: auto; }
.talkitem { display: flex; align-items: center; gap: var(--s-4); width: 100%; min-height: 0;
  padding: 10px 12px; border: 1px solid transparent; border-radius: var(--r-md);
  background: none; color: var(--ink); cursor: pointer; text-align: start;
  font-size: var(--t-base); }
.talkitem:hover { background: var(--chip-bg); }
.talkitem.active { background: var(--chip-bg); border-color: var(--line); font-weight: 600; }
/* The reader's own description of the conversation, clamped to two lines: it
   is a sentence they wrote, not a contact name, so it needs room to be read
   and a limit so twenty of them still fit in a rail. */
.talkitem-name { flex: 1 1 auto; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2;
  -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35;
  /* Same reason as .tile-title: clamping lines does nothing to a word that is
     wider than the rail it sits in. */
  overflow-wrap: anywhere; }
.talkitem-meta { flex: 0 0 auto; font-size: var(--t-xs); font-variant-numeric: tabular-nums;
  color: var(--muted); }

.talkmain { min-width: 0; border: 1px solid var(--line); border-radius: var(--r-lg);
  background: var(--card); padding: 16px 18px; }
.talkmainhead { display: flex; align-items: center; gap: var(--s-5); margin-bottom: 12px;
  padding-bottom: 12px; border-bottom: 1px solid var(--line); }
/* text-transform: none, against the global h3 rule (style.css:267, which
   uppercases and letter-spaces every h3 as a section label). This is not a
   section label, it is a sentence the reader wrote about their own life, and
   SHOUTING IT BACK AT THEM reads as a category they picked from a list, which
   is exactly what this replaced. */
.talkmainwho { flex: 1 1 auto; min-width: 0; margin: 0; font-size: var(--t-lg);
  font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--ink);
  line-height: 1.35; overflow-wrap: anywhere; }
/* `button.talkback`, not `.talkback`. The control is <button class="ghost
   talkback">, and the base `button.ghost` rule sets `display: inline-flex` at
   (0,1,1) — one class MORE specific than a bare `.talkback`, so this rule lost
   the cascade wherever it was written and the button showed on every screen.
   That is why "All conversations" sat above a thread on a 1440px window with
   the conversation list already open beside it, offering to go somewhere the
   reader could already see. It is a phone control: below 860px the list is
   hidden and only one pane shows at a time. Matching the specificity is the
   whole fix — the media query below has to do the same, or hiding it here
   would hide it on the phone too, where it is the only way back. */
button.talkback { display: none; }      /* one pane only, on a phone */
/* A CHEVRON BESIDE THE WORDS, and on the narrowest screens instead of them.
   "All conversations" is 145px of a 260px header on a 320px phone, which left
   the three actions beside it 114px they did not have — the bin wrapped onto a
   line of its own. Every phone messaging app answers this the same way and the
   arrow is understood without the words; the accessible name stays on the
   button either way, so nothing is lost to a screen reader.
   Rotated rather than a second icon, exactly as `.calnav` does — and in RTL
   "back" points the other way, so the rotation comes off. */
button.talkback { gap: var(--s-3); }
button.talkback .icon { flex: 0 0 auto; width: 16px; height: 16px;
  transform: rotate(180deg); }
html[dir="rtl"] button.talkback .icon { transform: none; }
.talktrash { color: var(--muted); }
/* --urgent-ink because this is TEXT. `--urgent` is a fill: as text on the dark
   chip it measured 2.39:1, well under AA, and had been shipping that way. */
.talktrash:hover { color: var(--urgent-ink); background: var(--chip-bg); }

/* The header over the "who are you talking to?" form holds one control, the
   way back. No rule under it: the border on `.talkmainhead` separates a
   heading from a transcript, and under a lone arrow it reads as a divider with
   nothing on one side of it. */
.talkstarthead { border-bottom: 0; padding-bottom: 0; margin-bottom: 4px; }
.talkstart { padding: 6px 2px; }
.talkstartq { display: block; font-size: var(--t-xl); font-weight: 600; margin-bottom: 12px; }
.talkstart textarea { width: 100%; }

/* Grouped like a real conversation: messages in one run sit tight together,
   and a change of speaker opens the gap. */
/* PADDING ON BOTH SIDES, AND THE NUMBER IS THE FOCUS RING'S.
 *
 * `.asklog` gives this box `overflow-y: auto`, and a box that scrolls on one
 * axis clips the other — so anything drawn OUTSIDE a bubble's own edges is cut
 * off at the wall. The composer draws exactly that: `outline: 3px` at
 * `outline-offset: 2px` (see .bubedit below) reaches 5px past the bubble.
 *
 * The room for it is given once, on .asklog and its two fellow scrolling
 * boxes, near the overscroll rule at the top of this file — the 2px that used
 * to sit here was the same bug noticed from the other direction and patched by
 * half. */
.talklog { max-height: 58vh; gap: var(--s-2); }

/* THREE PANELS, NOT A COLUMN INSIDE A CARD.
   List | conversation | what Fahim has to say. The rail used to live INSIDE
   the middle card, which meant its share was whatever remained after a 210px
   sidebar, the page gutters and two lots of card padding — a number nothing on
   screen states, and one I got wrong twice: at 860px it left the conversation
   220px and broke the document card's title one letter per line.

   As a panel of the page the arithmetic is stated rather than implied, and the
   three widths add up to something you can check by reading the rule. */
/* The queries below ask THIS ELEMENT how wide it is. A viewport query was
   measuring the window while the content sat in a 760px column, so a 1600px
   screen "qualified" for three panels and then divided 760px between them.
   A container query cannot be wrong about the space it is in. */
/* THE CONTAINER IS THE PARENT, not the grid itself. An element cannot query
   its own container: `container-type` on .talkwrap makes it a container for
   its DESCENDANTS, so every rule in here that targets .talkwrap matched
   nothing at all and the grid fell back to whatever it had. Measured, not
   guessed: the used tracks came back "0px 744px 740px" against a template
   that says "210px minmax(420px, 1fr) 330px". */
/* Named `talk` because only the messages layout queries it; harmless on the
   other spaces, which do not. */
.app-space .wrap { container-type: inline-size; container-name: talk; }

.talkwrap.hasrail { grid-template-columns: minmax(0, 1fr); }
@container talk (min-width: 700px) {
  .talkwrap.hasrail { grid-template-columns: 210px minmax(0, 1fr); }
  .talkwrap.hasrail .talkrail { grid-column: 2; }
}
@container talk (min-width: 1080px) {
  /* 420px is the FLOOR, and past that the panel takes a share of whatever is
     going: 0.4fr against the conversation's 1fr. Pinned at 420 it looked
     small again beside a 1500px conversation on a wide monitor, which is the
     same complaint one screen size along. */
  .talkwrap.hasrail {
    grid-template-columns: 210px minmax(400px, 1fr) minmax(420px, 0.4fr); }
  /* Column only, never a row. Pinning `grid-row: 1` on the middle panel made
     auto-placement fill row 1 from column 1 with the definitely-placed item
     FIRST, so the conversation took the 210px sidebar column and the sidebar
     was pushed out. Source order already puts these three in the right
     columns; saying it twice was what broke it. */
  .talkwrap.hasrail .talkrail { grid-column: 3; }
}
/* 420px is the floor, not a preference: below it the document card starts
   breaking its title. min-content on the middle column would let the rail
   squeeze it; minmax() will overflow the page first, which is visible and
   therefore fixable, instead of quietly ruining the column that matters. */

.talkrail { min-width: 0; border: 1px solid var(--line);
  border-radius: var(--r-lg); background: var(--card); padding: 14px 16px; }
@container talk (min-width: 1080px) {
  .talkrail { position: sticky; top: 84px;
    max-height: calc(100vh - 120px); overflow-y: auto; }
}
.talkrail .talkcard { border: 0; padding: 0; background: none; margin: 0; }
.talkrail .chat-empty { margin-top: var(--s-7); }
/* The rail already scrolls. A second scroll region inside it means two
   scrollbars fighting over one gesture. */
.talkrail .talkasklog { max-height: none; }
/* The copy button, once it has taken the text. */
.blockcopy.done { color: var(--ok-ink, var(--accent-ink)); }

/* PICK ONE, BY WHAT IT DOES. Three ready-to-send emails stacked put the
   reading and the warning a long way from the text they describe, and made
   choosing a matter of scrolling between two of them. The labels are what the
   reader is actually deciding between, so the labels are the control. */
.talkpick { display: flex; flex-wrap: wrap; gap: var(--s-2);
  margin: var(--s-6) 0 var(--s-5); }
.pickbtn { flex: none; width: 34px; height: 34px; display: grid;
  place-items: center; border: 1px solid var(--line); background: none;
  color: var(--muted); border-radius: var(--r-pill); cursor: pointer;
  font: inherit; font-size: var(--t-md); font-variant-numeric: tabular-nums; }
.pickbtn:hover { color: var(--ink); border-color: var(--muted); }

.pickbtn.on { background: var(--accent); border-color: var(--accent);
  color: var(--on-accent); font-weight: 600; }

.talktabs { display: flex; gap: var(--s-2); margin-bottom: var(--s-5);
  border-bottom: 1px solid var(--line); }
.talktab { display: inline-flex; align-items: center; gap: var(--s-3);
  border: 0; background: none; color: var(--muted); cursor: pointer;
  font: inherit; font-size: var(--t-md); font-weight: 600;
  padding: var(--s-4) var(--s-4); border-bottom: 2px solid transparent;
  /* Flat. The shared button rule rounds every corner, which turned the active
     tab's underline into a floating pill sitting below the strip instead of an
     underline joined to it. */
  border-radius: 0;
  margin-bottom: -1px; }
.talktab .icon { width: 16px; height: 16px; }
.talktab:hover { color: var(--ink); }
.talktab:focus-visible { border-radius: var(--r-xs); }
.talktab.on { color: var(--accent); border-bottom-color: var(--accent); }

.talkasklog { max-height: 52vh; gap: var(--s-5); }
.talkaskrow { display: flex; }
.talkaskrow.mine { justify-content: flex-end; }
/* NOT a bubble. A bubble is a message somebody sent, and nothing on this tab
   goes anywhere — it is Fahim reading the conversation back to the reader. */
/* No pre-wrap: the paragraphs are real elements now, and pre-wrap on top of
   them doubles every blank line. */
.talkanswer { max-width: 100%; text-align: start;
  font-size: var(--t-lg); line-height: 1.6; }
.talkaskwait { display: flex; align-items: center; gap: var(--s-3); }

/* THE FACTS INSIDE THE PROSE. A date you have to be somewhere on, a fee you
   have to bring, an address you have to write to — findable at a glance
   rather than by reading the paragraph again. Quiet: this is emphasis, not
   decoration, and four highlighted things in a sentence would highlight
   nothing. */
.fact-when, .fact-money { font-weight: 600; color: var(--ink);
  white-space: nowrap; }
/* Tappable, because the alternative is copying 38 characters by hand off a
   screen, in a script that is not the reader's. */
.fact-mail { color: var(--accent-ink); text-decoration: underline;
  text-underline-offset: 2px; overflow-wrap: anywhere; }
.fact-mail:hover { text-decoration-thickness: 2px; }

/* Prose spacing wherever Fahim writes. First and last child flush, so a
   single paragraph does not push its own container open. */
.talkanswer p, .talkreads p, .note.warn p, .ask-msg p, .cmpbody p { margin: 0 0 var(--s-5); }
.talkanswer p:last-child, .talkreads p:last-child, .note.warn p:last-child,
.ask-msg p:last-child, .cmpbody p:last-child { margin-bottom: 0; }
.talkanswer ul, .talkreads ul, .note.warn ul, .ask-msg ul, .cmpbody ul {
  margin: 0 0 var(--s-5); padding-inline-start: var(--s-8); }
.talkanswer li, .talkreads li, .note.warn li, .ask-msg li, .cmpbody li {
  margin-bottom: var(--s-2); }
/* The label and the first paragraph share a line: "What they are asking:" is
   a lead-in, not a heading. */
.talkreads > strong, .note.warn > strong { display: inline; }
.talkreads > strong + p, .note.warn > strong + p { display: inline; }
.talkaskform { margin-top: var(--s-5); }
.talkline { display: flex; align-items: center; gap: var(--s-2); max-width: 100%; }
.talkline.runstart { margin-top: 12px; }
.talkline.theirs { justify-content: flex-start; }
/* row-reverse, so the bubble pins to the trailing edge and the × lands inside
   it, with the bubble still first in the DOM, which is what a screen reader
   and a text-only copy of the page follow. Mirrors for Arabic on its own,
   because flex row direction IS the inline axis. */
.talkline.mine { flex-direction: row-reverse; justify-content: flex-start; }
/* 32px: the delete control's 24px box plus the row's 4px gap, with 4px of
   slack. It was 26px against a 16px control; the number has to follow the
   button, or a full-width message and its × add up to more than the row. */
.talkline .ask-msg { max-width: calc(100% - 32px); }

/* THE GHOST BUBBLES. One on each side at the end of the thread: click the side
   the message belongs on. A single composer with a who-is-speaking toggle
   asked the reader to set a mode before typing; a bubble on the side it will
   land on asks nothing. */
.ghostbub { min-height: 0; padding: 10px 14px; border: 1px dashed var(--line);
  border-radius: var(--r-md); background: none; color: var(--muted); cursor: pointer;
  font-size: var(--t-base); font-weight: 600; }
.ghostbub:hover { border-style: solid; border-color: var(--accent);
  color: var(--accent); }
.talkline.theirs .ghostbub { border-end-start-radius: 4px; }
.talkline.mine .ghostbub { border-end-end-radius: 4px; }

/* Writing into one of them: the bubble becomes the editor, on the same side,
   so the message is composed where it will sit. */
.bubedit { max-width: 85%; min-width: min(300px, 100%); padding: 10px 12px;
  border-radius: var(--r-md); }
.bubedit.talk-them { background: var(--chip-bg); }
.bubedit.talk-me { background: var(--accent); }
/* font: inherit is not tidiness. Without it the browser default (monospace)
   applies, and a monospace fallback with no Arabic support renders Arabic as
   DISCONNECTED letterforms, م ا ذ ا instead of ماذا. Caught by screenshotting
   the page in Arabic, which no test would have shown. */
.bubedit textarea { width: 100%; border: 0; background: none; resize: none;
  padding: 0; margin: 0; min-height: 0; font: inherit; line-height: 1.45;
  color: inherit; }
.bubedit.talk-me textarea { color: var(--on-accent); }
.bubedit.talk-me textarea::placeholder { color: rgba(255, 255, 255, .72); }
/* The textarea IS the bubble, so its own outline would draw a second box
   inside one the reader already sees. The focus indicator moves to the bubble
   instead of being deleted — `outline: none` here left a keyboard user with no
   indication at all of where they were. */
.bubedit textarea:focus { outline: none; box-shadow: none; }
.bubedit:has(textarea:focus) { outline: 3px solid var(--focus-line); outline-offset: 2px; }
.bubacts { display: flex; align-items: center; gap: var(--s-3); margin-top: 8px; }
.bubacts .ghost, .bubacts .go { min-height: 0; padding: 5px 12px; font-size: var(--t-md); }
.bubacts .talkicon { margin-inline-end: auto; }
.bubedit.talk-me .bubacts .ghost { color: var(--on-accent); border-color: rgba(255, 255, 255, .5); }

/* `flex: 0 0 auto` is what makes the 34px below true. In the conversation
   header this button sits beside a chip that can be as long as a document
   title, and as a shrinkable flex item it was squeezed to 18px wide — a
   declared 34px target that renders at half that is worse than one that was
   never claimed. */
.talkicon { display: inline-grid; place-items: center; cursor: pointer;
  width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; border: 0; background: none;
  color: var(--muted); min-height: 0; padding: 0; }
.talkicon .icon { width: 18px; height: 18px; }
.talkicon:hover { background: var(--chip-bg); color: var(--ink); }
.bubedit.talk-me .talkicon { color: rgba(255, 255, 255, .85); }
.bubedit.talk-me .talkicon:hover { background: rgba(255, 255, 255, .18); color: var(--on-accent); }

/* Hidden until the reader reaches for it. A delete control on every bubble is
   clutter in a transcript they are reading, not editing, but on touch there
   is no hover, so it stays faintly visible there. */
/* 24x24 DRAWN, not 16x17. The glyph is the same size; the BOX is the target,
   and WCAG 2.2 asks 24px of it. The 44px halo below only exists on coarse
   pointers, which left a touch laptop and a mouse-driven phone-width window
   aiming at 16px. Grown here rather than exempted in the test: an exemption
   would have to be argued from the spacing rule, and this costs nothing. */
.talkdel { flex: 0 0 auto; border: 0; background: none; color: var(--muted);
  width: 24px; height: 24px; place-items: center;
  opacity: 0; cursor: pointer; font-size: var(--t-xl); line-height: 1; padding: 0;
  min-height: 0; transition: opacity var(--dur-1); }
.talkline:hover .talkdel, .talkdel:focus-visible { opacity: .75; }
.talkdel:hover, .talkdel.always { opacity: 1; }
/* ON TOUCH IT IS THE ONLY WAY, so it has to be visible enough to count as a
   control. There is no hover on a phone: this opacity IS the button. At .4 the
   × measured 1.76:1 against the card in the light palette (2.22:1 in dark) —
   under the 3:1 that WCAG 1.4.11 asks of a control's own graphics, and the
   reader who wants a message gone has nothing else to aim at. .8 measures
   3.57:1 light and 5.06:1 dark, and still reads as the quiet control it is
   meant to be beside the message it belongs to. The hit area is already 44px
   on coarse pointers; see the touch-target block near the end of this file. */
@media (hover: none) { .talkdel { opacity: .8; } }

.talkcard { margin-top: 14px; }
.talkreads { margin: 6px 0 10px; }
.talkopts { display: flex; flex-direction: column; gap: var(--s-6); margin-top: 12px; }
/* No border. This sits inside .talkcard, which sits inside .talkmain, three
   nested frames read as a form, and the option is the only one of the three
   the reader is actually choosing between. A tinted surface separates them. */
.talkopt { border: 0; border-radius: var(--r-md); padding: 13px 14px;
  background: var(--field); }
.talklabel { display: inline-block; font-size: var(--t-sm); font-weight: 700; padding: 3px 9px;
  border-radius: var(--r-pill); background: var(--chip-bg); color: var(--muted); margin-bottom: 8px; }
/* `start`, not the inherited alignment. `[dir="rtl"] { text-align: right }`
   is a global rule and text-align INHERITS, so a German message inside an
   Arabic interface came out right-aligned: the dir attribute fixed its
   direction and nothing fixed its alignment. `start` resolves against the
   block's OWN direction, which is the one thing that is always correct here,
   and it covers dir="auto" on the translation too. */
/* THE MESSAGE ITSELF, framed. It is a thing to be taken somewhere else and
   pasted, not a paragraph of Fahim's prose, and a code block is the shape
   everybody already reads that way: a header saying what it is with the copy
   action in it, then the content, boxed. */
.talkblock { border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--chip-bg); overflow: hidden; margin-bottom: var(--s-6); }
.talkblockhead { display: flex; align-items: center; gap: var(--s-4);
  justify-content: space-between; padding: var(--s-3) var(--s-4) var(--s-3) var(--s-6);
  border-bottom: 1px solid var(--line); }
/* The label takes the slack so both buttons sit together on the right, where
   a code block puts its actions. */
.talkblockhead .talklabel { margin: 0; margin-inline-end: auto; }
.blockicon { display: inline-grid; place-items: center; flex: none;
  width: 28px; height: 28px; border: 0; background: none; color: var(--muted);
  cursor: pointer; border-radius: var(--r-xs); }
.blockicon .icon { width: 16px; height: 16px; }
.blockicon:hover { color: var(--ink); background: var(--card); }

/* Pressed, not merely hovered: with the toggle on, the block is showing a
   language the reader is NOT sending, and that has to be obvious from the
   control as well as from the caption above the text. */
.blockicon.on { color: var(--on-accent); background: var(--accent); }
.blockcopy { display: inline-flex; align-items: center; gap: var(--s-2);
  flex: none; border: 0; background: none; color: var(--muted); cursor: pointer;
  font: inherit; font-size: var(--t-sm); padding: var(--s-2) var(--s-4);
  border-radius: var(--r-xs); }
.blockcopy .icon { width: 14px; height: 14px; }
.blockcopy:hover { color: var(--ink); background: var(--card); }

.talkblock .talktext { margin: 0; padding: var(--s-6); }
/* Inside the block's padding, like the text it captions. Left at the frame's
   edge it read as a label for the frame rather than for what is in it. */
.talkblock .talkglosslabel { margin: 0; padding: var(--s-5) var(--s-6) 0; }

.talktext { margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere;
  text-align: start;
  font: inherit; font-size: var(--t-lg); line-height: 1.55; }

/* The caption above a translated block. Everything else that used to be here
   styled a <details> drawer, which the translate toggle replaced. */
.talkglosslabel { margin: 0 0 4px; font-size: var(--t-sm); letter-spacing: .04em;
  text-transform: uppercase; color: var(--muted); }

/* The screenshot rows, before anything is saved. Editable on purpose: Fahim
   guesses which side is which from the layout, and a wrong guess poisons every
   suggestion made afterwards. */
.talkparse { margin-top: 12px; }
.talkrow { display: flex; align-items: center; gap: var(--s-4); margin-bottom: 8px; flex-wrap: wrap; }
/* The one row the server refused. A save of two hundred messages that fails
   over one of them has to be able to point at it — the sentence alone leaves
   the reader reading two hundred rows to find the one. */
.talkrow.bad .talkrowtext { border-color: var(--warn-border);
  background: var(--warn-bg); color: var(--warn-ink); }
/* A MESSAGE, NOT A NAME. This was an <input>: one line, no wrap, and whatever
   did not fit the field was off the screen — on a 360px phone that is most of
   a sentence, in a card whose entire job is letting the reader READ each row
   and say who sent it. It is a textarea now, grown to its content by
   _talkGrow and capped at four lines (TALK_ROW_MAX), after which the row
   scrolls inside itself rather than pushing the save button down a page. */
.talkrowtext { flex: 1 1 220px; min-width: 0; resize: none; overflow-y: auto;
  line-height: 1.4; }
.talkseg { flex: 0 0 auto; }
/* The warning and the one control that answers it, on one line. "Check who
   said what" and "swap both sides" belong together: the second is what you
   press when the first turns out to be true of the whole import. */
.talkparsehead { display: flex; align-items: flex-start; gap: var(--s-4);
  flex-wrap: wrap; margin-bottom: 8px; }
.talkparsenote { flex: 1 1 240px; margin: 0; }
button.talkswap { flex: 0 0 auto; align-self: center; }
.talkparsecount { margin: 0 2px 10px; }
/* What an import could not carry. A note rather than a warning: nothing is
   wrong, the conversation is simply longer than one go — but it is never
   silent, because a list that shows 500 of 900 messages and says nothing reads
   as all of them. */
.talktrimmed { margin: 0 0 12px; }
/* The folded middle of a long import. A row of its own rather than a link in
   the caption: it is where the hidden messages ARE, so pressing it opens them
   in place instead of jumping the page. */
.talkmore { display: flex; align-items: center; justify-content: center;
  gap: var(--s-3); width: 100%; margin: 4px 0 12px; padding: 8px 12px;
  border: 1px dashed var(--line); border-radius: var(--r-md);
  background: none; color: var(--muted); cursor: pointer; font-size: var(--t-sm); }
.talkmore:hover { background: var(--chip-bg); color: var(--ink); }
.talkmore svg { transform: rotate(90deg); }
/* Save on the right, where the primary action is everywhere else in the app,
   and both controls out of reach of a mis-tap on the row above. */
.talkparseacts { margin-top: 4px; }

/* Bringing a whole conversation in. A quiet row under the ghost bubbles: the
   bubbles are the common act and this is the bigger, rarer one, so it reads as
   an offer rather than competing with them. */
.talkimportrow { display: flex; justify-content: center; margin: 10px 0 2px; }
.talkimport { display: inline-flex; align-items: center; gap: var(--s-3);
  padding: 8px 14px; border: 1px dashed var(--line); border-radius: var(--r-pill);
  background: none; color: var(--muted); cursor: pointer; font-size: var(--t-sm); }
.talkimport:hover:not(:disabled) { background: var(--chip-bg); color: var(--ink);
  border-style: solid; }
.talkimport:disabled { cursor: default; opacity: .7; }
.talkimport svg { width: 16px; height: 16px; }

/* The import card: a big paste box, because what goes in it is an entire
   conversation and a three-row textarea says "a sentence". */
.tiarea { width: 100%; min-height: 140px; resize: vertical; margin-bottom: 8px; }
.tierr { margin: 0 0 8px; }
/* The camera at one end, cancel and read at the other. `.dlg-controls` pushes
   everything to the end, so the two groups are laid out here instead — a
   spacer between them wrapped the row and stacked the buttons on top of each
   other, which read as two different decisions. */
.tiacts { justify-content: space-between; align-items: center; gap: var(--s-4); }
.tibtns { display: flex; align-items: center; gap: var(--s-5); }
label.tishot { display: inline-flex; align-items: center; gap: var(--s-3);
  cursor: pointer; }
.tiwho { display: flex; flex-direction: column; gap: var(--s-3); margin: 4px 0 12px; }
.tiname { display: flex; align-items: center; gap: var(--s-4); width: 100%;
  padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-md);
  background: none; color: var(--ink); cursor: pointer; text-align: start;
  font-size: var(--t-base); font-weight: 600; overflow-wrap: anywhere; }
.tiname:hover, .tiname:focus-visible { background: var(--chip-bg); }
.tinamedot { flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%;
  background: var(--accent); }

/* How much of the conversation the suggestion was built on. A caption at the
   top of the card, not a warning: nothing is wrong, the reader simply has more
   messages than one answer can weigh. */
.talkpartial { display: flex; align-items: flex-start; gap: var(--s-3);
  margin: 0 0 10px; color: var(--muted); }
.talkpartial svg { flex: 0 0 auto; width: 15px; height: 15px; margin-top: 2px; }

/* Starting the side chat again. Above the log rather than under it: it is
   about everything below it, and a control at the foot of a growing thread
   moves every time an answer arrives. */
.askreset { display: flex; justify-content: flex-end; margin-bottom: var(--s-4); }
.askreset .linkbtn { display: inline-flex; align-items: center; gap: var(--s-3);
  font-size: var(--t-sm); }
.askreset svg { width: 14px; height: 14px; }

/* Comparing several conversations ---------------------------------------- */
/* STACKED, NOT SIDE BY SIDE. The rail is 210px and the label is a spaced-out
   uppercase heading: "YOUR CONVERSATIONS" and "Compare" on one row wraps the
   heading onto two lines in English and does worse in German ("Ihre
   Gespräche" beside "Vergleichen"). A column fits every language. */
.talksidetop { display: flex; flex-direction: column; gap: var(--s-1); }
.talkcmptoggle { align-self: flex-end; font-size: var(--t-sm); }
/* A ticked conversation, in the list's own vocabulary: the channel glyph
   becomes a tick, and the row takes the same fill an open conversation does.
   Two ways of being "the one you mean" that never show at once — the list is
   either picking or opening. */
.talkitem.picked { background: var(--chip-bg); border-color: var(--accent); }
.talkitem.picked .talkch { color: var(--accent); }
.talkcmpbar { display: flex; flex-direction: column; gap: var(--s-3);
  padding-top: var(--s-4); border-top: 1px solid var(--line); }
.talkcmpbar .go { width: 100%; }
.talkcmpbar .sub { margin: 0; }
/* Which conversations the answer is about, above the answer. With none of them
   open, this is the only thing on screen that says so. */
.cmpchips { display: flex; flex-wrap: wrap; gap: var(--s-3); margin-bottom: 12px; }
.cmpchip { display: inline-flex; align-items: center; gap: var(--s-3);
  max-width: 100%; padding: 5px 10px; border-radius: var(--r-pill);
  background: var(--chip-bg); color: var(--ink); font-size: var(--t-sm);
  overflow-wrap: anywhere; }
.cmpchip svg { flex: 0 0 auto; width: 14px; height: 14px; color: var(--muted); }
.cmplog { min-height: 120px; }
.cmpnote { flex: 1 1 auto; margin: 0; font-size: var(--t-xs); }

/* Under the conversation list it describes, not under the page.
   
   It used to sit outside .talkwrap, so it rendered full-width at the bottom of
   the page — level with nothing, closest to the sidebar's left edge, reading
   as a footnote about the whole screen rather than about the list of
   conversations that is the thing being deleted. */
.talkttl { margin-top: 16px; font-size: var(--t-md); }

/* One pane on a phone: the list, or the thread with a way back. 860px is where
   a 210px rail plus a readable thread stop fitting side by side — and it is
   the same step the nav uses, so the two never disagree about what a small
   screen is. */
@media (max-width: 860px) {
  .talkwrap { grid-template-columns: minmax(0, 1fr); }
  /* Same specificity as the container rule above and later in the file, so it
     wins. Needed because the LIST is hidden here: leaving the two-column
     template in place kept a 210px track reserved for something that is not
     on screen, and handed the conversation the sliver next to it. */
  .talkwrap.hasrail { grid-template-columns: minmax(0, 1fr); }
  /* And release the rail from column 2, or it opens an implicit second column
     in a one-column grid and sits BESIDE the conversation in 30px of space.
     Resetting the template without resetting the placement is half a fix. */
  .talkwrap.hasrail .talkrail { grid-column: 1; }
  .talkside { position: static; display: none; }
  .talkside.open { display: flex; }
  .talkwrap:not(:has(.talkmain)) .talkside,
  .talkside:only-child { display: flex; }
  .talkside.open ~ .talkmain { display: none; }
  /* AND THE PANEL BESIDE IT, which is a third pane and belongs to the
     conversation the list is covering. It kept rendering under the list, so
     "all conversations" showed the index of every chat with one chat's
     suggested emails printed underneath it — including while picking
     conversations to compare, where the answer on screen was about none of
     the ones being ticked. One pane means one. */
  .talkside.open ~ .talkrail { display: none; }
  button.talkback { display: inline-flex; }   /* see the note at .talkback */
  .talkmain { padding: 14px; }
  .talklog { max-height: none; }

  /* THE HEADER STACKS, because five things do not fit across 360px.

     Back, the name of the conversation, and three icon buttons sat on one flex
     row: the back button is a phrase ("All conversations"), the icons are
     34px each and do not shrink, and what was left for the name was ~90px —
     which broke "Ausländeramt" one letter per line beside a chip reading
     "Fin…". The heading is the one item here that is a sentence, so it takes
     a line of its own and the controls share the line above it.

     Order, not markup: the DOM keeps back → name → actions, which is the
     reading order a screen reader wants and the one the desktop lays out. */
  .talkmainhead { flex-wrap: wrap; gap: var(--s-3) var(--s-4); }
  .talkmainhead > button.talkback { order: 1; }
  .talkmainhead > .talkicon { order: 2; }
  /* The gap between the back button and the first icon, so the actions sit at
     the far end of the row rather than crowding the control that goes back.
     Written against the heading because that is what precedes them in the
     DOM — `:first-of-type` would match nothing here, the buttons are not the
     first button in the header. */
  .talkmainhead > .talkmainwho + .talkicon { margin-inline-start: auto; }
  /* A whole line, and it may use all of it: `1 0 100%` rather than the
     shrinkable `1 1 auto` it has on a wide screen. */
  .talkmainwho { order: 3; flex: 1 0 100%; font-size: var(--t-md); }
  /* Its own line under the name, not a tail on the end of it: as an inline
     chip after a two-line heading it took whatever the last line left, which
     on this screen was room for three characters and an ellipsis. */
  .talkmainhead .talkdocchip { display: flex; width: fit-content;
    max-width: 100%; margin: 6px 0 0; }
  /* One row, because the list under it is now the whole screen rather than a
     210px rail: stacked, "Compare" sat alone on a line of its own halfway
     across, level with nothing. */
  .talksidetop { flex-direction: row; align-items: baseline;
    justify-content: space-between; gap: var(--s-4); }
}

/* The narrowest phones: the arrow alone. 400px rather than the layout's 860px
   because this is about how much room the LABEL needs, which is a different
   question from how many panes fit. */
@media (max-width: 400px) {
  .talkbacktext { display: none; }
  button.talkback { padding-inline: 12px; }
}

/* The AI icon inside the reader's own message box, and the versions it comes
   back with. Attached to the bubble being written, not floating at the foot of
   the page: these are versions of THAT message, and the reader is picking one
   to drop into the box directly above. */

/* The waiting state, on the button that started the call.
   It cannot be in a fixed place: the options card is routinely taller than the
   viewport and two different buttons start this call, one at each end of it,
   so a busy line above renders off the top of the screen and one below renders
   off the bottom. The button the reader just pressed is the only element that
   is certainly on screen. */
.btnspin { display: inline-block; width: 15px; height: 15px; vertical-align: -2px;
  border: 2px solid currentColor; border-top-color: transparent; border-radius: var(--r-pill);
  opacity: .8; animation: spin .7s linear infinite; }
@media (prefers-reduced-motion: reduce) { .btnspin { animation-duration: 2.4s; } }
[aria-busy="true"] { cursor: progress; }

/* Suggestions that are being replaced. Dimmed rather than removed, so the
   layout does not collapse and jump the page under the reader mid-request. */
.talkopts.stale { opacity: .45; pointer-events: none; transition: opacity var(--dur-1) var(--ease); }

/* Errors sit next to the control that caused them, for the same reason. */
.talkerr { margin-top: var(--s-4); }
.bubedit .talkcard { margin-top: 10px; }
.talkcard { border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: 15px 16px; background: var(--calm-bg); border-color: var(--calm-border); }
.talkrefine { display: flex; align-items: center; gap: var(--s-4); margin-top: 12px; }
.talkrefine input { flex: 1 1 200px; min-width: 0; }

/* The channel badge in the conversation list, and the document a conversation
   belongs to. Both are answers to "which one is this?" asked at a glance, so
   they are a glyph and a chip rather than another line of text. */
.talkch { flex: 0 0 auto; display: inline-grid; place-items: center; color: var(--muted); }
.talkch .icon { width: 15px; height: 15px; }
.talkitem.active .talkch { color: var(--accent-ink); }
.talkdocchip { display: inline-flex; align-items: center; gap: var(--s-2);
  margin-top: var(--s-2); padding: var(--s-1) var(--s-4) var(--s-1) var(--s-3);
  border: 1px solid var(--calm-border); background: var(--calm-bg);
  border-radius: var(--r-pill); font-size: var(--t-xs); font-weight: 600;
  color: var(--accent-ink); text-decoration: none; max-width: 100%; }
/* 24px, because a chip is still a target: it measured 114x21 in the
   conversation header, and it is the way back to the document being
   discussed. */
.talkdocchip { min-height: 24px; }
.talkdocchip .icon { width: 13px; height: 13px; flex: 0 0 auto; }
.talkdocchip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.talkdocchip:hover { border-color: var(--accent-ink); }
.talkstart select { width: 100%; margin-bottom: var(--s-6); }
.doctalks { margin-top: var(--s-6); }
.doctalk { display: flex; align-items: center; gap: var(--s-4); width: 100%; min-height: 0;
  padding: var(--s-5) var(--s-6); margin-top: var(--s-2); border: 1px solid var(--line);
  border-radius: var(--r-md); background: none; color: var(--ink); cursor: pointer;
  text-align: start; font-size: var(--t-base); }
.doctalk:hover { background: var(--hover-bg); border-color: var(--accent-ink); }
.doctalk-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }

/* The document a conversation was started from, shown as the opening message.
   It IS what the other side said, but a wall of its own text in a grey bubble
   reads as nothing; the thing the reader recognises is the letter. */
.talkdoccard { display: flex; gap: var(--s-5); align-items: flex-start;
  max-width: 85%; min-width: 0;
  padding: var(--s-5); border: 1px solid var(--line); border-radius: var(--r-md);
  border-end-start-radius: var(--r-xs); background: var(--chip-bg);
  color: var(--ink); text-decoration: none;
  transition: border-color var(--dur-1) var(--ease); }
.talkdoccard:hover { border-color: var(--accent-ink); }
.talkdocthumb { flex: 0 0 auto; width: 46px; height: 60px; object-fit: cover;
  border-radius: var(--r-xs); border: 1px solid var(--line); background: var(--card); }
.talkdocbody { display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; }
.talkdoctitle { font-weight: 600; font-size: var(--t-md); line-height: 1.35;
  overflow-wrap: anywhere; }
/* Three lines of the letter, enough to recognise it and not enough to read it
   here: the document's own page is one tap away. */
.talkdocx { font-size: var(--t-sm); color: var(--muted); line-height: 1.45;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: anywhere; }
/* A document whose scan was deleted, or whose thumbnail has not been made yet.
   A blank tile keeps the card's shape; a broken image icon says "fault". */
.talkdocnothumb { display: block; }

/* ASK FAHIM, in the panel. This used to sit inside the message bubble, which
   was right when the panel did not exist: everything Fahim does now lives on
   one side of the screen, including the waiting, and the bubble is a message
   box again. */
/* ABOVE the suggestions, not under them. A ready-to-send email is tall, and
   putting the ask beneath it meant scrolling past the whole message to reach
   the control that replaces it. */
/* STACKED, not side by side. What the reader types here is a sentence about
   what to change, not a keyword, and beside a button in a 320px panel it had
   about twenty characters of visible room. The box gets the full width and
   grows; the button sits under it. */
.talkaskrow2 { display: flex; flex-direction: column; align-items: stretch;
  gap: var(--s-3); margin: var(--s-6) 0; }
/* TWO LINES AT REST, and the floor is a min-height rather than the `rows`
   attribute because the grow-with-what-you-type handler writes an inline
   height off scrollHeight — which for an empty box is one line, and would
   collapse a two-row field the moment it was wired. min-height outranks an
   inline height, so the floor holds and the growing still works.
   min-width: 0 stays: a flex item's default minimum is its content, so a long
   placeholder would otherwise push the box past the edge of the panel. */
.talkaskrow2 textarea { width: 100%; min-width: 0; border: 1px solid var(--control-line);
  border-radius: var(--r-sm); background: var(--field, transparent);
  padding: var(--s-4) var(--s-5); font: inherit;
  font-size: var(--t-md); line-height: 1.45; resize: vertical;
  min-height: calc(2 * 1.45em + 2 * var(--s-4) + 2px);
  /* The script decides the side, so a German instruction inside an Arabic
     interface still starts where its own language starts. */
  text-align: start; }
/* Under the box and at its trailing edge, which is the right in English and
   the left in Arabic — flex-end follows the writing direction, a hard-coded
   `right` would not. */
.talkaigo { flex: none; align-self: flex-end; display: inline-flex;
  align-items: center; gap: var(--s-3); }
.talkaigo .icon { width: 16px; height: 16px; }


/* ==========================================================================
   TOUCH TARGETS — the glyph stays small, the target does not.
   The control rule at the top of this file sets a 44px floor, and every
   icon-only button in the app quietly opts out of it by setting its own width
   and height: measured on the Finances screen, 38 controls came back under
   44px, the smallest being the ledger's delete at 20×28 and the chat tab's
   close at 18×18. Making them all 44px visually would turn a dense list into a
   toolbar, so instead the HIT AREA is grown past the glyph with a centred
   pseudo-element, and only where there are fingers.

   `(pointer: coarse)` rather than a width breakpoint: the question is what is
   pointing at the screen, not how wide it is — a touch laptop and a 1400px
   tablet both want this, and a 400px window on a desktop does not.
   ========================================================================== */
@media (pointer: coarse) {
  /* ---- 16px ON A FORM CONTROL IS AN iOS RULE, NOT A DESIGN CHOICE. -------
     Safari and WKWebView ZOOM THE PAGE when a control smaller than 16px takes
     focus, and they do not zoom back out on blur. Measured on Finances at
     390px, every control on the screen was 13.31px — the search box, "add a
     spend", the category and currency pickers, the date field — so on the
     shipped iPhone build, tapping any of them jerked the layout in and left it
     there. That is the single loudest "this is a website in a wrapper" tell an
     app can have, and it is on the control the reader uses most.

     THE VIEWPORT IS NOT THE FIX. `maximum-scale=1` / `user-scalable=no` also
     stops the zoom, and it stops PINCH zoom with it — the thing a reader with
     low vision uses to read a letter. vault.html deliberately ships neither;
     this rule is what lets it keep deliberately shipping neither.

     EVERY CONTROL, NOT THE ONES SOMEBODY LISTED. The first version of this
     rule was `#view input, #view select, …` and it was the drift this codebase
     suffers from most: `#view` is the vault's main element, so the landing
     page's own email field — the FIRST input anybody ever touches, on a phone,
     before they have an account — was not covered, and neither was the chat
     drawer's composer. The selector below is the base control rule's, verbatim,
     so anything that rule sizes gets the floor and a control added next year
     gets it without anyone remembering this comment.

     `!important`, and it is not laziness. Five rules give a control a SMALLER
     size on purpose — .editform, .acctfield, .rangerow's date, .chat-model,
     .dlg-field — and every one of them outranks an element selector. This is a
     platform minimum: a component may choose to look smaller, and may not
     choose to make the page jump when it is touched. Nothing legitimately
     wants a control under 16px on a finger, because "under 16px" IS the zoom.

     `max()` and not a flat 16: the size is still the scale's, and 16 is only a
     floor under it — a tablet resolving --t-lg to 15 gets 16, a hypothetical
     18 would keep 18. The threshold is the RENDERED size, which is what max()
     is measuring. Same shape as the 44px hit areas below, same kind of reason.

     `(pointer: coarse)`, so a desktop keeps the smaller control it has room
     for. An iPad zooms too; a narrow window on a laptop does not. */
  select,
  input:where(:not([type=checkbox],[type=radio],[type=file],[type=range])),
  textarea {
    font-size: max(16px, var(--t-lg)) !important;
  }

  /* .casechip is in this list because it is the ONLY door to a case: there is
     no nav item, so a reader who cannot hit it cannot reach the feature. */
  .lact, .del, .factx, .talkdel, .chat-del, .mtx, .eye, .caltoggle,
  .blockicon, .blockcopy, .pweye, .talkicon, .foldbtn, .editbtn, .infobtn,
  .casechip, .casetoggle, .delbtn, .ox {
    position: relative;
  }
  .lact::after, .del::after, .factx::after, .talkdel::after, .chat-del::after,
  .mtx::after, .eye::after, .caltoggle::after, .blockicon::after,
  .blockcopy::after, .pweye::after, .talkicon::after, .foldbtn::after,
  .editbtn::after, .infobtn::after, .casechip::after, .casetoggle::after,
  .delbtn::after, .ox::after {
    content: ""; position: absolute; top: 50%; left: 50%;
    width: max(100%, 44px); height: max(100%, 44px);
    transform: translate(-50%, -50%);
  }
  /* AND THE ROWS THAT HOLD THEM GET WIDER GAPS, or this makes things worse.
     A 30px control with a 44px hit area reaches 7px past its own edge on each
     side. The ledger's edit and delete sat 2px apart, so their hit areas
     overlapped by 12px — measured — and the overlap belongs to whichever comes
     LATER in the DOM, which on a money row is the delete. Growing the targets
     without growing the spacing would have made the destructive control easier
     to hit by accident than it was before.
     30px box + 14px of gap is the minimum that separates two 44px areas. */
  .lacts, .fbody, .pwentry-tools, .pwactions, .lact-group { gap: var(--s-7); }
  /* .foldtools with them, and it was the same bug the paragraph above
     describes: 2px apart, so the halos overlapped by 12px and the overlap went
     to the one later in the DOM — on a category header that is DELETE, sitting
     under the right-hand edge of Rename's hit area. Measured at 390px: the
     three buttons' centres were 30px apart where 44 is the floor.
     Now three controls, since Ask joined the row, which is one more chance to
     land on the destructive one. */
  .foldtools { gap: var(--s-7); }

  /* The legal footer nav, which has no icon to centre a halo on. Padding takes
     the whole row past 44px and carries the spacing with it — the links wrap,
     and two 44px halos on a wrapped row would overlap on the line break. */
  /* --s-6, not --s-5: the text box is 22px, so 10px each side lands on 42 and
     misses the 44 floor by two. Measured, not assumed. */
  /* Paid / not paid. Measured at 28px — over the 24px floor and well under the
     44px every other control grows to on touch, and these two sit side by side
     where a mis-hit records the opposite answer. min-height rather than more
     padding: the pair must stay on one line beside the amount. */
  .paidpick .pbtn { min-height: 44px; }
  /* .legal-here with them: the current page is a <span>, so a selector that
     names only `a` re-creates the misalignment this override was widening —
     the selected tab floats above the row on touch, where the padding is
     largest and the gap most obvious. */
  .legal-nav a, .legal-here { padding-block: var(--s-6); }
  .legal-nav { gap: var(--s-3) var(--s-7); }
  /* The sidebar's own legal row, same shape, same reasoning. Its text box is
     20px, so 12px each side clears 44. */
  .side-legal a { padding-block: var(--s-6); }
  .side-legal { gap: var(--s-3) var(--s-7); }
}


/* The plan card's contents and billing dates.
   
   Three groups that read as three groups: what you are on, what it includes,
   what you have paid. The first pass let the browser draw the feature list and
   put the billing line flush against the last bullet, so all of it ran together
   as one grey block and the card looked like a dump of strings. */
.planinc { margin: 18px 0 8px; font-size: var(--t-sm); font-weight: 600;
  letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.planfeat { margin: 0; padding: 0; list-style: none;
  display: grid; gap: var(--s-4); font-size: var(--t-md); }
/* A tick rather than a bullet: every line here is something the reader HAS. */
.planfeat li { display: flex; align-items: flex-start; gap: var(--s-5); }
.planfeat li::before { content: "✓"; color: var(--accent); font-weight: 700;
  line-height: 1.35; flex: none; }
/* The money is a different kind of fact from the feature list, so it gets a
   rule above it rather than one more line in the same run of text. */
.planbill { margin: 6px 0 0; }

/* ---- the super-user badge ----
   Somebody an operator has put above the paywall. It sits at the top of the
   plan card where "next payment" would be, because that is the question it
   answers: there isn't one.

   Quiet, not loud. The temptation with a status like this is gold and a
   gradient, and the reader it is for is a caseworker with forty letters to get
   through — a banner that celebrates at them every time they open Settings
   wears out in a week. It reads as a fact about the account: accent-tinted,
   one line, the same corner radius as everything else on the card. */
.superbadge {
  display: flex; align-items: center; gap: var(--s-5);
  margin: 4px 0 2px; padding: 12px 14px;
  border: 1px solid var(--calm-border, var(--line));
  border-radius: var(--r-md); background: var(--chip-bg);
}
.superstar { display: inline-grid; place-items: center; flex: none;
  width: 32px; height: 32px; border-radius: 50%;
  background: var(--card); color: var(--accent-ink); }
.superstar svg { width: 18px; height: 18px; }
.supertext { display: flex; flex-direction: column; gap: var(--s-1); min-width: 0; }
.supertext strong { font-size: var(--t-lg); line-height: 1.25; }
/* `.sub` is the muted look; the paragraph margin is p.sub's and this is a
   span, so nothing to undo here — see the note on .sub. */
.supertext .sub { font-size: var(--t-md); }
.planbill:first-of-type { margin-top: 16px; padding-top: 14px;
  border-top: 1px solid var(--line); }


/* The failure box. Bottom-centre, above everything, out of the way of the
   page-level work bar at the top — the two can be on screen at once (a
   translation running while a save fails) and must not overlap.
   Logical properties, so an Arabic reader gets it laid out correctly without a
   mirrored rule. */
.toast {
  /* This rule had the only safe-area handling in the stylesheet, written inline
     before there was a token. Same behaviour, said once. */
  position: fixed; inset-block-end: max(18px, var(--sa-bottom));
  inset-inline: 0; z-index: 10000;
  display: flex; justify-content: center; padding-inline: var(--s-7);
  pointer-events: none;
}
.toast > span {
  pointer-events: auto; cursor: pointer;
  max-width: 34rem; text-align: start;
  background: var(--card); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--r-md);
  padding: var(--s-5) var(--s-7); font-size: var(--t-base);
  box-shadow: var(--shadow-2);
  animation: toast-in var(--dur-2) var(--ease) forwards;
}
/* A failure is not decoration: it gets the urgent edge, on the side the
   reader's language starts from. */
.toast-err > span { border-inline-start: 3px solid var(--urgent-ink); }
/* And the operator screen's success variant. Same box, same place; only the
   edge differs, so a grant and a refusal are told apart without reading. */
.toast-ok > span { border-inline-start: 3px solid var(--ok); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
  /* The box stays — it is information. It just stops sliding. */
  .toast > span { animation: none; }
}

/* ---- the pager, shared by the activity log and the profile's extra facts ----
 *
 * Two corrections to the first cut, both visible the moment it rendered.
 *
 * SMALLER BUTTONS. `.ghost` is 11px/22px and 600 weight because it is a
 * primary action — "Delete this document", "Stop viewing". A page turn is
 * navigation inside one card, and at that size two of them outweighed the five
 * rows they page through.
 *
 * THE THREE PARTS ARE ONE GROUP, CENTRED — not pushed to the card's edges.
 * `.controls` is a plain flex row, so the indicator first sat wherever the
 * button before it happened to end. `space-between` centred it but stranded a
 * 53px button in each far corner of a full-width card, which reads as a page
 * footer rather than as one control. Centring the group keeps the count in the
 * middle AND the buttons next to what they act on.
 *
 * `min-width` on the count, so the buttons do not shift as the number changes,
 * and it is wide enough for the longest of the fourteen ("Seite 1 von 4",
 * "صفحة 1 من 4"). tabular-nums stops the digits themselves jittering.
 *
 * nowrap, because a pager that breaks onto two lines stops reading as one
 * control — and at three short items there is nothing to gain by wrapping.
 */
.pager { justify-content: center; gap: var(--s-6); flex-wrap: nowrap; }
/* The gap is what gives, not the arrows. nowrap above is deliberate, so when
   the three items are 6px wider than a 320px column something has to shrink —
   and with the default minimum that something was the Previous label, which
   broke onto two lines and made the arrow taller than the dots beside it.
   Two gaps at 8px instead of 12px buy back 8px, which is enough, and the
   control still reads as one piece. */
/* The ARROWS only. `.pager button` would also match the dots inside
   .pagerdots and beat their own padding on specificity, which is how a
   7px dot ends up in a 14px-wide box. */
.pager > button.ghost { padding: 7px 14px; font-size: var(--t-sm); }
/* The gap and the side padding give; the labels do not. nowrap above is
   deliberate, so when the three items come to 245px in a 231px column
   something has to shrink — and with a flex item's default minimum that
   something was the Previous label, which broke onto two lines and left the
   arrow taller than the dots beside it ("E mëparshme" at 320px). Four pixels
   off each gap and six off each side of the two arrows is the 14px needed.
   AFTER the rule above, not before: that one sets `padding` as a shorthand, so
   at equal specificity it would otherwise reset the inline padding set here. */
@media (max-width: 430px) {
  .pager { gap: var(--s-4); }
  .pager > button.ghost { padding-inline: var(--s-4); }
}
.pager .pageof {
  min-width: 8rem; text-align: center; white-space: nowrap;
  font-size: var(--t-md); font-variant-numeric: tabular-nums;
  /* MARGIN ZERO, and this is the whole of why "Page 1 of 11" floated above the
     Previous and Next beside it. The span carries `.sub` for its colour, and
     `.sub` carries `margin: 0 0 22px` for use as a paragraph. A flex row with
     align-items:center centres the MARGIN BOX, so 22px of nothing underneath
     lifted the text 11px — the row was aligned correctly and looked broken. */
  margin: 0;
}

/* Page dots. A button each, never a decorated span — see _pagerHtml.
 *
 * The DOT is 7px and the TARGET is not: the padding gives every one a hit area
 * comfortably past the 24px WCAG 2.2 minimum, on a control that is otherwise
 * exactly the kind a thumb misses. Making the dot itself bigger would have
 * bought the same target and a row of headlights. */
.pagerdots { display: flex; align-items: center; gap: var(--s-2); }
.pagerdot {
  /* 9px all round, so the 7px dot sits in a 25x25 target. Measured, because
     5px of side padding looked identical on screen and produced a 17px-wide
     box — under WCAG 2.2's 24x24 minimum on the axis nobody checks, on a
     control whose whole problem is that it is small. */
  border: 0; background: none; padding: 9px; line-height: 0; cursor: pointer;
  border-radius: var(--r-pill);
}
.pagerdot::before {
  content: ""; display: block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--line);
  transition: background var(--dur-1) var(--ease), transform var(--dur-1) var(--ease);
}
.pagerdot:hover::before { background: var(--muted); }
.pagerdot.on::before { background: var(--accent); transform: scale(1.25); }
@media (prefers-reduced-motion: reduce) {
  .pagerdot::before { transition: none; }
}


/* ------------------------------------------------------------------------
   A FINGER CANNOT HOVER, BUT IT LEAVES :hover BEHIND.

   Every card here lifts on hover — 2px, 3px, a shadow, a moved chevron — which
   is right with a pointer and wrong with a thumb. A touch device has no way to
   stop hovering: tapping a card applies :hover, the card rises, and the state
   STAYS applied until something else is tapped. So the last thing the reader
   sees on the screen they are leaving is the card they touched, lifted and
   held there, and because that lifted card is what the outgoing snapshot
   captures, it then sits frozen mid-lift for the length of the cross-fade.

   Reported exactly that way: "it goes up a little bit and freezes, then you
   are navigated". Both halves are this rule, and the transition only made the
   freeze long enough to notice.

   `(hover: none)` is the real question — not screen width. A phone, a tablet
   and a touchscreen laptop all answer it the same way, and a laptop with both
   a trackpad and a touchscreen answers `hover: hover` and correctly keeps the
   lift. Written as one reset AFTER every rule it undoes, rather than wrapping
   eight rules scattered over three thousand lines, so a card added later
   cannot forget to opt out.

   Only the movement is undone. The colour and border changes stay: they cost
   nothing when they linger, and reverting each one correctly per card is the
   kind of edit that silently gets one wrong. ------------------------------ */
@media (hover: none) {
  .stat:hover, .space:hover, .doc:hover, .doc-tile:hover,
  .orow:hover, .astat:hover { transform: none; }
  .space:hover .chev { transform: none; }
  /* The chevron is mirrored in RTL by a transform of its own, so "no hover
     movement" here means back to the mirror, not back to nothing. */
  [dir="rtl"] .space:hover .chev { transform: scaleX(-1); }
  [dir="rtl"] .orow:hover { transform: none; }
}


/* ------------------------------------------------------------------------
   UPLOADING SHOULD NOT LOOK LIKE THE SITE RELOADING.

   /new and /vault are two documents, so sending the reader to their letter is
   a real navigation: the page goes blank, the header and the sidebar are torn
   down, and both are built again a moment later. Measured locally, click to
   first paint of the new page is ~220ms of white — and locally is the good
   case, with the stylesheet and every script already in the service worker.
   It reads as the site restarting at the exact moment the reader has handed
   over something they care about.

   `navigation: auto` makes the browser cross-fade the two documents instead.
   Both pages load this stylesheet, which is the whole opt-in — there is no
   script, and a browser without view transitions simply navigates as before.

   The named elements do not cross-fade, they PERSIST: the header and the rail
   are the same furniture on both pages, and fading them out and back in is
   what makes a navigation feel like a reload. Naming them tells the browser
   they are the same thing in both documents, so only the content between them
   changes.
   ------------------------------------------------------------------------ */
/* ------------------------------------------------------------------------
   WHEN THE OPERATING SYSTEM TAKES THE PALETTE AWAY.

   Windows High Contrast, and "Increase contrast" on other platforms, replace
   every colour on the page with a handful of system ones. That is the point:
   somebody who needs a specific pair of colours to read at all gets them
   everywhere. Backgrounds and shadows are discarded; borders and outlines are
   kept and remapped.

   Measured before this block existed: all eight urgency dots on the documents
   screen rendered as identical white circles. The entire signal — which of
   these letters needs action — was gone, for exactly the readers most likely
   to be relying on assistive settings.

   The shapes in the `.dot` rule survive on their own, because they are drawn
   with border and outline rather than fill and shadow. What is restored here
   is the SEMANTIC part: the three states keep their own colours, which is what
   `forced-color-adjust: none` is for, and it is used here rather than anywhere
   else in this file because this is the one place colour carries meaning that
   no system colour can express.
   ------------------------------------------------------------------------ */
@media (forced-colors: active) {
  .dot { forced-color-adjust: none; }
  /* The ring on `.dot` (unclassed = informational) is a border, so it is kept
     and remapped by the OS on its own — nothing to restore. */
  .dot.action-needed { background: var(--action-ink); border-color: var(--action-ink); }
  .dot.URGENT { background: var(--urgent-ink); border-color: var(--urgent-ink);
    outline-color: var(--urgent-ink); }
}

@view-transition { navigation: auto; }

header.top { view-transition-name: chrome-top; }
.sidebar   { view-transition-name: chrome-rail; }

/* THE ONE PIECE OF MOTION IN THIS APP THAT NOBODY CHOSE.
 *
 * Left alone, a view transition cross-fades over 250ms on Chrome's own curve.
 * Every other moving thing here is 140ms or 220ms on `--ease`, from the scale
 * at the top of this file — so the LARGEST movement in the product, the one
 * that plays on every navigation, was the only one running on a number that
 * came from the browser rather than from the design.
 *
 * 250ms is also simply slower than this app should feel. Measured end to end
 * on a 4x-throttled phone: 76ms of work before anything moves, then 250ms of
 * fade — a third of a second to change tabs, most of it waiting out an
 * animation. At `--dur-2` the same navigation lands in under 300ms and the
 * fade stops being the slowest part of it.
 *
 * The group is what MOVES an element between its old and new position, which
 * is what `chrome-top` and `chrome-rail` need when the header or the rail
 * changes height between screens; old/new are the cross-fade of the content
 * between them. All three on one scale, or the parts disagree mid-flight.
 *
 * Reduced motion needs nothing here — the block below turns the whole
 * transition off rather than making it a slower version of itself. */
::view-transition-group(root),
::view-transition-group(chrome-top),
::view-transition-group(chrome-rail),
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--dur-2);
  animation-timing-function: var(--ease);
}

/* The whole point of the transition is to be calmer than a hard cut, so a
   reader who has asked for less motion gets the hard cut back rather than a
   slower version of it. */
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}


/* ---- by model: a ranked bar list ----
   The bar encodes SPEND and nothing else. Calls ride as a label beside it,
   because two measures of different scale on one axis is the dual-axis
   mistake — the reader cannot tell which of "cheap" and "busy" a bar means.

   One hue for every row, deliberately. Identity is the row's own label, so a
   colour per model would carry no information; a categorical palette earns
   its place only where colour is the sole thing telling two series apart.

   The track stays visible under the fill so a near-zero row still reads as a
   row rather than as blank space — and the fill has a 2px floor for the same
   reason. */
.modelbars { display: grid; gap: var(--s-4); margin-top: var(--s-4); }
.mrow {
  display: grid; align-items: center; gap: var(--s-4);
  grid-template-columns: minmax(9rem, 14rem) 1fr minmax(11rem, auto);
}
.mname { font-weight: 600; }
/* One row means no bar to align against, so the figure sits straight after the
   name instead of across an empty track. */
.modelbars.bare .mrow { grid-template-columns: minmax(9rem, 14rem) auto; }
.mtrack {
  background: var(--chip-bg); border-radius: var(--r-pill);
  height: var(--s-5); overflow: hidden;
}
.mfill {
  background: var(--accent); height: 100%; border-radius: var(--r-pill);
  /* Rounded data-end only; the baseline end stays square so the bar reads as
     anchored rather than floating.
     LOGICAL corners, because the bar grows from the right in Arabic — physical
     `left` would square off the growing end and round the anchored one, which
     is the mirror of what this is for. */
  border-start-start-radius: 0; border-end-start-radius: 0;
}
/* `end`, not `right`: in Arabic the figure belongs on the other edge, and a
   physical side here is the exact bug test_rtl_arrows exists to catch. */
.mfig { text-align: end; font-variant-numeric: tabular-nums; }
/* Stacked on a narrow screen: three columns at 430px puts the figure on its
   own line anyway, and the bar between name and number reads worse than under
   both. */
@media (max-width: 720px) {
  .mrow { grid-template-columns: 1fr; gap: var(--s-1); }
  .mfig { text-align: start; }
}

/* ============================================================================
   THE PHONE PAYS FOR EVERY PIXEL OF CHROME, so it is charged less for it.

   Measured on a 360×800 phone before this block: the Documents screen spent
   498px — five eighths of the viewport — on the brand bar, the nav pill, the
   title, the toolbar, the search card and the count line, and the first
   document began below the fold. The Finances screen put its four figures one
   per row and pushed the ledger a screen and a half down.

   The column counts are fixed where they are defined (`.doc-grid`,
   `.mcard` — both now take `min(…, half a row)` so two always fit). What is
   left is the padding, and each of these is the same rule the desktop uses
   with a phone's budget rather than a new layout. Nothing here changes above
   560px, and nothing here changes what is on the screen — only how much room
   the frame around it takes.
   ========================================================================= */
@media (max-width: 560px) {
  /* 14px of gutter rather than 18: 8px of content width, which is what decides
     whether two 168px tiles fit at 360. `max()` keeps the safe area — on a
     notched phone in landscape the rounded corner is still wider than this. */
  .wrap { padding-inline: max(14px, var(--sa-side));
          padding-block-start: calc(10px + var(--sa-top));
          /* ROOM UNDER THE LAST ROW. The nav is fixed to the foot of the
             screen below this width, so without it the bar covers whatever it
             was meant to sit beside. ~56px of bar plus the home indicator. */
          padding-block-end: calc(56px + var(--sa-bottom)); }
  /* AND CLEARANCE FOR BOTH when the chat button is up: it now rides above the
     nav rather than 20px off the bottom, so the last row has to clear the two
     of them. Same selector as the all-widths FAB rule, which outranks a bare
     `.wrap` and would otherwise win here. */
  body:has(.chatfab:not([hidden])) .wrap {
    padding-block-end: calc(152px + var(--sa-bottom)); }
  .spacehead-row { margin: 2px 0 12px; }
  /* THE COUNT AND THE "+" SHARE A LINE. Two full-width lines for a five-word
     heading and one icon button is 67px, and this screen has the least room to
     spare of any in the app. `.unfilestrip` — the drop target that appears only
     mid-drag — takes its own line when it does appear, or it would land beside
     the button at a third of its width. */
  /* The margins move to the ROW, and the heading inside it keeps none: with
     the h3 carrying its own 12px the button was centred against a box that was
     taller than the text in it, and sat visibly high beside the count. */
  .countrow { display: flex; flex-wrap: wrap; align-items: center;
              justify-content: space-between; gap: var(--s-6); margin: 12px 0 8px; }
  .countrow .countline { margin: 0; }
  .countrow .folderbar { margin: 0; }
  .countrow .unfilestrip { flex: 1 1 100%; }
  .countline { margin: 12px 0 8px; }
  /* THE FILTER TOGGLE KEEPS ITS OWN LINE, and that was tried the other way.
     Beside the search box it saves 54px, and at 360px — the commonest phone
     width there is — it leaves the box 206px when the placeholder needs 217,
     so "Search your documents" rendered as "Search your docun". French needs
     220px and misses at every phone width. A row that fits in English on a
     Pixel and nowhere else is not a layout; the 54px stays spent. */
  .docbar { margin: 2px 0 10px; padding: 10px; }
  .searchrow input { padding-block: 9px; }
  .doc-grid > .grid-head { margin-top: 12px; }
  .tile-body { padding: 8px 9px 10px; }
  /* A HOME MINI-LIST TITLE GETS A SECOND LINE INSTEAD OF AN ELLIPSIS.
     One nowrap line is right on a desktop, where these fit. On a 360px phone
     the same rule cut "Residence-permit appointment, room 108" to
     "Residence-permit appoi…" — it needed 284px and had 231 — and what it cut
     is the half that says WHERE to go. Two lines, then the ellipsis, so a
     pathological title still cannot push the card open.
     The clamp goes on the <bdi>, not on `.mtitle`: the isolated run is where
     the truncation has to happen, or an Arabic UI trims a German title from
     the wrong end — see the note beside the base rule. */
  .mini-item .mtitle { white-space: normal; }
  .mtitle > bdi { white-space: normal; overflow-wrap: anywhere;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
  /* The money tiles and the ledger under them. Same trim, same reason: four
     figures and a list of spends, not four panels and a list of panels. */
  .mcards { gap: var(--s-5); margin-bottom: 14px; }
  .mcard { padding: 11px 12px; }
  .lrow { padding: 9px 0; }
}
