/* Soroudi base — the layout language every dashboard shares.
 *
 * THIS FILE IS THE SOURCE. tools/sync-shared.js copies it into every app as
 * public/base.css; those copies are OUTPUT, and `npm run check-shared` fails the
 * build if one has been edited in place. Change it here and run `npm run sync`.
 * A per-app tweak belongs in that app's own styles.css, which loads after this
 * and wins.
 *
 * WHERE IT COMES FROM. All of this was worked out in soroudi-trips over a long
 * stretch of tuning, and this file is that work extracted so its siblings stop
 * being separate products. Trips still owns the PALETTE (the six themes in
 * each styles.css) and it owns the design decisions here; what it does not own
 * any more is a private copy of them.
 *
 * WHAT IT DELIBERATELY DOES NOT CONTAIN: anything that knows what an app shows.
 * A trip card, a transaction row and a medication list are different objects and
 * should look different. This file is the grid they sit on -- type scale, the
 * four display preferences, focus, motion, and the settings controls that change
 * them -- not the objects themselves.
 *
 * HOW THE PREFERENCES TRAVEL BETWEEN APPS, which is the part that makes the
 * dashboards feel like one: they are read from `soroudi_display_v1` in
 * localStorage, and since the dashboards were moved under one origin
 * (dash.rajuabju.com/<app>/) that storage is genuinely shared. Setting the text
 * size in Trips sets it in Finance, with no server, no syncing and no API. The
 * theme is the exception and still goes through the SHARED D1 database, because
 * it predates the move and has to survive a new device.
 */

/* ---------------------------------------------------------------------------
   The unit everything is measured in
   --------------------------------------------------------------------------- */

/* --px is one CSS pixel expressed in rem: 1/16rem, exactly 1px while the root
   font size is the 16px browsers ship with. Every size below multiplies by it
   instead of writing px directly.
 *
 * The point is what happens when the root ISN'T 16px. A px is a px whatever the
 * reader has asked for, so a stylesheet written in px ignores the browser's
 * font-size setting and Android's text-scaling slider -- the two controls a
 * person with poor eyesight actually reaches for. In rem it follows them.
 *
 * --fs is separate and does its own job: it is the app's OWN text-size
 * preference. The two multiply, so a reader who has set both gets both. */
:root {
  --px: 0.0625rem;
  --fs: 1.22;
  --lh: 1.5;
  --pad: 1;
  --main-w: 1600px;
  --cards-w: 1000px;
  --detail-w: 1200px;
}

/* A size in the app's scale. Use it for every font-size:
     font-size: var(--t-14);   not   font-size: 14px;
   The named steps exist so a stylesheet does not fill up with calc() and so the
   set of sizes in use stays small enough to be a scale rather than a habit. */
:root {
  --t-11:   calc(11   * var(--px) * var(--fs));
  --t-12:   calc(12   * var(--px) * var(--fs));
  --t-12-5: calc(12.5 * var(--px) * var(--fs));
  --t-13:   calc(13   * var(--px) * var(--fs));
  --t-13-5: calc(13.5 * var(--px) * var(--fs));
  --t-14:   calc(14   * var(--px) * var(--fs));
  --t-14-5: calc(14.5 * var(--px) * var(--fs));
  --t-15:   calc(15   * var(--px) * var(--fs));
  --t-16:   calc(16   * var(--px) * var(--fs));
  --t-17:   calc(17   * var(--px) * var(--fs));
  --t-20:   calc(20   * var(--px) * var(--fs));
  --t-25:   calc(25   * var(--px) * var(--fs));
}

/* ---------------------------------------------------------------------------
   The four display preferences
   ---------------------------------------------------------------------------
   Set as data attributes on <html> by the pre-paint script in each index.html,
   so they apply before the first frame rather than causing a visible reflow. */

