/* 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;
  /* THE SAME 44px FLOOR AS .views button ABOVE, and missed for the same
     reason it was: these are bare <button> elements with no class on them,
     so the `.btn` floor further down cannot reach them and neither could
     the check that reads it. Measured at a real 375px on 2026-09-19, at
     Danny's larger text size: 42.15px -- 24.7 of line-height, 16 of padding
     and 2 of border, which is what padding sized for a label comes to. Two
     pixels, on every button a display preference is set with -- sixteen of
     them in medical alone. Pinned by check-chrome. */
  min-height: 44px;
}
.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.
     All seven dashboards that carry .brand-name now mark the wordmark up as
     <h1 class="brand-name">. Trips used a <span> until 2026-09-13, which made
     it the only page in the suite with no heading of any level — nothing for a
     screen reader to navigate by. The rule below is what makes the two
     interchangeable, so the fix was one tag and no visual change.
     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)); }

  /* ON A PHONE THE CHROME HOLDS ITS SIZE WHILE THE CONTENT SCALES.
     The rule above says the actions must not wrap, and at the default text
     size they do not have to. At a larger one they did not fit either, and a
     flex row that cannot wrap and cannot shrink pushes the PAGE instead:
     measured on Medical at 375px, 14px over at an 18px root, 48 at 20 and 82
     at 22, with the theme button off the side of the screen and every screen
     in the app scrolling sideways with it. Purchases was 2px over for the
     same reason. Nothing in a grid caused it and nothing in an app's own
     stylesheet did; it is these three lines of shared header.
     What grows without needing to is the chrome. `.hdr-btn` is square and
     text-scaled -- 44px at the default, 60 at a 22px root -- and three of them
     take 48px of a 375px line to say nothing more than they said at 44. The
     mark does the same. Both are pinned here to the size they ALREADY ARE at
     the default text size, so no reader sees anything shrink; what stops is
     the growth, and only on a phone, where the line is the scarce thing.
     The height still scales, because vertical room is not what ran out -- so
     these stop being square above the default size, which is the trade and is
     why it is written down. 44px in both directions remains the floor the
     comment on `.hdr-btn` insists on, and neither dimension goes under it.
     The rest is spacing, and it is the difference between fitting and not:
     with the caps alone Medical was still 19px over at a 22px root.
     Measured after: 0 over at a 16, 18, 20 and 22px root -- AT THE DEFAULT TEXT
     SIZE, which is what that pass varied and is why it read as finished. It was
     not; see the block at the end of this rule, which carries the combination
     it missed and the two lines that close it. At 24 -- 150% text -- Medical
     was 13px over again, because "+ Add documents" plus three controls plus a
     mark is more than 375px holds at that size. The thing that gives is still
     not the label, which says the noun on purpose: it is the width of the
     button around it, and the label wraps. */
  /* `.hdr-btn`'s cap is NOT here. It belongs to this block by meaning and it
     cannot live in it: `.hdr-btn` is defined 460 lines further down with the
     same specificity, so a copy here loses to it everywhere and does nothing.
     It sits immediately below that definition instead. Found by
     tools/check-deadcss.js within minutes of writing this, which is the whole
     reason that checker exists. */
  .brand-mark { width: 34px; }
  .top-in { gap: 8px; }
  .top-actions { gap: 6px; }
  .top-actions > .btn { padding-left: 9px; padding-right: 9px; }

  /* AND THE ROW IS ALLOWED TO GIVE. The measurement above stopped one step
     short: it moved the ROOT size from 16 to 22 and left the app's own text
     control at its default, so it never tried the combination Danny actually
     runs -- `huge` (--fs 1.38) ON a large root. There the caps above are not
     enough and the bar was 4px over at a 20px root and 18 at 22, pushing every
     screen in the app sideways again.
     A flex item's automatic minimum is its content, so `.top-actions` refused
     to shrink below the row it wanted and the overflow went to the page. These
     two lines let it shrink instead; the label then wraps inside its button,
     which the rule far below now lets grow to hold it. Nothing is truncated
     and nothing moves to a second row -- the header is 88px at huge/22 where
     it was 95px broken, and the page does not scroll at any of the twenty
     combinations from small/16 to huge/24, including the 24px root the note
     above records as unfixable. Measured 2026-09-21. */
  .top-actions { min-width: 0; }
  .top-in .btn {
    min-width: 0;
    /* AND THE LAST RESORT, which the line above makes reachable. Shrinking past
       min-content is what stops the page moving, and min-content is the longest
       WORD -- so below that width the word itself hangs out of the button: 20px
       of "documents" at a 22px root with the larger text size, 35 at 24.
       `anywhere` breaks inside a word only when the line offers no other
       opportunity, so this does nothing at any size where the label fits, and
       at the two where it does not it costs a hyphenless break rather than a
       page that scrolls sideways or a label with its end cut off.
       The alternative was letting the actions wrap to a second row, which was
       measured too: 163px of header against 150 for this, and the three icon
       buttons split across both rows. Taller AND worse arranged. */
    overflow-wrap: anywhere;
  }
}

