/* === Fonts ===
   Vendored rather than loaded from a CDN, the same way htmx and leaflet
   are: the app has no asset pipeline and no third-party runtime
   dependencies, and that should stay true of its typography.

   Archivo's latin file is a variable font (fvar/gvar/avar), so one 35KB
   file covers every weight the app uses and `font-weight: 100 900` lets
   the browser interpolate rather than synthesise. IBM Plex Mono ships
   static per weight, hence two files. Both are SIL Open Font License. */
@font-face {
    font-family: 'Archivo';
    font-style: normal;
    font-weight: 100 900;
    font-stretch: 100%;
    font-display: swap;
    src: url('/static/fonts/archivo-latin.woff2') format('woff2');
}

@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url('/static/fonts/plex-mono-500-latin.woff2') format('woff2');
}

@font-face {
    font-family: 'IBM Plex Mono';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url('/static/fonts/plex-mono-600-latin.woff2') format('woff2');
}

/* === Tokens ===
   Every colour, space, size and radius in this file resolves to one of
   these. A raw value outside this block is a bug, with the short list of
   exceptions in DESIGN.md section 2.

   Dark is the default. Light follows the OS unless a [data-theme] on
   <html> says otherwise, so a manual toggle later is markup only. */
:root {
    /* Colour - dark */
    --color-bg: #0a0c0e;
    --color-surface: #14181c;
    --color-raised: #1b2126;
    --color-border: #2c343a;
    --color-text: #f2f5f7;
    --color-text-muted: #97a3ab;
    /* The accent's two jobs need two values and they are NOT
       interchangeable: --color-accent is a FILL behind white labels,
       --color-accent-ink is accent-coloured TEXT on the page ground.
       #fbbf24 cannot carry white text; #a16207 cannot be read as text on
       near-black. They collapse to one value in light mode only. */
    --color-accent: #a16207;
    --color-accent-ink: #fbbf24;
    --color-on-accent: #ffffff;
    --color-success: #5ee39a;
    --color-danger: #f87171;
    --color-overlay: rgba(0, 0, 0, 0.7);
    /* The shading a scroll container puts on an edge it can still scroll
       towards. Not --color-overlay: a black gradient over the near-black
       surface is invisible, so the cue would never show in the default
       theme. Each theme shades in whichever direction reads as depth
       against its own surface (DESIGN.md section 2.1). */
    --color-scroll-shadow: rgba(255, 255, 255, 0.18);
    --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4);
    --shadow-raised: 0 12px 32px rgba(0, 0, 0, 0.45);
    --shadow-sheet: 0 -20px 50px rgba(0, 0, 0, 0.7);

    /* Type */
    --font-sans: 'Archivo', system-ui, -apple-system, 'Segoe UI', sans-serif;
    --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
    --text-xs: 0.75rem;
    --text-sm: 0.875rem;
    --text-base: 1rem;
    --text-lg: 1.25rem;
    --text-xl: 1.5rem;
    --text-2xl: 2rem;

    /* Spacing */
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-5: 1.5rem;
    --space-6: 2rem;
    --space-7: 3rem;

    /* Shape */
    --radius-sm: 4px;
    --radius: 6px;
    --radius-lg: 8px;
    --radius-pill: 999px;
    --border: 1px;
    --border-input: 2px;

    /* Controls. Touch sizes are the unqualified default and step DOWN at
       md - see the control-height rule further down. */
    --tap-min: 44px;
    --control-h: 56px;
    --action-h: 62px;

    --transition: 150ms ease;

    /* The one icon CSS has to draw itself, because it is a ::before on an
       element JavaScript toggles a class on rather than markup we can put
       a {{template}} in. Kept here so it sits with the other tokens
       instead of inline in the rule. Same path as icon-check in
       templates/partials/icons.html - change both together. */
    --icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m20 6-11 11-5-5'/%3E%3C/svg%3E");

    /* Prose-width default (login, admin pages). App shells that need more
       room for grids/tables (e.g. the fishing app) override this on a
       wrapping element instead of widening every .container globally. */
    --container-max: 48rem;
}

@media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) {
        --color-bg: #f2f3f1;
        --color-surface: #ffffff;
        --color-raised: #e9ebe8;
        --color-border: #d3d7d4;
        --color-text: #0d0f11;
        --color-text-muted: #545c62;
        /* Amber stays amber in light mode by flipping the LABEL to near-black
           rather than darkening the fill to brown - #92500a under white text
           reads as mud and throws away the one colour the app has. Dark ink
           on #f59e0b is 8.9:1, better than the white-on-brown it replaces. */
        --color-accent: #f59e0b;
        --color-on-accent: #0d0f11;
        --color-accent-ink: #92500a;
        --color-success: #166534;
        --color-danger: #b91c1c;
        --color-overlay: rgba(0, 0, 0, 0.45);
        --color-scroll-shadow: rgba(0, 0, 0, 0.18);
        --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06);
        --shadow-raised: 0 12px 32px rgba(0, 0, 0, 0.12);
        --shadow-sheet: 0 -20px 50px rgba(0, 0, 0, 0.18);
    }
}