/* WHICH THEME THE BROWSER'S OWN CONTROLS SHOULD BE DRAWN IN.
   ---------------------------------------------------------------------------
   `color-scheme` is what a <select> popup, a date picker, a spin button and a
   scrollbar look at. It was never set -- the only mention of it anywhere was
   `<meta name="color-scheme" content="light dark">`, which declares that both
   are SUPPORTED and says nothing about which is in force, so every native
   control followed the OS instead of the palette the person chose.

   On a phone in light mode running the Navy palette, that meant the asset-class
   dropdown opened as a WHITE popup while its text was the dark theme's muted
   grey: unreadable, and unreadable only when open, which is why it survived
   several screenshots. The date inputs in every app had the same bug waiting.

   The three dark palettes and the three light ones are listed explicitly rather
   than inferred. A seventh theme must be added here as well as to the palette
   -- and if it is forgotten, it inherits `light`, which is the safe half:
   wrong-looking rather than invisible. */
:root { color-scheme: light; }
:root[data-theme="dark"],
:root[data-theme="forest"],
:root[data-theme="tangerine"] { color-scheme: dark; }
:root[data-theme="light"],
:root[data-theme="desert"],
:root[data-theme="crimson"] { color-scheme: light; }

:root[data-text="small"]  { --fs: 1; }
:root[data-text="medium"] { --fs: 1.10; }
:root[data-text="large"]  { --fs: 1.22; }
:root[data-text="huge"]   { --fs: 1.38; }

/* Compact and Default both keep one primary column; Compact is just narrower.
   Full is where content is allowed to tile, and only there. */
:root[data-width="compact"] { --main-w: 1160px; --cards-w: 600px;  --detail-w: 720px; }
:root[data-width="default"] { --main-w: 1600px; --cards-w: 1000px; --detail-w: 1200px; }
:root[data-width="full"]    { --main-w: 100%;   --cards-w: 100%;   --detail-w: 96vw; }

:root[data-spacing="tight"]   { --lh: 1.35; }
:root[data-spacing="normal"]  { --lh: 1.5; }
:root[data-spacing="relaxed"] { --lh: 1.75; }

/* Density multiplies padding, not type. An app applies it to its own rows:
     padding: calc(12px * var(--pad)) calc(14px * var(--pad));
   Shrinking the type instead would be a worse trade -- the reader asked for
   less whitespace, not for smaller words. */
:root[data-density="comfortable"] { --pad: 1; }
:root[data-density="cosy"]        { --pad: .78; }
:root[data-density="compact"]     { --pad: .58; }

/* THE TYPEFACE, IN ONE PLACE, BECAUSE IT WAS IN NONE.

   This file has carried the type SCALE since it was extracted, but never the
   FACE, so every app declared its own body font and two of them forgot. Home &
   Vehicles and Life Admin shipped in Times New Roman at the browser's default
   16px while their siblings were in the system sans at 15 -- a serif dashboard
   sitting one tab away from sans ones, which is exactly how the
   family stopped looking like one product.

   It hid for two reasons worth knowing. The apps that got it right mostly used
   the `font:` SHORTHAND, so a search for `font-family` finds nothing in them
   (finance's own stylesheet carries a comment saying that trap cost someone an
   afternoon). And an app with no declaration at all looks fine in a screenshot
   to anyone not comparing two tabs side by side.

   --font-sans rather than a bare declaration: an app that genuinely needs a
   different face for one element can reach for the token instead of pasting the
   stack once per app. */