/* WHAT A READER WHO MAY ONLY LOOK IS NOT OFFERED.

   Every dashboard's API already refuses the write: _grants.js asks for `edit`
   on anything that is not a GET, and would refuse with every button forced back
   on with the console open. This is not the control. It is so the app does not
   offer things it will then reject -- a child opening Medical and being shown
   Delete on their own records, clicking it, and being told no.

   Trips has done this since it had its own roles; the other six did not, and on
   2026-09-15 Danny asked for them. Trips is untouched by these rules: it uses
   neither `primary` nor `danger` anywhere, and hides its own writes through
   `.is-edit`, which is the first selector here.

   THE CONVENTION IS ALREADY IN THE MARKUP, which is why this is three rules and
   not a hundred tagged buttons. In all six, `.btn.primary` and `.btn.danger`
   are what write -- Add someone, Record maintenance, Delete panel, Mark
   purchased, Take a snapshot, Archive. Checked label by label before this was
   written. Two escape hatches for the exceptions:

     [data-close]  a button that only shuts a dialog. Medical styles its Done as
                   primary, and closing a dialog is not a write.
     .is-read      anything else that looks like an action and is not one. Reach
                   for it rarely, and say why at the call site.

   And two for the other direction -- a write that carries neither class:

     [data-add]    already in family and Life Admin on the plain buttons that
                   open an add form. It says "this starts an add", which is a
                   write by definition, so it is honoured rather than retagged.
     .is-edit      everything else: per-row Edit and Remove buttons, and plain
                   ones like Medical's "+ Add a person". Trips' own convention,
                   borrowed rather than reinvented.

   ON `html` RATHER THAN `body`, because the class is set in the <head> by
   shared/access-boot.js before the body exists -- same reason, and the same
   half-second, as the dashboard links above. Trips sets `body.role-view` from
   its own role model and keeps its own rule; the two do not collide.

   `cannot-admin` is one dashboard's problem: Life Admin's Access tab is the key
   to all seven and needs `admin`, not `edit`, so it is marked separately. */
html.cannot-write .is-edit,
html.cannot-write [data-add],
html.cannot-write .btn.primary:not([data-close]):not(.is-read),
html.cannot-write .btn.danger:not([data-close]):not(.is-read) { display: none !important; }
html.cannot-admin .is-owner { display: none !important; }

/* SAYS WHY THE BUTTONS ARE MISSING. Without it the app just looks broken, which
   is the lesson trips learned and wrote down. Only ever shown once the level is
   actually KNOWN -- never while it is still in flight, or somebody who can edit
   is told they cannot for half a second. */
.level-tag {
  display: inline-flex; align-items: center;
  min-height: 44px; padding: 0 calc(10 * var(--px) * var(--fs));
  border: 1px solid var(--border); border-radius: calc(9 * var(--px) * var(--fs));
  font-size: calc(13 * var(--px) * var(--fs));
  color: var(--muted); background: var(--surface);
  white-space: nowrap;
}