:root[data-theme="light"] {
    --color-bg: #f2f3f1;
    --color-surface: #ffffff;
    --color-raised: #e9ebe8;
    --color-border: #d3d7d4;
    --color-text: #0d0f11;
    --color-text-muted: #545c62;
    /* Amber stays amber in light mode by flipping the LABEL to near-black
       rather than darkening the fill to brown - #92500a under white text
       reads as mud and throws away the one colour the app has. Dark ink
       on #f59e0b is 8.9:1, better than the white-on-brown it replaces. */
    --color-accent: #f59e0b;
    --color-on-accent: #0d0f11;
    --color-accent-ink: #92500a;
    --color-success: #166534;
    --color-danger: #b91c1c;
    --color-overlay: rgba(0, 0, 0, 0.45);
    --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.06);
    --shadow-raised: 0 12px 32px rgba(0, 0, 0, 0.12);
    --shadow-sheet: 0 -20px 50px rgba(0, 0, 0, 0.18);
}

/* === Reset === */
*,
*::before,
*::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

/* === Base === */
html {
    font-size: 16px;
    scroll-behavior: smooth;
}

body {
    font-family: var(--font-sans);
    background: var(--color-bg);
    color: var(--color-text);
    line-height: 1.6;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

/* === Layout === */
.container {
    width: 100%;
    max-width: var(--container-max);
    margin: 0 auto;
    padding: 0 var(--space-4);
}

main {
    flex: 1;
}

/* The class is in the selector because every <main> and <footer> in the app
   also carries .container, whose `padding` shorthand sets all four sides
   and so zeroed the vertical padding these rules meant to have. Both have
   been no-ops: content ran up to the bottom edge of the bar above it, and
   the footer's text sat on its own top border. */
main.container {
    padding-block: var(--space-5);
}

footer.container {
    padding-block: var(--space-5);
}

/* === Typography === */
h1,
h2,
h3 {
    line-height: 1.2;
    font-weight: 700;
}

h1 {
    font-size: var(--text-xl);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-bottom: var(--space-3);
}

h2 {
    font-size: var(--text-lg);
    margin-bottom: var(--space-2);
}

p {
    margin-bottom: var(--space-4);
}

a {
    color: var(--color-accent-ink);
    text-decoration: none;
    transition: color var(--transition);
}

a:hover {
    color: var(--color-text);
}

ul {
    padding-left: var(--space-5);
    margin-bottom: var(--space-4);
}

li {
    margin-bottom: var(--space-1);
}

code {
    background: var(--color-raised);
    padding: 0.15em 0.4em;
    border-radius: var(--radius-sm);
    font-family: var(--font-mono);
    font-size: 0.9em;
}

/* Every measurement, count, time and numeric table cell is set in the
   mono face. That is what lets the highscore and catches tables align
   down the decimal without a single alignment rule. */
.num {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
}

/* === Icons ===
   The inline SVGs defined in templates/partials/icons.html. Sized in em so
   an icon tracks whatever text it sits next to rather than needing a size
   per call site, and stroked in currentColor so it follows the theme - the
   two things the emoji they replaced could not do. */
.icon {
    width: 1em;
    height: 1em;
    flex-shrink: 0;
    /* Optical centring against a text baseline; icons inside a flex
       container (buttons) are centred by the flexbox instead and are
       unaffected by this. */
    vertical-align: -0.125em;
}

/* Standing in for a missing photo, so these are sized by their box rather
   than by any adjacent text. */
.recent-catch-icon--placeholder .icon,
.gallery-item-placeholder .icon {
    width: 55%;
    height: 55%;
    color: var(--color-text-muted);
}

/* === Focus ===
   One ring for everything interactive. Before this, :focus-visible
   appeared exactly twice in the whole app, so keyboard users had no
   indicator at all on any button, link or form control. */
:where(a, button, .btn, input, select, textarea, summary, [tabindex]):focus-visible {
    outline: 2px solid var(--color-accent-ink);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

/* Present to a screen reader, absent on screen. For content a sighted
   reader gets from somewhere else - the dashboard's h1, which the current
   tab already says - where dropping the element outright would leave a
   hole in the heading outline.

   The clip-path/1px-box recipe rather than display:none or visibility:
   hidden, both of which take the element out of the accessibility tree
   along with the layout. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/* The `hidden` attribute is a UA-stylesheet rule (`display: none`), so any
   author rule that sets `display` beats it - and this app's base button
   rule sets `inline-flex`, which means every <button hidden> in it renders
   anyway. Inside a flex container the value is even blockified to `flex`,
   so it renders as a block.

   Hence the one !important in this stylesheet: an attribute that says an
   element is not relevant has to win over a rule that only says how it
   would look if it were. .push-ios-hint in fishing.css carries a comment
   about being bitten by exactly this; that workaround is no longer the
   only thing standing between a hidden element and the page. */
[hidden] {
    display: none !important;
}

/* === Skip link ===
   The first focusable element on every page. Off-screen until it takes
   focus, then parked over the top-left corner of the viewport.

   translateY rather than display/visibility or a 1px clip: a hidden-by-
   display element cannot be focused at all, and the transform keeps the
   link in the tab order while it is out of sight. Fixed rather than
   absolute so it lands somewhere visible even when focus reaches it on a
   scrolled page. z-index sits above the nav bars but well below the
   dialog layer, so it never rides over an open sheet. */
.skip-link {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 100;
    margin: var(--space-2);
    padding: var(--space-2) var(--space-4);
    background: var(--color-raised);
    color: var(--color-text);
    border: var(--border) solid var(--color-border);
    border-radius: var(--radius);
    font-size: var(--text-sm);
    font-weight: 500;
    transform: translateY(calc(-100% - var(--space-4)));
    transition: transform var(--transition);
}

/* :focus, not :focus-visible - a skip link is reached by keyboard only, and
   the heuristics behind :focus-visible are not worth betting its appearance
   on. */
.skip-link:focus {
    transform: translateY(0);
}

/* <main> is focusable only as the skip link's target (tabindex="-1"), and
   it matches the [tabindex] arm of the focus-ring rule above - which draws
   a ring around the entire page when the skip link is used. Suppressed
   here: the point of that focus is to move the reading position, and the
   page's own heading is the confirmation. No keyboard user can tab TO this
   element, so nothing loses an indicator it needed. */
main:focus,
main:focus-visible {
    outline: none;
}

/* === Nav ===
   Every page has one bar, the header (templates/partials/app-header.html):
   the wordmark or app switcher, the current app's tabs, and the account
   menu. A page outside every app (sign-in, the site admin pages) has no
   tabs, so its bar is the wordmark and the menu.

   Don't try to make a nav row *wrap* onto extra lines on a phone. Two
   attempts at it - stacking the row into a column, then splitting the
   account controls into an email row and a buttons row - both produced an
   ugly, unpredictable break somewhere in the middle. Keep it one row and
   let the tab row scroll. The account controls stay out of the row's
   width by being one <details> menu (templates/partials/user-menu.html)
   rather than an address and two buttons.

   The bare `nav` rule is for the other bars, such as the admin tabs; the
   header overrides its padding. */
nav {
    background: var(--color-surface);
    border-bottom: var(--border) solid var(--color-border);
    padding: var(--space-3) 0;
}

/* Sticky because one bar is cheap enough to keep on screen, and because
   the alternative - on a trips list several screens long - is scrolling
   back to the top to go anywhere. The z-index clears the map's own
   stacking context (.map-container, z-index 1) and stays well under the
   dialog layer, so an open sheet still covers it. */
.app-nav {
    position: sticky;
    top: 0;
    z-index: 50;
    background: var(--color-surface);
    border-bottom: 1px solid var(--color-border);
    /* The bare `nav` rule's vertical padding would push the current tab's
       underline off the bar's bottom border. The row's contents carry its
       height. */
    padding: 0;
}

.app-nav .container {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    min-height: var(--tap-min);
}

/* Not on a phone, in a bar with tabs: at 67px the wordmark takes a fifth
   of the bar, leaving the tab row about a tab and a half wide. Nothing
   else there can give way - the tabs are the point of the bar and the
   account menu is already down to a chip - and a Home Screen install
   names the app on its own icon anyway. A bar with no tabs keeps it, since
   hiding it would leave the bar empty, and the app switcher always shows,
   since it is the only way to another app. */
.app-nav-tabs .container > .app-brand {
    display: none;
}

@media (min-width: 768px) {
    .app-nav-tabs .container > .app-brand {
        display: inline-flex;
    }
}

/* Everything at the end of the bar that isn't navigation. Like the
   wordmark it never shrinks: the tab row is what gives way, by scrolling,
   when the bar runs out of width. */
.app-nav-actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-shrink: 0;
}

.app-nav-links {
    list-style: none;
    display: flex;
    gap: var(--space-5);
    /* proximity, not mandatory: a nudge that stops a scroll leaving a tab
       half-cut, without fighting a deliberate flick to the far end. */
    scroll-snap-type: inline proximity;
    /* The row's height comes from the links themselves (see .nav-link),
       which is what lets the current tab's underline sit flush on the
       bar's own bottom border rather than floating above it. */
    padding: 0;
    margin: 0;
    /* Six links don't fit a phone-width row and have nowhere to wrap to
       without pushing every row below it down, so the row scrolls
       horizontally instead of overflowing the whole page (the pattern
       .table-scroll uses). */
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    /* flex: 1 + min-width: 0 are what let this row become narrower than
       its content. Without them it refuses to shrink and pushes the
       account menu out of the bar instead of scrolling. */
    flex: 1;
    min-width: 0;
}

/* A list item is a block in an inline formatting context, so an
   inline-flex link inside one sits on a text baseline and leaves a few
   pixels of descender space beneath it - enough to lift the current tab's
   underline off the bar's bottom border. Making the item a flex container
   takes the link out of that context. The margin is the prose `li` rule's,
   which has no business in a nav row. */
.app-nav-links li {
    display: flex;
    margin: 0;
    scroll-snap-align: start;
}

/* The wordmark, and the app's only statement of its own name once a Home
   Screen install has hidden the browser's chrome. Uppercase and tracked so
   it reads as a mark rather than as another link. It is a link back to the
   app wherever there is an app to go back to, and a plain <span> on the
   login page, where it would only point at a page that bounces you
   straight back here. */
.app-brand {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap-min);
    flex-shrink: 0;
    color: var(--color-text);
    font-size: var(--text-sm);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    white-space: nowrap;
}