:root {
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

/* THE SCROLLBAR'S SPACE, RESERVED WHETHER OR NOT THERE IS A SCROLLBAR.
   On Windows, where the scrollbar takes real layout width rather than floating
   over the page, a view that fits the window and a view that does not get
   content areas of different widths. Everything centred with `margin: 0 auto`
   -- the header bar, main, the view tabs -- then sits about 8px further left
   the moment a list grows past the fold, and slides back when it shrinks.
   Nothing is mis-sized; the whole page moves. Danny reported it on 2026-09-13
   as a shift in trips, purchases and finance, which are the apps whose views
   change height enough to cross that line while switching.

   `stable` reserves the gutter always, so the width never changes. It is
   inert where scrollbars are overlays (macOS, iOS, Android), which is why the
   Browser pane cannot reproduce the bug: it reports a scrollbar width of 0.
   No layout moves on those platforms and nothing is reserved. */
html { scrollbar-gutter: stable; }

/* THE `hidden` ATTRIBUTE, MADE TO ACTUALLY HIDE THINGS.
   ---------------------------------------------------------------------------
   `hidden` is how every app here shows and hides, and the browser's own
   `[hidden] { display: none }` lives in the UA stylesheet -- which ANY author
   rule outranks regardless of specificity. Learned in purchases, where
   `.btn { display: inline-flex }` silently un-hid a Delete button, and hit
   again on 2026-09-13 in family, where `.field { display: flex }` left a
   "Died on" box on screen for somebody who is alive.

   Trips, purchases, medical and finance each carried their own copy of this
   rule; hub, home, admin and family did not, which is exactly the split a
   shared file exists to end. The four copies are now redundant rather than
   load-bearing -- the rule is identical, so they change nothing. */
[hidden] { display: none !important; }

/* THE BOX MODEL, and it belongs here for the same reason the typeface does.
   Five apps carried `* { box-sizing: border-box }` in their own styles.css;
   Home & Vehicles and Life Admin carried nothing, so those two ran the whole
   page on content-box while their five siblings ran on border-box.

   Danny reported it on 2026-09-13 as the bottom nav being "slightly larger" in
   exactly those two apps, which is precisely what it looked like from outside.
   The mechanism: `.famnav a` sets `min-height: var(--famnav-h)` (54px). Under
   border-box that is the whole tab. Under content-box the 6px+6px padding and
   the 2px border are added to it, so the bar came out 68px instead of 54 --
   measured 67.18px against 59.64px on the others, at 375px with his larger
   text size. Nothing about the nav was wrong; it was reading a different box
   model underneath.

   It was never only the nav. Every padded element in those two apps was bigger
   than its counterpart -- the nav was just the one place two apps sit side by
   side often enough to notice.

   This file is authored for border-box throughout, which is the other half of
   the argument for putting it here: the five apps that declared it were not
   adding a preference, they were repairing an omission in their shared base.
   The five copies are now redundant rather than wrong, and are left where they
   are -- they declare the same value, and deleting a line from five stylesheets
   to prove a point is how a sixth thing breaks. */
*, *::before, *::after { box-sizing: border-box; }

body {
  /* THE BROWSER'S 8px, WHICH FIVE APPS WERE STILL WEARING. Only the hub and
     trips reset it, so in Purchases, Medical, Finance, Home & Vehicles and Life
     Admin the whole page sat 8px in from the window -- including the sticky
     header bar, which then stopped 8px short of each edge while the family nav
     along the bottom, being position:fixed, ran the full width. A header that
     does not reach the edge above a bar that does. Measured 2026-09-13: .top at
     8→1001 against .famnav at 0→1009.
     It belongs here for the same reason the typeface and the box model do: it
     is not a design decision any app was making, it is one five of them had
     never got around to undoing. */
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--t-15);
  line-height: var(--lh);
}

/* Form controls do NOT inherit the page font: a <button> or <input> with no
   font of its own is drawn by the platform, which is why Life Admin's buttons
   were Times too and trips' were Arial while its body was not. `font: inherit`
   is the whole fix, and it belongs here rather than in seven stylesheets. */
button, input, select, textarea { font-family: inherit; }

/* The page's own column. Every app's main content sits in one of these so that
   "Compact" and "Full" mean the same thing in every one. */
.wrap { width: 100%; max-width: var(--main-w); margin-inline: auto; }

/* ---------------------------------------------------------------------------
   Settings controls
   ---------------------------------------------------------------------------
   The segmented control the display preferences are chosen with. Identical in
   every app, because a settings screen that differs per dashboard is the first
   place "one product" stops being true. */