/* NO DASHBOARD LINK IS DRAWN BEFORE WE KNOW WHETHER IT IS THIS READER'S.

   The other half of shared/access-boot.js, which runs in every app's <head>
   and puts this class on the root element when it has no remembered answer.
   Every tile on the hub and every tab in the bar carries data-app; nav.js
   hides the denied ones once /api/v1/me answers, and until 2026-09-15 that
   left the full set on screen in the meantime -- 537ms to 1009ms, measured in
   production, of showing a reader every dashboard in the family before taking
   away the ones that are not theirs.

   The hub is exempt: it is the launcher, and hiding the way back would strand
   a reader on a dashboard.

   display, not visibility, because the tiles are a grid and the tabs are a
   flex row -- reserving space for seven dashboards to then show one would
   trade a flash of the wrong thing for a hole in the right one.

   THE CLASS IS ONLY EVER ADDED BY SCRIPT, so a browser with no JavaScript
   never matches this and sees the full launcher, and access-boot.js takes it
   off again after five seconds if nav.js never answers. Both halves of that
   are deliberate: this is decoration over a decision the Worker enforces, and
   it must never become the thing that locks somebody out. */
html.access-unknown [data-app]:not([data-app="hub"]) { display: none !important; }

/* 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. Vertical padding is zeroed because
   the height is now explicit; the flex centring each app already uses does the
   rest.

   "COMFORTABLY ABOVE THE 44px TOUCH TARGET ONCE THE READER HAS ASKED FOR LARGER
   TYPE" is what this said until 2026-09-15, and it was wrong in a way worth
   recording, because the `min-height: 0` below was put there to make it wrong.
   That line exists to defeat the 44px floor `.btn` already carried, so that the
   bar could be 36 units tall -- and 36 units is 36px at the small text size,
   39.6 at medium and 43.92 at large. Large is the DEFAULT. So three of the four
   text sizes, the default among them, drew the header controls under the floor
   the rest of the suite is held to, and the checker never saw it because the
   rule declares no number a floor-check would recognise.

   Measured at a real 375px on a phone: refresh, display and theme all came back
   43.91px tall. Nobody would notice 0.09px; somebody on the smaller text sizes
   is missing four to eight, on the three controls at the top of every screen.

   The floor is now explicit and absolute -- 44px, not 44 * var(--px), because a
   touch target is a physical size and must not shrink with a reader's browser
   font. The scaled height still wins above it, so `huge` is unchanged at
   49.7px and nothing about the bars Danny already looks at moves. */
.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 {
  /* A FLOOR, NOT A CEILING. This was `height` plus `min-height: 44px`, which
     is a fixed box, and a fixed box CLIPS a label that needs two lines. The
     labelled header action is the one that does: measured on Medical's
     Documents view at a real 375px, "+ Add documents" has a max-content of
     170px and the room left beside the mark and the three icon buttons is
     159 -- so it wraps, and the second line was cut off by the 44px box. Not
     only at a large text size: it was clipped at the DEFAULT (large/16), and
     at ten of the twenty text-size-by-root-size combinations from small/16 to
     huge/24. Nothing said so, because a clipped line reports no overflow that
     a horizontal sweep would see: it took comparing scrollHeight against
     clientHeight in a real browser, and no checker in this repo can do that --
     they read the source, and the source of this looked correct.
     `max()` keeps both floors that were here: 44px absolute, because a touch
     target is a physical size and must not shrink with a reader's browser
     font, and the scaled height above it, so `huge` is unchanged at 49.7px.
     What changes is only that the control may now grow past them. */
  min-height: max(44px, calc(36 * var(--px) * var(--fs)));
  padding-top: 0; padding-bottom: 0;
}