/* The height lives on the <a>, not on the row: a link box is only its
   line of text - around 20px - which is well under the tap floor
   DESIGN.md section 4 makes non-negotiable, however tall the bar around
   it happens to be. inline-flex is what lets min-height apply at all. */
.nav-link {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap-min);
    color: var(--color-text);
    font-size: var(--text-base);
    font-weight: 500;
    white-space: nowrap;
}

.nav-link:hover {
    color: var(--color-accent-ink);
}

/* The tab you're on. Hangs off aria-current rather than a class, so the
   thing a screen reader announces and the thing you can see are the same
   fact, and neither can be set without the other.

   Ink, not fill: this is accent-coloured text (DESIGN.md section 2.1).
   currentColor keeps the rule under it in step with the label. An inset
   shadow rather than a border, which would add to the link's height and
   shift every other link in the row by 2px the moment one became
   current. */
.nav-link[aria-current="page"] {
    color: var(--color-accent-ink);
    box-shadow: inset 0 -2px 0 currentColor;
}

/* === Footer ===
   Two lines at most: what this is, and which build of it you are looking
   at, the second linking to what changed and to how the app works. */
.app-footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: var(--space-2) var(--space-4);
    /* The `footer` rule above sets the size and colour; this adds only the
       two-line layout. */
}