.seg { display: flex; gap: 6px; flex-wrap: wrap; margin: 2px 0 16px; }
/* Two rows belonging to one control sit closer than two separate controls do. */
.seg.seg-row-tight { margin-bottom: 6px; }
.seg button {
  border: 1px solid var(--border); background: var(--surface-2); color: var(--text);
  border-radius: 9px; padding: 8px 14px; cursor: pointer;
  font: inherit; font-size: var(--t-13-5); font-weight: 600;
}
.seg button.on { background: var(--accent); border-color: transparent; color: var(--accent-contrast); }
.seg-label {
  font-size: var(--t-12-5); font-weight: 700; color: var(--muted);
  text-transform: uppercase; letter-spacing: .04em;
}
/* A live sample of the setting being chosen, so the choice is visible before it
   is made rather than after. */
.seg-preview {
  border: 1px dashed var(--border); border-radius: 10px; margin-bottom: 4px;
  /* Density-aware, so the preview box reacts to the density setting the same way
     the rows it is previewing do. A fixed padding here would make the preview a
     poor sample of the thing it is meant to preview. */
  line-height: var(--lh); padding: calc(12px * var(--pad)) calc(14px * var(--pad));
}
.seg-preview .p1 { font-weight: 650; font-size: var(--t-14-5); }
.seg-preview .p2 { color: var(--muted); font-size: var(--t-13); margin-top: calc(3px * var(--pad)); }

/* ---------------------------------------------------------------------------
   The app header
   ---------------------------------------------------------------------------
   Measured across the five before this existed: heights of 56, 62, 65, 70 and
   80px, three different class names, brand marks between 20 and 34px, wordmarks
   between 18.3 and 30.5px, and inner columns of 1180px, 1600px and none. Each
   was reasonable on its own and the set was not, which is exactly what you
   notice switching apps inside one installed PWA.

   The geometry is here; the markup stays each app's own. An app opts in by
   giving its header bar `.top` and wrapping its contents in `.top-in`.

   THE SAFE-AREA INSET GOES ON THE BAR, NOT THE CONTENT. On an installed iPhone
   the bar runs under the notch, so the padding that clears it has to sit on the
   element painting the background -- put it on the inner column and the
   background stops short of the top of the screen. Finance had it on the bar's
   BOTTOM padding, which pushed its own content down by the height of a notch it
   was not clearing. */
.top {
  position: sticky; top: 0; z-index: 40;
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: saturate(1.4) blur(12px);
  -webkit-backdrop-filter: saturate(1.4) blur(12px);
  border-bottom: 1px solid var(--border);
  padding-top: env(safe-area-inset-top);
}
.top-in {
  max-width: var(--main-w); margin: 0 auto;
  padding: calc(12px * var(--pad)) 16px;
  display: flex; align-items: center; gap: 12px;
}
/* The wordmark is a name, not a control: nothing in the brand should invite a
   tap, because the only header action that navigates is Refresh. */
.brand { display: flex; align-items: center; gap: 9px; min-width: 0; }
/* Scaled with the text, not pinned: trips did this and the others did not, so
   raising the text size grew the wordmark and left the mark beside it stranded
   at its original size. height:auto keeps whatever aspect the file has. */
.brand-mark { width: calc(26 * var(--px) * var(--fs)); height: auto; display: block; flex: 0 0 auto; }
.brand-name {
  /* THE BROWSER'S h1 MARGIN, WHICH SET THE HEIGHT OF TWO HEADERS.
     Six of the seven dashboards that carry .brand-name mark the wordmark up
     as <h1 class="brand-name"> and trips uses a <span>. An h1 carries margin: 0.67em 0 from the user-agent
     stylesheet, and this rule never cleared it -- so the wordmark added 27.8px
     of its own margin to a 31.1px line, making .brand 58.9px tall inside a row
     whose other controls are 43.9px, and the whole bar 83.9px instead of 67.9px.
     Purchases, Medical and Finance happened to escape it because their own
     stylesheets reset heading margins globally; Home & Vehicles and Life Admin
     did not, which is the entire reason those two headers were taller.
     Measured 2026-09-13. */
  margin: 0;
  font-size: var(--t-17); font-weight: 650; letter-spacing: -.01em;
  /* One line, and the name is what gives way on a narrow phone -- the actions
     beside it are the things a person came to press. */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }

/* A phone gives back the horizontal padding first, because the wordmark and the
   actions are competing for a 390px line and the actions must not wrap. Two of
   the four apps had this and two did not, which left the bar at three different
   heights on a phone while looking identical on a desktop. */
@media (max-width: 560px) {
  .top-in { padding: calc(10px * var(--pad)) 12px; }

  /* MARK ONLY on a phone. The wordmark is redundant there -- you opened the
     app, and the family nav along the bottom already says which one you are in
     -- and it was not fitting: three of the four truncated their own name to
     "Soroudi Purcha…". Dropping it hands the width to the controls, so nothing
     has to truncate or crowd the mark. Trips worked this out first and had it
     alone; it belongs to all of them. */
  .brand-name { display: none; }
  .brand { flex: 0 0 auto; gap: 0; }
  .brand-mark { width: calc(28 * var(--px) * var(--fs)); }
}

/* ONE HEIGHT FOR HEADER CONTROLS, which is what actually made the bars differ.
   The brand row is identical in every dashboard; the bar around it was 62,
   70, 80 and 60px because the tallest button in each set was 37, 45, 45 and
   35px. A header's height is decided by its biggest control, so that is where
   the consistency has to be enforced -- matching the paddings would only hold
   until one app's button gained a border.

   Scaled with the text like everything else, and comfortably above the 44px
   touch target once the reader has asked for larger type. Vertical padding is
   zeroed because the height is now explicit; the flex centring each app already
   uses does the rest. */
.top-in .btn,
.top-in .icon-btn,
.top-in .mini-btn,
/* .text-btn was missed when this rule was written, and `.top-actions > button`
   did not catch it either because trips calls its row .header-actions. So trips'
   Sign Out sat at 37px beside four 44px controls -- not tall enough to change
   the bar's height, which is why it survived the pass that set these, and
   visibly the odd one out to anyone looking at the row. Measured 2026-09-13. */
.top-in .text-btn,
.top-actions > button,
.header-actions > button,
/* The hub's header is .head inside main, not a sticky .top-in bar, so none of
   the selectors above reach its theme button. Named here rather than given a
   height in the hub's own stylesheet, so there is still ONE place that
   decides how tall a header control is. Added 2026-09-13 with that button. */
.head > .btn,
.hdr-btn {
  height: calc(36 * var(--px) * var(--fs));
  min-height: 0;
  padding-top: 0; padding-bottom: 0;
}

/* ...AND THE CONTENTS CENTRED INSIDE IT. Giving a control an explicit height
   says nothing about where its text goes. A <button> centres its own label, so
   this was invisible until the hub's Sign Out went in as an <a class="btn">:
   an anchor is display:block, so the label sat 2px from the top of a 44px chip
   with 17px of air under it. Reported 2026-09-13.

   Scoped to `.head > .btn` rather than added to the height rule above, which
   also matches buttons in the seven dashboards' headers -- those already
   centre, and
   turning them into flex containers could move something that is currently
   right for a reason this file cannot see. */
.head > .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* An anchor styled as a button keeps the underline the browser gives links,
     which is what "Sign Out" was wearing next to an un-underlined theme
     button. */
  text-decoration: none;
  /* The same corner as .hdr-btn beside it. The hub's own .btn rule sets a flat
     10px, which does not scale with the text and so drifted from the theme
     button's 10.98px at Danny's size -- two chips side by side with different
     corners. */
  border-radius: calc(9 * var(--px) * var(--fs));
}