/* EVERY ICON BUTTON IN THE SUITE, WHICH WAS 27px.
   Measured at a real 375px on 2026-09-14: the ✕ in home's asset sheet came out
   27 tall and 30 wide, against a 44px touch target. It is in six apps, roughly
   thirty of them, and it is the control a person reaches for MOST on a phone --
   the way out of anything that opened over the page, often one-handed with a
   thumb, often in a hurry.

   It was small because it inherits `.btn.small` type and `.btn.icon` padding,
   and five-and-eight pixels of padding around one glyph is 27px however the
   glyph scales. Nothing was wrong with the padding; it was being asked to
   produce a touch target, which padding sized for a label cannot do.

   A HARD 44px FLOOR, not the scaled height the header controls above use.
   Those are `calc(36 * var(--px) * var(--fs))`, which reaches 44 only once the
   reader has asked for larger type -- fine for a row of chrome that grows with
   everything around it, wrong here. Someone who prefers SMALL text has not
   asked for a smaller target; if anything they are likelier to need the
   generous one. So the floor does not scale down, and padding may still grow it.

   EVERY .btn.icon, not just the close. This started scoped to [data-close]
   because the same class is also the ✕ that REMOVES a row, and 44px in a dense
   list is a bigger change than a header. Widened deliberately once the rows
   were looked at:

     The remove ✕ is the more dangerous of the two to miss. Mis-tapping close
     costs a reopen; mis-tapping in a row of small targets deletes a service
     record, and in admin the ✕ sits directly beside a LINK to the document.

   THAT NEIGHBOUR IS ALSO WHY THE HIT AREA IS REAL RATHER THAN EXPANDED. The
   tidy trick for a small icon is a transparent ::after reaching past its box,
   which keeps the layout untouched. Next to a link it is the wrong trick: the
   invisible area covers the link's tail and silently takes taps meant for it,
   which is worse than a small button because nothing looks wrong. A control
   that is 44px is 44px to the eye and to the thumb alike.

   Rows grow. `.row-right` already wraps and drops to its own line under 560px,
   so the cost is a few pixels of height on lists that had room. */

/* AND EVERY ORDINARY .btn, for the same reason and with a worse history.
   Measured at a real 375px on 2026-09-14: "+ Add an item" in home, "Add
   someone" in family and Life Admin's quick actions all came out 41px tall.
   Each app draws its own .btn -- there was no shared rule until this one --
   and the four that set nothing landed wherever their padding took them.

   THE THREE THAT DID SET SOMETHING ARE THE PART WORTH READING. The hub set
   42px. Purchases and Medical set 38px, under a comment that says in as many
   words "44px is the smallest reliably tappable target on a phone, and this
   app is mostly used on one". The comment was right and the declaration under
   it was not, in two apps, for as long as both have existed -- the same shape
   as a test that asserts nothing while describing what it asserts. Those
   three declarations are gone now, because a per-app min-height BEATS this
   rule: base.css loads first and the specificity is identical, so anything an
   app still declares wins, and the one it would win over is this floor.

   A floor, not a height: padding, a taller label or a larger text size may all
   grow a button past 44px, and none of them may take it below.

   Pinned by tools/check-chrome.js, which fails the build both if this rule
   loses its floor and if an app reintroduces a smaller one.

   BOTH DIMENSIONS, because only the height was ever floored and a target has
   two. `.hdr-btn` has carried a width floor since it was written -- "a
   43.91px-wide target is as short of the floor as a 43.91px-tall one" -- and
   the argument was never specific to it. Measured at a real 375px on
   2026-09-21: finance's Budget view had a `.btn.small` 39px wide and 44 tall,
   and Life Admin's and home's filter rows had chips at 37, 40 and 43. A short
   label is exactly what produces a narrow button, so the controls this catches
   are the ones saying "All", "×" or "Go" -- which sit in rows of other targets
   and are the easiest in the suite to miss with a thumb.

   READ THE `button.chip` RULE BELOW BEFORE CHANGING THIS ONE. A min-width on a
   flex item also replaces its automatic minimum, which was min-content, so a
   floor for the short buttons is a licence to squeeze the long ones. It caused
   exactly that on the chips and `flex-shrink: 0` is what answers it there.
   It is NOT repeated here, for two reasons: a .btn is not always in a row that
   wraps, so refusing to shrink would move the overflow to the page rather than
   to a second line; and the header's own labelled button is deliberately
   allowed to shrink, which is what keeps that bar on one row. Swept clean
   across all six dashboards at three text sizes after this landed -- but it is
   the kind of rule that is clean until the day someone writes a long label in a
   row that does not wrap, so measure that row rather than assuming. */
.btn {
  min-height: 44px;
  min-width: 44px;
}