.app-footer p {
    margin: 0;
}

.app-version {
    font-family: var(--font-mono);
    margin-left: var(--space-2);
}

.app-footer-sep {
    margin: 0 var(--space-2);
}

/* Each app's release notes page, which the footer links to. */
.release-notes-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.release-notes-list li {
    padding: var(--space-2) 0;
    border-bottom: 1px solid var(--color-border);
}

.release-notes-list a {
    color: var(--color-text-muted);
    margin-right: var(--space-2);
}

/* === Account menu ===
   The <details> at the end of both bars. Relative, because the panel below
   hangs off it. */
.user-menu {
    position: relative;
    flex-shrink: 0;
}

/* The disclosure triangle a browser draws itself is the one part of
   <details> worth replacing: it sits on the wrong side, it is drawn
   differently in every engine, and the summary already carries a chevron
   of its own from the icon set. */
.user-menu-summary {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-1);
    min-height: var(--tap-min);
    min-width: var(--tap-min);
    color: var(--color-text);
    list-style: none;
    cursor: pointer;
}

.user-menu-summary::-webkit-details-marker {
    display: none;
}

/* Initials on a disc. Sized like a tap target in its own right, since on
   a phone it is one. The address it stands in for is a line inside the
   panel. */
.user-menu-chip {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    border-radius: var(--radius-pill);
    background: var(--color-raised);
    border: var(--border) solid var(--color-border);
    font-size: var(--text-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
}

.user-menu[open] .user-menu-chip {
    border-color: var(--color-accent-ink);
}

.user-menu[open] .user-menu-summary {
    color: var(--color-accent-ink);
}

.user-menu-panel {
    position: absolute;
    right: 0;
    top: calc(100% + var(--space-2));
    z-index: 60;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-2);
    min-width: 12rem;
    padding: var(--space-3);
    background: var(--color-raised);
    border: var(--border) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-raised);
}

/* The panel's own buttons are its full width; a Logout sitting at its
   natural width in a 12rem panel reads as a stray control rather than as
   the menu's one action. */
.user-menu-panel .btn,
.user-menu-panel button {
    width: 100%;
}

.user-menu-panel-email {
    margin: 0;
    padding: 0 var(--space-1) var(--space-1);
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    /* An address is a single long word; without this the panel widens to
       fit it rather than wrapping. */
    overflow-wrap: anywhere;
}

/* === App switcher ===
   The wordmark, when the site has more than one app: a <details> like the
   account menu, for the same reasons, listing every app. Its summary keeps
   the wordmark's look, so the bar reads the same with one app or several.
   static/js/nav.js closes it when attention moves elsewhere. */
.app-switcher {
    position: relative;
    flex-shrink: 0;
}

.app-switcher > summary {
    gap: var(--space-1);
    list-style: none;
    cursor: pointer;
}

.app-switcher > summary::-webkit-details-marker {
    display: none;
}

.app-switcher[open] > summary {
    color: var(--color-accent-ink);
}

.app-switcher-panel {
    position: absolute;
    left: 0;
    top: calc(100% + var(--space-2));
    z-index: 60;
    min-width: 12rem;
    margin: 0;
    padding: var(--space-2);
    list-style: none;
    background: var(--color-raised);
    border: var(--border) solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-raised);
}

.app-switcher-panel li {
    margin: 0;
}

.app-switcher-panel a {
    display: flex;
    align-items: center;
    min-height: var(--tap-min);
    padding: 0 var(--space-3);
    border-radius: var(--radius);
    color: var(--color-text);
}

.app-switcher-panel a:hover {
    color: var(--color-accent-ink);
}

/* The app you are in, marked the way the current tab is: accent ink. */
.app-switcher-panel a[aria-current="true"] {
    color: var(--color-accent-ink);
    font-weight: 700;
}

/* === Card === */
.card {
    background: var(--color-surface);
    border: var(--border) solid var(--color-border);
    border-radius: var(--radius-lg);
    padding: var(--space-5);
    margin-top: var(--space-6);
    box-shadow: var(--shadow-card);
}

/* Help behind an "i" at a card's top-right corner: how the thing the card
   manages works, for whoever wants to know, out of the way of everyone
   else. A <details>, so it opens and closes with no script, on a tap as
   readily as a click, and a screen reader announces it as the disclosure
   it is.

   The summary sits in the corner, out of the flow, so a closed disclosure
   takes no room at all; opened, the text lands in the flow right under the
   heading, pushing the card down rather than floating over it - on a phone
   a popover would cover the very controls it explains. */
.card:has(> .card-help) {
    position: relative;
}

.card:has(> .card-help) > h2 {
    padding-right: var(--tap-min);
}

.card-help > summary {
    position: absolute;
    top: var(--space-3);
    right: var(--space-3);
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: var(--tap-min);
    min-height: var(--tap-min);
    border-radius: var(--radius-pill);
    color: var(--color-text-muted);
    cursor: pointer;
    list-style: none;
}