/* THE VIEW SWITCHER, and where it belongs.
 *
 * A row of segmented controls sitting at the top of the PAGE, not a second bar
 * in the header. The family nav along the bottom is the app switcher; a header
 * that also looks like navigation is how a person stops being able to tell
 * which of the two moves them between dashboards. Finance worked this out and
 * the other three had not: Life Admin, Home & Vehicles and Medical each ran
 * their sections as a bare row of text inside the sticky header -- a third
 * navigation surface, stacked under the first two. Danny asked for finance's
 * arrangement everywhere on 2026-09-13.
 *
 * Trips is deliberately exempt and keeps its own chips: they filter a list
 * rather than switch a view, which is a different job that should not be
 * wearing the same clothes.
 *
 * BOTH SELECTION ATTRIBUTES are styled. Finance and Medical mark the live tab
 * with aria-selected="true" (they are tablists); Home & Vehicles and Life Admin
 * use aria-current="page" (they are navs). Both spellings are correct for the
 * element each app chose, and styling the pair here means none of their
 * JavaScript had to change to adopt this -- the thing that would otherwise have
 * turned a stylesheet change into four behavioural ones.
 *
 * Every colour is a token all eight apps define, so this paints correctly in
 * all six palettes without naming one. */
.views {
  display: flex;
  gap: calc(4 * var(--px));
  margin: 0 0 calc(14px * var(--pad));
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: calc(4 * var(--px));
  /* Seven sections do not fit across a phone at a readable size, and truncating
     "Medications" to "Medicati…" is worse than scrolling. `flex: 1 0 auto`
     below lets them share the width when there is room and keep their own when
     there is not, so the strip scrolls only on the screens that need it. */
  overflow-x: auto;
  scrollbar-width: none;
}
.views::-webkit-scrollbar { display: none; }
.views button {
  flex: 1 0 auto;
  padding: calc(8px * var(--pad)) calc(12px * var(--pad));
  border: 0;
  border-radius: var(--r-sm);
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: calc(13 * var(--px) * var(--fs));
  font-weight: 550;
  cursor: pointer;
  white-space: nowrap;
}
.views button[aria-selected="true"],
.views button[aria-current="page"] {
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow);
}
.views button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* The count beside a section name: the reason a section is worth opening, and
   an empty one says so without being opened. Medical is the only app with
   these today; it is here rather than there so a second app that wants them
   does not invent a second look. */
.views button .n {
  display: inline-block;
  margin-left: calc(5 * var(--px));
  font-size: calc(11.5 * var(--px) * var(--fs));
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

/* THE THREE CONTROLS EVERY DASHBOARD'S HEADER CARRIES: Refresh, Settings and
   the theme. Danny asked for them on 2026-09-13, after finding that trips had
   all three, Purchases and Medical hid Refresh and Display inside a "⋯" menu,
   and Finance, Home and Life Admin had a text chip reading "Display" and no
   Refresh at all -- four different answers to "how do I reload this page".
   Trips' gear was the one he picked, so trips is the reference here.

   The rule lives in base.css because the height rule above was not enough on
   its own: it made the controls the same HEIGHT while each app went on drawing
   them from its own `.icon-btn` or `.btn.icon`, which are per-app and were
   never the same width, radius or border. `.hdr-btn` is the whole control, so
   an app that uses it cannot draw it differently -- and the app-specific
   classes are left off these three buttons deliberately, so nothing local
   reaches them.

   An app's own primary action (trips' New Trip, purchases' Add) is NOT this:
   it is that app's own object and stays in that app's stylesheet. */
.hdr-btn {
  width: calc(36 * var(--px) * var(--fs));
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding-left: 0; padding-right: 0;
  border: 1px solid var(--border);
  border-radius: calc(9 * var(--px) * var(--fs));
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
  font-size: calc(16.5 * var(--px) * var(--fs));
  line-height: 1;
}
.hdr-btn:hover { background: var(--surface-2); }
/* A control that is doing something has to LOOK like it. Refresh spent a whole
   release feeling broken partly because a re-fetch that finds the same numbers
   changes nothing on screen -- pressing it and seeing nothing move is
   indistinguishable from pressing a dead button. shared/version.js adds this
   for the length of the work. */
.hdr-btn.is-busy { opacity: .6; cursor: default; }
.hdr-btn.is-busy .ico { animation: hdr-spin .7s linear infinite; transform-origin: 50% 50%; }
@keyframes hdr-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .hdr-btn.is-busy .ico { animation: none; }
}
.hdr-btn:focus-visible { outline: 2px solid var(--accent, currentColor); outline-offset: 2px; }
/* Drawn icons rather than glyph characters: ⚙ and ↻ render at a different size
   and weight on every platform, and at this size the gear read as a smudge.
   currentColor so the icon follows the button through hover and all six
   palettes without naming one. */