/* A CHIP YOU CAN PRESS, which is not every chip.
   `.chip` styles two different things in this suite and always has: in Life
   Admin it is the record-filter row (buttons), and in Home & Vehicles the very
   same rule paints the odometer and task-count pills beside an asset's name --
   spans, read rather than tapped. They share a stylesheet block because they
   share a look, so flooring `.chip` would inflate a decorative label into a
   44px slab in a dense list.

   `button.chip` is the whole distinction, and it is also why this can live
   here: element-plus-class outranks an app's bare `.chip`, so it wins on
   specificity rather than on load order and no app has to give anything up.
   Measured at a real 375px on 2026-09-14: Life Admin's eight filter chips were
   28px tall.

   The width floor arrived seven days later, for the same reason and off the
   same measurement as `.btn` above: a filter row's chips are named after what
   they filter, so the "All" that sits first in every one of those rows is
   always the narrowest thing in it -- 37px in home and Life Admin both.

   AND `flex-shrink: 0` WITH IT, WHICH IS NOT OPTIONAL. A min-width is also the
   thing that REPLACES `min-width: auto`, and auto on a flex item means
   min-content -- so declaring 44px raises the short chips and LOWERS the long
   ones, handing the row permission to squeeze them to 44 for the first time.
   It did, immediately: purchases' filter row put "Researching" in a 60px chip
   with 42px of the word hanging out of it at the default text size and 90 at a
   22px root. Caught by the sweep that was verifying the floor itself, having
   been shipped and deployed first.
   `max(44px, min-content)` is how this wants to be written and the browser does
   not support it -- CSS.supports says no, so the whole declaration is dropped
   and nothing happens, which is worse than either half. Refusing to shrink says
   the same thing and works: these rows all wrap, so a chip that will not give
   width takes the next line instead of losing its word. */
button.chip {
  min-height: 44px;
  min-width: 44px;
  flex-shrink: 0;
}

/* SELECTS, the other control a thumb has to find.
   Purchases and Medical each set `min-height: 38px` on `.sort select, .field
   select` -- the same 38 their .btn carried, from the same hand -- so the sort
   control measured 42px once padding had its say. Every other app sets no
   height at all. This is the floor; those two declarations are gone, because
   `.sort select` outranks a bare `select` and would beat this.

   DELIBERATELY NOT a bare `input`. A checkbox is an input, and `min-height`
   beats the explicit `height: 16px` that medical's "show past" box relies on --
   it would become 16 wide and 44 tall, which is not a bigger target, only a
   stranger one. The rule below answers text inputs without that cost. */
select {
  min-height: 44px;
}

/* TEXT INPUTS, which this comment used to call "a real question and a separate
   one" and leave alone. The question was answered on 2026-09-15 by checking the
   family archive on a phone: its search box measured 41px at the larger text
   size, against a 44px floor every button, tab, chip and select in the suite
   had been put on that week.

   BY TYPE, NOT BY ELEMENT, which is the whole reason this is now safe. A
   checkbox and a radio are inputs too, and they are not in this list, so the
   16px and 18px boxes in medical and trips keep the size they were given. Only
   controls somebody types into are floored.

   A bare `input` selector is weak, so anything more specific beats it -- the
   same way `.sort select` beat the floor above until those declarations were
   removed. tools/check-chrome.js fails the build for an app rule that sets a
   smaller one. */
input[type="text"], input[type="search"], input[type="email"],
input[type="tel"], input[type="url"], input[type="number"],
input[type="password"], input[type="date"], input[type="time"],
input[type="datetime-local"], input[type="month"], input[type="week"],
input:not([type]), textarea {
  min-height: 44px;
}

/* A BUTTON THAT READS AS A LINK, for an action that belongs inside a sentence
   -- "reopen", "show the rest", a name you can jump to. A real <button>,
   because it does something to this page rather than going anywhere, and
   styling it as a link is the honest way to say "this is a word you can press"
   without it competing with the real controls around it.

   IT WAS COPIED INTO THREE APPS AND USED BY NONE. admin, family and home each
   carried this rule, byte-identical, and nothing in any of them referenced it
   -- found on 2026-09-14 when finance became the first app to want one. That
   is the shape of drift tools/check-chrome.js exists to catch: a fragment
   living in an app's own stylesheet with no owner and no tool told to look.

   The 44px floor, and inline-flex to reach it, for the same reason as
   `.person .jump` in family: these sit in running text, so the words and
   punctuation around them have to keep flowing, and padding would overlap the
   neighbour instead of moving it. */