.card-help > summary::-webkit-details-marker {
    display: none;
}

.card-help > summary .icon {
    width: 1.25rem;
    height: 1.25rem;
}

.card-help > summary:hover,
.card-help[open] > summary {
    color: var(--color-accent-ink);
}

.card-help-body {
    margin-bottom: var(--space-4);
    padding: var(--space-3) var(--space-4);
    border-left: 3px solid var(--color-accent-ink);
    background: var(--color-raised);
    border-radius: var(--radius);
    font-size: var(--text-sm);
}

.card-help-body p {
    margin: 0 0 var(--space-2);
}

.card-help-body p:last-child {
    margin-bottom: 0;
}

/* === Forms ===
   None of this existed before: there was no input, select, textarea or
   label rule anywhere in the app, so every form rendered with browser
   default controls - light-on-dark against this palette, and below 16px,
   which makes iOS Safari zoom the viewport on focus. The 16px minimum
   here is that fix and is not negotiable.

   .form-group, .form-row and .form-actions were used across nine
   templates without ever being defined. They are defined here. */
.form-group {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    margin-bottom: var(--space-4);
}

label,
.form-group > span:first-child {
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--color-text-muted);
}

/* Short numeric pairs (catch-form's Length/Weight) stay side by side even
   on a phone: they are narrow enough, and the vertical space matters more
   inside the sheet than a full-width field would. */
.form-row {
    display: flex;
    gap: var(--space-3);
}

.form-row > .form-group {
    flex: 1;
    min-width: 0;
}

input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]),
select,
textarea {
    width: 100%;
    min-height: var(--control-h);
    padding: 0 var(--space-3);
    font-family: inherit;
    font-size: var(--text-base);
    font-weight: 500;
    color: var(--color-text);
    background: var(--color-bg);
    /* The 2px border is load-bearing: it is what makes a field read as a
       field in glare, where a background difference alone washes out. */
    border: var(--border-input) solid var(--color-border);
    border-radius: var(--radius);
    transition: border-color var(--transition);
    /* Several labels in the app WRAP their control rather than pointing at
       it with `for` (the highscore filters, the map layer toggles). Since
       text-transform and letter-spacing inherit, the uppercase label rule
       above would otherwise reach inside and upper-case the control's own
       text and every <option> in it. */
    text-transform: none;
    letter-spacing: normal;
}

textarea {
    padding: var(--space-3);
    line-height: 1.5;
    resize: vertical;
}

input[type="number"] {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
}

input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):hover,
select:hover,
textarea:hover {
    border-color: var(--color-text-muted);
}

input::placeholder,
textarea::placeholder {
    color: var(--color-text-muted);
    opacity: 0.7;
}

/* Native chevron removed so the control matches the rest; the replacement
   is drawn as a background SVG with currentColor baked in per theme via
   the stroke attribute, since background-image cannot read a CSS var. */
select {
    appearance: none;
    padding-right: var(--space-7);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2397a3ab' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right var(--space-3) center;
    cursor: pointer;
}

@media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) select {
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23545c62' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
    }
}

:root[data-theme="light"] select {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23545c62' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}

/* A date field is the whole click target for its calendar (see
   static/js/date-picker.js), so it says so with the pointer. The native
   calendar icon is drawn in the page's color-scheme, which nothing else
   sets - left alone it is dark ink on the dark theme's near-black field,
   and the one visible hint that a picker exists disappears. */
input[type="date"] {
    color-scheme: dark;
    cursor: pointer;
}

input[type="date"]::-webkit-calendar-picker-indicator {
    cursor: pointer;
}

@media (prefers-color-scheme: light) {
    :root:not([data-theme="dark"]) input[type="date"] {
        color-scheme: light;
    }
}

:root[data-theme="light"] input[type="date"] {
    color-scheme: light;
}

/* A file input is not a text field, so it never triggers the iOS
   zoom-on-focus this size rule exists to prevent - but "16px minimum, no
   exceptions" is easier to keep and to check than a rule with a carve-out
   in it, and the control reads better at this size anyway. */
input[type="file"] {
    width: 100%;
    font-family: inherit;
    font-size: var(--text-base);
    color: var(--color-text-muted);
}

input[type="file"]::file-selector-button {
    min-height: var(--tap-min);
    padding: 0 var(--space-4);
    margin-right: var(--space-3);
    font-family: inherit;
    font-size: var(--text-sm);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-text);
    background: var(--color-raised);
    border: var(--border-input) solid var(--color-border);
    border-radius: var(--radius);
    cursor: pointer;
}

input[type="checkbox"],
input[type="radio"] {
    width: 1.25rem;
    height: 1.25rem;
    accent-color: var(--color-accent);
    cursor: pointer;
}

/* A photo field's current photo and the preview of a new one, which
   static/js/photo-preview.js fills in and every app's photo forms share. */
.photo-preview-wrap {
    margin-top: var(--space-2);
}

.photo-preview {
    display: block;
    max-width: 100%;
    max-height: 200px;
    border-radius: 8px;
    object-fit: cover;
}

.photo-preview-wrap .btn {
    margin-top: var(--space-2);
}

.photo-size-note {
    margin: var(--space-1) 0 0;
    font-size: var(--text-sm);
    color: var(--color-text-muted);
}

/* Stacked on a phone with the primary action on top, where the thumb is.
   The DOM order is the phone order; at md the row reverses visually so
   the primary lands bottom-right, which is the desktop convention. */
.form-actions {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    margin-top: var(--space-5);
}