.hdr-btn .ico {
  width: calc(21 * var(--px) * var(--fs));
  height: calc(21 * var(--px) * var(--fs));
  display: block;
  fill: currentColor;
}

/* ---------------------------------------------------------------------------
   A bar that scrolls rather than wraps
   ---------------------------------------------------------------------------
   A row of filters or tabs that wraps to two lines on a phone pushes the content
   down and changes height as it is used. Scrolling keeps the page still. The
   scrollbar is hidden because the row is finger-scrolled, not pointer-scrolled. */
.scroll-x {
  display: flex; gap: 6px; overflow-x: auto; flex-wrap: nowrap;
  -webkit-overflow-scrolling: touch; scrollbar-width: none;
}
.scroll-x::-webkit-scrollbar { display: none; }
.scroll-x > * { flex: 0 0 auto; }

/* Wide content -- a table, a chart -- scrolls inside its own box. The page body
   must never scroll sideways. */
.scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* ---------------------------------------------------------------------------
   Modal sheets
   ---------------------------------------------------------------------------
   Geometry only. What a sheet contains, and how it is painted, stays with the
   app; where it sits and how tall it is are the same question in all of them.
   Trips has no <dialog> at all, so this reaches purchases, medical and finance. */
dialog.sheet {
  width: min(560px, calc(100vw - 24px));
  max-height: min(88vh, 900px);

  /* TOP-ANCHORED, NOT CENTRED. A dialog centred with `margin: auto` re-centres
     itself every time its content changes height -- a photo finishing loading,
     search results arriving, a field appearing as another is filled in -- so the
     whole panel slides upward and downward under the pointer while it is being
     used. Pinning the top edge means only the bottom edge moves, which nobody
     notices and nothing has to chase. */
  margin: 6vh auto auto;
}

@media (max-width: 560px) {
  dialog.sheet {
    /* A bottom sheet on a phone: full width, square top corners, sitting on the
       bottom edge where a thumb is. */
    width: 100vw; max-width: 100vw;
    border-radius: var(--radius) var(--radius) 0 0;

    /* dvh, NOT vh. On a phone browser vh is the LARGE viewport -- the height
       the page would have with the URL bar hidden -- so a sheet sized in vh and
       anchored to the bottom hangs off the screen for as long as that bar is
       showing, and its footer buttons are the part that goes. dvh is the
       viewport as it actually is at this moment. */
    max-height: calc(100dvh - 6vh);

    /* auto on top keeps it against the bottom; the inset keeps it clear of the
       home indicator on an installed iPhone, which a flat 0 did not. */
    margin: auto auto env(safe-area-inset-bottom);
  }
}

/* ---------------------------------------------------------------------------
   Focus
   ---------------------------------------------------------------------------
   Without this, focus falls to the browser default outline, which is
   near-invisible against Navy, Forest and Burnt. Keyed to :focus-visible so it
   appears for keyboard use and not on every tap. Two rings, light over dark, so
   it reads on any of the six palettes rather than only the one it was designed
   against. */
:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent);
  border-radius: 4px;
}
/* color-mix is recent; without it the outline alone still does the job. */
@supports not (color: color-mix(in srgb, red 50%, blue)) {
  :focus-visible { box-shadow: none; }
}

/* A status line for screen readers where the visible content is decorative
   shapes. Off-screen rather than display:none -- a hidden node is not read. */
.sr-status {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; padding: 0;
}

/* ---------------------------------------------------------------------------
   Motion
   ---------------------------------------------------------------------------
   Anything added to an app's own stylesheet should animate `transform` and
   `opacity` only. Those two belong to the compositor, so they cost no layout and
   no repaint and never touch the main thread -- which is what makes animation
   affordable on a phone at 8% battery in an airport. Animating width, height,
   top or margin runs layout every frame and is what makes a page feel hot in the
   hand. */