.linkish {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--accent);
  text-decoration: underline;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}
/* A REAL LINK INSIDE A SENTENCE, which has the same 44px problem as .linkish
   above and cannot have the same answer.
   Life Admin's Insurance lede sends you to Medical and to Home & Vehicles
   mid-sentence; both measured 21.8px on a phone at the larger text size. The
   obvious fix is the one .linkish uses -- inline-flex to a 44px box -- and it
   was tried first. It works, and it drags the leading of any line holding a
   link out to 44px while the lines around it stay at 30, so a paragraph reads
   as though somebody pressed return in the wrong places.
   A BUTTON CAN AFFORD THAT AND A PARAGRAPH CANNOT. .linkish sits in captions
   and one-line labels where a taller line costs nothing. This is prose.
   So the BOX stays exactly as the text laid it out, and only the TOUCH AREA
   grows: an empty, invisible ::after stretched to 44px and centred on the link.
   The line height does not move, and a thumb gets the target anyway.
   Two costs, both stated so nobody has to rediscover them.
   The area overlaps the lines above and below. Harmless where those lines are
   text, which is the case this exists for. Two of these on ADJACENT lines would
   overlap each other and the later one would win -- so one per line, which
   prose does anyway. Measured in Life Admin at 375px and the larger text size:
   the two links sit 49px apart and two 44px areas need 44, so they clear.
   And it does not MEASURE as fixed. getBoundingClientRect on the link still
   reports 21.8px, because the box really is 21.8px; the target is the ::after.
   Any sweep looking for controls under 44px will flag these two for ever. That
   is the sweep being literal, not the links being broken -- check ::after's
   height before believing it. */
.textlink {
  color: var(--accent);
  text-decoration: underline;
  position: relative;
}
.textlink::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  height: 44px;
}

.btn.icon,
.icon-btn {
  min-width: 44px;
  min-height: 44px;
  /* An explicit size says nothing about where the glyph goes: without this the
     ✕ sits at the top-left of a 44px box. The same lesson as the hub's Sign
     Out anchor two rules above. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* ...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; }
/* AND SOMETHING HAS TO SAY THAT IT SCROLLS, because the two lines above hide
   the only thing that would. Measured 2026-09-21 at a true 375px: the strip
   holds more off screen than on in Life Admin -- 614px hidden at the default
   text size and 1008 at a 22px root -- and 489 in finance, 362 in home, 325 in
   medical, 156 in family. A strip that continues looked exactly like one that
   ends, which is the fault trips' own .detail-actions comment names: "a row
   that happens to break at a chip boundary is indistinguishable from a
   complete one".

   NOT A MASK, which is how .filters does it one app over. That works there
   because the chips sit on the page background; .views has its own surface, a
   border and a 14px radius, and a mask fades the element itself -- the strip
   would dissolve its own right edge and cut its border mid-stroke.

   A STICKY PSEUDO-ELEMENT INSTEAD, which is the one overlay that survives
   here. A pseudo-element of a flex container becomes a flex item, and an
   absolutely positioned one belongs to the scrollable area and slides away
   with the tabs -- the same pair of facts written above the famnav fades, and
   the reason those had to be real elements. `sticky` is what neither of those
   is: it stays at the edge of the scrollport while the tabs move under it. The
   negative margin means it costs the row no width, and being positioned it
   paints above the buttons, which a background never could.

   Toggled from nav.js rather than left on, so the trailing fade is not sitting
   over the last tab once there is nothing further to reach. */
.views::before,
.views::after {
  content: "";
  position: sticky;
  align-self: stretch;
  flex: 0 0 auto;
  width: calc(26 * var(--px));
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  transition: opacity .18s ease;
}
.views::before { left: 0; margin-right: calc(-26 * var(--px));
  background: linear-gradient(to right, var(--surface-2), transparent); }
.views::after { right: 0; margin-left: calc(-26 * var(--px));
  background: linear-gradient(to left, var(--surface-2), transparent); }
.views.sx-l::before { opacity: 1; }
.views.sx-r::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .views::before, .views::after { transition: none; }
}
.views button {
  flex: 1 0 auto;
  /* THE SAME 44px FLOOR AS .btn.icon ABOVE, for the same reason. Measured at a
     real 375px on 2026-09-14, at Danny's larger text size: every app's row of
     section tabs came out 40px tall -- finance, home, admin, family and
     medical alike, because this is the one rule that draws them. Padding sized
     for a label cannot produce a touch target; the floor can. Four pixels on
     the bar, and the one control a person taps to get anywhere in an app
     stops being the one under the suite's own rule. Pinned by check-chrome. */
  min-height: 44px;
  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));
  /* Square, so the width needs the same floor as the height above -- these are
     the only controls in the suite sized in both directions at once, and a
     43.91px-wide target is as short of the floor as a 43.91px-tall one. */
  min-width: 44px;
  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;
}