.form-actions > button,
.form-actions > .btn {
    width: 100%;
}

.form-actions > button[type="submit"],
.form-actions > .btn-primary {
    min-height: var(--action-h);
}

/* === Buttons === */
button,
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: var(--tap-min);
    /* Tall AND wide (DESIGN.md 3.5). Height alone is not enough: the
       notifications toggle, back when it was an icon-only button in the
       nav bar with its label hidden, came out 40px wide from padding plus
       a 1em icon - under the floor, while passing every height check. A
       text button is wider than this anyway, so the floor only ever binds
       on an icon-only one. */
    min-width: var(--tap-min);
    padding: 0 var(--space-5);
    font-family: inherit;
    font-size: var(--text-sm);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-on-accent);
    background: var(--color-accent);
    border: none;
    border-radius: var(--radius);
    cursor: pointer;
    transition: background var(--transition), border-color var(--transition);
}

/* Used across several templates with no definition of its own, so it
   silently fell back to the base button. Kept as an explicit alias rather
   than stripped from the markup: it reads as intent at the call site. */
.btn-primary {
    color: var(--color-on-accent);
    background: var(--color-accent);
}

button:hover,
.btn:hover {
    background: color-mix(in srgb, var(--color-accent) 85%, var(--color-text));
}

button:disabled,
.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

/* The usual ghost/outline treatment. Was also used with no definition
   (catch-form's Cancel / Pick from map / Remove photo) and looked
   identical to the primary until it was given one. */
.btn-secondary {
    color: var(--color-text);
    background: transparent;
    border: var(--border-input) solid var(--color-border);
}

.btn-secondary:hover {
    background: var(--color-raised);
    border-color: var(--color-text-muted);
}

.btn-danger {
    color: var(--color-danger);
    background: transparent;
    border: var(--border-input) solid var(--color-danger);
}

.btn-danger:hover {
    color: var(--color-on-accent);
    background: var(--color-danger);
}

/* Visually smaller, but the hit area still clears the 44px floor - the
   height stays and only the horizontal padding and type shrink. */
.btn-sm {
    padding: 0 var(--space-3);
    font-size: var(--text-xs);
}

form.inline {
    display: inline;
    margin: 0;
}

/* === Scroll shadows ===
   What every horizontally scrolling container in the app uses to say so -
   wide tables, and the two nav rows above. On a phone there is no
   scrollbar to see, and without this a row that scrolls looks exactly
   like a row that ends.

   The four gradients are the standard shadow trick: the two `local`
   layers scroll with the content and mask the shadow when that edge is
   reached, the two `scroll` layers stay put and show it when it is not.
   No JavaScript, and it appears only when there is overflow.

   The mask colour is the surface, which every one of these sits on: a
   table inside a .card, and the tab row inside the header. */
.table-wrap,
.table-scroll,
.app-nav-links {
    background:
        linear-gradient(to right, var(--color-surface) 40%, transparent) 0 0 / 3rem 100% no-repeat local,
        linear-gradient(to left, var(--color-surface) 40%, transparent) 100% 0 / 3rem 100% no-repeat local,
        radial-gradient(farthest-side at 0 50%, var(--color-scroll-shadow), transparent) 0 0 / 1rem 100% no-repeat scroll,
        radial-gradient(farthest-side at 100% 50%, var(--color-scroll-shadow), transparent) 100% 0 / 1rem 100% no-repeat scroll;
}

/* === Table ===
   Wide tables scroll rather than reflow: it keeps the column structure
   legible, and it already worked. */
.table-wrap,
.table-scroll {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    margin: var(--space-4) 0;
}

.table {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--text-sm);
}

.table th,
.table td {
    text-align: left;
    padding: var(--space-2) var(--space-3);
    border-bottom: var(--border) solid var(--color-border);
    white-space: nowrap;
}

.table th {
    color: var(--color-text-muted);
    font-weight: 700;
    font-size: var(--text-xs);
    text-transform: uppercase;
    letter-spacing: 0.1em;
}

.table-id {
    color: var(--color-text-muted);
    font-family: var(--font-mono);
    font-size: var(--text-xs);
}

.cell-wrap {
    white-space: normal;
}

.text-muted {
    color: var(--color-text-muted);
}

/* === Status pills (admin airtags error feed) === */
.pill {
    display: inline-block;
    padding: var(--space-1) var(--space-3);
    border-radius: var(--radius-pill);
    font-size: var(--text-xs);
    font-weight: 700;
    white-space: nowrap;
}

.pill-ok {
    background: var(--color-raised);
    color: var(--color-success);
}

.pill-warn {
    background: var(--color-raised);
    color: var(--color-accent-ink);
}

.pill-error {
    background: var(--color-raised);
    color: var(--color-danger);
}

/* === Admin users page: role pills + toggle checkboxes === */
.role-pills {
    margin-bottom: var(--space-1);
}

.role-toggles {
    display: flex;
    gap: var(--space-3);
}

.role-toggle {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    white-space: nowrap;
}

/* The signed-in admin's own admin checkbox (see admin-user-roles-cell) is
   rendered disabled, and should read as locked rather than merely idle. */
.role-toggle input[disabled] {
    cursor: not-allowed;
    opacity: 0.5;
}

/* === Admin sub-tabs (Users / AirTags) === */
.admin-tabs {
    display: flex;
    gap: var(--space-1);
    border-bottom: var(--border) solid var(--color-border);
    margin-top: var(--space-5);
    overflow-x: auto;
}