/* Reduced motion is not a nicety and not optional. Vestibular disorders make
   sweeping motion genuinely unpleasant, and the setting is how someone says so
   once rather than per app.
 *
 * Near-zero rather than `none` so animationend/transitionend still fire -- a
 * handler waiting for one would otherwise hang forever, which is the usual way
 * this well-meant block breaks a page. !important so it wins wherever an app's
 * own stylesheet puts its animations. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---- version chip and panel -------------------------------------------------
 *
 * Paired with shared/version.js, which is copied into every app the same way
 * this file is. Every colour here comes from the per-app palette tokens, so the
 * panel arrives already wearing whichever of the six themes the reader chose --
 * the alternative, a fixed palette, would have looked correct in exactly one.
 *
 * The overlay is self-contained rather than reusing an app's own dialog: the
 * five roll their own, no two alike, and a shared component that depended on one
 * of them would be a shared component in name only. */
.sv-chip {
  font: inherit; font-size: var(--t-12-5); color: var(--muted);
  background: none; border: 0; padding: 2px 4px; margin: 0;
  cursor: pointer; border-radius: calc(var(--radius) / 2);
  font-variant-numeric: tabular-nums;
}
.sv-chip:hover { color: var(--text); }
.sv-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.sv-back {
  position: fixed; inset: 0; z-index: 60; display: grid; place-items: center;
  background: rgba(0, 0, 0, .45); padding: 16px;
}
.sv-card {
  width: min(30rem, 100%); max-height: 84vh; overflow-y: auto;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: calc(18px * var(--pad));
  box-shadow: 0 18px 50px rgba(0, 0, 0, .28);
}
.sv-title { margin: 0 0 10px; font-size: var(--t-17); font-weight: 650; }
.sv-line { margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.sv-ver { font-size: var(--t-17); font-weight: 650; font-variant-numeric: tabular-nums; }
.sv-sep { color: var(--muted); }
.sv-hash {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--t-13); color: var(--muted); word-break: break-all;
}
.sv-hint { margin: 0; color: var(--muted); font-size: var(--t-13); line-height: var(--lh); }

.sv-notes { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 12px; }
.sv-sub { margin: 0 0 8px; font-size: var(--t-13-5); font-weight: 650; }
.sv-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 7px; }
.sv-list li { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: baseline; }
.sv-date {
  color: var(--muted); font-size: var(--t-12-5); white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.sv-what { font-size: var(--t-13-5); line-height: var(--lh); }

/* PINNED, because on a phone it was not reachable.
 *
 * Measured at 375x812 against the live build: the card fits the screen, but its
 * CONTENT does not -- twenty changelog entries push the buttons past the card's
 * own scroll, so opening the panel showed no way to act and no way out but the
 * backdrop. Force Update is the entire reason this panel exists and it was the
 * part below the fold.
 *
 * Sticky rather than fixed: the card is the scroller, so the row rides its
 * bottom edge and the notes scroll under it. The negative margins bleed it to
 * the card's padding edges so nothing shows through beside it. */
.sv-row {
  display: flex; gap: 8px; justify-content: flex-end;
  position: sticky; bottom: calc(-18px * var(--pad));
  margin: 18px calc(-18px * var(--pad)) calc(-18px * var(--pad));
  padding: 12px calc(18px * var(--pad));
  background: var(--surface);
  border-top: 1px solid var(--border);
}
.sv-btn {
  font: inherit; font-size: var(--t-13-5); padding: 7px 13px; cursor: pointer;
  border-radius: calc(var(--radius) / 1.4); border: 1px solid var(--border);
  background: var(--surface-2); color: var(--text);
}
.sv-btn:hover { border-color: var(--accent); }
.sv-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sv-btn[disabled] { opacity: .6; cursor: default; }
/* The one control here that throws work away -- every cache and the registered
   worker -- so it does not look like Close. */
.sv-danger { border-color: var(--accent); color: var(--accent); background: none; }

@media (max-width: 420px) {
  .sv-row { flex-direction: column-reverse; }
  .sv-btn { width: 100%; }
}