/* ON A PHONE IT STOPS GROWING SIDEWAYS. See the header block far above for the
   measurements: three of these at 60px wide instead of 44 take 48px of a 375px
   line, which is most of why Medical's header pushed every screen in the app
   82px sideways at a 22px root.
   Only the width. The height still scales, so these stop being square above the
   default text size -- that is the trade, and vertical room was never what ran
   out. Both dimensions stay at or above the 44px floor the comment above
   insists on.
   BELOW the rule it caps, not up in the phone block where it reads like it
   belongs: same specificity, so source order is the only thing that decides. */
@media (max-width: 560px) {
  .hdr-btn { width: 44px; }
}

.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; }

/* A <dialog>, AND THAT IS THE WHOLE FIX. This was a plain div at z-index 60,
   and the version chip lives inside the Display sheet -- which is a <dialog>
   opened with showModal(). A modal dialog is promoted to the browser's TOP
   LAYER, which paints above every normal-flow element no matter what its
   z-index says, so opening the version panel from Settings drew it completely
   behind Settings: laid out, 343x682, and invisible. elementFromPoint at the
   centre of the card returned the sheet. Tapping the chip looked like nothing
   happening, on a phone especially, where the sheet fills the screen.
   z-index cannot win against the top layer. The only way up there is to be in
   it, so this is a dialog too -- and two modals stack in the order they were
   promoted, which puts this one above the sheet it was opened from.

   The element is still the full-screen backdrop rather than using ::backdrop,
   so `ev.target === back` still means "clicked outside the card" and the dim
   stays a single 45% rather than two stacked over the sheet. */
.sv-back {
  position: fixed; inset: 0; z-index: 60;
  /* The UA gives every dialog margin: auto, a border, its own padding and a
     max-width/height that would shrink this off the edges. */
  margin: 0; border: 0; padding: 16px;
  width: 100%; height: 100%; max-width: none; max-height: none;
  background: rgba(0, 0, 0, .45); color: inherit;
  display: none;
}
/* Guarded by [open] (0,2,0) so it beats the UA's `dialog:not([open])` rule --
   a bare `display: grid` here would make a closed dialog visible. */
.sv-back[open] { display: grid; place-items: center; }
.sv-back::backdrop { background: transparent; }
.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%; }
}

/* TEXT FOR A SCREEN READER AND NOT FOR THE SCREEN.
   Three apps were already writing it -- medical's `.sr-only` on a column of
   Remove buttons, family's `.visually-hidden` on a search label, and finance's
   description of the spending donut -- and NOT ONE OF THE THREE NAMES WAS EVER
   DEFINED. So all of it rendered: the donut's alternative text came out as a
   322 by 329 pixel paragraph of prose under the chart, repeating every slice.
   Found by measuring it rather than by looking, because the paragraph is below
   the fold on a phone and looks like ordinary content when you scroll to it.
   Both names are taken, because both are already in the markup and renaming
   them across three apps would be a bigger change than honouring them.
   The clip-rect pattern rather than `display: none`, which removes it from the
   accessibility tree as well and defeats the point. */
.sr-only,
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}