.admin-tab {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap-min);
    padding: 0 var(--space-4);
    color: var(--color-text-muted);
    font-size: var(--text-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
    transform: translateY(1px);
}

.admin-tab:hover {
    color: var(--color-text);
}

.admin-tab.active {
    color: var(--color-text);
    /* The ink, not the fill: a 2px mark in --color-accent sits at 1.93:1
       on the light ground, under the 3:1 WCAG asks of a graphical
       element. The ink clears it in both (11.7:1 dark, 5.6:1 light). */
    border-bottom-color: var(--color-accent-ink);
}

/* === HTMX indicator === */
.htmx-indicator {
    display: none;
}

.htmx-request .htmx-indicator {
    display: inline;
}

/* === Footer === */
footer {
    text-align: center;
    color: var(--color-text-muted);
    font-size: var(--text-xs);
    border-top: var(--border) solid var(--color-border);
}

/* === Auth pages === */
.auth-wrapper {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 70vh;
}

.auth-card {
    width: 100%;
    max-width: 420px;
    background: var(--color-surface);
    border: var(--border) solid var(--color-border);
    border-radius: var(--radius-lg);
    padding: var(--space-7) var(--space-6);
    text-align: center;
    box-shadow: var(--shadow-raised);
    animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

@keyframes fadeUp {
    from {
        opacity: 0;
        transform: translateY(20px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.auth-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 64px;
    height: 64px;
    border-radius: 50%;
    background: var(--color-raised);
    color: var(--color-accent-ink);
    margin-bottom: var(--space-6);
}

.auth-card h1 {
    font-size: var(--text-xl);
    margin-bottom: var(--space-2);
}

.auth-card p.subtitle {
    color: var(--color-text-muted);
    font-size: var(--text-sm);
    margin-bottom: var(--space-6);
}

/* The invite-code form on the no-access page: a label, the field and the
   button stacked full width, the card's centred text put back to reading
   order for the label. */
.no-access-code-form {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    text-align: left;
}

.auth-providers {
    list-style: none;
    padding: 0;
    margin: 0 0 var(--space-6) 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
}

.btn-provider {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3);
    width: 100%;
    min-height: var(--control-h);
    background: var(--color-raised);
    border: var(--border-input) solid var(--color-border);
    color: var(--color-text);
    padding: 0 var(--space-4);
    border-radius: var(--radius);
    font-size: var(--text-sm);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    transition: background var(--transition), border-color var(--transition);
}

.btn-provider:hover {
    background: var(--color-surface);
    border-color: var(--color-text-muted);
    color: var(--color-text);
}

.auth-footer {
    font-size: var(--text-xs);
    color: var(--color-text-muted);
}

/* === Admin tip-of-day form ===
   The tip is admin-authored HTML rendered as-is on the dashboard (see
   fishing.Handler.dashboardTip), so this box is sized and monospaced for
   comfortably writing/reading markup, not a one-line plain-text field. */
.tip-of-day-textarea {
    display: block;
    max-width: 40rem;
    min-height: 16rem;
    font-family: var(--font-mono);
    /* 16px like every other control: it is a textarea, so below that it
       zooms the viewport on focus in iOS Safari, admin page or not. */
    font-size: var(--text-base);
}

/* Rendered tip on the dashboard: reset the default margin on a leading/
   trailing <p> (or similar block element) an admin's HTML may include, so
   the card's own padding is the only spacing, not doubled up. */
.tip-content > :first-child {
    margin-top: 0;
}

.tip-content > :last-child {
    margin-bottom: 0;
}

/* The shared error fragment (templates/partials/error-message.html) and the
   page-level slot it lands in. Both layouts load this stylesheet, which is
   why the rule lives here rather than in fishing.css: an error on an admin
   or login page has to look like an error too. */
.form-error {
    color: var(--color-danger);
    font-size: var(--text-sm);
    margin-bottom: var(--space-3);
}

/* Empty until something goes wrong, and taking up no room until then. */
.app-error:not(:empty) {
    max-width: var(--container-max);
    margin: var(--space-4) auto 0;
    padding: var(--space-3) var(--space-4);
    border: var(--border-input) solid var(--color-danger);
    border-radius: var(--radius);
    background: var(--color-surface);
}

.app-error .form-error {
    margin-bottom: 0;
}

/* A site admin viewing the app as a group member (see
   internal/apps/fishing/viewas.go) - in the accent colour, so it can't be
   mistaken for how the app looks to them normally. */
.view-as-banner {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    max-width: var(--container-max);
    margin: var(--space-4) auto 0;
    padding: var(--space-3) var(--space-4);
    border: var(--border-input) solid var(--color-accent);
    border-radius: var(--radius);
    background: var(--color-surface);
}

/* Catches logged with no signal, still waiting to be sent (see
   static/apps/fishing/js/offline-catches.js). Shaped like the view-as banner above,
   since both say the same kind of thing - the app is not in its ordinary
   state. In the accent colour rather than the danger one: nothing is lost,
   something is pending. */
.offline-queue {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    max-width: var(--container-max);
    margin: var(--space-4) auto 0;
    padding: var(--space-3) var(--space-4);
    border: var(--border-input) solid var(--color-accent);
    border-radius: var(--radius);
    background: var(--color-surface);
}

/* === Responsive ===
   Mobile-first: the phone layout is the unqualified default above and
   these min-width queries are the desktop overrides. Breakpoints are
   sm 480 / md 768 / lg 1024 (DESIGN.md section 2.5). */
@media (min-width: 768px) {
    :root {
        /* Touch sizes step down at pointer widths, where a 56px field
           looks clumsy. This is the only place they change. */
        --control-h: 44px;
        --action-h: 44px;
    }

    .container {
        padding: 0 var(--space-5);
    }

    h1 {
        font-size: var(--text-2xl);
    }

    /* Primary action back to bottom-right, the desktop convention. The
       DOM keeps the phone order, so this reverses it visually. */
    .form-actions {
        flex-direction: row-reverse;
        justify-content: flex-start;
    }

    .form-actions > button,
    .form-actions > .btn {
        width: auto;
    }
}

/* === Reduced motion === */
@media (prefers-reduced-motion: reduce) {
    html {
        scroll-behavior: auto;
    }

    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

/* A value to copy, with its Copy button (static/js/copy-button.js), and
   a group invite's link, QR code and code - in every app that invites. */

.copy-field {
    margin: var(--space-2) 0;
}

.copy-field > span {
    display: block;
    font-size: var(--text-xs);
    color: var(--color-text-muted);
    margin-bottom: var(--space-1);
}

.copy-field-row {
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

.copy-field-row code {
    flex: 1;
    min-width: 0;
    overflow-wrap: anywhere;
    background: var(--color-raised);
    padding: var(--space-1) var(--space-2);
    border-radius: var(--radius);
    font-size: var(--text-sm);
}

.copy-field-row .copy-btn {
    flex-shrink: 0;
}

/* A group invite's QR code (see groupinvite.QRSVG). The SVG draws its own white
   ground and quiet zone, which a scanner needs in either theme, so the
   frame only sizes it: big enough to scan from across a boat, never wider
   than a phone. */
.invite-qr-frame {
    display: flex;
    justify-content: center;
    margin: var(--space-3) 0;
}

.invite-qr {
    display: block;
    width: min(16rem, 100%);
    height: auto;
    border-radius: var(--radius);
}

/* The short code is read aloud and typed in, so it gets the mono face and
   room between its characters. */
.copy-field-row code.invite-code {
    font-family: var(--font-mono);
    font-size: var(--text-lg);
    letter-spacing: 0.12em;
}

/* === Page headings and dialogs ===
   Every app opens its forms in a <dialog> (static/js/dialogs.js), headed
   like a page by an .app-header, so both are styled here rather than in
   any one app. */
.app-header {
    margin-bottom: var(--space-6);
}

/* Below md a dialog is a bottom sheet, not a centred box (DESIGN.md D5).
   A form several fields deep - the fishing app's catch form has seven -
   in a viewport-centred fixed box with no max-height loses the fields a
   phone keyboard pushes off-screen, since the box itself cannot scroll.

   As a sheet it is anchored to the bottom edge, capped at 92svh - svh,
   not vh, is what makes this work, because vh on iOS is the height with
   the URL bar hidden and overshoots while it is showing - and the body
   scrolls internally while the surrounding page does not. */
dialog {
    position: fixed;
    inset: auto 0 0 0;
    max-height: 92svh;
    /* Sized by its insets rather than by a percentage. With both edges
       pinned the padding is drawn inside the sheet whatever box-sizing
       says, where a width of 100% plus padding comes out one padding
       wider than the screen and pushes the buttons off the right edge.
       box-sizing is stated here for the same reason: the geometry of a
       full-bleed padded box depends on it, so it does not ride on the
       reset reaching this element. */
    box-sizing: border-box;
    width: auto;
    max-width: 100%;
    margin: 0;
    padding: var(--space-5);
    overflow-y: auto;
    overscroll-behavior: contain;
    background: var(--color-surface);
    border: none;
    border-top: var(--border) solid var(--color-border);
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    box-shadow: var(--shadow-sheet);
    color: var(--color-text);
    z-index: 2000;
}

/* A grab handle, so it reads as a sheet rather than a panel that slid in
   by mistake. Decorative only - the close button is the real affordance. */
dialog::before {
    content: "";
    display: block;
    width: 40px;
    height: 4px;
    margin: 0 auto var(--space-4);
    border-radius: var(--radius-pill);
    background: var(--color-border);
}

@media (min-width: 768px) {
    dialog {
        inset: 50% auto auto 50%;
        transform: translate(-50%, -50%);
        width: 90%;
        max-width: min(480px, 100%);
        max-height: 85vh;
        padding: var(--space-6);
        border: var(--border) solid var(--color-border);
        border-radius: var(--radius-lg);
        box-shadow: var(--shadow-raised);
    }

    dialog::before {
        display: none;
    }
}

dialog::backdrop {
    background: var(--color-overlay);
    backdrop-filter: blur(4px);
    z-index: 1999;
}

/* The confirmation dialog (templates/partials/confirm-dialog.html) is
   narrower than a form dialog: it holds one question and two buttons, and
   at a form's width that question reads as a single long line. */
@media (min-width: 768px) {
    .confirm-dialog {
        max-width: 26rem;
    }
}

/* The question is the dialog's heading in everything but weight - it is
   what the reader is being asked, so it takes the room a heading would
   rather than sitting like body copy under one that isn't there. */
.confirm-dialog-question {
    margin: 0;
    font-size: var(--text-lg);
    line-height: 1.4;
    /* The question carries names the app did not choose - a group, a
       boat, a game, a person - and one long enough to beat the sheet's
       width would otherwise make the sheet scroll sideways. */
    overflow-wrap: anywhere;
}
