/* ==========================================================================
   eSheets Mobile — design system
   --------------------------------------------------------------------------
   Used only by m.php. Nothing else on the site loads this file, and this file
   loads nothing else — telstyleesheets.css, news.css, tasks.css et al. are all
   deliberately absent, so no rule here can reach a desktop page and no desktop
   rule can reach here.

   Every token is prefixed --m- so that even if this file were ever loaded
   alongside telstyleesheets.css (which owns the unprefixed --nav-bar-height),
   nothing could collide.

   Two standing rules for anyone editing this file:

     1. Values live in the token block. If you find yourself typing a colour,
        a spacing number or a radius anywhere below the ":root" block, it
        almost certainly wants to be a token instead. Flow learned this the
        hard way: 26 hand-typed rgba(153, 51, 102, ...) tints came loose from
        the one variable that was supposed to control them.

     2. Contrast ratios in the comments are measured, not guessed. If you
        change a colour, recompute the pair it is annotated against. The
        targets are WCAG AA: 4.5:1 for body text, 3:1 for large text and for
        the boundary of any interactive control.
   ========================================================================== */


/* ==========================================================================
   1. TOKENS
   ========================================================================== */

:root {
    /* ---- Brand -----------------------------------------------------------
       #993366 is the fixed Trafalgar brand colour. Every other brand value is
       DERIVED from it with color-mix() rather than typed as a snapshot hex, so
       a change to --m-brand carries through automatically. The comments record
       what each mix actually resolves to today, which is what the contrast
       figures were measured against. */
    --m-brand: #993366;
    /* No separate --m-brand-rgb triplet. A second representation of the same
       colour is a second thing to keep in sync; where a translucent brand is
       needed, color-mix(in srgb, var(--m-brand) N%, transparent) derives it
       from the one source of truth. */

    /* = #7a2952. Pressed/active states, and text that needs more weight than
       the brand itself. 9.27:1 on white, 8.88:1 on --m-surface-alt.

       Mixed toward --m-weight-base rather than toward `black`, because "more
       weight than the brand" means "further from the ground", and on a dark
       ground that direction is white. In this theme the base IS black, so the
       resolved value is unchanged. */
    --m-brand-deep: color-mix(in srgb, var(--m-brand) 80%, var(--m-weight-base));

    /* THE BRAND AS INK, which is not the same token as the brand as a FILL.
       -----------------------------------------------------------------------
       --m-brand is a fill: the app bar, the FAB, a pressed chip, the primary
       button, the selected day. Every one of those pairs it with
       --m-on-brand, so what it has to satisfy is "white reads on this".

       A handful of rules instead use it as INK on a surface — a link, a
       project number, the current tab, the agenda block's left rail, the drag
       ghost's edge, the focus ring. What those have to satisfy is the
       opposite: "this reads on the surface behind it".

       On a light ground one colour can do both jobs and #993366 does. On a
       dark one nothing can — a fill that carries white text is by definition
       too dark to be ink on near-black. Splitting the token is what lets the
       dark theme answer the two separately; here it is the same colour, so
       this theme renders exactly as it did before the split. */
    --m-brand-ink: var(--m-brand);

    /* = #f1e2ea. Chip fills and icon wells. --m-brand-deep on this is 7.42:1.
       Mixed toward --m-mix-base — the ground a tint is diluted INTO — so the
       same 14% reads as a tint in either theme instead of as a pale wash
       floating on a dark page. */
    --m-brand-tint: color-mix(in srgb, var(--m-brand) 14%, var(--m-mix-base));

    /* = #f7eff3. Large calm areas — sheet headers, the agenda's "now" band.
       --m-brand on it is 6.16:1 (the worst pair in this file, still AA),
       --m-brand-deep 8.20:1, --m-text 14.53:1. */
    --m-brand-wash: color-mix(in srgb, var(--m-brand) 8%, var(--m-mix-base));

    /* White on --m-brand is 6.95:1 — comfortably AA at any size. The brand
       is a dark fill in BOTH themes, so this stays white in both. */
    --m-on-brand: #ffffff;

    /* The foreground for a saturated STATUS fill — the tick inside a completed
       task's checkbox (drawn on --m-success) and the confirm sheet's solid
       destructive button (on --m-danger).

       A separate token from --m-on-brand because the two diverge in the dark
       theme and did so silently: the status colours lighten there so they can
       be read as text, which turns the tick into white-on-pale-green at
       1.76:1. --m-on-brand cannot follow them, because the brand fill it
       belongs to stays dark. Found by tools/contrast.py, which checks the
       tick pair in every theme. */
    --m-on-fill: #ffffff;

    /* Pressed/hover wash for a control sitting ON the brand bar, where the
       usual surface tokens would be invisible. Reached only through
       --m-appbar-wash now, which is what lets the dark theme swap the whole
       wash for a brand tint without this value having to mean two things. */
    --m-on-brand-wash: rgba(255, 255, 255, 0.16);

    /* ---- Project identity (Mobile App #71) -------------------------------
       A hue per project, so a job can be recognised rather than read. Applied
       ONLY to .m-prjnum[data-prj]; app/text.py:project_hue() picks the step
       from the project number itself, so it is stable forever with no column,
       no admin screen and nothing to keep in sync.

       FIVE STEPS, AND ALL FIVE IN ONE ARC. Everything from 140° to 40° going
       the short way is already spoken for on this screen — brand magenta at
       325°, planned teal at 175° (#68), success green at 145°, danger red at
       3°, warn amber at 39°, the pinned-row yellow at 54°. The blue-violet
       run from 200° to 285° is the only stretch with no semantic neighbour,
       and it fits five hues that stay apart at 13px. Six were tried; at 16°
       spacing two of them read as the same colour on a phone.

       The cost is honest and worth stating: with five steps, two of the jobs
       on your screen will sometimes share a colour. That is a weaker cue than
       a unique one, not a wrong one — the number is right there beside it, and
       five is still five times the information carried by the single brand
       magenta every project number wore before.

       Deliberately NOT applied to agenda blocks. The rail on a block already
       means logged / planned / Outlook, and a second meaning on the same
       edge is the "one flag wearing two colours" mistake the --m-pin-bg note
       argues against. It is also why nothing here is duplicated into
       mobile-views.js: the hue is only ever server-rendered, so there is no
       second copy of the rule to drift.

       5.10:1 to 7.66:1 on --m-bg, all AA at body size. */
    --m-prj-1: #316e8d;
    --m-prj-2: #3d62ac;
    --m-prj-3: #423dac;
    --m-prj-4: #6b3dac;
    --m-prj-5: #913dac;

    /* ---- The two grounds every derivation reads (Mobile App #67) ---------
       Named so that one set of color-mix() expressions serves both themes.

       --m-mix-base is the ground a TINT or WASH is diluted into. It tracks
       --m-surface: a tint is a colour laid thinly over the card it sits on,
       so on a dark card it must dilute toward that card and not toward white.

       --m-weight-base is the direction MORE CONTRAST lies in — black on a
       light ground, white on a dark one. It is what the "-deep" values mix
       toward.

       Both are literal here rather than var(--m-surface), because
       --m-surface is #ffffff in this theme and the two ideas coincide; the
       dark block sets them apart from each other explicitly. */
    --m-mix-base: #ffffff;
    --m-weight-base: #000000;

    /* ---- Surfaces --------------------------------------------------------
       Three distinct roles, deliberately separate even where two happen to
       look similar: the page behind everything, the card/sheet surface that
       sits on it, and a subtly recessed fill used inside a card (input wells,
       zebra rows). Collapsing them would make a future dark palette fight
       itself. */
    --m-bg: #f4f4f6;
    --m-surface: #ffffff;
    --m-surface-alt: #fafafb;
    --m-surface-sunken: #eeeef1;

    /* ---- Lines -----------------------------------------------------------
       --m-border is a DECORATIVE separator (1.29:1). It must never be the only
       thing marking the boundary of an interactive control — WCAG requires 3:1
       for that, which is what --m-control-border is for (3.41:1 on white,
       3.10:1 on the page background). */
    --m-border: #e2e2e8;
    --m-control-border: #8a8a97;

    /* The day-change hint tab (Plane #65 — James: "try #ccc"). A token because
       csscheck.py fails a hard-coded hex outside this block, and because #67
       (dark mode) will need a counterpart for it like everything else here.
       10.2:1 against --m-text, which is what the tab's label now uses: the old
       white text was for a near-black tab and would be 1.6:1 on this. */
    --m-swipehint-bg: #ccc;

    /* ---- Text ------------------------------------------------------------
       Measured on --m-surface / --m-bg respectively. */
    --m-text: #1f1f24;          /* 16.41:1 / 14.94:1 */
    --m-text-muted: #5c5c68;    /*  6.59:1 /  6.00:1 */
    /* DECORATION ONLY from Mobile App #71 — never body copy, never a value
       somebody has to read. 5.26:1 / 4.79:1 clears AA, but AA is measured on
       an indoor screen and this app is used outdoors, at arm's length, through
       a screen protector: the figure is a floor here, not a target. The rules
       that used it for readable text now take --m-text-muted, eleven of them,
       including the agenda's hour labels - which are not decoration, they are
       the timeline's axis.

       WHAT IS LEFT IS GLYPHS AND RAILS, NEVER A WORD: the empty-state icon,
       the search field's magnifier, the layout grip, the hidden-widget eye,
       and the 3px rail down an Outlook meeting. If you find yourself putting
       this token on text again, the answer is --m-text-muted.

       tools/contrast.py holds it to the 3:1 large-text floor rather than 4.5,
       which is the check that keeps the distinction honest: raise it back to a
       body-text pair and the tool will start demanding a body-text ratio. */
    --m-text-faint: #6b6b77;

    /* ---- Status ----------------------------------------------------------
       Each foreground is AA on white AND on its own paired background, so a
       status pill reads correctly either way round. Colour is never the only
       signal — see the .m-badge rules, which always carry an icon or a word. */
    --m-danger: #b3261e;        /* 6.54:1 on white, 5.72:1 on --m-danger-bg */
    --m-danger-bg: #fdecea;
    --m-success: #16733a;       /* 5.92:1 on white, 5.21:1 on --m-success-bg */
    --m-success-bg: #e6f4ea;
    --m-warn: #8a5a00;          /* 5.93:1 on white, 5.36:1 on --m-warn-bg */
    --m-warn-bg: #fdf3d9;

    /* ---- Pinned ----------------------------------------------------------
       A row fill, not a status. The desktop paints a pinned ("starred") task
       row lemonchiffon (css/tasks.css:84) and the team reads it as a sticky
       note - Asif's own words on Mobile App #10. Deliberately the SAME hex
       rather than a mobile reinterpretation: one flag wearing two colours on
       two screens is a thing to learn twice.

       Not derived from --m-warn, though both are yellow. They are different
       ideas: --m-warn is a status the app asserts ("this needs attention"),
       --m-pin-bg is a mark the person put there themselves.

       Measured against every foreground a task row can actually carry:
       --m-text 15.48:1, --m-text-muted 6.22:1, --m-text-faint 4.96:1,
       --m-warn 5.59:1, --m-danger 6.17:1 - all still AA at body size. The
       unticked checkbox border (--m-control-border) is 3.21:1, over the 3:1
       that a control boundary needs. */
    --m-pin-bg: #fffacd;

    /* ---- Planned ---------------------------------------------------------
       Time SET ASIDE (Mobile App #46), and its own hue since #68 — James:
       "planned and outlook are too similar, make planned a different colour".
       They were both a dashed outline over the page, so at 1px on a phone the
       only thing telling them apart was the hue of a hairline. Three things on
       this timeline now each own a colour and each wear it as a solid left
       rail: brand = logged, plan = planned, --m-text-faint = an Outlook
       meeting.

       Teal is chosen for distance rather than taste. It is the far side of the
       wheel from --m-brand, so the two do not converge as thin lines, and it is
       saturated where the Outlook grey is not — which is the pair James
       actually reported. Green (--m-success) and amber (--m-warn/--m-pin-bg)
       were unavailable: both already assert something about a row.

       Derived from --m-plan the way the brand family is derived from
       --m-brand, so one value moves all three.

       6.36:1 on --m-surface, 5.79:1 on --m-bg. */
    --m-plan: #0f6b62;

    /* = #0c564e. The block's text, on --m-plan-tint: 6.93:1. 8.55:1 on
       --m-surface, which is what the PLANNED pill sits on. */
    --m-plan-deep: color-mix(in srgb, var(--m-plan) 80%, black);

    /* = #ddeae9. The block fill. --m-text on it is 13.30:1, --m-plan-deep
       6.93:1, and the --m-plan border on it 5.16:1. It is 1.23:1 against
       --m-surface — a fill this quiet is a grouping cue, never the only
       signal. The dashed border and the PLANNED pill still carry the meaning,
       which is what the note at .m-agenda__block--planned requires. */
    --m-plan-tint: color-mix(in srgb, var(--m-plan) 14%, var(--m-mix-base));

    /* ---- Space -----------------------------------------------------------
       One scale. Every margin, padding and gap in this file comes from it. */
    --m-s1: 0.25rem;   /*  4px */
    --m-s2: 0.5rem;    /*  8px */
    --m-s3: 0.75rem;   /* 12px */
    --m-s4: 1rem;      /* 16px */
    --m-s5: 1.5rem;    /* 24px */
    --m-s6: 2rem;      /* 32px */

    /* ---- Radius ---------------------------------------------------------- */
    --m-r-sm: 6px;
    --m-r-md: 10px;
    --m-r-lg: 16px;
    --m-r-pill: 999px;

    /* ---- Type ------------------------------------------------------------
       Sized in rem so the OS text-size setting is honoured. --m-fs-md is the
       body/control size: 1rem = 16px at default, which is also the threshold
       below which iOS Safari zooms the viewport on input focus. Do not set any
       input smaller than this. */
    --m-font: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto,
              'Helvetica Neue', Arial, sans-serif;
    --m-font-num: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
    --m-fs-xs: 0.75rem;
    /* 14px, raised from 13px (Mobile App #71). Same reasoning as the note on
       --m-text-faint: this step carries nearly every meta row in the app, and
       13px grey at arm's length in daylight is the pair of decisions that made
       those rows hard to read. Nothing here is width-constrained — the day
       strip's chips are sized from --m-fs-xs and --m-fs-md, not this. */
    --m-fs-sm: 0.875rem;
    --m-fs-md: 1rem;
    --m-fs-lg: 1.125rem;
    --m-fs-xl: 1.375rem;
    --m-lh-tight: 1.25;
    --m-lh-normal: 1.5;

    /* ---- Elevation ------------------------------------------------------- */
    --m-shadow-1: 0 1px 2px rgba(16, 16, 24, 0.06);
    --m-shadow-2: 0 2px 8px rgba(16, 16, 24, 0.10);
    --m-shadow-3: 0 -4px 24px rgba(16, 16, 24, 0.18);
    --m-backdrop: rgba(16, 16, 24, 0.45);

    /* ---- Controls --------------------------------------------------------
       --m-tap is the minimum size of anything you can touch. It is a floor,
       applied via min-height/min-width, not a fixed size. */
    --m-tap: 2.75rem;           /* 44px */
    --m-input-h: 2.875rem;      /* 46px */
    --m-field-gap: var(--m-s3);
    /* The ink token, not the fill one: a ring is drawn ON a surface and has
       to read against it. Same colour here, correct in the dark theme. */
    --m-focus-ring: var(--m-brand-ink);
    --m-focus-width: 3px;

    /* ---- Chrome ----------------------------------------------------------
       --m-safe-b keeps the tab bar clear of the iPhone home indicator. It
       resolves to 0px everywhere that has no notch, so it costs nothing. */
    --m-appbar-h: 3.5rem;

    /* THE APP BAR'S OWN FIVE ROLES, so the dark theme can re-light the bar
       without a single component rule living inside a media query - the rule
       section 1a opens with, and the reason the bar could not be re-lit before
       these existed.

       IN THIS THEME THEY RESOLVE TO EXACTLY WHAT THE BAR ALREADY WAS: the
       brand as a fill, white on it, white's own 16% wash for hover and the
       "you are here" pill, and no bottom rule. The light bar is unchanged, in
       colour and in total height - James kept the maroon bar when the app bar
       rebalance was shown to him (Thread 49, UX_REVIEW.md §A), and that
       decision is untouched here.

       --m-appbar-edge is `transparent` rather than absent so the border BOX
       exists in both themes and the bar never changes height between them.
       Flow's own toolbar buttons keep a 1px border for the same reason, and
       css/home-beta.css's app-bar note records it as the thing adopted from
       them. box-sizing is border-box (section 2), so the 1px comes out of the
       content box and the bar's total height is the same number it was. */
    --m-appbar-bg: var(--m-brand);
    --m-appbar-fg: var(--m-on-brand);
    --m-appbar-wash: var(--m-on-brand-wash);
    /* The ink for a hovered or current action. On the brand bar the only thing
       that reads is the white the icon is already drawn in, which is what the
       .is-current note below says; in the dark theme the bar stops being the
       brand, so the brand becomes available as an accent and this splits away
       from --m-appbar-fg. */
    --m-appbar-current-fg: var(--m-on-brand);
    --m-appbar-edge: transparent;
    --m-tabbar-h: 3.75rem;
    --m-safe-t: env(safe-area-inset-top, 0px);
    --m-safe-b: env(safe-area-inset-bottom, 0px);
    --m-safe-l: env(safe-area-inset-left, 0px);
    --m-safe-r: env(safe-area-inset-right, 0px);
    /* Height of the on-screen keyboard, published by scripts/mobile.js from the
       VisualViewport API. 0px here is the correct resting value and the correct
       fallback where that API is missing, so nothing depends on the JS having
       run. See the sheet rules in section 21 (Mobile #6). */
    --m-kb: 0px;

    /* The visible height, published by scripts/mobile.js from the
       VisualViewport API (Plane #63). DECLARED AS 100dvh, which is both the
       correct resting value and the correct behaviour before that JS has run
       or on a browser without the API - so the shell is never sized by a token
       that does not exist, and nothing depends on the script having run.

       It exists because dvh is a CSS guess at the viewport and the standalone
       PWA is where that guess is wrong: measured on James's phone, body
       resolved to 812 against an innerHeight of 775, on an 874 screen. */
    --m-vvh: 100dvh;
    --m-content-max: 40rem;

    /* ---- Motion ---------------------------------------------------------- */
    --m-dur-fast: 120ms;
    --m-dur: 200ms;
    --m-ease: cubic-bezier(0.2, 0, 0.2, 1);

    /* ---- Agenda (Phase 3 — declared here so the scale is decided once) ----
       The agenda day is 96 rows of 15 minutes. --m-slot-h is one of those
       rows; the hour band is therefore 4x this. Kept as tokens because both
       the grid and the block-positioning maths read them. */
    --m-slot-h: 1.125rem;       /* 18px per 15 min -> 72px per hour */
    /* How many of those rows the timeline draws. 44 is 07:00 to 18:00, the
       working day the agenda opens on (James, 2026-08-29: "hide any hours
       before 7 AM and after 6 PM... screen real estate is important").

       renderAgenda() overwrites this on the grid element every time it draws,
       widening it when the day holds anything outside those hours. The value
       here is what the box is sized at BEFORE that runs: at 96 an empty grid
       would be 1728px of nothing on first paint, and the page would visibly
       collapse when the day arrived. Kept in step with WORKDAY_FIRST_SLOT and
       WORKDAY_LAST_SLOT in mobile-views.js, which are the real definition. */
    --m-agenda-slots: 44;
    --m-agenda-gutter: 3.25rem;
}


/* ==========================================================================
   1a. THE DARK THEME (Mobile App #67)
   --------------------------------------------------------------------------
   ONLY TOKENS ARE REDEFINED HERE. No component rule appears in this block and
   none should: a colour whose only definition sits inside a media query is a
   colour that does not exist in the other theme, and the whole point of the
   token architecture in section 1 was to make this block possible without
   touching a single component.

   Following the SYSTEM setting and nothing else, which is what #67 asks for.
   There is no in-app light/dark switch: adding one means either a second
   hand-maintained copy of every token below (selected by a `data-theme`
   attribute) or a preprocessor, and a second copy of a palette is exactly the
   failure this file's header records — "26 hand-typed rgba tints came loose
   from the one variable that was supposed to control them". If a switch is
   wanted later, the thing to add is the attribute selector AND a check that
   the two blocks define the same token set, not the attribute alone.

   Every ratio in the comments below is measured by tools/contrast.py against
   THIS block, in the same run that measures the light one. Change a value here
   and re-run it; the pairs are declared once and checked in both themes.

   ALIGNED TO FLOW DARK, 2026-08-30. Flow opened its dark theme to every user
   the same week (Plane #319/#323), after a measured accessibility pass of its
   own, and the two apps are used by the same people on the same phone - so
   the neutral ramp, the text greys, the accent and the three status pairs are
   now Flow's values rather than a second opinion about the same problem. Read
   /var/www/flow/app/static/theme-dark.css beside this block: each token below
   that took a Flow value names the Flow token it came from.

   Three things did NOT follow Flow, each for a reason recorded at the token:
   --m-control-border (Flow's is under 3:1 on this app's pinned row),
   --m-surface-sunken (Flow puts input wells above the card, this app cuts
   them into it) and --m-pin-bg (Flow has no pinned row). The mobile-only
   families - the plan colours and the five project hues - had nothing in Flow
   to align to and were re-measured rather than re-picked.

   This is an alignment, not a shared stylesheet. Mobile #33 is explicit that
   the apps share the database and nothing else, and Flow is a different tree
   on a different host; a copied value that drifts is the cost, and it is the
   same cost the --m-pin-bg note already accepts against the desktop.

   What is NOT inverted, deliberately:

     - The app bar and the FAB keep the brand as a fill. A dark theme would
       normally quieten saturated chrome, and the UX review's finding A argues
       it should be quietened in BOTH themes - but that is a change to the most
       recognisable thing in the app and it needs a phone and a prototype, not
       a media query. Doing it here only would also mean the two themes
       disagreed about what the app looks like, which is a worse answer than
       either theme alone.
     - --m-on-brand stays white. It is the foreground for every SATURATED FILL,
       not just the brand one: the completed task tick draws it on
       --m-success and the confirm sheet's destructive button on --m-danger.
       Flipping it dark would have broken both.
   ========================================================================== */

@media (prefers-color-scheme: dark) {
:root {
    /* Flow's dark theme declares this, and it is the half of a dark theme CSS
       cannot paint: the UA canvas, the scrollbars, and every NATIVE control -
       the date and time pickers the sheets use, the selects, the spinners.
       Without it those render light-on-light inside an otherwise dark app.
       Safe under prefers-color-scheme because it only ever agrees with the
       media query that encloses it. */
    color-scheme: dark;

    /* ---- The two grounds. These carry the whole block: every tint, wash and
       "-deep" in section 1 is a color-mix() onto one of them, so redefining
       the pair re-derives the brand and plan families at once. */
    --m-mix-base: #1e1f24;      /* tracks --m-surface, as in the light theme */
    --m-weight-base: #ffffff;   /* "more weight" is now TOWARD white */

    /* ---- Brand.
       --m-brand stays a FILL and keeps white on it: 5.33:1, still AA. It is
       lifted off #993366 because the same maroon that reads as brand on white
       reads as a smudge on near-black - this is the one colour that has to be
       chosen for the ground rather than derived from it. Unchanged by the Flow
       alignment: Flow has no brand-filled chrome to match it against, and the
       note above rules out quietening the app bar here alone.

       --m-brand-ink is the other half of the split made in section 1: the
       link, the project number, the current tab, the agenda rail and the
       focus ring, all of which are drawn ON a surface. It is now Flow Dark's
       own accent (theme-dark.css's --secondary-color), so an accent glyph is
       the same colour in both apps. 5.52:1 on --m-surface, 6.17:1 on --m-bg,
       5.19:1 on --m-brand-wash, 4.92:1 on --m-brand-tint - lower than the
       #ef9ac9 it replaces, and above every floor the pairs demand.

       --m-brand-deep is Flow's second accent step (--button-hover-bg), which
       is the same relationship to the accent that this token has to the ink:
       one notch further from the ground. Still typed rather than derived -
       80% of --m-brand mixed toward white lands at #d190b3, on the wrong side
       of --m-brand-ink. 6.52:1 on --m-surface, 5.82:1 on --m-brand-tint,
       6.13:1 on --m-brand-wash. */
    --m-brand: #b3407c;
    --m-brand-ink: #cc7ab8;
    --m-brand-deep: #d989c4;

    /* Near-black, because every status FILL lightens in this theme. See the
       note beside the token in section 1. Flow's --button-text, which does
       the same job on its own lightened accent. */
    --m-on-fill: #1a1b20;

    /* ---- Surfaces. The page is the darkest thing, cards sit above it, a
       sunken well sits below the card it is cut into. The same three roles as
       the light theme, in the same order, so no component learns a new one.

       THE RAMP IS FLOW DARK'S, taken value for value: --m-bg is its
       --page-background, --m-surface its --surface/--background, and
       --m-surface-alt its --row-alt-bg. The greys are neutral with a slight
       blue lift - each step is roughly (r, r+1, r+6) - which is what makes
       the two apps read as one product on the same phone.

       --m-surface-sunken is the one step Flow has no counterpart for, and the
       one place the two models genuinely disagree: Flow puts its input wells
       ABOVE the card (--input-bg is --row-alt-bg), this app cuts them into
       it. The role is not changed to match - a well that is lighter than its
       card is a different component, not a different colour - so the value is
       Flow's ramp continued one step DOWN past --page-background instead. */
    --m-bg: #131418;
    --m-surface: #1e1f24;
    --m-surface-alt: #26272d;
    --m-surface-sunken: #0e0f13;

    /* ---- Lines. Flow's 2026-08-30 accessibility pass found its own dark
       borders at 1.48:1 and called them illegible; --m-border was 1.42:1 here
       for the same reason. Taking Flow's --border-color puts it at 2.05:1 -
       raised until page structure is visible and NOT to 3:1, because 1.4.11
       governs the boundary of a CONTROL and this token is a divider. Every
       control boundary in the app is --m-control-border, which is the whole
       point of them being two tokens.

       --m-control-border does NOT take Flow's --input-border (#73757f). That
       value clears 3:1 on Flow's own surfaces, but this app has a surface
       Flow does not: a pinned row, where #73757f lands at 2.81:1 and an
       unticked checkbox loses its edge. tools/contrast.py checks that pair,
       which is how the difference was found rather than shipped. Kept at
       Flow's grey family, at the strength this app's grounds need: 4.98:1 on
       --m-surface, 5.57:1 on --m-bg, 3.90:1 on --m-pin-bg. */
    --m-border: #4e505a;
    --m-control-border: #8b8d97;

    /* Was #ccc, which on this ground is a white slab. The tab is a shape, not
       text, but --m-text sits on it: 6.42:1. Same value as --m-border by
       coincidence of Flow's ramp, not by derivation - it is a slab, not a
       rule, and the two are free to move apart. */
    --m-swipehint-bg: #4e505a;

    /* ---- Text. Flow's --text-color and --light-text. Not pure white -
       #ffffff on a near-black ground haloes badly on OLED, which is most of
       the phones this runs on, and Flow stops at #e6e6e6 for its own reasons.
       13.18:1 and 6.52:1 on --m-surface.

       --m-text-faint stays DECORATION ONLY (see the note in section 1) and is
       held to the 3:1 large-text floor: Flow's --scrollbar-thumb-hover, the
       nearest thing it has to a glyph grey, at 4.48:1 on --m-surface. */
    --m-text: #e6e6e6;
    --m-text-muted: #a3a3a3;
    --m-text-faint: #83858f;

    /* ---- Status. Flow Dark's three status pairs, taken whole: its
       --danger-fg/-bg, --success-fg/-bg and --warning-fg/-bg. Like this
       app's own, they are not the light originals darkened - each is
       re-picked for a dark ground, foregrounds brightened and fills
       dark-tinted rather than pale - so the two palettes were already built
       to the same rule and adopting Flow's costs nothing but agreement.

       Checked both ways round by tools/contrast.py, as before: 5.93/5.38 for
       danger on --m-surface and on its own fill, 6.00/4.92 for success,
       8.40/6.61 for warn. Success on its own fill is the tightest at 4.92:1,
       which is where Flow's pass left it too. */
    --m-danger: #ff6b6b;
    --m-danger-bg: #3a2020;
    --m-success: #4caf6d;
    --m-success-bg: #1f3324;
    --m-warn: #e0b34d;
    --m-warn-bg: #3a3020;

    /* The sticky-note yellow. This is the ONE token that deliberately breaks
       the light theme's rule about matching the desktop hex: css/tasks.css
       paints a pinned row lemonchiffon and mobile matched it exactly so the
       flag would not have to be learned twice. That argument is about the
       DESKTOP's screen, which has no dark theme - and #fffacd here would be a
       floodlit row on a near-black list. Same idea, same corner of the wheel,
       chosen for this ground. --m-text on it is 10.31:1.

       Left where it was by the Flow alignment. Flow has no pinned row, and
       every value near enough to separate it from the new --m-warn-bg by
       luminance takes --m-danger on a pinned row under 4.5:1. The two are
       within 1.01:1 of each other, which is the case .m-badge's currentColor
       hairline already exists for - see the note there; it was written for
       the light theme, where the same pair is 1.04:1. */
    --m-pin-bg: #38320f;

    /* ---- Planned (#68). Same split as the brand: --m-plan is the rail and
       the dashed edge, --m-plan-deep the text. --m-plan-tint is derived from
       --m-mix-base above and needs no value here. Mobile-only - the agenda
       has no counterpart in Flow - so the alignment leaves both alone; they
       were re-measured against the new grounds, at 6.56:1 and 11.07:1 on
       --m-surface. */
    --m-plan: #3fb5a6;
    --m-plan-deep: #8fe3d6;

    /* ---- Project identity. The same five hues, lifted to sit on this
       ground: 6.76:1 to 6.82:1 on --m-surface, deliberately level with each
       other so no job looks more important than another. Mobile-only, like
       the plan family, and unchanged - only re-measured, the surface having
       moved a step lighter. */
    --m-prj-1: #71aecd;
    --m-prj-2: #8fa7d7;
    --m-prj-3: #a3a0dd;
    --m-prj-4: #b69bdc;
    --m-prj-5: #c894d9;

    /* ---- The app bar, DARK ONLY (James, 2026-08-30: "only adjust the header
       top bar for dark mode. Check esheets beta dark mode").
       ------------------------------------------------------------------
       THE REFERENCE IS css/home-beta.css's .hb-appbar, which James had
       already approved and pointed at. That bar is `background: var(--panel)`
       - the SURFACE, not the brand - with `border-bottom: 1px solid
       var(--grid-2)`, its nav items transparent and tinting to the brand on
       hover, and its current item a brand wash carrying brand ink. Every one
       of those five roles is reproduced here, token for token:

         home-beta          here                  resolves to
         --panel            --m-appbar-bg         #1e1f24
         --ink              --m-appbar-fg         #e6e6e6
         --brand-hover      --m-appbar-wash       --m-brand-tint (#332430)
         --brand            --m-appbar-current-fg --m-brand-ink  (#cc7ab8)
         --grid-2           --m-appbar-edge       --m-border     (#4e505a)

       Flow's own --toolbar-bg is #1a1b20, a step DARKER than its surface. The
       beta's choice is followed instead of Flow's, because the beta is the
       reference James named and it is the eSheets-family answer; the bar
       reads as a raised surface over --m-bg either way.

       WHY THIS IS NOT THE REBALANCE THAT WAS REVERTED. That one took the
       maroon off the bar in the LIGHT theme, on a salience argument, and
       James declined it - see the tokens in section 1, which are untouched.
       This is the narrower case the note at the top of this block had already
       reserved judgement on: at #b3407c the bar is the single brightest thing
       on a #131418 page, which is the specific effect a dark theme exists to
       avoid, and the app is used in the dark by people who chose the setting.
       The two themes now disagree about the bar, which that note called a
       worse answer than either theme alone - it is overruled, and this is why:
       the disagreement it feared was mobile-light against mobile-dark, and the
       family has since settled. Flow's toolbar and the eSheets beta's app bar
       are BOTH neutral in both of their themes. Mobile-light's maroon bar is
       the outlier now, kept deliberately because James chose it; matching the
       family in the one theme where it also fixes a real glare problem is not
       the same bet the reverted change made.

       Three things this does NOT do, all of them out of the stated scope:
       the FAB, the tab bar's current-tab brand and the primary button keep
       the brand as a fill, and the light bar is not touched.

       --m-appbar-current-fg carries the Admin "you are here" pill and a
       hovered action. On --m-appbar-wash it is 4.92:1 and on the bar itself
       5.52:1 - the same two numbers as the brand-ink pairs, because it is
       the same colour doing the same job on the same two grounds. */
    --m-appbar-bg: var(--m-surface);
    --m-appbar-fg: var(--m-text);
    --m-appbar-wash: var(--m-brand-tint);
    --m-appbar-current-fg: var(--m-brand-ink);
    --m-appbar-edge: var(--m-border);

    /* ---- Elevation. A shadow cannot darken a near-black page, so the values
       deepen and the backdrop goes nearly opaque; separation in this theme is
       carried by --m-surface being lighter than --m-bg, which is why those two
       are further apart here than their light counterparts. Already the same
       weight as Flow Dark's single --shadow (black at 0.5), so the alignment
       had nothing to change. */
    --m-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.40);
    --m-shadow-2: 0 2px 8px rgba(0, 0, 0, 0.50);
    --m-shadow-3: 0 -4px 24px rgba(0, 0, 0, 0.60);
    --m-backdrop: rgba(0, 0, 0, 0.66);
}
}


/* ==========================================================================
   1b. THE [hidden] RESET
   --------------------------------------------------------------------------
   The HTML `hidden` attribute only hides an element because the UA stylesheet
   says `display: none` - which ANY author `display` immediately outranks. So
   `element.hidden = true` silently does nothing to a component that sets its
   own display, and this file is full of those.

   That had already bitten five times and been patched five times, once per
   component (.m-toast, .m-panel, .m-task, .m-progress, #m-bug-status). Two
   were missed, and both were live bugs:

     - .m-btn is display:inline-flex, so `$('m-task-delete').hidden = !isEdit`
       did nothing and a DELETE button sat on the New task sheet.
     - .m-field__hint is display:block, so the "One per line creates several
       tasks" hint stayed on screen while EDITING an existing task, where it is
       not true.

   One rule instead of a sixth patch. !important is deliberate and is the same
   thing every CSS reset does here: `hidden` is an author's explicit statement
   that an element is not to be shown, and no component rule should be able to
   overrule it by accident. Nothing in this app sets an inline display, so
   there is nothing legitimate for it to fight.

   The five per-component rules below are now redundant. They are left in
   place, doing no harm, rather than making this change bigger than it is.
   ========================================================================== */

[hidden] {
    display: none !important;
}


/* ==========================================================================
   2. RESET / BASE
   ========================================================================== */

*,
*::before,
*::after {
    box-sizing: border-box;
}

html {
    /* Prevent iOS bumping text up in landscape, which breaks the agenda grid. */
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;

    /* THE CANVAS. Not a workaround - the only mechanism there is.
       ---------------------------------------------------------------------
       Whatever sits between the bottom of the layout viewport and the bottom
       of the screen is painted by the CANVAS. No element can reach it: that is
       what made .m-tabbar::after pointless, and it is why the shell below -
       which correctly ends the bar at the bottom of the LAYOUT viewport - does
       not by itself make that strip disappear.

       With no background on <html>, the BODY's propagates to the canvas, so
       the strip was --m-bg: grey, and visibly not the bar. Setting one here
       stops that propagation and makes this the canvas colour.

       --m-surface, because the thing the strip meets is the tab bar. */
    background: var(--m-surface);
}

body {
    margin: 0;
    font-family: var(--m-font);
    font-size: var(--m-fs-md);
    line-height: var(--m-lh-normal);
    color: var(--m-text);
    background: var(--m-bg);

    /* Kept for the two pages that are NOT the app shell - the sign-in page and
       the deposit slip - which still scroll as ordinary documents. */
    overscroll-behavior-y: none;
}

/* THE APP SHELL (Plane #63, settled 2026-08-30).
   -------------------------------------------------------------------------
   James: "the taskbar element should be anchored to the bottom of the
   screen... always... without exception."

   Three attempts before this one tried to make a `position: fixed` tab bar
   reach the bottom of the SCREEN. It cannot, and that is not a bug to work
   around: `fixed` is defined against the LAYOUT viewport, and on iOS that is
   not the screen whenever browser chrome is showing - this issue's own figures
   say Viewport 812, Screen 874. Colouring the leftover 62 px to match the bar
   hid it; it did not anchor anything.

   So the document stops scrolling and the shell does the work: exactly one
   dynamic viewport tall, laid out as a column of app bar / main / tab bar. The
   tab bar is the last child of a box exactly as tall as the visible area, so it
   ends where the visible area ends - by construction, with nothing positioned
   and nothing that can drift.

   `dvh`, not `vh`: vh is the LARGEST viewport on iOS and would overshoot by the
   height of the chrome, putting the bar back off the bottom. vh first as the
   fallback for anything that does not know dvh.

   SCOPED TO [data-view], which only the app shell carries. m-login.html and
   the deposit slip share this stylesheet and are ordinary scrolling documents;
   giving them `height: 100dvh; overflow: hidden` would clip the sign-in form
   the moment the keyboard shrank the viewport, which is precisely when it must
   still scroll.

   The scroll moves to .m-main. Checked before doing it: the client has no
   document-scroll dependencies - only scrollIntoView, which works in any scroll
   container, and one horizontal strip.scrollTo. */
body[data-view] {
    display: flex;
    flex-direction: column;
    /* Three steps, weakest first, so every browser lands on the best value it
       has: vh for anything ancient, dvh where it is understood, and --m-vvh
       once the VisualViewport API has answered. The last is the only one that
       is measured rather than inferred. */
    height: 100vh;
    height: 100dvh;
    height: var(--m-vvh);
    overflow: hidden;
}

h1, h2, h3, p, ul, ol, figure {
    margin: 0;
}

ul, ol {
    padding: 0;
    list-style: none;
}

img {
    max-width: 100%;
    height: auto;
    display: block;
}

a {
    color: var(--m-brand);
    text-decoration-thickness: 1px;
    text-underline-offset: 2px;
}

button {
    font: inherit;
    color: inherit;
}

input, select, textarea {
    font: inherit;
    /* Never below 1rem — iOS Safari zooms the viewport on focus otherwise,
       and there is no way back out of that zoom without a pinch. */
    font-size: var(--m-fs-md);
    color: var(--m-text);
}

/* Focus. One rule, applied to everything, never removed anywhere below.
   :focus-visible so a touch tap does not leave a ring behind, while keyboard
   and switch-control users always get one. */
:focus-visible {
    outline: var(--m-focus-width) solid var(--m-focus-ring);
    outline-offset: 2px;
    border-radius: var(--m-r-sm);
}

/* Visible only to assistive technology. Used for the text of icon-only
   controls where an aria-label would lose the punctuation/context. */
.m-sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* Skip link — hidden until focused, then pinned to the top-left. */
.m-skip {
    position: absolute;
    left: var(--m-s2);
    top: -10rem;
    z-index: 100;
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-surface);
    color: var(--m-brand);
    border-radius: var(--m-r-sm);
    box-shadow: var(--m-shadow-2);
}

.m-skip:focus {
    top: calc(var(--m-safe-t) + var(--m-s2));
}


/* ==========================================================================
   3. LAYOUT — app bar, main, tab bar
   ========================================================================== */

.m-appbar {
    /* In flow at the top of the shell, not fixed over it. `relative` only so
       z-index still applies - it must paint above .m-main's scrolling content,
       which is what a fixed bar used to get for free. */
    position: relative;
    flex: 0 0 auto;
    z-index: 30;
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    height: calc(var(--m-appbar-h) + var(--m-safe-t));
    padding: var(--m-safe-t) calc(var(--m-s4) + var(--m-safe-r)) 0
             calc(var(--m-s4) + var(--m-safe-l));
    background: var(--m-appbar-bg);
    color: var(--m-appbar-fg);
    /* Transparent in this theme, --m-border in the dark one. Declared in both
       so the border box exists either way and the bar's height never moves
       between themes - see the token note in section 1. */
    border-bottom: 1px solid var(--m-appbar-edge);
}

.m-appbar__title {
    font-size: var(--m-fs-lg);
    font-weight: 600;
    line-height: var(--m-lh-tight);
    letter-spacing: 0.01em;
    margin-right: auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.m-appbar__sub {
    display: block;
    font-size: var(--m-fs-xs);
    font-weight: 400;
    opacity: 0.85;
}

/* Icon button sitting on the brand bar. */
.m-appbar__action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    padding: 0 var(--m-s2);
    background: transparent;
    border: 1px solid transparent;
    border-radius: var(--m-r-pill);
    color: var(--m-appbar-fg);
    cursor: pointer;
}

.m-appbar__action:hover,
.m-appbar__action:active {
    background: var(--m-appbar-wash);
    /* Same colour it already was on the brand bar, the brand accent on the
       dark one - home-beta's .hb-nav a:hover does exactly this. */
    color: var(--m-appbar-current-fg);
}

.m-appbar__action:focus-visible {
    /* The bar's own foreground, so the ring is white on the maroon bar and
       near-white on the dark one. Not --m-focus-ring: the brand ink it
       resolves to is the thing the ring would have to read against up here. */
    outline-color: var(--m-appbar-fg);
}

/* The Admin control's "you are here" state (Mobile App #29).
   The tab bar signals current with a brand-coloured inset rule, which cannot
   work up here - the bar IS the brand colour, so a brand-on-brand marker is
   invisible. On this background the only thing that reads is the white the
   icon is already drawn in, so it is a filled pill: a solid wash plus a
   visible ring, which is distinguishable from :hover's wash alone.

   THE DARK THEME IS THE CASE THAT PARAGRAPH RULES OUT. There the bar is a
   surface, not the brand, so the brand IS available as a marker and the pill
   becomes what home-beta's .hb-nav a.is-current already is: a brand wash
   carrying brand ink. Both come from --m-appbar-* tokens, so the rule is one
   rule and the light bar keeps the white-on-wash pill described above. */
.m-appbar__action.is-current {
    background: var(--m-appbar-wash);
    border-color: var(--m-appbar-current-fg);
    color: var(--m-appbar-current-fg);
}

/* forced-colours mode throws away both the wash and the border colour above,
   so the only surviving signal would be nothing at all. */
@media (forced-colors: active) {
    .m-appbar__action.is-current {
        border-color: Highlight;
        border-width: 2px;
    }
}

.m-main {
    /* THE SCROLLER. The document does not scroll any more - see the shell note
       on body - so this is the box that does.

       The padding no longer clears the two bars: they are siblings in the
       column and take their own space, including their own safe-area insets.
       What is left is the page's own gutter. The bottom keeps a little more
       than the top so the last card does not sit hard against the tab bar. */
    flex: 1 1 auto;
    overflow-y: auto;
    /* Contain, not none: a scroll that reaches the end of THIS box must not
       chain to the document behind it and rubber-band the whole shell. */
    overscroll-behavior-y: contain;
    -webkit-overflow-scrolling: touch;
    padding: var(--m-s4)
             calc(var(--m-s4) + var(--m-safe-r))
             var(--m-s5)
             calc(var(--m-s4) + var(--m-safe-l));
    max-width: var(--m-content-max);
    margin: 0 auto;
    width: 100%;
}

/* ---- Tab bar ------------------------------------------------------------
   Four destinations plus a central create button. Anything more than four
   destinations stops being scannable at a glance on a phone, which is why
   filing a note and writing a status update live on the create button
   instead of taking tabs of their own — they are things you DO, not places
   you go. */

.m-tabbar {
    /* The last child of a shell that is exactly one viewport tall, so it ends
       where the viewport ends. Nothing is positioned and there is nothing to
       drift - which is the whole point of Plane #63. */
    position: relative;
    flex: 0 0 auto;
    z-index: 30;
    display: grid;
    /* Auto columns rather than a hard-coded count.
       This bar carries five items and the "+" must sit dead centre, which is
       the whole reason Admin is NOT in here (Mobile App #29 - it was, and a
       sixth item moved the "+" to 41.7% and squeezed "Expenses" to a width
       that clips on a 360px screen). Admin lives in the app bar instead.
       The count is left to the markup anyway: it was `repeat(5, 1fr)` when
       m.php briefly emitted six children, and the sixth wrapped to an implicit
       second row that the fixed height below pushed off the bottom of the
       screen - so the tab was invisible rather than merely cramped. Auto-flow
       cannot fall out of step with the markup the way a literal count did. */
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    align-items: stretch;
    height: calc(var(--m-tabbar-h) + var(--m-safe-b));
    padding-bottom: var(--m-safe-b);
    background: var(--m-surface);
    border-top: 1px solid var(--m-border);
}


.m-tab {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-height: var(--m-tap);
    padding: var(--m-s1) var(--m-s1);
    color: var(--m-text-muted);
    font-size: var(--m-fs-xs);
    line-height: 1;
    text-decoration: none;
    border: 0;
    background: none;
}

.m-tab__icon {
    display: inline-flex;
    line-height: 0;
}

/* Icons are inline SVG sized in em, so they scale with the text around them
   rather than needing a size per call site. currentColor means they inherit
   whatever the parent control's colour already is, including in
   forced-colours mode. */
.m-icon {
    width: 1.25em;
    height: 1.25em;
    flex: 0 0 auto;
}

.m-tab__glyph {
    width: 1.375rem;
    height: 1.375rem;
    flex: 0 0 auto;
}

.m-badge__glyph {
    width: 0.875em;
    height: 0.875em;
    flex: 0 0 auto;
}

.m-empty__glyph {
    width: 2rem;
    height: 2rem;
}

/* The active tab is marked three ways on purpose — colour, weight, and the
   bar above it — so it does not depend on colour perception alone. The
   markup also carries aria-current="page". */
.m-tab[aria-current='page'] {
    color: var(--m-brand);
    font-weight: 600;
    box-shadow: inset 0 3px 0 0 var(--m-brand);
}

/* Central create button. Sits in the grid like the others so nothing overlaps
   and the hit areas stay even. */
.m-tab--create {
    justify-content: center;
}

.m-fab {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--m-tap);
    height: var(--m-tap);
    border: 0;
    border-radius: var(--m-r-pill);
    background: var(--m-brand);
    color: var(--m-on-brand);
    line-height: 0;
    box-shadow: var(--m-shadow-2);
    cursor: pointer;
}

.m-fab:active {
    background: var(--m-brand-deep);
}


/* ==========================================================================
   4. SECTIONS, CARDS, LISTS
   ========================================================================== */

.m-section + .m-section {
    margin-top: var(--m-s5);
}

.m-section__head {
    display: flex;
    align-items: baseline;
    gap: var(--m-s2);
    margin-bottom: var(--m-s2);
    padding: 0 var(--m-s1);
}

.m-section__title {
    font-size: var(--m-fs-sm);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--m-text-muted);
}

.m-section__link {
    margin-left: auto;
    font-size: var(--m-fs-sm);
}

.m-card {
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-1);
    overflow: hidden;
}

.m-card__body {
    padding: var(--m-s4);
}

/* A stack of rows inside one card, hairline-separated. */
.m-list > * + * {
    border-top: 1px solid var(--m-border);
}

/* One row. Rendered as a <button> or <a> when it does something, in which
   case it inherits the min-height floor and the shared focus ring. */
.m-row {
    display: flex;
    align-items: flex-start;
    gap: var(--m-s3);
    width: 100%;
    min-height: var(--m-tap);
    padding: var(--m-s3) var(--m-s4);
    background: none;
    border: 0;
    text-align: left;
    color: inherit;
    text-decoration: none;
}

button.m-row,
a.m-row {
    cursor: pointer;
}

button.m-row:active,
a.m-row:active {
    background: var(--m-surface-alt);
}

.m-row__main {
    flex: 1 1 auto;
    min-width: 0;
}

.m-row__title {
    font-size: var(--m-fs-md);
    line-height: var(--m-lh-tight);
    color: var(--m-text);
}

.m-row__meta {
    margin-top: 2px;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-row__aside {
    flex: 0 0 auto;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
    text-align: right;
}

/* Project number — tabular so a column of them lines up.

   The default colour is the brand as INK, which is what it has always been.
   It stays that way for the one place this class is NOT a project number:
   view-invoices renders an invoice reference with it, and an invoice has no
   project hue to take. */
.m-prjnum {
    font-family: var(--m-font-num);
    font-variant-numeric: tabular-nums;
    font-size: var(--m-fs-sm);
    color: var(--m-brand-ink);
}

/* Mobile App #71 — the project's own hue, where the value really is a project
   number. Colour is never the only channel here and cannot be: the number
   itself is the label, sitting in the element being coloured. Someone who
   cannot separate step 3 from step 4 reads exactly what they read before. */
.m-prjnum[data-prj='1'] { color: var(--m-prj-1); }
.m-prjnum[data-prj='2'] { color: var(--m-prj-2); }
.m-prjnum[data-prj='3'] { color: var(--m-prj-3); }
.m-prjnum[data-prj='4'] { color: var(--m-prj-4); }
.m-prjnum[data-prj='5'] { color: var(--m-prj-5); }

/* ---- Meter -------------------------------------------------------------
   Progress toward a GOAL — hours against the week, tasks done today.

   A separate component from .m-progress, which is the upload bar. They look
   alike on purpose and mean different things: .m-progress says "this is
   happening, wait", and has an indeterminate sweep for when it cannot say how
   far along it is. A meter is never indeterminate and never busy; it is a
   quantity you could also read as a number.

   aria-hidden, because you CAN also read it as a number: every meter in this
   app sits directly beneath its own figures ("7.5 / 40 h"), so announcing the
   bar as well would read the same fact twice. The accessible name comes from
   the text, which is the thing that was always there. */
.m-meter {
    display: block;
    height: 5px;
    margin-top: var(--m-s2);
    background: var(--m-surface-sunken);
    border-radius: var(--m-r-pill);
    overflow: hidden;
}

.m-meter__fill {
    display: block;
    height: 100%;
    border-radius: inherit;
    background: var(--m-brand);
    /* Width is set inline from the server or the client; it is data, not
       style. 0 is the correct resting value for a week nothing is logged in. */
    width: 0;
    transition: width var(--m-dur) var(--m-ease);
}

/* Over the target. Not an error — working 42 hours in a 40-hour week is a
   fact about the week, not a fault — so it takes the brand's deeper tone
   rather than --m-danger, which would read as something being wrong. */
.m-meter--over .m-meter__fill {
    background: var(--m-brand-deep);
}

/* Nothing logged at all. The track alone is a thin grey line that reads as a
   rule rather than as an empty measure, so it gets a visible edge. */
.m-meter--empty {
    box-shadow: inset 0 0 0 1px var(--m-border);
}

/* ---- Stat tiles --------------------------------------------------------- */

.m-stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
    gap: var(--m-s3);
}

/* Each tile is an <a> to the screen its figure came from (Mobile App #16).
   The link styling is reset here rather than the tile being wrapped in one:
   the whole tile should be the tap target, not the text inside it. */
.m-stat {
    display: block;
    padding: var(--m-s3) var(--m-s4);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-1);
    color: inherit;
    text-decoration: none;
}

/* Touch has no hover, so this is for the moment of the tap - and for anyone on
   a phone with a mouse or trackpad attached. */
.m-stat:active {
    background: var(--m-brand-wash);
}

.m-stat__value {
    display: block;
    font-size: var(--m-fs-xl);
    font-weight: 600;
    line-height: var(--m-lh-tight);
    font-variant-numeric: tabular-nums;
}

.m-stat__label {
    display: block;
    margin-top: 2px;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

/* The reference half of a figure — the "/ 40 h" in "7.5 / 40 h" (#71).
   Smaller and quieter than the value it qualifies, because the number that
   changes is the one worth reading first; but still --m-text-muted rather
   than --m-text-faint, because it IS read. */
.m-stat__of {
    font-size: var(--m-fs-md);
    font-weight: 500;
    color: var(--m-text-muted);
}

.m-stat--alert .m-stat__value {
    color: var(--m-danger);
}


/* ==========================================================================
   5. BADGES, CHIPS, SEGMENTED CONTROL
   ========================================================================== */

/* Status is never carried by colour alone — every badge shows a glyph or a
   word as well, so it survives greyscale and colour-vision differences. */
.m-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--m-s1);
    padding: 1px var(--m-s2);
    border-radius: var(--m-r-pill);
    font-size: var(--m-fs-xs);
    font-weight: 600;
    line-height: 1.6;
    white-space: nowrap;
    /* A hairline of the badge's own ink, so a pill keeps its shape on a tinted
       row as well as on white. Without it the "Important" badge (--m-warn-bg)
       sits on a pinned row (--m-pin-bg) at 1.04:1 and the pill vanishes,
       leaving a floating word - and right now EVERY important open task is
       also pinned (3 of 3), so that is the common case, not the edge one.
       Derived from currentColor rather than a token per variant, so a new
       badge variant gets the right edge for free. */
    border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}

.m-badge > svg {
    flex: 0 0 auto;
}

.m-badge--danger  { background: var(--m-danger-bg);  color: var(--m-danger); }
.m-badge--success { background: var(--m-success-bg); color: var(--m-success); }
.m-badge--warn    { background: var(--m-warn-bg);    color: var(--m-warn); }
.m-badge--brand   { background: var(--m-brand-tint); color: var(--m-brand-deep); }
.m-badge--muted   { background: var(--m-surface-sunken); color: var(--m-text-muted); }

/* Filter chips. Real <button>s with aria-pressed, so the state is announced
   rather than only shown. */
.m-chips {
    display: flex;
    gap: var(--m-s2);
    overflow-x: auto;
    padding: var(--m-s1) var(--m-s1) var(--m-s3);
    /* Let the strip run to the screen edge while the content stays inset. */
    margin: 0 calc(-1 * var(--m-s4));
    padding-left: var(--m-s4);
    padding-right: var(--m-s4);
    scrollbar-width: none;
}

.m-chips::-webkit-scrollbar {
    display: none;
}

.m-chip {
    flex: 0 0 auto;
    min-height: var(--m-tap);
    padding: 0 var(--m-s4);
    background: var(--m-surface);
    border: 1px solid var(--m-control-border);
    border-radius: var(--m-r-pill);
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
    cursor: pointer;
}

/* A chip can be a button (Tasks - aria-pressed) or a link (the activity feed's
   All/My projects - aria-current). Same look either way; only the semantics of
   "selected" differ, so both states share one rule. */
.m-chip {
    display: inline-flex;
    align-items: center;
    text-decoration: none;
}

/* Mid-swap. Only a fade, so the card does not jump or collapse while the new
   rows are on their way - the height change on paint is enough of a signal
   that something happened. aria-busy carries it for a screen reader. */
.m-card[aria-busy='true'] {
    opacity: 0.55;
    transition: opacity 0.12s linear;
}

.m-chip[aria-current='true'],
.m-chip[aria-pressed='true'] {
    background: var(--m-brand);
    border-color: var(--m-brand);
    color: var(--m-on-brand);
    font-weight: 600;
}

/* Segmented control — two or three mutually exclusive views. Uses a radio
   group underneath so arrow keys move between options natively.

   The selected pill is ONE element that slides (James, Plane #58: "when
   selecting a tab, can the pill slide over smoothly?"). It used to be a
   background on whichever option was checked, so it vanished from one and
   appeared on the other with nothing in between.

   Done in CSS off :checked rather than in JS, so the pill cannot end up
   somewhere the radio group is not: there is no state to keep in step, and
   arrow-key selection animates for free. --m-seg-n is how many options there
   are, which is what makes one slot's width and one slot's travel the same
   number, whether the control has two or three. */
.m-seg {
    --m-seg-n: 2;
    display: flex;
    position: relative;
    padding: var(--m-s1);
    background: var(--m-surface-sunken);
    border-radius: var(--m-r-pill);
}

.m-seg:has(.m-seg__option:nth-child(3)) {
    --m-seg-n: 3;
}

.m-seg::before {
    content: '';
    position: absolute;
    z-index: 0;
    top: var(--m-s1);
    bottom: var(--m-s1);
    left: var(--m-s1);
    width: calc((100% - 2 * var(--m-s1)) / var(--m-seg-n));
    border-radius: var(--m-r-pill);
    background: var(--m-surface);
    box-shadow: var(--m-shadow-1);
    /* Translated by its OWN width, so one slot of travel is 100% whatever the
       control is sized at. The global reduced-motion block in section 11
       already covers *::before and flattens this to nothing. */
    transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
}

.m-seg:has(.m-seg__option:nth-child(2) .m-seg__input:checked)::before {
    transform: translateX(100%);
}

.m-seg:has(.m-seg__option:nth-child(3) .m-seg__input:checked)::before {
    transform: translateX(200%);
}

/* Nothing selected is not a state either template can render, but a pill
   parked on an option that is not chosen would be a lie rather than a blank,
   so it is worth the one rule. */
.m-seg:not(:has(.m-seg__input:checked))::before {
    opacity: 0;
}

.m-seg__option {
    flex: 1 1 0;
    position: relative;
    /* Above the sliding pill. Both are positioned with z-index auto, so
       painting order would otherwise be DOM order - and ::before is generated
       first only by convention worth not relying on. */
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: calc(var(--m-tap) - var(--m-s2));
    border-radius: var(--m-r-pill);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
    cursor: pointer;
}

.m-seg__input {
    position: absolute;
    opacity: 0;
    width: 100%;
    height: 100%;
    margin: 0;
    cursor: pointer;
}

.m-seg__input:checked + .m-seg__label {
    color: var(--m-brand-deep);
    font-weight: 600;
}

.m-seg__label {
    pointer-events: none;
}

.m-seg__option:has(.m-seg__input:focus-visible) {
    outline: var(--m-focus-width) solid var(--m-focus-ring);
    outline-offset: 2px;
}


/* ==========================================================================
   6. BUTTONS
   ========================================================================== */

.m-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--m-s2);
    min-height: var(--m-tap);
    padding: 0 var(--m-s4);
    border-radius: var(--m-r-md);
    border: 1px solid transparent;
    background: var(--m-surface);
    color: var(--m-text);
    font-size: var(--m-fs-md);
    font-weight: 500;
    text-decoration: none;
    cursor: pointer;
}

.m-btn--primary {
    background: var(--m-brand);
    color: var(--m-on-brand);
}

.m-btn--primary:active {
    background: var(--m-brand-deep);
}

.m-btn--secondary {
    background: var(--m-surface);
    border-color: var(--m-control-border);
    color: var(--m-brand-deep);
}

.m-btn--secondary:active {
    background: var(--m-brand-wash);
}

.m-btn--ghost {
    background: transparent;
    color: var(--m-brand-deep);
}

.m-btn--danger {
    background: var(--m-surface);
    border-color: var(--m-control-border);
    color: var(--m-danger);
}

.m-btn--block {
    width: 100%;
}

.m-btn,
.m-chip,
.m-action,
.m-menu-item,
.m-fab,
.m-tab {
    transition: background-color var(--m-dur-fast) var(--m-ease),
                color var(--m-dur-fast) var(--m-ease),
                border-color var(--m-dur-fast) var(--m-ease);
}

.m-btn[disabled],
.m-btn[aria-disabled='true'] {
    opacity: 0.55;
    cursor: not-allowed;
}

/* Quick-action tiles on Today — big, unmissable, one tap from a cold start. */
.m-actions {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
    gap: var(--m-s3);
}

.m-action {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--m-s2);
    min-height: 4.5rem;
    padding: var(--m-s4);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-1);
    color: var(--m-text);
    font-size: var(--m-fs-md);
    font-weight: 500;
    text-align: left;
    cursor: pointer;
}

.m-action:active {
    background: var(--m-brand-wash);
}

.m-action__icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    border-radius: var(--m-r-pill);
    background: var(--m-brand-tint);
    color: var(--m-brand-deep);
}


/* ==========================================================================
   7. FORMS
   ========================================================================== */

/* The gap between one row of a form and the next.
   -------------------------------------------------------------------------
   A "row" is any of three things - a plain .m-field, a two-up .m-field-pair,
   or the .m-flags toggle row - and every combination needs the same gap.

   Only `.m-field + .m-field` existed, so a pair or the flags row sat FLUSH
   against whatever was above it. On the task sheet that was three joins in a
   row: Project -> Assign to/Due -> List/Est. hours -> the flag toggles, all
   with no space, while Task -> Project above them had the full gap. The
   inconsistent rhythm is what makes the form look mis-set.

   Written out rather than done with a gap on .m-sheet__body, because .m-field
   is used outside sheets too and a container gap would have silently restyled
   those. A new row type has to be added here - hence the comment. */
.m-field + .m-field,
.m-field + .m-field-pair,
.m-field + .m-flags,
.m-field-pair + .m-field,
.m-field-pair + .m-field-pair,
.m-field-pair + .m-flags,
.m-flags + .m-field,
.m-flags + .m-field-pair {
    margin-top: var(--m-field-gap);
}

.m-field__label {
    display: block;
    margin-bottom: var(--m-s1);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    color: var(--m-text);
}

.m-field__hint {
    display: block;
    margin-top: var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-field__error {
    display: block;
    margin-top: var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-danger);
}

.m-input,
.m-select,
.m-textarea {
    display: block;
    width: 100%;
    min-height: var(--m-input-h);
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-surface);
    /* 3.10:1 against the page, 3.41:1 against the card — the WCAG minimum for
       a control boundary is 3:1, so this border is doing real work and must
       not be softened to --m-border. */
    border: 1px solid var(--m-control-border);
    border-radius: var(--m-r-md);
    color: var(--m-text);
}

.m-textarea {
    min-height: 7rem;
    resize: vertical;
    line-height: var(--m-lh-normal);
}

.m-select {
    /* Keep the platform's own dropdown affordance — a custom arrow here would
       cost more than it gains and iOS renders selects as a native wheel. */
    appearance: auto;
}

.m-input[aria-invalid='true'],
.m-select[aria-invalid='true'],
.m-textarea[aria-invalid='true'] {
    border-color: var(--m-danger);
    border-width: 2px;
}

.m-input::placeholder,
.m-textarea::placeholder {
    color: var(--m-text-muted);
}

/* Two fields side by side (amount + tax, start + duration). Collapses to one
   column under 22rem so it still works on a small phone in a large text size. */
.m-field-pair {
    display: grid;
    /* minmax(0, …), not a bare 1fr - see the note on .m-agenda__grid. This pair
       holds <input>s, and an input's intrinsic min-content width comes from its
       `size` (about 20 characters by default), not from its CSS. A bare 1fr
       track cannot go under that, so on a narrow phone the two columns together
       could demand more than the screen and push the sheet sideways. */
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--m-s3);
    /* Mobile App #19 - "vertical alignment should be top". Two halves whose
       labels wrap differently should start level rather than stretch to a
       common height. */
    align-items: start;
}

/* The actual cause of #19, and it is not align-items.
   -------------------------------------------------------------------------
   .m-field + .m-field carries the STACKING gap between fields in a form. The
   right-hand half of a pair is an adjacent sibling of the left-hand half, so
   it was collecting that margin-top as well - pushing the whole right column
   down by one --m-field-gap (12px) relative to the left. It affects all four
   pairs (task assignee/due, task type/duration, time start/hours, expense
   total/HST), not only the one that got reported.

   Cancelled while the two are genuinely side by side. */
.m-field-pair > .m-field + .m-field {
    margin-top: 0;
}

@media (max-width: 22rem) {
    .m-field-pair {
        /* Same reason as above, and this is the width where it would actually
           have bitten. */
        grid-template-columns: minmax(0, 1fr);
    }

    /* Collapsed to one column, they are stacked again - so the stacking gap
       is wanted after all, or the two fields would sit flush against each
       other. Restored rather than left cancelled. */
    .m-field-pair > .m-field + .m-field {
        margin-top: var(--m-field-gap);
    }
}


/* ==========================================================================
   8. PROJECT PICKER
   ========================================================================== */

.m-picker__current {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    width: 100%;
    min-height: var(--m-input-h);
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-surface);
    border: 1px solid var(--m-control-border);
    border-radius: var(--m-r-md);
    text-align: left;
    cursor: pointer;
}

.m-picker__current[data-empty='true'] {
    color: var(--m-text-muted);
}

.m-picker__results {
    margin-top: var(--m-s2);
    max-height: 18rem;
    overflow-y: auto;
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    background: var(--m-surface);
}

.m-picker__group {
    padding: var(--m-s2) var(--m-s4);
    font-size: var(--m-fs-xs);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--m-text-muted);
    background: var(--m-surface-alt);
    position: sticky;
    top: 0;
}


/* ==========================================================================
   9. SHEETS (native <dialog>)
   --------------------------------------------------------------------------
   <dialog> is used rather than a hand-rolled overlay because showModal() gives
   a real focus trap, a real backdrop, Esc-to-close and correct inertness of
   the page behind it — all of which would otherwise have to be written, and
   are the things hand-rolled modals reliably get wrong.
   ========================================================================== */

.m-sheet {
    width: 100%;
    max-width: var(--m-content-max);
    max-height: 92dvh;
    margin: auto auto 0;
    padding: 0;
    border: 0;
    border-radius: var(--m-r-lg) var(--m-r-lg) 0 0;
    background: var(--m-surface);
    color: var(--m-text);
    box-shadow: var(--m-shadow-3);
    overflow: hidden;
}

.m-sheet::backdrop {
    background: var(--m-backdrop);
}

.m-sheet[open] {
    display: flex;
    flex-direction: column;
    animation: m-sheet-in var(--m-dur) var(--m-ease);
}

@keyframes m-sheet-in {
    from { transform: translateY(6%); opacity: 0; }
    to   { transform: translateY(0);  opacity: 1; }
}

.m-sheet__head {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s3) var(--m-s4);
    background: var(--m-brand-wash);
    border-bottom: 1px solid var(--m-border);
}

.m-sheet__title {
    font-size: var(--m-fs-lg);
    font-weight: 600;
    margin-right: auto;
}

.m-sheet__close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    background: none;
    border: 0;
    border-radius: var(--m-r-pill);
    color: var(--m-text-muted);
    cursor: pointer;
}

.m-sheet__body {
    flex: 1 1 auto;
    overflow-y: auto;
    padding: var(--m-s4);
    /* Momentum scroll inside the sheet without dragging the page behind it. */
    overscroll-behavior: contain;
}

.m-sheet__foot {
    display: flex;
    gap: var(--m-s3);
    padding: var(--m-s3) var(--m-s4)
             calc(var(--m-s3) + var(--m-safe-b));
    border-top: 1px solid var(--m-border);
    background: var(--m-surface);
}

.m-sheet__foot > .m-btn {
    flex: 1 1 0;
}

/* A sheet that is just a menu of actions — the create sheet. */
.m-sheet--menu .m-sheet__body {
    padding: var(--m-s2) 0 calc(var(--m-s2) + var(--m-safe-b));
}

.m-menu-item {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    width: 100%;
    min-height: 3.5rem;
    padding: var(--m-s3) var(--m-s4);
    background: none;
    border: 0;
    text-align: left;
    font-size: var(--m-fs-md);
    color: var(--m-text);
    cursor: pointer;
}

.m-menu-item:active {
    background: var(--m-brand-wash);
}

.m-menu-item__icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.25rem;
    height: 2.25rem;
    flex: 0 0 auto;
    border-radius: var(--m-r-pill);
    background: var(--m-brand-tint);
    color: var(--m-brand-deep);
}

.m-menu-item__sub {
    display: block;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}


/* ==========================================================================
   10. FEEDBACK — toast, live region, empty, skeleton
   ========================================================================== */

/* Every save result, success or failure, is written into this region as well
   as shown, so screen readers hear it. It is visually the toast itself. */
.m-toast {
    position: fixed;
    left: 50%;
    bottom: calc(var(--m-tabbar-h) + var(--m-safe-b) + var(--m-s3));
    z-index: 40;
    transform: translateX(-50%);
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    max-width: calc(100vw - var(--m-s6));
    padding: var(--m-s3) var(--m-s4);
    border-radius: var(--m-r-md);
    background: var(--m-text);
    color: var(--m-surface);
    font-size: var(--m-fs-sm);
    box-shadow: var(--m-shadow-2);
}

.m-toast[hidden] {
    display: none;
}

/* Mobile App #41 — the toast is shown as a popover so it joins the top layer
   and is drawn above an open sheet. That means the [popover] UA styles now
   apply to it, and they set several properties this component leaves to the
   initial value. Each is put back here.

   `inset: 0` is the one that matters: with `bottom` set above and `top` coming
   from the UA, the box would be over-constrained and stretch to fill the
   screen. The rest — margin, border, overflow — are cosmetic but wrong.

   padding, background, colour and max-width are NOT repeated: author rules beat
   UA rules whatever their specificity, so the block above already wins those.
   Hiding is still the `hidden` attribute, because `.m-toast`'s display:flex
   likewise beats the UA's rule for a closed popover. */
.m-toast[popover] {
    inset: auto;
    left: 50%;
    bottom: calc(var(--m-tabbar-h) + var(--m-safe-b) + var(--m-s3));
    margin: 0;
    border: 0;
    overflow: visible;
}

.m-toast--danger {
    background: var(--m-danger);
}

.m-toast--success {
    background: var(--m-success);
}

.m-empty {
    padding: var(--m-s6) var(--m-s4);
    text-align: center;
    color: var(--m-text-muted);
}

.m-empty__icon {
    display: inline-flex;
    color: var(--m-text-faint);
    margin-bottom: var(--m-s2);
}

.m-skeleton {
    height: 1rem;
    border-radius: var(--m-r-sm);
    background: linear-gradient(90deg,
        var(--m-surface-sunken) 25%,
        var(--m-surface-alt) 50%,
        var(--m-surface-sunken) 75%);
    background-size: 400% 100%;
    animation: m-shimmer 1.4s linear infinite;
}

@keyframes m-shimmer {
    to { background-position: -200% 0; }
}


/* ==========================================================================
   11. REDUCED MOTION
   --------------------------------------------------------------------------
   Not a nicety: for some people animation causes actual nausea. Everything
   here still works with zero animation, so honouring the preference costs
   nothing and is applied globally rather than per-component.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}


/* ==========================================================================
   12. FORCED COLOURS (Windows high contrast / some Android modes)
   --------------------------------------------------------------------------
   In forced-colours mode the browser replaces our palette wholesale. The two
   things that break are backgrounds used to convey state (the active tab, a
   pressed chip) and borders we set to transparent. Restore both with the
   system keywords.
   ========================================================================== */

@media (forced-colors: active) {
    .m-btn,
    .m-chip,
    .m-card,
    .m-stat,
    .m-action {
        border: 1px solid ButtonBorder;
    }

    .m-tab[aria-current='page'],
    .m-chip[aria-current='true'],
    .m-chip[aria-pressed='true'] {
        forced-color-adjust: none;
        background: Highlight;
        color: HighlightText;
    }

    /* The segmented control is the "active tab" this section's note is about,
       and it was never in the list above. Its selected state is a background
       and a shadow, both of which this mode discards, so the only surviving
       cue was the label's font weight. Same treatment as .m-tab: the pill is
       painted, and the label it sits behind is given the matching foreground.
       Written against the sliding pill (#58) rather than the option, because
       the pill is now the thing that carries the fill. */
    .m-seg::before {
        forced-color-adjust: none;
        background: Highlight;
    }

    .m-seg__input:checked + .m-seg__label {
        forced-color-adjust: none;
        color: HighlightText;
    }
}


/* ==========================================================================
   13. PANELS, NOTICES, HINTS
   ========================================================================== */

.m-panel {
    margin-top: var(--m-s4);
}

.m-panel[hidden] {
    display: none;
}

/* A standing condition, not a transient toast — a locked timesheet, say.
   Always carries an icon as well as the colour. */
.m-notice {
    display: flex;
    align-items: flex-start;
    gap: var(--m-s2);
    margin-top: var(--m-s3);
    padding: var(--m-s3);
    border-radius: var(--m-r-md);
    font-size: var(--m-fs-sm);
}

.m-notice--warn {
    background: var(--m-warn-bg);
    color: var(--m-warn);
}

.m-notice > svg {
    flex: 0 0 auto;
    margin-top: 2px;
}

.m-hint {
    margin-top: var(--m-s3);
    padding: 0 var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

/* A planned block: time set aside, with nothing posted to the timesheet
   (Mobile App #46).

   The dashed border and the tag BOTH carry the difference from logged time.
   Colour alone would not: these two states must be distinguishable on a phone
   in sunlight, and by someone who cannot tell the fill colours apart.

   #68 changed what the colour IS and added the fill back. This block used to be
   transparent with a dashed --m-brand edge, on the reasoning that an outline
   already means "not yet real" and a fill would let a plan compete with logged
   time. That was right about the vocabulary and wrong about the neighbour: an
   Outlook meeting is ALSO a dashed outline over the page, so the two differed
   only in the hue of a 1px hairline and James reported them as the same thing.

   A tint at 14% does not compete with a logged block — it is 1.23:1 against the
   surface — but it does say "this rectangle is one of yours", which the hollow
   version did not. The dashed edge is kept, so "not settled yet" still reads,
   and the left rail is now solid --m-plan: the same grammar the logged block
   and the meeting ghost already use, with a different colour in it. */
.m-agenda__block--planned .m-agenda__button {
    background: var(--m-plan-tint);
    border: 1px dashed var(--m-plan);
    border-left: 3px solid var(--m-plan);
    color: var(--m-plan-deep);
}

/* Only ever rendered inside a planned block (mobile-views.js:1087), so it is
   coloured for that fill rather than needing a modifier. --m-surface rather
   than a wash: a wash of --m-plan on a tint OF --m-plan is 1.10:1 and the pill
   disappears into the block it is sitting on. --m-plan-deep on --m-surface is
   8.55:1. */
.m-agenda__tag {
    display: inline-block;
    margin-left: var(--m-s2);
    padding: 0 var(--m-s2);
    border-radius: var(--m-r-pill);
    background: var(--m-surface);
    color: var(--m-plan-deep);
    font-size: var(--m-fs-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

/* Swipe feedback (Mobile App #47).

   The panel follows the finger and this says where it is going. Pinned rather
   than in flow so appearing costs no reflow of the agenda underneath - a hint
   that shoved the timeline down as it arrived would be its own small bug. */
/* A full-height band down the edge you are swiping toward, the way Outlook
   reveals an action under a dragged row.

   FULL VIEWPORT HEIGHT is the requirement, not decoration: the agenda is a
   24-hour timeline that scrolls, and a small pill anchored anywhere is off
   screen for most scroll positions. A band from top to bottom is visible
   wherever you happen to be looking.

   FIXED, so it takes no layout space. An earlier version used position: sticky
   and pushed the agenda down ~20px as it appeared - content jumping while you
   drag it is worse than no feedback at all. */
.m-swipehint {
    /* A TAB at the middle of the screen, not a full-height band (James, Plane
       #47: "can it look more like a tab that is always middle of the screen,
       regardless of scroll position"). position:fixed is what makes "regardless
       of scroll position" true; top:50% is what makes it the middle.

       This replaced a band running the whole height of the viewport, and that
       change deletes a problem rather than moving it. The band had to be
       layered BEHIND the app bar and the tab bar (z-index 20 against their 30)
       because an earlier version started it at
       `calc(--m-appbar-h + --m-safe-t)` — exactly the app bar's height — and
       still showed a white notch at the top on a real iPhone. The lesson was
       not the arithmetic: it was that two independently-positioned elements
       made to meet on a seam will eventually miss. A tab floating at mid-height
       touches neither bar, so there is no seam to miss. The z-index is kept all
       the same, so that if it ever grows again it grows behind them.

       --m-swipehint-bg, not --m-brand. The app bar is --m-brand, so a
       brand-coloured hint read as an extension of the chrome rather than as
       something transient.

       It was --m-text (near-black) with white on it for that reason. #ccc
       (Plane #65) keeps it just as distinct from the chrome while being far
       lighter, and the LABEL GOES DARK WITH IT — white on #ccc is 1.6:1 and
       unreadable, so the two had to change together. Note this is now the one
       place that does NOT share .m-toast's dark-on-light treatment. */
    position: fixed;
    top: 50%;
    left: 0;
    z-index: 20;
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: 4.5rem;
    /* Twice the 42px it was (Plane #65). The padding is unchanged and the flex
       centring does the work, so the label stays in the middle of a taller tab
       rather than being pushed around by a bigger box. */
    min-height: 5.25rem;
    margin: 0;
    padding: var(--m-s3) var(--m-s2);
    /* Square against the edge it is anchored to, rounded on the side that
       faces the screen - which is what makes it read as a tab pulled out from
       the edge rather than a floating chip. */
    border-radius: 0 var(--m-r-lg) var(--m-r-lg) 0;
    background: var(--m-swipehint-bg);
    color: var(--m-text);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    text-align: center;
    /* It is never a target - the finger is mid-gesture and belongs to the
       swipe. Also why it can sit in the layer permanently rather than being
       display:none'd, which is what lets it transition at all. */
    pointer-events: none;
    /* Parked just off its own edge. It SLIDES rather than appearing (James,
       #47: "can the black dark indicator slide out smoothly"), which the old
       `hidden` toggle could not do at any speed: display:none to display:flex
       is not a transitionable change, so the band simply popped. */
    opacity: 0;
    transform: translate(-100%, -50%);
    transition: transform var(--m-dur) var(--m-ease),
                opacity var(--m-dur) var(--m-ease);
}

.m-swipehint--right {
    left: auto;
    right: 0;
    border-radius: var(--m-r-lg) 0 0 var(--m-r-lg);
    transform: translate(100%, -50%);
}

/* Out. Equal specificity to --right and deliberately AFTER it, so this wins
   for both edges; removing it hands the element back to whichever side rule
   applies, and it slides out the way it came. */
.m-swipehint--in {
    opacity: 1;
    transform: translate(0, -50%);
}

.m-swipehint[hidden] {
    display: none;
}

/* The panel is dragged by transform, not by scroll position: transform does not
   reflow, so this stays smooth on the oldest phone in the field. The transition
   is applied ONLY on release - during the drag it must track the finger with no
   lag at all, which is why the class is added rather than always present. */
.m-panel--sliding {
    transition: transform var(--m-dur) var(--m-ease);
}

/* One plan whose day has arrived, offered for logging (Mobile App #46).
   Stacked rather than a row of columns: the two buttons need real tap targets
   and a project name plus a remark will not share a 360px line with them. */
.m-planrow {
    display: flex;
    flex-direction: column;
    gap: var(--m-s2);
    padding: var(--m-s3) var(--m-s4);
}

.m-planrow + .m-planrow {
    border-top: 1px solid var(--m-border);
}

.m-planrow__title {
    display: block;
    font-size: var(--m-fs-md);
    line-height: var(--m-lh-tight);
    color: var(--m-text);
}

.m-planrow__meta {
    display: block;
    margin-top: var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-planrow__actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--m-s2);
}

/* An offer on the page, rather than a control hidden in a menu. Grid rather
   than flex: the icon holds its own column so the title, body and buttons all
   align to one left edge, and the buttons wrap under the text on a narrow
   screen instead of squeezing it. */
.m-prompt {
    display: grid;
    /* The text column. Its content wraps, so min-content is the longest WORD
       rather than the whole sentence - a milder version of the same trap, but
       the same fix and the same reasoning. */
    grid-template-columns: auto minmax(0, 1fr);
    gap: var(--m-s2) var(--m-s3);
    padding: var(--m-s4);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-lg);
    background: var(--m-brand-wash);
}

.m-prompt__icon {
    display: inline-flex;
    color: var(--m-brand);
}

.m-prompt__main {
    grid-column: 2;
}

.m-prompt__title {
    font-size: var(--m-fs-md);
    font-weight: 600;
    color: var(--m-text);
}

.m-prompt__body {
    margin-top: var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-prompt__actions {
    grid-column: 2;
    display: flex;
    justify-content: flex-end;
    gap: var(--m-s2);
    margin-top: var(--m-s2);
}

/* Numbered steps, for instructions the app cannot carry out itself
   (Mobile App #43 - installing is a browser gesture, not a page action).
   list-style-position: outside with padding-left rather than inside, so a step
   that wraps to a second line aligns under its own text and not under the
   number. */
.m-steps {
    margin: var(--m-s3) 0 0;
    padding-left: var(--m-s5);
    list-style: decimal outside;
    color: var(--m-text);
    font-size: var(--m-fs-md);
    line-height: var(--m-lh-normal);
}

.m-steps li + li {
    margin-top: var(--m-s3);
}

.m-actions-row {
    margin-bottom: var(--m-s4);
}

.m-amount {
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    color: var(--m-text);
    white-space: nowrap;
}

.m-linkbtn {
    padding: 0;
    background: none;
    border: 0;
    color: var(--m-brand-deep);
    font-size: inherit;
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}


/* ==========================================================================
   14. TASKS
   ========================================================================== */

.m-task {
    display: flex;
    align-items: flex-start;
    gap: var(--m-s2);
    padding: var(--m-s2) var(--m-s3);
}

.m-task + .m-task {
    border-top: 1px solid var(--m-border);
}

/* The task you arrived at from Today (Plane #62). It fades rather than staying
   marked: the point is to answer "which one?" at the moment you land, and a
   highlight that persisted would become a second, wrong kind of state on a list
   that already uses colour for Important and Overdue. The global reduced-motion
   block flattens this, which leaves the task simply not highlighted - correct,
   because the scroll has already put it under your eyes. */
@keyframes m-task-arrive {
    from { background: var(--m-brand-tint); }
    to   { background: transparent; }
}

.m-task.is-arrived {
    animation: m-task-arrive 2s var(--m-ease);
}

.m-task[hidden] {
    display: none;
}

/* Pinned rows carry the desktop's sticky-note yellow. Suppressed once the task
   is done, matching css/tasks.css:84's own :not(.complete) - a finished task
   comes off the fridge. is-done is toggled optimistically in JS, so the fill
   clears on the tap rather than after the round trip. */
.m-task[data-pinned='1']:not(.is-done) {
    background: var(--m-pin-bg);
}

/* The two task flags, both glyphs (Mobile App #15). They lead the title rather
   than sitting as pills in the meta row.

   Glyphs survive forced-colors mode, where the row fill above is replaced by
   the system's own Canvas - which is exactly why the icons in this app are
   inline SVG and not a font. The accessible name comes from the wrapper's
   aria-label; see mobile/view-tasks.php for why it is an aria-label and
   emphatically not a visually-hidden word. */
.m-task__flag {
    /* inline-block, not inline: it keeps .m-task.is-done's line-through from
       being drawn across the glyph. A done task is still a pinned task, so the
       marker stays - it just should not look struck out. */
    display: inline-block;
    vertical-align: -1px;
    margin-right: var(--m-s1);
}

/* Distinguished by SHAPE first - a star and a square exclamation - so the pair
   still reads correctly in forced colours, where both of these colours are
   thrown away. Pinned also carries the row fill; important carries nothing
   else, which is why it takes the stronger colour of the two. */
.m-task__flag--pin {
    color: var(--m-warn);
}

/* 6.54:1 on --m-surface and 6.17:1 on --m-pin-bg, so it holds on a pinned row
   as well as a plain one. */
.m-task__flag--important {
    color: var(--m-danger);
}

/* Sized on the glyph rather than the wrapper so the wrapper's box is exactly
   the mark, with no stray line-height under it. */
.m-task__flag-glyph {
    display: block;
    width: 0.875rem;
    height: 0.875rem;
}

/* The tick is its own control with its own hit area, so completing a task and
   opening it are never the same tap. */
.m-task__check {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: var(--m-tap);
    height: var(--m-tap);
    padding: 0;
    background: none;
    border: 0;
    border-radius: var(--m-r-pill);
    color: var(--m-control-border);
    cursor: pointer;
}

.m-task__tick {
    width: 1.125rem;
    height: 1.125rem;
    padding: 2px;
    border: 2px solid currentColor;
    border-radius: var(--m-r-sm);
    /* Unchecked shows an empty box: the tick itself is hidden, the border is
       the affordance. */
    color: var(--m-control-border);
    stroke: transparent;
}

.m-task__check[aria-pressed='true'] .m-task__tick {
    background: var(--m-success);
    border-color: var(--m-success);
    /* --m-on-fill, not --m-on-brand: this is drawn on --m-success. */
    stroke: var(--m-on-fill);
}

.m-task__body {
    flex: 1 1 auto;
    min-width: 0;
    min-height: var(--m-tap);
    padding: var(--m-s1) 0;
    background: none;
    border: 0;
    text-align: left;
    cursor: pointer;
}

.m-task__title {
    display: block;
    font-size: var(--m-fs-md);
    line-height: var(--m-lh-tight);
    color: var(--m-text);
}

/* Completed state is carried by the strikethrough and the muted colour as well
   as by the green tick — never by colour alone. */
.m-task.is-done .m-task__title {
    color: var(--m-text-muted);
    text-decoration: line-through;
}

/* A team member's task on one of your schedules (Mobile App #21 / #207).
   Read-only by construction: the initials pill takes the checkbox's slot, so
   there is no control here to complete someone else's work with. Muted so the
   rows you CAN act on stay the ones that read first. */
.m-task--team .m-task__title {
    color: var(--m-text-muted);
}

.m-task__owner {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    /* The same footprint as .m-task__check, so own and team rows line up. */
    width: var(--m-tap);
    height: var(--m-tap);
    font-size: var(--m-fs-xs);
    font-weight: 600;
    /* 7.42:1 on --m-brand-tint. */
    color: var(--m-brand-deep);
}

/* A small divider inside a card - "Team" above the read-only rows, the same
   break task-feed.js draws on the desktop. */
.m-subhead {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s2) var(--m-s3) var(--m-s1);
    border-top: 1px solid var(--m-border);
    font-size: var(--m-fs-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--m-text-muted);
}

.m-task__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--m-s2);
    margin-top: var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}


/* ==========================================================================
   15. TIME — week bar, day strip, agenda
   ========================================================================== */

.m-weekbar {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    margin-bottom: var(--m-s3);
}

.m-weekbar__label {
    flex: 1 1 auto;
    text-align: center;
    font-size: var(--m-fs-sm);
    font-weight: 600;
    color: var(--m-text-muted);
}

.m-weekbar__nav {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    border-radius: var(--m-r-pill);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    color: var(--m-brand-deep);
    font-size: var(--m-fs-lg);
    text-decoration: none;
}

/* Three weeks of days on one horizontal rail: one week back, one forward
   (James, 2026-08-29). Exactly seven fit the viewport, so a full week is what
   you see and panning moves in familiar units rather than an arbitrary
   fraction of a week. */
.m-daystrip {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: calc((100% - 6 * var(--m-s1)) / 7);
    gap: var(--m-s1);
    /* The offset parent for centreStrip()'s arithmetic: it reads a chip's
       offsetLeft as the scrollLeft that puts that chip at the left edge, which
       is only true if offsets are measured from the strip. */
    position: relative;
    overflow-x: auto;
    /* Settle tidily onto a week boundary, but `proximity` not `mandatory`:
       a mandatory snap fights you when you only want to peek at the edge. */
    scroll-snap-type: x proximity;
    /* Horizontal panning inside the strip must not become the browser's
       back-swipe. Without this, panning to last week can leave the app. */
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    /* No scroll-behavior here on purpose: the initial centring must be an
       instant jump, not a visible animation on every page load. The JS asks
       for smooth explicitly when smooth is what it wants. */

    /* The strip owns the space under itself (James, 2026-08-30: "the spacing in
       the day selector is crammed"). It had none, and got away with it while
       the next thing was the segmented control - a pill on the page background,
       which reads as a gap of its own. The day summary band is a bordered card
       with a hard edge, so it sat flush against the chips and made the strip
       look packed. Belongs here rather than as a margin-top on whatever follows,
       which is the version that breaks again the next time something is
       inserted. */
    margin-bottom: var(--m-s3);
}

.m-daystrip::-webkit-scrollbar {
    display: none;
}

.m-day {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1px;
    min-width: 0;
    /* For the week seam below, which is drawn in the gap rather than made of
       one. The offset parent for centreStrip() is still .m-daystrip - that is
       the nearest positioned ANCESTOR, and a chip is not its own. */
    position: relative;
    min-height: var(--m-tap);
    padding: var(--m-s2) 0;
    border-radius: var(--m-r-md);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    color: var(--m-text-muted);
    text-decoration: none;
    /* Mobile App #75. A double tap on a chip adds a full day, and without this
       iOS spends that same gesture zooming the page instead - the viewport is
       deliberately scalable (no maximum-scale, so pinch still works and the OS
       text-size setting is honoured), which leaves double-tap-to-zoom on.

       `manipulation` is the narrow opt-out: it removes double-tap zoom and the
       300 ms click delay that comes with it, and keeps panning and pinch. NOT
       `touch-action: none`, which the drag handle and the agenda grid use -
       that would also kill the horizontal pan this strip is built around. */
    touch-action: manipulation;
}

/* A seam at each week start — Sunday, since a timesheet week runs Sunday to
   Saturday (app/week.py) — so three weeks read as three weeks rather than as
   twenty-one numbers in a row. Not on the first chip: there is no week before
   it to be separated from. */
.m-day--weekstart {
    scroll-snap-align: start;
}

/* A hairline in the gap the grid already leaves, NOT a margin. Margin on a grid
   item eats into its own track instead of pushing the tracks apart, so the
   Sunday chip would have come out narrower than the other six and the visible
   week would no longer have filled the strip exactly. This costs no layout at
   all: every chip stays the same width, and seven still fill the viewport. */
.m-day--weekstart:not(:first-child)::before {
    content: '';
    position: absolute;
    left: calc(var(--m-s1) / -2);
    top: 15%;
    bottom: 15%;
    width: 1px;
    /* --m-control-border, not --m-border. This IS a decorative separator, and
       the token note above says decorative separators use --m-border - but at
       1.29:1 a one-pixel line in a four-pixel gap is not faint, it is absent.
       Rendered and looked at: with --m-border there was no seam at all. Nothing
       here marks an interactive boundary, so reaching for the stronger colour
       breaks no rule; it only makes a line that exists visible. */
    background: var(--m-control-border);
}

/* The selected day is marked by fill AND weight AND aria-current="date". */
.m-day[aria-current='date'] {
    background: var(--m-brand);
    border-color: var(--m-brand);
    color: var(--m-on-brand);
    font-weight: 600;
}

.m-day__dow {
    font-size: var(--m-fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.m-day__num {
    font-size: var(--m-fs-md);
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
}

.m-day__hrs {
    font-size: var(--m-fs-xs);
    font-variant-numeric: tabular-nums;
    opacity: 0.85;
}

.m-agenda-actions {
    margin-bottom: var(--m-s3);
}

.m-agenda {
    overflow: hidden;
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-1);
}

/* 96 rows of 15 minutes. Blocks are placed by grid-row rather than absolute
   pixel offsets, so the whole timeline rescales with --m-slot-h and with the
   reader's text size instead of drifting out of alignment. */
.m-agenda__grid {
    display: grid;
    /* minmax(0, 1fr), NOT 1fr, AND THAT IS THE WHOLE BUG.
       -------------------------------------------------------------------
       `1fr` means `minmax(auto, 1fr)`, and that `auto` floor is the largest
       item's MIN-CONTENT width. .m-agenda__kw is `white-space: nowrap`, so its
       min-content is the entire untruncated subject - "Design/CAD Weekly Team
       Meeting" - and the track was forced that wide. The grid then overflowed
       .m-agenda, whose `overflow: hidden` sliced the right-hand edge off: no
       right border on the block, and the text running under the card edge.

       text-overflow: ellipsis did not save it. Ellipsis is what an element does
       when it is TOO NARROW for its text; it contributes nothing to how wide
       the element is asked to be, so the track grew and the ellipsis never
       engaged.

       minmax(0, 1fr) lets the track shrink below min-content, which is what
       makes the ellipsis do its job. Flagged by tools/csscheck.py from now on -
       a bare 1fr in a grid template is the footgun, not this one instance. */
    grid-template-columns: var(--m-agenda-gutter) minmax(0, 1fr);
    grid-template-rows: repeat(var(--m-agenda-slots), var(--m-slot-h));
    position: relative;
}

.m-agenda__hour {
    grid-column: 1 / -1;
    border-top: 1px solid var(--m-border);
    padding-left: var(--m-s2);
}

.m-agenda__hour:first-child {
    border-top: 0;
}

.m-agenda__time {
    font-size: var(--m-fs-xs);
    font-variant-numeric: tabular-nums;
    color: var(--m-text-muted);
}

.m-agenda__block {
    grid-column: 2 / -1;
    /* A grid item's automatic minimum size is min-content, the same trap the
       track had. Both ends need releasing or the item re-inflates the track it
       sits in. */
    min-width: 0;
    padding: 1px var(--m-s2) 1px 0;
    z-index: 1;
    /* LANES (Mobile App #77). Every block is grid-column: 2 / -1, so two at the
       same hour used to be drawn on top of each other and the later one won.
       layOutAgendaLanes() sets these two to a matching pair of percentages -
       0%/50% and 50%/0% for two blocks - and leaves them UNSET when a block
       overlaps nothing, which is most of the day, so the resting value here is
       the full width it always had.

       Margins, not width: the grid track is the containing block, so a
       percentage margin is a percentage of the lane column, and setting both
       edges keeps them symmetrical. The same reasoning as the desktop's
       applyLane(), which uses left/right on an absolutely positioned block.

       Declared here as well as set from the client, the same way
       .m-agenda__now declares its own --m-now-slots: the component gets a
       resting value of its own rather than depending on JavaScript having run,
       and csscheck.py can account for the token. */
    --m-block-left: 0;
    --m-block-right: 0;
    margin-left: var(--m-block-left);
    margin-right: var(--m-block-right);
}

.m-agenda__button {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 1px;
    width: 100%;
    height: 100%;
    /* A flex container is itself a flex ITEM of nothing here, but its own row
       children cannot shrink below their content unless this is set - and a
       block that cannot shrink is a block that gets clipped. */
    min-width: 0;
    overflow: hidden;
    padding: var(--m-s1) var(--m-s2);
    background: var(--m-brand-tint);
    border: 0;
    border-left: 3px solid var(--m-brand);
    border-radius: var(--m-r-sm);
    color: var(--m-brand-deep);
    text-align: left;
    cursor: pointer;
}

/* The same arithmetic applies to real blocks, which stack THREE lines - project,
   keyword and hours - into whatever height the duration gives them. A 30-minute
   block was clipping exactly as the meetings were.

   The hours go first: "10:00 AM · 1 h" is the line the timeline itself already
   tells you, so it is the one that can be dropped without losing anything. */
.m-agenda__block.is-short .m-agenda__button,
.m-agenda__block.is-tiny .m-agenda__button {
    flex-direction: row;
    align-items: center;
    gap: var(--m-s2);
}

.m-agenda__block.is-short .m-agenda__hrs,
.m-agenda__block.is-tiny .m-agenda__hrs {
    display: none;
}

.m-agenda__block.is-short .m-agenda__kw,
.m-agenda__block.is-tiny .m-agenda__kw {
    flex: 1 1 auto;
    min-width: 0;
}

/* Back to `flex: 0 0 auto`, its original and correct value.

   It was briefly made shrinkable, chasing the overflow above at the FLEX level.
   That was the wrong level - the track was the problem - and it had a visible
   cost of its own: the span holds the number AND #68's PLANNED pill, so letting
   it compress wrapped them onto two lines inside a 36px block. Squeezing a
   thing that should not squeeze, to fix a container that would not let go.

   A project number and its state pill are short, fixed and identifying. The
   keyword is the part that gives, and now that the track can shrink it does -
   by ellipsis, as it was always styled to. */
.m-agenda__block.is-short .m-agenda__prj,
.m-agenda__block.is-tiny .m-agenda__prj {
    flex: 0 0 auto;
}

/* 15 minutes is 18px. Padding goes, and the keyword shrinks to match the
   project number so the two sit on one line without either being cut. */
.m-agenda__block.is-tiny .m-agenda__button {
    padding-top: 0;
    padding-bottom: 0;
}

.m-agenda__block.is-tiny .m-agenda__kw {
    font-size: var(--m-fs-xs);
}

.m-agenda__button:disabled {
    cursor: default;
    opacity: 0.75;
}

.m-agenda__prj {
    font-family: var(--m-font-num);
    font-size: var(--m-fs-xs);
    font-weight: 700;
}

.m-agenda__kw {
    font-size: var(--m-fs-sm);
    line-height: var(--m-lh-tight);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--m-text);
}

.m-agenda__hrs {
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}

/* ---- "Now" (Mobile App #71) --------------------------------------------
   The current time, drawn across the day you are looking at.

   The token block has described --m-brand-wash as being for "the agenda's now
   band" since the file was written; the band itself was never built. Every
   calendar these people already use has one, so its absence was not read as a
   difference — it was read as the app not knowing what time it is, and it cost
   a moment of re-orientation on the screen opened most.

   Positioned by --m-now-slots, a FRACTIONAL slot index that initAgendaNow()
   sets on the element. Doing the arithmetic in slots rather than in pixels
   means the line stays correct when --m-slot-h changes and when the reader's
   text size scales the grid, exactly as the blocks do.

   Drawn ABOVE the blocks (z-index 3, over .m-agenda__block's 1 and the drag
   ghost's 2) because it is the one thing on the timeline that is never
   covered: a block that hid it would make the day look like it had not
   started. aria-hidden, and rendered only on today — the time of day is not a
   property of the day being READ, and announcing "now" on a Thursday you are
   editing in advance would be a small lie. */
.m-agenda__now {
    position: absolute;
    left: 0;
    right: 0;
    z-index: 3;
    height: 0;
    border-top: 2px solid var(--m-brand-ink);
    pointer-events: none;
    /* Declared here as well as set from the client, so the component has a
       resting value of its own: a rule whose position exists only once some
       JavaScript has run is one that draws at slot 0 - 7am - if that
       JavaScript ever fails. It also keeps the token accounted for in
       csscheck.py, the same way .m-seg declares its own --m-seg-n. */
    --m-now-slots: 0;
    top: calc(var(--m-slot-h) * var(--m-now-slots));
}

/* NO KNOB. There was a 6px dot at the head of this line, on the reasoning that
   a rule needs a start - but it landed in the hour gutter right beside the hour
   label, where the only other things are "9 AM" and a hairline, and it read as
   an artifact rather than as a marker (James, 2026-08-30: "there is an
   unexpected dot by the time line"). The rule alone is unambiguous: it is the
   only horizontal brand-coloured line on the timeline, and it moves. */

/* ---- The day summary band (Mobile App #71) ------------------------------
   What this day amounts to, above the grid that shows it.

   An empty working day is 44 slots of ruled nothing — about 790px, most of a
   phone screen — and a container with nothing in it reads as a failure state
   even when the day genuinely held one meeting and no work. This is the
   sentence that turns that into a fact: the screen states what the day is
   instead of showing a void and leaving the reader to conclude it.

   It is also the answer to the question the screen is opened to ask, which
   was previously only obtainable by scrolling the whole day.

   A real live region: the day changes under the reader when the strip is
   panned or the agenda swiped, and the summary is the shortest true statement
   of what they just moved to. */
.m-daysum {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--m-s1) var(--m-s3);
    margin-bottom: var(--m-s3);
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-daysum__hours {
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--m-text);
}

/* Pushed to the end rather than given a margin, so it stays last however many
   facts the day happens to carry. */
.m-daysum__rest {
    margin-left: auto;
}

/* ---- Today's closure, above the task list (Mobile App #71) --------------
   The same shape as .m-daysum and for the same reason: a count that can be
   read at a glance, above the list it describes, rather than a total the
   reader has to assemble by scanning rows.

   The meter sits on its own line under the words at every width. Putting it
   inline would make it as wide as whatever space the sentence left over,
   which means the same progress would draw a different length on two phones. */
.m-tasksum {
    margin-bottom: var(--m-s3);
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    color: var(--m-text-muted);
}

/* .m-meter's own margin-top already separates it from the line above. */
.m-tasksum .m-meter {
    margin-top: var(--m-s2);
}

/* Mobile App #49 — touch drag.
   -------------------------------------------------------------------------
   The callout and selection suppression is not polish. A press of about 450ms
   on iOS pops the system "Copy / Look Up" callout and starts a text selection,
   both of which land on top of the gesture. Without these the drag is unusable
   on an iPhone regardless of what the JS does.

   Still required after DRAG_ARM_MS came down to 280 (see its note in
   mobile-views.js), and the relationship is worth keeping straight: arming
   earlier means this app claims the finger BEFORE iOS starts thinking about
   its own long press, rather than at the same instant. It does not mean the
   system gesture stopped existing - a finger held on a block for half a second
   still crosses that threshold, and these three lines are what it lands on. */
.m-agenda__grid,
.m-agenda__button {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
}

/* The provisional block: where the entry WOULD land. Dashed to borrow the
   vocabulary #46 already established for a block that is not settled yet. */
.m-agenda__ghost {
    grid-column: 2 / -1;
    z-index: 2;
    display: flex;
    align-items: center;
    overflow: hidden;
    margin: 1px var(--m-s2) 1px 0;
    padding: var(--m-s1) var(--m-s2);
    background: var(--m-brand-tint);
    border: 2px dashed var(--m-brand);
    border-radius: var(--m-r-sm);
    font-size: var(--m-fs-xs);
    font-variant-numeric: tabular-nums;
    color: var(--m-brand-deep);
    pointer-events: none;
}

/* The block being carried. Faded rather than hidden, so the timeline still
   shows where it came from while the ghost shows where it is going. */
/* THE CARRIED BLOCK (Mobile App #77, James: "preferred to maintain the
   appearance of the dragging element, again like home-beta.php").
   ------------------------------------------------------------------------
   This was `opacity: 0.35`, from when the block stayed where it was and a
   dashed ghost was carried in its place: the faded thing was the ORIGIN, not
   the thing in your hand. The block is now what moves, so fading it would fade
   the only thing the finger is holding - and its project, its colour and its
   label are exactly what makes it recognisable while it travels.

   So it is not dimmed at all. It is LIFTED: raised above its neighbours so it
   passes over them rather than through them, and given the shadow that says it
   has come off the surface. --m-shadow-2 is the same elevation a sheet uses,
   which is the app's existing vocabulary for "this is on top of things now".

   z-index 2, deliberately between .m-agenda__block's 1 and .m-agenda__now's 3:
   the carried block covers the blocks reflowing around it, and the now-line
   still covers everything, which is the one thing on this timeline that is
   never obscured. */
.m-agenda__block.is-dragging {
    z-index: 2;
    box-shadow: var(--m-shadow-2);
    border-radius: var(--m-r-sm);
}

/* No transition on the block being CARRIED - it must track the finger exactly,
   and 120ms of easing on the thing you are holding reads as lag. The blocks it
   passes are the ones that animate: they are reflowing on their own account and
   a lane change that snaps is what made the desktop's version read as a bounce
   even after the pin fixed the ordering.

   Margin only, which is what a lane change actually moves. Transitioning `all`
   here would also catch the grid-row change on the carried block, which is the
   one thing that must not ease. */
.m-agenda__block,
.m-agenda__meeting {
    transition: margin-left var(--m-dur-fast) var(--m-ease),
                margin-right var(--m-dur-fast) var(--m-ease);
}

.m-agenda__block.is-dragging {
    transition: none;
}

/* Someone who has asked the OS for less motion gets the lane change instantly.
   The reflow still happens - it is layout, not decoration, and hiding it would
   hide where the block is going. */
@media (prefers-reduced-motion: reduce) {
    .m-agenda__block,
    .m-agenda__meeting {
        transition: none;
    }
}

/* Being resized, not carried. It stays put and only one edge moves, so it is
   left at full strength - fading it would suggest the whole block had been
   picked up, which is the other gesture. */
.m-agenda__block.is-resizing .m-agenda__button {
    border-left-style: dashed;
}

/* Belt and braces with the JS preventDefault(): while a drag is armed the
   page must not scroll or rubber-band under the finger. */
/* The document no longer scrolls, so locking `body` locks nothing. It is
   .m-main that has to be held still while a block is being dragged. */
body.m-dragging .m-main {
    overflow: hidden;
    touch-action: none;
}

/* Passkey sign-in (Mobile App #50).
   -------------------------------------------------------------------------
   Both entry points ship hidden and are revealed by JS only once
   isUserVerifyingPlatformAuthenticatorAvailable() resolves true, so nothing
   here should assume the block is ever shown. */
.m-login__passkey {
    margin-top: var(--m-s4);
}

/* A rule with the word centred in it. Border on the pseudo-elements rather than
   a background image so it follows --m-border in both themes. */
.m-login__or {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    margin: 0 0 var(--m-s3);
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}

.m-login__or::before,
.m-login__or::after {
    content: '';
    flex: 1;
    border-top: 1px solid var(--m-border);
}

/* Outlined, not filled. The password form's own submit is the primary action on
   this page - a second solid button beside it would make the two compete, and
   this one cannot work at all until a device has been enrolled. */
.m-login__passkey-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--m-s2);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    color: var(--m-text);
}

.m-field__hint--error {
    color: var(--m-danger);
}

/* Outlook meetings on the timeline.
   -------------------------------------------------------------------------
   GHOSTS, deliberately unlike a block. A meeting is something in your calendar,
   not hours on your timesheet, and drawing them alike would invite somebody to
   believe the time was already logged. Dashed edge, no fill, and BEHIND the
   real blocks (z-index below .m-agenda__block's 1) so a logged block always
   wins the space where they overlap - which they usually do, because the block
   was made FROM the meeting. */
.m-agenda__meeting {
    grid-column: 2 / -1;
    /* Same as .m-agenda__block: a grid item in the same track. */
    min-width: 0;
    z-index: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 1px;
    overflow: hidden;
    margin: 1px var(--m-s2) 1px 0;
    /* Lanes, exactly as .m-agenda__block takes them (Mobile App #77) - a ghost
       and a block at the same hour are two things competing for one column, so
       they are laned together in one pass. The right value composes with the
       gutter the shorthand above already set, rather than replacing it.

       Its own resting pair, for the same reason .m-agenda__block declares one:
       these are siblings, and a custom property set on one does not reach the
       other. */
    --m-block-left: 0%;
    --m-block-right: 0%;
    margin-left: var(--m-block-left);
    margin-right: calc(var(--m-s2) + var(--m-block-right));
    padding: var(--m-s1) var(--m-s2);
    background: none;
    /* Three sides dashed, the left edge SOLID (James, 2026-08-29: "can we
       remove the dash from the outlook left border? Leave the other borders
       dashed as they are"). Dashed still says "this is not yours yet - it is a
       meeting, not logged time"; the solid edge is what anchors it.

       This is a real border again. It had been a repeating-linear-gradient,
       because CSS scales a dashed border's DASH LENGTH with its width: at 3px
       the left edge drew dashes three times longer than the 1px sides and the
       two rhythms visibly disagreed, and no property sets weight and dash
       period independently. With the dash gone that whole problem goes with it,
       and a solid border needs no help. The 3px weight is unchanged - James on
       the gradient: "leaving the lineweight is good". */
    border: 1px dashed var(--m-border);
    border-left: 3px solid var(--m-text-faint);
    border-radius: var(--m-r-sm);
    color: var(--m-text-muted);
    text-align: left;
    cursor: pointer;
}

/* Already logged: still shown, because its absence would read as the meeting
   having vanished, but visibly finished so nobody logs it twice. */
/* SHORT MEETINGS GET A DIFFERENT LAYOUT, not a smaller one.
   -------------------------------------------------------------------------
   A slot is 18px. A 30-minute meeting is 36px; the stacked title and meta come
   to roughly 40px with padding, so they overflowed and `overflow: hidden` cut
   them THROUGH THE MIDDLE OF THE LETTERS - the title was sliced horizontally
   and unreadable, which is what James reported.

   Shrinking the type would have made an illegible thing smaller. Turning the
   column into a ROW is what actually fits: one line of 16px inside 36px, with
   room to spare. */
.m-agenda__meeting.is-short {
    flex-direction: row;
    align-items: center;
    gap: var(--m-s2);
}

.m-agenda__meeting.is-short .m-agenda__meeting-title {
    flex: 1 1 auto;
    min-width: 0;
}

.m-agenda__meeting.is-short .m-agenda__meeting-meta {
    flex: 0 0 auto;
}

/* 15 minutes is 18px, which one padded line still does not fit. The padding
   goes and the time goes with it - the block's POSITION on the timeline already
   says when it is, so the subject is the only thing that has to survive. */
.m-agenda__meeting.is-tiny {
    flex-direction: row;
    align-items: center;
    padding-top: 0;
    padding-bottom: 0;
}

.m-agenda__meeting.is-tiny .m-agenda__meeting-title {
    flex: 1 1 auto;
    min-width: 0;
    font-size: var(--m-fs-xs);
}

.m-agenda__meeting.is-tiny .m-agenda__meeting-meta {
    display: none;
}

.m-agenda__meeting-title {
    font-size: var(--m-fs-sm);
    line-height: var(--m-lh-tight);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.m-agenda__meeting-meta {
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}

.m-empty--agenda {
    padding: var(--m-s4);
    text-align: center;
    color: var(--m-text-muted);
}

.m-line-summary {
    margin-bottom: var(--m-s3);
    padding: var(--m-s3);
    background: var(--m-surface-sunken);
    border-radius: var(--m-r-md);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}


/* ==========================================================================
   16. FORMS IN SHEETS — checkbox, attachments, history
   ========================================================================== */

.m-sheet__form {
    display: flex;
    flex-direction: column;
    min-height: 0;
    /* The form is the sheet's flex child, so its body is what scrolls and the
       footer buttons stay put above the keyboard. */
    overflow: hidden;
}

.m-textarea--short {
    min-height: 4.5rem;
}

/* Two glyph toggles for the task flags (Mobile App #15). They replaced a
   "Mark as important" checkbox, and .m-check went with it - that checkbox was
   its only user in the whole app, so the rules are gone rather than left to rot.

   Modelled on the desktop's own pair (css/tasks.css:452-476): muted when off,
   coloured when on. The one deliberate difference is WHICH colour. The desktop
   turns both brand-pink; here each toggle lights in the colour its own glyph
   takes on the task row - amber star, red exclamation - so the toggle predicts
   exactly what you will see in the list afterwards. Matching the desktop's pink
   would have matched one screen at the cost of the other. */
.m-flags {
    display: flex;
    gap: var(--m-s2);
}

.m-flag {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Full tap target. The desktop's equivalents are ~16pt glyphs, which is
       fine for a mouse and not for a thumb. */
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    padding: 0;
    background: var(--m-surface);
    /* 3.41:1 - this border is the whole boundary of the control when the
       toggle is off, so it must not be softened to --m-border. */
    border: 1px solid var(--m-control-border);
    border-radius: var(--m-r-md);
    color: var(--m-control-border);
    cursor: pointer;
}

.m-flag__glyph {
    width: 1.25rem;
    height: 1.25rem;
}

.m-flag--important[aria-pressed='true'] {
    background: var(--m-danger-bg);
    border-color: var(--m-danger);
    color: var(--m-danger);
}

.m-flag--pin[aria-pressed='true'] {
    background: var(--m-pin-bg);
    border-color: var(--m-warn);
    color: var(--m-warn);
}

/* Forced colours throws the fill and the border away, so the pressed state
   would look identical to the unpressed one. Same treatment the tab bar and
   the filter chips already use. aria-pressed still carries it for a screen
   reader either way. */
@media (forced-colors: active) {
    .m-flag[aria-pressed='true'] {
        forced-color-adjust: none;
        background: Highlight;
        color: HighlightText;
    }
}

.m-attach {
    margin: 0;
    padding: 0;
    border: 0;
}

.m-attach__buttons {
    display: flex;
    flex-wrap: wrap;
    gap: var(--m-s2);
}

/* The file inputs are visually hidden inside their <label>, so the label IS
   the button. Keyboard focus lands on the input, so the label needs to show
   the ring on its behalf. */
.m-attach__buttons .m-btn:focus-within {
    outline: var(--m-focus-width) solid var(--m-focus-ring);
    outline-offset: 2px;
}

.m-attach__list {
    margin-top: var(--m-s3);
    display: flex;
    flex-direction: column;
    gap: var(--m-s2);
}

.m-attach__item {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    padding: var(--m-s2);
    background: var(--m-surface-alt);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
}

.m-attach__thumb {
    width: 3rem;
    height: 3rem;
    flex: 0 0 auto;
    object-fit: cover;
    border-radius: var(--m-r-sm);
    background: var(--m-surface-sunken);
}

.m-attach__thumb--file {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: var(--m-fs-xs);
    font-weight: 700;
    color: var(--m-text-muted);
}

.m-attach__name {
    flex: 1 1 auto;
    min-width: 0;
    font-size: var(--m-fs-sm);
    overflow: hidden;
    text-overflow: ellipsis;
}

.m-attach__size {
    display: block;
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}

.m-attach__remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    background: none;
    border: 0;
    border-radius: var(--m-r-pill);
    color: var(--m-danger);
    font-size: var(--m-fs-lg);
    cursor: pointer;
}

/* Upload / analysis progress (Mobile App #8).
   --------------------------------------------------------------------------
   James: "An 'Analyzing' spinner or bar would be good for uploads/photos."
   Sits under the attachment list because that is where the waiting happens.

   Two modes, and the distinction is honest rather than decorative: the upload
   reports real bytes, so that phase gets a real bar; shrinking the photo and
   reading it on the server report nothing, so those get the indeterminate
   sweep. A determinate-looking bar that is actually a guess is worse than no
   bar, because it invites someone to conclude the app has hung at 80%. */
.m-progress {
    margin-top: var(--m-s3);
}

.m-progress[hidden] {
    display: none;
}

.m-progress__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--m-s2);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

/* Tabular figures so a counter climbing 8% -> 88% does not shuffle the label
   beside it on every frame. */
.m-progress__pct {
    flex: 0 0 auto;
    font-family: var(--m-font-num);
    font-variant-numeric: tabular-nums;
}

.m-progress__track {
    display: block;
    height: 6px;
    margin-top: var(--m-s1);
    background: var(--m-surface-sunken);
    border-radius: var(--m-r-pill);
    overflow: hidden;
}

.m-progress__bar {
    display: block;
    width: 0;
    height: 100%;
    background: var(--m-brand);
    border-radius: inherit;
    transition: width 0.18s linear;
}

/* Indeterminate: a short bar sweeping the track. No width transition here or
   the sweep and the transition fight each other. */
.m-progress--busy .m-progress__bar {
    width: 40%;
    transition: none;
    animation: m-progress-sweep 1.1s ease-in-out infinite;
}

@keyframes m-progress-sweep {
    from { transform: translateX(-110%); }
    to   { transform: translateX(260%); }
}

/* The global reduced-motion block freezes every animation at its final frame.
   For this one that frame is the bar swept off the right-hand end, i.e. an
   empty track - which reads as "nothing is happening" at exactly the moment
   something is. Give it a static muted fill instead: still visibly a busy
   state, no movement at all. Must come after the global block to win. */
@media (prefers-reduced-motion: reduce) {
    .m-progress--busy .m-progress__bar {
        width: 100%;
        transform: none;
        opacity: 0.4;
    }
}

/* Same reason this is here rather than in section 12: an equal-specificity
   override has to come after the rule it overrides, and section 12 is further
   up the file. In forced colours the bar and the track would otherwise be
   painted the same system colour and the bar would vanish. */
@media (forced-colors: active) {
    .m-progress__track {
        border: 1px solid ButtonBorder;
    }

    .m-progress__bar {
        forced-color-adjust: none;
        background: Highlight;
    }
}

.m-history {
    max-height: 14rem;
    overflow-y: auto;
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
}

.m-history__item {
    padding: var(--m-s3);
}

.m-history__item + .m-history__item {
    border-top: 1px solid var(--m-border);
}

.m-history__text {
    font-size: var(--m-fs-sm);
    line-height: var(--m-lh-normal);
}

.m-history__meta {
    margin-top: var(--m-s1);
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}


/* A save that did not happen, said inside the form it belongs to and left
   there until the next attempt (Mobile App #71).

   Deliberately NOT a toast. The sheet keeps every typed value when a save
   fails, so what the person comes back to is a filled-in form with an enabled
   Save button - which looks exactly like one they have not submitted yet. A
   message that has already faded cannot tell them apart, and closing the sheet
   at that point is what actually loses the work.

   Styled as a notice rather than a field error: it is about the whole
   attempt, not about one input. */
.m-formerror {
    display: flex;
    gap: var(--m-s2);
    margin-bottom: var(--m-s3);
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-danger-bg);
    border: 1px solid color-mix(in srgb, var(--m-danger) 30%, transparent);
    border-radius: var(--m-r-md);
    color: var(--m-danger);
    font-size: var(--m-fs-sm);
}


/* ---- Disclosure for a long tail (Plane #69) -----------------------------
   The expenses screen opens on the current month; everything older sits behind
   this. A real <details>, so the state, the keyboard handling and the
   expanded/collapsed announcement are the platform's and it works with no
   JavaScript at all. */
.m-more {
    margin-top: var(--m-s5);
}

.m-more__toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--m-s2);
    min-height: var(--m-tap);
    padding: 0 var(--m-s4);
    background: var(--m-surface);
    border: 1px solid var(--m-control-border);
    border-radius: var(--m-r-md);
    color: var(--m-brand-deep);
    font-size: var(--m-fs-md);
    font-weight: 500;
    cursor: pointer;
    /* The default disclosure triangle sits outside the button's centring and
       drags the label off-centre. The open/closed state is still carried by
       the element itself and announced; what is removed is only the marker. */
    list-style: none;
}

.m-more__toggle::-webkit-details-marker {
    display: none;
}

.m-more__toggle:active {
    background: var(--m-brand-wash);
}

/* Once open, the button has done its job and should stop competing with the
   months it just revealed. */
.m-more[open] .m-more__toggle {
    margin-bottom: var(--m-s3);
    border-color: var(--m-border);
    color: var(--m-text-muted);
}


/* ==========================================================================
   17. BUG REPORT
   ========================================================================== */

/* The status line inside the report sheet. role="alert" on the element, so a
   failure is announced as well as shown - which matters more here than
   elsewhere: there is no local copy of the report, so a send that quietly
   failed would lose what the person wrote. */
#m-bug-status[hidden] {
    display: none;
}

#m-bug-status {
    margin-top: var(--m-s3);
    padding: var(--m-s3);
    background: var(--m-danger-bg);
    border-radius: var(--m-r-md);
}


/* ==========================================================================
   18. SIGN IN
   --------------------------------------------------------------------------
   m-login.php only. A single centred column rather than the app shell — there
   is no nav to show someone who is not signed in yet.
   ========================================================================== */

.m-login-body {
    /* Fill the viewport so the card centres vertically on a tall phone but
       still scrolls if the keyboard shrinks the visual viewport. dvh rather
       than vh so iOS's collapsing address bar does not clip the button. */
    min-height: 100dvh;
    display: flex;
}

.m-login {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: var(--m-s5);
    width: 100%;
    max-width: 24rem;
    margin: auto;
    padding: var(--m-s5) calc(var(--m-s4) + var(--m-safe-r))
             calc(var(--m-s5) + var(--m-safe-b)) calc(var(--m-s4) + var(--m-safe-l));
}

.m-login__brand {
    text-align: center;
}

.m-login__logo {
    width: auto;
    max-width: 11rem;
    max-height: 4rem;
    margin: 0 auto var(--m-s4);
}

.m-login__title {
    font-size: var(--m-fs-xl);
    font-weight: 600;
    line-height: var(--m-lh-tight);
    color: var(--m-brand);
}

.m-login__sub {
    font-size: var(--m-fs-sm);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--m-text-muted);
}

.m-login__form {
    display: flex;
    flex-direction: column;
    gap: var(--m-field-gap);
    padding: var(--m-s5) var(--m-s4);
    background: var(--m-surface);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-1);
}

/* role="alert" on the element, so a failed sign-in is announced rather than
   only appearing. Carries no icon because the text is the whole message. */
.m-login__error {
    padding: var(--m-s3);
    background: var(--m-danger-bg);
    border-radius: var(--m-r-md);
    color: var(--m-danger);
    font-size: var(--m-fs-sm);
}

.m-login__submit {
    margin-top: var(--m-s2);
}

.m-login__alt {
    text-align: center;
    font-size: var(--m-fs-sm);
}

/* PASSKEY FIRST — a device that has enrolled or signed in with a passkey
   (passkey.js initSignIn adds .m-login--passkey). James, 2026-09-17: "hide
   (collapse) the login form if the user has a passkey", modelled on a banking
   app's sign-in: the brand, one large "Sign in with this device" button, and a
   "Use password" link that OPENS the form in place (.m-login--password-open).

   Selectors carry .m-login--passkey because .m-login__passkey-btn above sets
   its own background, and a later equal-weight rule would win over it. */
.m-login--passkey .m-login__brand   { order: 0; }
.m-login--passkey .m-login__passkey { order: 1; margin-top: 0; display: flex; flex-direction: column; }
.m-login--passkey .m-login__form    { order: 2; }
.m-login--passkey .m-login__alt     { order: 3; }

.m-login--passkey .m-login__or { display: none; }

/* COLLAPSED, not merely hidden from view: display:none takes the fields out of
   the tab order and out of the screen reader's reading, which is what "not on
   this screen" should mean. The button that opens it says aria-expanded. */
.m-login--passkey:not(.m-login--password-open) .m-login__form { display: none; }

.m-login--passkey:not(.m-login--password-open) .m-login__passkey-btn {
    background: var(--m-brand);
    border-color: var(--m-brand);
    color: var(--m-on-brand);
    min-height: calc(var(--m-tap) + var(--m-s4));
    font-size: var(--m-fs-lg);
    font-weight: 600;
}
.m-login--passkey:not(.m-login--password-open) .m-login__passkey-btn:active {
    background: var(--m-brand-deep);
}

.m-login--passkey #m-passkey-hint { margin-top: var(--m-s2); text-align: center; }

.m-login__use-password {
    align-self: center;
    min-height: var(--m-tap);
    margin-top: var(--m-s4);
    padding: 0 var(--m-s4);
    border: 0;
    background: none;
    color: var(--m-brand-deep);
    font-size: var(--m-fs-lg);
    font-weight: 600;
    cursor: pointer;
}
.m-login__use-password:focus-visible {
    outline: 2px solid var(--m-brand);
    outline-offset: 2px;
    border-radius: var(--m-r-md);
}

/* REMEMBER MY USERNAME — who last signed in on this phone, above everything
   below the brand, with the way out beside it. */
.m-login__who {
    order: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: center;
    column-gap: var(--m-s2);
    text-align: center;
}
.m-login__who-label {
    flex: 1 0 100%;
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
}
.m-login__who-name {
    font-size: var(--m-fs-lg);
    font-weight: 600;
    overflow-wrap: anywhere;
}
.m-login__not-you {
    min-height: var(--m-tap);
    padding: 0 var(--m-s2);
    border: 0;
    background: none;
    color: var(--m-brand-deep);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    text-decoration: underline;
    cursor: pointer;
}
.m-login--passkey .m-login__who { order: 1; }
.m-login--passkey .m-login__passkey { order: 2; }
.m-login--passkey .m-login__form { order: 3; }
.m-login--passkey .m-login__alt { order: 4; }

/* ==========================================================================
   19. ACCOUNT SHEET, AND THE TRAVELLING BUG BUTTON
   ========================================================================== */

.m-appbar__initials {
    font-size: var(--m-fs-sm);
    font-weight: 700;
    letter-spacing: 0.04em;
    /* The bar's own wash, so the ring stays as faint against the dark bar as
       it always was against the maroon one (1.2:1 in both). It is a decorative
       edge on a control whose label is the affordance - the initials read at
       13.2:1 - not the boundary 1.4.11 asks about. */
    border-color: var(--m-appbar-wash);
}

.m-sheet__subtitle {
    display: block;
    font-size: var(--m-fs-sm);
    font-weight: 400;
    color: var(--m-text-muted);
}

.m-menu-item--danger {
    color: var(--m-danger);
}

.m-menu-item__icon--danger {
    background: var(--m-danger-bg);
    color: var(--m-danger);
}

/* The report button moves out of the app bar and into whichever sheet is open
   (scripts/mobile-bug.js), so it has to read correctly on both grounds: white
   on the brand bar, muted on a sheet header. It inherits currentColor either
   way, so re-colouring the button is enough — the icon follows. */
.m-sheet__head .m-appbar__action {
    color: var(--m-text-muted);
}

.m-sheet__head .m-appbar__action:hover,
.m-sheet__head .m-appbar__action:active {
    background: var(--m-brand-tint);
    color: var(--m-brand-deep);
}

.m-sheet__head .m-appbar__action:focus-visible {
    outline-color: var(--m-focus-ring);
}


/* ==========================================================================
   20. CONFIRM SHEET
   --------------------------------------------------------------------------
   Replaces window.confirm(), which iOS Safari suppresses when it is raised
   from inside a modal <dialog> — see scripts/mobile.js's confirm().
   ========================================================================== */

/* Centred rather than bottom-anchored: this is a question, not a form, and it
   should read as interrupting rather than as another screen. */
.m-sheet--confirm {
    margin: auto;
    max-width: 22rem;
    border-radius: var(--m-r-lg);
}

.m-sheet--confirm .m-sheet__body {
    padding-top: 0;
    color: var(--m-text-muted);
}

.m-sheet--confirm .m-sheet__head {
    background: var(--m-surface);
    border-bottom: 0;
}

/* A filled destructive button. Distinct from .m-btn--danger, which is the
   outlined version used where deleting is one option among several — here it
   is the action being confirmed, so it carries the weight. */
.m-btn--danger-solid {
    background: var(--m-danger);
    /* --m-on-fill, not --m-on-brand: this is drawn on --m-danger. */
    color: var(--m-on-fill);
}

.m-btn--danger-solid:active {
    /* Same 80%-black mix the brand uses for its own pressed state. */
    background: color-mix(in srgb, var(--m-danger) 80%, black);
}

/* The PIN is five digits, so the field does not need to be full width — and a
   short field is itself a hint about how much to type. letter-spacing because
   a masked numeric string is easier to count at a glance when spaced out. */
.m-input--pin {
    max-width: 9rem;
    letter-spacing: 0.4em;
    font-family: var(--m-font-num);
}

.m-login__error-hint {
    display: block;
    margin-top: var(--m-s1);
    font-size: var(--m-fs-xs);
    opacity: 0.9;
}


/* ==========================================================================
   21. iOS FIXES (Mobile #5, #6)
   ========================================================================== */

/* Mobile #5 — "Date field extends beyond others".
   iOS Safari gives date/time inputs an intrinsic minimum width from their
   native control and will not shrink them to the container, so a field that is
   width:100% everywhere else rendered wider than its siblings and pushed past
   the sheet's padding. Dropping the native appearance and clearing the implied
   min-width lets them size like every other input; the tap-to-open picker is
   unaffected. ::-webkit-date-and-time-value carries its own centring, which is
   what left the text looking off once the box was the right size.

   NOTE: as of Mobile App #13 there is no input[type='time'] left in the app -
   the agenda's Start field became a <select> so it would open a wheel on Android
   like the Hours field beside it. The time half of these rules is kept anyway:
   it costs nothing, and it is the fix that has to be in place BEFORE the next
   time input is added, not after someone reports the width again. */
input[type='date'],
input[type='time'] {
    -webkit-appearance: none;
    appearance: none;
    min-width: 0;
    max-width: 100%;
}

input[type='date']::-webkit-date-and-time-value,
input[type='time']::-webkit-date-and-time-value {
    text-align: left;
    margin: 0;
}

/* Mobile #6 — "Autocomplete field is below the keyboard".
   A sheet is bottom-anchored and only as tall as its content, so a short one —
   the project picker, before its results load — sits right where the on-screen
   keyboard appears. iOS shrinks the VISUAL viewport when the keyboard opens but
   leaves the layout viewport alone, and dvh follows the layout viewport, so the
   sheet had no idea it had been covered.

   scripts/mobile.js measures the keyboard through the VisualViewport API and
   publishes it as --m-kb; the sheet then sits above it and caps its own height
   to what is actually visible. Falls back to 0px where the API is absent, which
   is exactly today's behaviour. */
.m-sheet {
    margin-bottom: var(--m-kb, 0px);
    max-height: calc(92dvh - var(--m-kb, 0px));
}

/* Global search (#32), reworked after James's #53: "instead of a dialog I expect
   the search to be a subtle overlay at the top of the screen and the results to
   populate in a bubble below it".
   -------------------------------------------------------------------------
   IT IS STILL A <dialog>, deliberately. Only the layout changed. showModal()
   is what gives this focus trapping, Escape, and top-layer rendering for free,
   and swapping a working interaction mechanism for a hand-rolled one is exactly
   what Plane #180 cost the whole app. The complaint was about where it sits,
   not what it is.

   TOP-ANCHORED IS ALSO THE KEYBOARD FIX, and the two asks in #53 turn out to be
   one problem. A bottom-anchored sheet gets pushed above the keyboard and then
   capped at 92dvh - kb, so with the field near the top of its body the results
   were squeezed into whatever strip was left. Anchored to the TOP, the field
   stays put and the results take every pixel between it and the keyboard. */
.m-sheet--search {
    /* BELOW THE HEADER, not over it (#53 follow-up). Same reason the swipe band
       was moved down in #47: the app bar is the only thing on screen that says
       where you are, and covering it while you are mid-task removes the answer
       to "what am I searching from?". */
    margin: calc(var(--m-appbar-h) + var(--m-safe-t)) auto auto;
    /* --m-kb is the keyboard height the app already measures from
       visualViewport (Mobile #6). No CSS length reports it: vh, dvh and 100%
       all follow the LAYOUT viewport, which iOS does not shrink. */
    max-height: calc(100dvh - var(--m-kb, 0px)
                     - var(--m-appbar-h) - var(--m-safe-t));
    /* NO `display` HERE. Plane #60: "the search bar is visible at the bottom of
       every page".

       A closed <dialog> is hidden by the UA stylesheet's `display: none`, and
       any author `display` outranks it - the same rule this file's [hidden]
       reset (section 1b) exists for. .m-sheet deliberately keeps its
       `display: flex` on .m-sheet[open] for exactly that reason; this rule set
       it unconditionally and opted the search sheet out, so the closed dialog
       laid out in normal flow at the end of the body and appeared at the foot
       of every screen.

       The flex column comes from .m-sheet[open] and does not need repeating.
       Nothing about how this sheet LOOKS belongs on `display`. */
    gap: var(--m-s2);
    /* The bar and the bubble carry their own surfaces, so the dialog itself is
       just a container - that is what makes it read as an overlay rather than
       as a panel with things on it. */
    background: none;
    box-shadow: none;
    border-radius: 0;
    overflow: visible;
    /* Even on all four sides: the gap below the header matches the gap at each
       side, so the overlay reads as floating rather than as hanging off the
       app bar. */
    padding: var(--m-s2);
}

/* It comes DOWN, not up (James, Plane #64: "search bar should slide down
   smoothly").

   .m-sheet[open] animates m-sheet-in — translateY(6%) to 0 — which is a sheet
   rising from the bottom edge. That is right for every other sheet in the app
   and backwards for this one, which is anchored under the header: it was
   sliding UP into place from below its resting position. The search sheet was
   simply inheriting the wrong direction.

   A rem, not the base animation's percentage: 6% of a bottom sheet is a
   sensible fraction of a short box, but this one's height is content-driven and
   capped near the full viewport, so a percentage would make a tall result list
   drop from much further away than a short one. A fixed distance keeps the
   movement the same whatever is in it. */
@keyframes m-search-in {
    from { transform: translateY(-1.5rem); opacity: 0; }
    to   { transform: translateY(0);       opacity: 1; }
}

.m-sheet--search[open] {
    animation: m-search-in var(--m-dur) var(--m-ease);
}

.m-sheet--search::backdrop {
    /* Much lighter than a sheet's. "Subtle" was the word, and with the bar now
       sitting below the app bar you should still be able to read the screen you
       are searching from. */
    background: color-mix(in srgb, var(--m-backdrop) 30%, transparent);
}

.m-search__bar {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s2) var(--m-s3);
    /* Rounded all round now that it floats below the header rather than hanging
       off the top edge. */
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-2);
    /* The opaque fallback. Transparency is only safe where the blur below is,
       because without it this is text over whatever happens to be behind. */
    background: color-mix(in srgb, var(--m-surface) 94%, transparent);
}

/* Where the platform can blur what is behind, the bar can be genuinely
   translucent and stay readable - the blur is what keeps the contrast, not the
   opacity. Guarded rather than assumed, so a browser without it gets the near
   opaque version above instead of unreadable text. */
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
    .m-search__bar {
        background: color-mix(in srgb, var(--m-surface) 62%, transparent);
        -webkit-backdrop-filter: saturate(180%) blur(16px);
        backdrop-filter: saturate(180%) blur(16px);
    }

    /* The bubble takes a little too, so the two read as one material - but far
       less, because this is the part that has to be READ. */
    .m-search__panel {
        background: color-mix(in srgb, var(--m-surface) 88%, transparent);
        -webkit-backdrop-filter: saturate(180%) blur(16px);
        backdrop-filter: saturate(180%) blur(16px);
    }
}

.m-search__icon {
    flex: 0 0 auto;
    display: flex;
    color: var(--m-text-faint);
}

.m-search__input {
    flex: 1 1 auto;
    min-width: 0;
    border: 0;
    background: none;
    padding-left: 0;
    padding-right: 0;
}

.m-search__cancel {
    flex: 0 0 auto;
    min-height: var(--m-tap);
    padding: 0 var(--m-s2);
    background: none;
    border: 0;
    color: var(--m-brand);
    font-size: var(--m-fs-sm);
    cursor: pointer;
}

/* The bubble. Scrolls on its own so the bar never leaves the screen, and it is
   the element the keyboard eats into rather than the whole dialog. */
.m-search__panel {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    padding: var(--m-s2);
    background: var(--m-surface);
    border-radius: var(--m-r-lg);
    box-shadow: var(--m-shadow-2);
}

/* Inside the bubble the cards would be a second frame around each section, so
   they lose their own chrome and the bubble is the frame. */
.m-search__panel .m-card {
    background: none;
    border: 0;
    box-shadow: none;
}

.m-search__panel .m-section {
    margin-bottom: var(--m-s2);
}

/* Customize Today View (#18). The row is a toggle plus two move buttons, so
   unlike other .m-row uses it is not itself the tap target. */
.m-layout-row {
    align-items: center;
}

/* The actions inside the Quick actions widget. Indented and rule-joined so they
   read as belonging to the row above rather than as more top-level widgets -
   which is the distinction the two drag scopes enforce. */
.m-layout-sub {
    margin-left: var(--m-s5, 2rem);
    border-left: 2px solid var(--m-border);
}

.m-layout-row--sub .m-row__title {
    font-size: var(--m-fs-sm);
}

/* Hidden widgets stay legible rather than being greyed into unreadability -
   this is the list where you go looking for the thing you turned off. */
.m-layout-row.is-hidden .m-row__title {
    color: var(--m-text-muted);
    text-decoration: line-through;
}

.m-layout-row__moves {
    display: flex;
    align-items: center;
    gap: var(--m-s1);
}

/* The drag handle. touch-action:none is the load-bearing line: without it the
   browser claims the gesture for scrolling and pointermove is never delivered,
   so the row simply refuses to move. It is scoped to the GRIP alone, which is
   what lets the rest of the row still scroll the sheet. */
.m-layout-grip {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--m-tap);
    height: var(--m-tap);
    margin-left: calc(var(--m-s2) * -1);
    color: var(--m-text-faint);
    cursor: grab;
    touch-action: none;
    -webkit-user-select: none;
    user-select: none;
}

.m-layout-row.is-lifted {
    background: var(--m-surface-alt);
    box-shadow: var(--m-shadow-2);
    border-radius: var(--m-r-sm);
}

.m-layout-row.is-lifted .m-layout-grip {
    cursor: grabbing;
}

/* The visibility control. James: clicking the row to toggle "isn't intuitive or
   clear", so it is its own button with its own glyph — an open eye when the
   widget shows, a struck-through one when it does not. */
.m-layout-eye {
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--m-tap);
    height: var(--m-tap);
    background: none;
    border: 0;
    color: var(--m-text);
    cursor: pointer;
}

.m-layout-eye[aria-pressed="false"] {
    color: var(--m-text-faint);
}

/* Keyboard-only reordering. A drag cannot be operated without a pointer, and
   this list must not become the one control a keyboard cannot use — so the
   buttons exist, are focusable, and appear only when focused. */
.m-layout-move--kb {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.m-layout-move--kb:focus-visible {
    position: static;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--m-tap);
    height: var(--m-tap);
    overflow: visible;
    clip-path: none;
    background: var(--m-surface-alt);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-sm);
    color: var(--m-text);
    cursor: pointer;
}

.m-layout-move--kb:disabled {
    opacity: 0.35;
    cursor: default;
}

/* The confirm sheet is centred and has no input, so it must not be pushed. */
.m-sheet--confirm {
    margin-bottom: auto;
    max-height: 92dvh;
}

/* Give the picker a working height even before its results arrive, so the
   search box is never the only thing on screen at the very bottom. */
#m-project-sheet .m-sheet__body {
    min-height: 60vh;
}


/* ==========================================================================
   22. RECEIPT READING PROMPT (Mobile #7)
   ========================================================================== */

.m-read__list {
    display: flex;
    flex-direction: column;
    gap: var(--m-s2);
    margin-top: var(--m-s3);
}

.m-read__row {
    display: flex;
    align-items: flex-start;
    gap: var(--m-s3);
    padding: var(--m-s3);
    background: var(--m-surface-alt);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
}

.m-read__check {
    width: 1.25rem;
    height: 1.25rem;
    flex: 0 0 auto;
    margin-top: 2px;
    accent-color: var(--m-brand);
}

.m-read__field {
    flex: 1 1 auto;
    min-width: 0;
}

.m-read__label {
    display: block;
    font-size: var(--m-fs-sm);
    font-weight: 600;
}

.m-read__value {
    display: block;
    font-family: var(--m-font-num);
    font-size: var(--m-fs-md);
    word-break: break-word;
}

/* What is already typed, shown so Apply is never a blind overwrite. */
.m-read__current {
    display: block;
    margin-top: 2px;
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}

.m-read__warnings:not(:empty) {
    margin-top: var(--m-s4);
    padding: var(--m-s3);
    background: var(--m-warn-bg);
    border-radius: var(--m-r-md);
    color: var(--m-warn);
    font-size: var(--m-fs-sm);
}

.m-read__warnings p + p {
    margin-top: var(--m-s2);
}


/* ==========================================================================
   23. ADMIN — PROJECT PAGE (Mobile App #11)
   ========================================================================== */

/* The read-only identity block. Not editable from a phone, so it reads as a
   heading rather than as a set of disabled fields. */
.m-prjhead__num {
    font-family: var(--m-font-num);
    font-size: var(--m-fs-sm);
    font-weight: 700;
    color: var(--m-brand);
}

.m-prjhead__name {
    margin-top: 2px;
    font-size: var(--m-fs-lg);
    font-weight: 600;
    line-height: var(--m-lh-tight);
}

.m-prjhead__meta {
    margin-top: var(--m-s1);
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

/* A select mid-save. Not a spinner: the control greys and stops responding,
   which is the honest signal that the value is not settled yet. */
.m-select:disabled,
.m-input:disabled {
    opacity: 0.6;
    cursor: progress;
}

/* The Admin screen's two destinations are an <a> and a <button> side by side;
   .m-action already styles both, this just keeps the link from inheriting the
   underline every other anchor gets. */
a.m-action {
    text-decoration: none;
}


/* ==========================================================================
   24. ADMIN — INVOICES (Mobile App #11 Phase B)
   ========================================================================== */

.m-chip__count {
    margin-left: var(--m-s1);
    opacity: 0.75;
    font-variant-numeric: tabular-nums;
}

.m-inv-total {
    margin: 0 0 var(--m-s3);
    padding: 0 var(--m-s1);
}

/* Mobile App #34 - the filter box above the chips. Only the gap is new; the
   field itself is a plain .m-field/.m-input, because a search box that looked
   unlike every other input in the app would be a second thing to learn. */
.m-inv-filter {
    margin-bottom: var(--m-s2);
}

/* Heading over one group of search results. Same job as .m-picker__group but
   not the same rule: that one is sticky inside a scrolling picker panel, and
   a heading that stuck to the top of the whole page under the app bar would
   be wrong here. */
.m-inv-group {
    margin: var(--m-s4) 0 var(--m-s2);
    padding: 0 var(--m-s1);
    font-size: var(--m-fs-xs);
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--m-text-muted);
}

/* The first group sits directly under the chips, which already carry their
   own bottom padding. */
.m-inv-group:first-child {
    margin-top: 0;
}

/* Aging is carried by a left edge rather than a row tint: a tinted row fights
   the amount beside it, and the edge still reads at a glance while scrolling.
   Each band also keeps its day count in the row text, so the colour is never
   the only signal. */
.m-inv--a30 { box-shadow: inset 3px 0 0 0 var(--m-warn); }
.m-inv--a60 { box-shadow: inset 3px 0 0 0 color-mix(in srgb, var(--m-warn) 60%, var(--m-danger)); }
.m-inv--a90 { box-shadow: inset 3px 0 0 0 var(--m-danger); }

.m-inv__paid {
    display: block;
    font-size: var(--m-fs-xs);
    font-weight: 400;
    color: var(--m-text-muted);
}

/* Label/value pairs in the invoice sheet. A definition list because that is
   what it is, which also gives a screen reader the pairing for free. */
.m-deflist {
    margin: 0;
}

.m-deflist__row {
    display: flex;
    align-items: baseline;
    gap: var(--m-s3);
    padding: var(--m-s2) 0;
    border-bottom: 1px solid var(--m-border);
}

.m-deflist__row dt {
    flex: 0 0 8rem;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-deflist__row dd {
    flex: 1 1 auto;
    margin: 0;
    font-variant-numeric: tabular-nums;
    text-align: right;
}

.m-deflist__head {
    margin: var(--m-s5) 0 var(--m-s2);
}


/* ==========================================================================
   25. ADMIN — RECEIVE PAYMENT (Mobile App #11 Phase C)
   ========================================================================== */

/* What recording the payment will DO to the invoice, in a sentence, updated as
   the amount is typed. This is where the "mark as settled" checkbox used to be:
   the server decides from the arithmetic now, so this states the conclusion
   rather than offering a choice that was never really one. */
.m-pay__outcome {
    margin: var(--m-s3) 0 0;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-pay__outcome:empty {
    display: none;
}

/* The deposit number under the summary in the deposit sheet. Quieter than the
   amount above it - it is a receipt for something already done, not a figure
   anybody needs to check. */
.m-deposit__note {
    display: block;
    margin-top: var(--m-s2);
    font-size: var(--m-fs-xs);
    font-weight: 400;
    color: var(--m-text-muted);
}

/* Which invoice, and what is outstanding on it - kept in view while an amount
   is being typed, because "applied here" is meaningless without it. */
.m-pay__summary {
    margin-bottom: var(--m-s4);
    padding: var(--m-s3);
    background: var(--m-surface-alt);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    font-size: var(--m-fs-sm);
}

.m-pay__summary strong {
    display: block;
    font-family: var(--m-font-num);
    font-size: var(--m-fs-lg);
    /* 7.04:1 on --m-surface-alt. This is the number the whole screen is
       about, so it takes the strongest text colour rather than the body one. */
    color: var(--m-text);
}

/* A real checkbox, back after section 16 retired the last one.
   "Mark the invoice as settled" is a genuine boolean that also reveals the
   status select beneath it, and the toggle-button pattern the task flags use
   would misrepresent that: those are two independent glyph states, this is one
   thing that is either true or false and that a screen reader must be able to
   announce as checked. */
.m-check {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    /* Overrides .m-field's column stack - box and label sit side by side. */
    flex-direction: row;
}

.m-check__box {
    width: 1.25rem;
    height: 1.25rem;
    flex: 0 0 auto;
    accent-color: var(--m-brand);
}

/* The label carries the tap target rather than the 20px box: the whole row is
   what a thumb aims at, and a <label for> forwards the tap to the input. */
.m-check__label {
    display: flex;
    align-items: center;
    min-height: var(--m-tap);
    font-size: var(--m-fs-md);
    /* Not --m-field__label's weight: this reads as a sentence, not a caption. */
    font-weight: 400;
    cursor: pointer;
}

/* =========================================================================
   Site photos (Mobile App #91) — view-photos.html / mobile-photos.js
   ========================================================================= */

.m-photos__number {
    color: var(--m-text-muted);
    font-weight: 500;
    margin-right: 0.35em;
}
.m-photos__pickrow {
    margin: 12px 0 0;
    display: flex;
    justify-content: center;
}
.m-section__head--tight { margin-top: 16px; }
.m-section__title--sub { font-size: var(--m-fs-sm); }

/* The camera stage. 3:4 in portrait; capped so the strip stays reachable. */
.m-cam {
    position: relative;
    background: #000;
    border-radius: var(--m-r-md);
    overflow: hidden;
    aspect-ratio: 3 / 4;
    max-height: 62vh;
    width: 100%;
}
.m-cam video {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}
.m-cam--idle video { visibility: hidden; }
.m-cam--flash::after {
    content: "";
    position: absolute;
    inset: 0;
    background: #fff;
    opacity: 0.7;
}

.m-cam__chips {
    position: absolute;
    top: 8px;
    left: 8px;
    right: 8px;
    display: flex;
    justify-content: space-between;
    gap: 8px;
    pointer-events: none;
    z-index: 2;
}
.m-cam__chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    max-width: 60%;
    padding: 4px 10px;
    border-radius: var(--m-r-pill);
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    font-size: var(--m-fs-sm);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.m-cam__dot {
    flex: 0 0 auto;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #9a9a9a;
}
.m-cam__dot--good { background: #3ddc84; }
.m-cam__dot--poor { background: #ffcc00; }
.m-cam__dot--none { background: #ff5b5b; }

.m-cam__start {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 14px;
    padding: 24px;
    text-align: center;
    color: #fff;
    z-index: 1;
}
.m-cam__blurb { margin: 0; font-size: var(--m-fs-sm); max-width: 28em; }
.m-cam__blurb--small { font-size: var(--m-fs-xs); opacity: 0.85; }

.m-cam__controls {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 12px;
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    padding: 0 12px;
    z-index: 2;
}
.m-cam__side { display: flex; justify-content: center; }
.m-cam__sidebtn {
    display: inline-block;
    padding: 8px 12px;
    border: 0;
    border-radius: var(--m-r-pill);
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    font-family: var(--m-font);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    cursor: pointer;
}
.m-cam__shutter {
    width: 72px;
    height: 72px;
    border-radius: 50%;
    border: 4px solid #fff;
    background: rgba(255, 255, 255, 0.35);
    justify-self: center;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-cam__shutter:active { background: #fff; }
.m-cam__shutter:disabled { opacity: 0.5; }

/* Video (2026-09-24). The shutter keeps its place in the controls grid; the Photo | Video switch sits
   above it and the running time below. In video mode the shutter's centre is red; recording turns it
   into a red square, the way a phone's own camera shows "stop". */
/* The switch FLOATS above the shutter, so the shutter row keeps its size and its side buttons stay level
   with it; the zoom bar and lens buttons step up by the switch's height while it is shown
   (.m-cam--modes, set by mobile-photos.js). The timer sits at the top, where a phone's camera puts it. */
.m-cam__shutterwrap { position: relative; justify-self: center; display: grid; }
.m-cam__modes {
    position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
    display: flex; gap: 2px; padding: 2px; border-radius: 999px; background: rgba(0, 0, 0, 0.45);
}
.m-cam--modes .m-cam__zoombar { bottom: 140px; }
.m-cam--modes .m-cam__lenses { bottom: 188px; }
.m-cam--modes.m-cam--full .m-cam__zoombar { bottom: calc(146px + var(--m-safe-b)); }
.m-cam--modes.m-cam--full .m-cam__lenses { bottom: calc(194px + var(--m-safe-b)); }
.m-cam__mode {
    min-height: 32px; padding: 0 12px; border: 0; border-radius: 999px;
    background: transparent; color: #fff; font: inherit; font-size: 0.85rem; cursor: pointer;
}
.m-cam__mode[aria-checked="true"] { background: #fff; color: #111; font-weight: 600; }
.m-cam--video .m-cam__shutter { background: radial-gradient(circle, #ff3b30 0 55%, rgba(255, 255, 255, 0.35) 57%); }
.m-cam--recording .m-cam__shutter { background: rgba(255, 255, 255, 0.35); position: relative; }
.m-cam--recording .m-cam__shutter::after {
    content: ""; position: absolute; inset: 22px; border-radius: 4px; background: #ff3b30;
}
.m-cam--recording .m-cam__modes { visibility: hidden; }
.m-cam__rec {
    position: absolute; top: calc(58px + var(--m-safe-t, 0px)); left: 50%; transform: translateX(-50%); z-index: 3;
    min-width: 4.5em; padding: 2px 8px; border-radius: 4px; text-align: center;
    background: #ff3b30; color: #fff; font-size: 0.85rem; font-variant-numeric: tabular-nums;
}
.m-photo-strip__video {
    position: absolute; top: 4px; left: 4px; padding: 1px 6px; border-radius: 4px;
    background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 0.7rem; line-height: 1.4;
}

/* This walk's shots, newest first, scrolling sideways. */
.m-photo-strip {
    display: flex;
    gap: 8px;
    overflow-x: auto;
    padding: 6px 0 8px;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: thin;
}
.m-photo-strip__empty {
    margin: 0;
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
}
.m-photo-strip__item {
    position: relative;
    flex: 0 0 auto;
    width: 84px;
    height: 84px;
    padding: 0;
    border: 0;
    border-radius: var(--m-r-sm);
    overflow: hidden;
    background: var(--m-surface-sunken);
    cursor: pointer;
}
.m-photo-strip__item img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}
.m-photo-strip__badge {
    position: absolute;
    left: 4px;
    right: 4px;
    bottom: 4px;
    padding: 2px 4px;
    border-radius: var(--m-r-sm);
    background: rgba(0, 0, 0, 0.65);
    color: #fff;
    font-size: var(--m-fs-xs);
    font-weight: 600;
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.m-photo-strip__badge--saved  { background: var(--m-success); }
.m-photo-strip__badge--failed { background: var(--m-danger); }
.m-photo-strip__badge--warn   { background: var(--m-warn); }
.m-photo-strip__progress {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 4px;
    background: var(--m-brand);
}

.m-photo-caption__preview {
    border-radius: var(--m-r-md);
    overflow: hidden;
    background: var(--m-surface-sunken);
    margin-bottom: 8px;
}
.m-photo-caption__preview img {
    display: block;
    width: 100%;
    max-height: 40vh;
    object-fit: contain;
}

/* Walk naming + pinch-zoom (Mobile App #91 iteration) */
.m-walk-rename {
    border: 0;
    background: none;
    padding: 2px 4px;
    color: var(--m-text-muted);
    cursor: pointer;
    line-height: 0;
}
.m-walk-rename svg { width: 18px; height: 18px; }
.m-walk-rename:active { color: var(--m-brand); }
.m-walk-name { display: flex; gap: 6px; margin: 6px 0 0; }
.m-walk-name .m-input { flex: 1 1 auto; min-width: 0; }

/* The review preview pinch-zooms (handled in mobile-photos.js); clip and own the gesture. */
.m-photo-caption__preview { touch-action: none; overflow: hidden; }
.m-photo-caption__preview img { transform-origin: center center; will-change: transform; }
.m-photo-caption__preview.is-zoomed { cursor: grab; }

/* Camera zoom bar (Mobile App #91). Pinch fights the page zoom on iOS, so this is a slider. */
.m-cam__zoombar {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    bottom: 96px;
    z-index: 3;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px;
    border-radius: var(--m-r-pill);
    background: rgba(0, 0, 0, 0.5);
}
.m-cam__zoom { width: 55vw; max-width: 320px; accent-color: #fff; }
.m-cam__zoomval {
    color: #fff;
    font-size: var(--m-fs-sm);
    font-variant-numeric: tabular-nums;
    min-width: 2.6em;
    text-align: right;
}
.m-cam__video, .m-cam video { transform-origin: center center; }

/* --------------------------------------------------------------------------
   Full-screen capture, lens buttons, last shot (2026-09-15)

   James, for a field user: the viewfinder should fill the screen, with a Done
   button to leave it. CSS rather than the Fullscreen API - iPhone Safari has
   none, an installed PWA has no chrome to hide anyway, and a modal <dialog>
   over a natively-fullscreen element is a top-layer question with a different
   answer per engine. The reasoning is kept beside setFullscreen() too.

   Height is --m-vvh (the MEASURED visual viewport, published by mobile.js),
   not dvh: in the standalone PWA dvh overshoots - 812 against an innerHeight
   of 775 on James's phone - and a viewfinder taller than the window puts the
   shutter under the home indicator.
   -------------------------------------------------------------------------- */
.m-cam--full {
    position: fixed;
    inset: 0;
    z-index: 35;                /* over the app chrome, under toasts (40) */
    max-height: none;
    aspect-ratio: auto;
    border-radius: 0;
    /* NO width or height. `inset: 0` already states "the whole of my containing
       block", and stating it twice is what broke this on a real phone: the box
       was pinned to `var(--m-vvh)`, a JS-published measurement that does not
       survive a rotation (see the orientation note in mobile.js). With inset
       alone there is no measurement to go stale, and no second opinion for the
       two to disagree about. */
}

/* THE BARS GO AWAY, and this is not decoration - without it the camera is not
   full screen at all. Measured on James's iPhone, 2026-09-15: the app bar still
   covered the top and the tab bar covered the SHUTTER, so no photo could be
   taken. `.m-cam` lives inside `.m-main`, and a fixed descendant of a scroll
   container is not reliably viewport-relative on iOS - so rather than argue
   about whose containing block it is, the shell is made to agree with the
   camera. With both bars display:none, `.m-main` is the only flex child left
   in a shell exactly one viewport tall, so it IS the viewport; zeroing its
   padding and cap makes its padding box the viewport too. The camera then
   fills the screen whichever box turns out to contain it. */
body.m-camfull .m-appbar,
body.m-camfull .m-tabbar,
body.m-immersive .m-appbar,
body.m-immersive .m-tabbar { display: none; }
body.m-camfull .m-main,
body.m-immersive .m-main {
    padding: 0;
    max-width: none;
    overflow: hidden;
}
/* object-fit stays `cover`: a letterboxed viewfinder is not "fills more of the
   screen", and the CAPTURE is the full sensor frame either way - captureBlob()
   reads the track, never what the preview happens to show. */
body.m-camfull,
body.m-immersive { overflow: hidden; }

.m-cam__done,
.m-cam__expand {
    position: absolute;
    z-index: 4;
    padding: 8px 16px;
    border: 0;
    border-radius: var(--m-r-pill);
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    font-family: var(--m-font);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
/* BOTH live under the chips at the top right, and Done is there for a reason
   found by looking at it: at the bottom right it sat directly on top of "New
   walk", which the bounds checks all passed because each control was inside
   the viewport - they were inside it in the same place. The compass and GPS
   chips own the top row and must never be covered, so this is the row below
   them, clear of the shutter row entirely in both states. */
.m-cam__done,
.m-cam__expand { right: 8px; top: 44px; }
.m-cam--full .m-cam__done,
.m-cam--full .m-cam__last { top: calc(44px + var(--m-safe-t)); }
.m-cam--full .m-cam__chips { top: calc(8px + var(--m-safe-t)); }
.m-cam--full .m-cam__controls { bottom: calc(18px + var(--m-safe-b)); }
.m-cam--full .m-cam__zoombar { bottom: calc(102px + var(--m-safe-b)); }
.m-cam--full .m-cam__lenses  { bottom: calc(150px + var(--m-safe-b)); }

/* Lens buttons (0.5× / 1×). Built at runtime and only when the phone really
   has more than one option - see lensOptions(). */
.m-cam__lenses {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    bottom: 144px;
    z-index: 3;
    display: flex;
    gap: 6px;
    padding: 4px;
    border-radius: var(--m-r-pill);
    background: rgba(0, 0, 0, 0.5);
}
.m-cam__lens {
    min-width: 44px;
    padding: 6px 10px;
    border: 0;
    border-radius: var(--m-r-pill);
    background: transparent;
    color: #fff;
    font-family: var(--m-font);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-cam__lens.is-on {
    background: #fff;
    color: #000;
}

/* LANDSCAPE, FULL SCREEN: the controls go to the right edge, in a column.
   James, 2026-09-15: "move the controls to right justified so the user can
   operate one-handed." Held sideways, the thumb of the hand gripping the phone
   reaches the right edge and nothing else — a shutter centred along the bottom
   is the one place it cannot go. So the bottom row becomes a right-hand strip,
   the DOM order (Library, shutter, New walk) reading top to bottom, with Done
   above it and everything else moved clear of that strip rather than under it.

   `.m-cam__controls` is a grid in portrait; the column direction here is what
   turns the same three children into a stack, so the markup does not fork. */
@media (orientation: landscape) {
    .m-cam--full .m-cam__controls {
        left: auto;
        right: calc(10px + var(--m-safe-r));
        top: 0;
        bottom: 0;
        width: 92px;
        padding: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 14px;
    }
    .m-cam--full .m-cam__done {
        top: calc(10px + var(--m-safe-t));
        right: calc(10px + var(--m-safe-r));
    }
    /* Everything else keeps out of the strip's way. 112px = the strip plus a
       gap, so nothing merely touches it either. */
    .m-cam--full .m-cam__chips { right: calc(112px + var(--m-safe-r)); }
    .m-cam--full .m-cam__zoombar,
    .m-cam--full .m-cam__lenses {
        left: auto;
        right: calc(112px + var(--m-safe-r));
        transform: none;
    }
    .m-cam--full .m-cam__zoombar { bottom: calc(14px + var(--m-safe-b)); }
    .m-cam--full .m-cam__lenses  { bottom: calc(62px + var(--m-safe-b)); }
    /* 55vw of a landscape viewport is half the screen; the slider only has the
       width left of the strip. */
    .m-cam--full .m-cam__zoom { width: 38vw; max-width: 260px; }
    /* Bottom left is the only corner nothing else wants now. */
    .m-cam--full .m-cam__last {
        top: auto;
        bottom: calc(14px + var(--m-safe-b));
        left: calc(10px + var(--m-safe-l));
    }
}

/* The newest shot, corner of the viewfinder. Full screen only: off it, the
   strip is right there below.

   TOP left, mirroring Done on the right, and not the bottom corner a camera
   app would use: the bottom row is Library / shutter / New walk edge to edge,
   and at the bottom left this thumbnail printed itself over the Library
   button. The band between the chips and the zoom bar is the only clear space
   on this screen. */
.m-cam__last {
    position: absolute;
    left: 8px;
    top: 44px;
    z-index: 4;
    width: 52px;
    height: 52px;
    padding: 0;
    border: 2px solid rgba(255, 255, 255, 0.85);
    border-radius: var(--m-r-sm);
    background: #000;
    overflow: hidden;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-cam__last img { width: 100%; height: 100%; object-fit: cover; display: block; }
.m-cam__lastcount {
    position: absolute;
    right: -4px;
    bottom: -4px;
    min-width: 18px;
    padding: 1px 4px;
    border-radius: var(--m-r-pill);
    background: var(--m-brand);
    color: #fff;
    font-size: var(--m-fs-xs);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    text-align: center;
}

/* The project chooser on the New walk tab — the search field and its results
   inline, so the tabs stay on screen while you choose (James, 2026-09-15). The
   rows reuse .m-row / .m-list, which is what the shared picker sheet draws, so
   the two look the same without sharing code. */
.m-photos__projects { margin-top: var(--m-s3); }
/* The field is inset; the list is not. `.m-card` has no horizontal padding
   because its list children run edge to edge, so an input at width:100% landed
   its border a pixel inside the card's and the two read as one doubled line. */
.m-photos__projfind { padding: 0 var(--m-s3); }
.m-photos__projlist { margin-top: var(--m-s2); }
/* Capped and scrolled rather than pushing the tabs off the top: fifteen recents
   plus the overhead codes is taller than a phone. */
.m-photos__projlist {
    max-height: 52vh;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior-y: contain;
}
.m-photos__projlist .m-picker__group:first-child { margin-top: 0; }

/* Mail alerts: one row per watched mailbox, with what it could watch below. */
.m-mailwatch__row {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    padding: var(--m-s3) 0;
    border-bottom: 1px solid var(--m-border);
}
.m-mailwatch__row:last-child { border-bottom: 0; }
.m-mailwatch__text { flex: 1 1 auto; min-width: 0; }
.m-mailwatch__name {
    margin: 0;
    font-size: var(--m-fs-md);
    font-weight: 600;
    overflow-wrap: anywhere;      /* an address has no spaces to break at */
}
.m-mailwatch__meta { margin: 2px 0 0; font-size: var(--m-fs-xs); color: var(--m-text-muted); }
/* A watch that cannot read its mailbox is not a quiet mailbox, and must not
   look like one. */
.m-mailwatch__meta.is-bad { color: var(--m-danger); }

/* The email a payment alert was about, at the top of Receive payment. A quoted
   note rather than a card: it is context for the form below it, not a thing to
   act on. */
.m-payment-mail {
    margin-bottom: var(--m-s3);
    padding: var(--m-s3);
    border-left: 3px solid var(--m-brand);
    border-radius: 0 var(--m-r-sm) var(--m-r-sm) 0;
    background: var(--m-brand-wash);
}
.m-payment-mail__label {
    margin: 0 0 var(--m-s1);
    font-size: var(--m-fs-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--m-text-muted);
}
.m-payment-mail__subject { margin: 0 0 2px; font-size: var(--m-fs-md); font-weight: 600; }
.m-payment-mail__meta { margin: 0; font-size: var(--m-fs-xs); color: var(--m-text-muted); }
.m-payment-mail__read {
    margin: var(--m-s2) 0 0;
    font-size: var(--m-fs-sm);
    font-weight: 600;
    color: var(--m-brand-deep);
    font-variant-numeric: tabular-nums;
}
.m-payment-mail__preview {
    margin: var(--m-s2) 0 0;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
    line-height: 1.45;
    max-height: 7.5em;
    overflow: hidden;
}

/* Site photos: New walk / Browse. Navigation that LOOKS like tabs — see the
   note in _photo_tabs.html for why it is not role="tablist". Equal halves, so
   neither reads as the lesser option, and the current one is filled rather than
   merely underlined: an underline alone is invisible against a segmented
   control on a phone held at arm's length. */
.m-phototabs {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 4px;
    margin-bottom: var(--m-s3);
    padding: 4px;
    border-radius: var(--m-r-pill);
    background: var(--m-bg);
    border: 1px solid var(--m-border);
}
.m-phototabs__tab {
    padding: 9px 12px;
    border-radius: var(--m-r-pill);
    text-align: center;
    text-decoration: none;
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    -webkit-tap-highlight-color: transparent;
}
.m-phototabs__tab.is-on {
    background: var(--m-brand);
    color: var(--m-on-brand);
}

/* ==========================================================================
   Site photos — the GALLERY (Mobile App #91, 2026-09-15) — view-gallery.html

   Browsing and sharing. One screen at a time; the desktop's 60/40 split needs
   a width a phone does not have. Colours come from the tokens, except inside
   the photo screen and the map, which are their own dark surfaces over
   photographs and follow the camera stage's convention.
   ========================================================================== */

.m-gal__screen { display: block; }

/* ---- screen 1: walks ---------------------------------------------------- */
.m-gal__find { margin-bottom: var(--m-s2); }
.m-gal__chips {
    display: flex;
    gap: var(--m-s2);
    overflow-x: auto;
    padding-bottom: var(--m-s2);
    margin-bottom: var(--m-s2);
    scrollbar-width: none;
}
.m-gal__chips::-webkit-scrollbar { display: none; }
.m-gal__chip {
    flex: 0 0 auto;
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-pill);
    background: var(--m-surface);
    color: var(--m-text-muted);
    font: inherit;
    font-size: var(--m-fs-sm);
    font-weight: 600;
    padding: 6px 14px;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-gal__chip.is-on {
    background: var(--m-brand);
    border-color: var(--m-brand);
    color: var(--m-on-brand);
}

.m-gal__walks { display: flex; flex-direction: column; }
.m-gal__walk {
    display: flex;
    align-items: center;
    gap: var(--m-s3);
    width: 100%;
    padding: var(--m-s3) var(--m-s2);
    border: 0;
    border-bottom: 1px solid var(--m-border);
    background: var(--m-surface);
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-gal__walk:first-child { border-top-left-radius: var(--m-r-md); border-top-right-radius: var(--m-r-md); }
.m-gal__walk:active { background: var(--m-brand-wash); }
/* min-width: 0 is what lets the title ellipsis instead of widening the row —
   a flex item's floor is its content otherwise, and a long walk name would
   push the previews off the screen. */
.m-gal__walktext { flex: 1 1 auto; min-width: 0; display: block; }
.m-gal__walktitle {
    display: block;
    font-size: var(--m-fs-md);
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.m-gal__walkmeta {
    display: block;
    margin-top: 2px;
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.m-gal__previews { flex: 0 0 auto; display: flex; gap: 3px; }
.m-gal__previews img {
    width: 34px;
    height: 34px;
    border-radius: var(--m-r-sm);
    object-fit: cover;
    background: var(--m-bg);
}
.m-gal__status { margin: var(--m-s3) 0 0; font-size: var(--m-fs-sm); color: var(--m-text-muted); }
.m-gal__more { margin: var(--m-s3) 0 0; text-align: center; }

/* ---- screen 2: the grid -------------------------------------------------- */
.m-gal__grouphead {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s2) 0;
}
.m-gal__grouptitle {
    flex: 1 1 auto;
    min-width: 0;
    margin: 0;
    font-size: var(--m-fs-sm);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.m-gal__groupnote { flex: 0 0 auto; font-size: var(--m-fs-xs); color: var(--m-text-muted); }
.m-gal__mapbtn {
    flex: 0 0 auto;
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-pill);
    background: var(--m-surface);
    color: var(--m-text);
    font: inherit;
    font-size: var(--m-fs-xs);
    font-weight: 600;
    padding: 5px 12px;
    cursor: pointer;
}
.m-gal__mapbtn:disabled { opacity: 0.45; cursor: default; }

/* Three across, edge to edge: on a 360px screen that is 118px a side, which is
   the smallest a photograph can be and still be recognised. */
.m-gal__grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 2px;
}
.m-gal__cell {
    position: relative;
    aspect-ratio: 1;
    padding: 0;
    border: 0;
    background: var(--m-bg);
    cursor: pointer;
    overflow: hidden;
    -webkit-tap-highlight-color: transparent;
}
.m-gal__cell img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    transition: transform var(--m-dur-fast) ease;
}
.m-gal__tick {
    position: absolute;
    right: 5px;
    top: 5px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    border: 2px solid rgba(255, 255, 255, 0.9);
    background: rgba(0, 0, 0, 0.25);
    opacity: 0;
}
/* The empty circles appear only once picking has started. A hundred of them
   over a wall of photographs is noise; none at all, once a long press has
   armed the mode, leaves no sign that tapping now selects rather than opens. */
.m-gal__grid.is-picking .m-gal__tick { opacity: 1; }
.m-gal__cell.is-picked .m-gal__tick {
    opacity: 1;
    background: var(--m-brand);
    border-color: #fff;
}
.m-gal__cell.is-picked .m-gal__tick::after {
    content: "\2713";
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: #fff;
    font-size: 12px;
    font-weight: 700;
}
/* The picked photo shrinks, which is the only cue that survives a photograph
   of any colour — a tint or a border can vanish into the picture. */
.m-gal__cell.is-picked img { transform: scale(0.86); border-radius: var(--m-r-sm); }
.m-gal__dir {
    position: absolute;
    left: 4px;
    bottom: 4px;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.55);
}
.m-gal__dir--dart { background: #fff; box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.5); }
.m-gal__dir--ring { background: transparent; border: 2px dashed rgba(255, 255, 255, 0.85); }
.m-gal__dir--dot  { background: rgba(255, 255, 255, 0.85); }

/* ---- screen 3: one photo ------------------------------------------------- */
.m-gal__photo { display: flex; flex-direction: column; gap: 0; }
.m-gal__stage {
    position: relative;
    background: #000;
    border-radius: var(--m-r-md) var(--m-r-md) 0 0;
    overflow: hidden;
    display: grid;
    place-items: center;
    min-height: 46vh;
}
.m-gal__stage img { width: 100%; max-height: 60vh; object-fit: contain; display: block; }
.m-gal__stage img[hidden], .m-gal__video[hidden] { display: none; }
.m-gal__video { width: 100%; max-height: 60vh; display: block; background: #000; touch-action: auto; }
.m-gal__photo.is-full .m-gal__video { max-height: none; height: 100%; object-fit: contain; }
.m-gal__chev {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 36px;
    height: 56px;
    border: 0;
    background: rgba(0, 0, 0, 0.42);
    color: #fff;
    font-size: 20px;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-gal__chev:disabled { opacity: 0.25; }
.m-gal__chev--prev { left: 0; border-radius: 0 var(--m-r-sm) var(--m-r-sm) 0; }
.m-gal__chev--next { right: 0; border-radius: var(--m-r-sm) 0 0 var(--m-r-sm); }

/* The camera-roll strip. */
.m-gal__film {
    display: flex;
    gap: 4px;
    overflow-x: auto;
    padding: 6px;
    background: #0b0b0e;
    scrollbar-width: none;
}
.m-gal__film::-webkit-scrollbar { display: none; }
.m-gal__frame {
    flex: 0 0 auto;
    width: 46px;
    height: 46px;
    padding: 0;
    border: 2px solid transparent;
    border-radius: var(--m-r-sm);
    background: #000;
    overflow: hidden;
    opacity: 0.55;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.m-gal__frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
.m-gal__frame.is-on { opacity: 1; border-color: #fff; }

.m-gal__meta {
    background: var(--m-surface);
    border-radius: 0 0 var(--m-r-md) var(--m-r-md);
    padding: var(--m-s3);
}
.m-gal__caption { margin: 0 0 var(--m-s2); font-size: var(--m-fs-md); line-height: 1.4; }
.m-gal__caption.is-empty { color: var(--m-text-muted); font-style: italic; }
.m-gal__facts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: var(--m-s3); }
.m-gal__fact {
    background: var(--m-brand-wash);
    color: var(--m-text-muted);
    border-radius: var(--m-r-pill);
    padding: 3px 10px;
    font-size: var(--m-fs-xs);
    font-variant-numeric: tabular-nums;
}
.m-gal__acts { display: flex; gap: var(--m-s2); }
/* nowrap so a label that does not fit is caught by the test as an overflow
   rather than silently doubling the row's height, which is what "Show on map"
   did before it became "Map". */
.m-gal__acts .m-btn { flex: 1 1 0; min-width: 0; white-space: nowrap; }

/* The photo viewer full screen. James, 2026-09-15: "the photo level viewer
   should be full screen and the user should be able to zoom." Squeezed between
   the app bar, the tabs and the tab bar it was showing a landscape photograph
   about 40% of the screen tall, letterboxed top and bottom, with the action row
   clipped by the tab bar. A photograph is the thing being looked at, so it gets
   the screen. `inset: 0` alone defines the box — no width or height to go stale
   on a rotation, the lesson from the camera. */
.m-gal__photo.is-full {
    position: fixed;
    inset: 0;
    z-index: 35;
    display: flex;
    flex-direction: column;
    background: #000;
    border-radius: 0;
}
.m-gal__photo.is-full .m-gal__stage {
    flex: 1 1 auto;
    min-height: 0;               /* or the picture pushes the strip off the bottom */
    border-radius: 0;
    overflow: hidden;            /* a zoomed photo is panned, not scrolled */
    touch-action: none;          /* the pinch is ours, not the page's */
}
.m-gal__photo.is-full .m-gal__stage img {
    max-height: none;
    width: 100%;
    height: 100%;
    object-fit: contain;
    transform-origin: center center;
    will-change: transform;
}
.m-gal__photo.is-full .m-gal__meta {
    flex: 0 0 auto;
    background: rgba(0, 0, 0, 0.72);
    color: #fff;
    border-radius: 0;
    padding-bottom: calc(var(--m-s3) + var(--m-safe-b));
}
.m-gal__photo.is-full .m-gal__caption { color: #fff; }
.m-gal__photo.is-full .m-gal__caption.is-empty { color: rgba(255, 255, 255, 0.62); }
.m-gal__photo.is-full .m-gal__fact {
    background: rgba(255, 255, 255, 0.14);
    color: #e8e8ee;
}
.m-gal__photo.is-full .m-gal__acts .m-btn { background: rgba(255, 255, 255, 0.14); color: #fff; }
.m-gal__photo.is-full .m-gal__acts .m-btn--primary { background: var(--m-brand); }
.m-gal__photo.is-full .m-gal__film { padding-left: var(--m-safe-l); padding-right: var(--m-safe-r); }

/* Close, top left, clear of the chips a photo does not have — mirrors the
   camera's Done so the two full-screen surfaces are left the same way. */
.m-gal__close {
    position: absolute;
    left: 8px;
    top: calc(8px + var(--m-safe-t));
    z-index: 4;
    padding: 8px 16px;
    border: 0;
    border-radius: var(--m-r-pill);
    background: rgba(0, 0, 0, 0.6);
    color: #fff;
    font: inherit;
    font-size: var(--m-fs-sm);
    font-weight: 600;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
/* While zoomed in, the chevrons would be under a panning finger and a tap on
   one would read as a failed pan. */
.m-gal__stage.is-zoomed .m-gal__chev { display: none; }

/* ---- screen 4: where they were taken -------------------------------------- */
.m-gal__map { display: block; }
/* The real basemap. Same aspect as the fallback plot below it so the screen
   does not jump when Google arrives late — the callback can land after the
   screen is already open. */
.m-gal__gmap {
    aspect-ratio: 1;
    max-height: 64vh;
    border-radius: var(--m-r-md);
    overflow: hidden;
    border: 1px solid var(--m-border);
    background: var(--m-bg);
}
.m-gal__mapnote {
    margin: var(--m-s2) 0 0;
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}
.m-gal__canvas {
    position: relative;
    aspect-ratio: 1;
    max-height: 64vh;
    border-radius: var(--m-r-md);
    background: var(--m-brand-wash);
    border: 1px solid var(--m-border);
    overflow: hidden;
}
.m-gal__pin {
    position: absolute;
    width: 14px;
    height: 14px;
    margin: -7px 0 0 -7px;
    padding: 0;
    border-radius: 50%;
    border: 2px solid var(--m-surface);
    background: var(--m-brand);
    cursor: pointer;
}
.m-gal__pin--ring { background: transparent; border-style: dashed; border-color: var(--m-brand); }
.m-gal__pin--dot  { background: var(--m-text-muted); }
/* THE OPEN PIN IS A DIFFERENT SHAPE, not a different tone. Rendered at the
   density a real walk produces — 34 pins in ~350px — a tint halo is invisible:
   there is always another pin close enough that a wash of colour reads as two
   pins touching rather than as one being marked. So it inverts (white core,
   heavy brand ring) and grows, which survives a crowd. The desktop session hit
   exactly this on its keyplan, on a fixture of two pins that could only ever
   prove "one is light and one is dark". */
.m-gal__pin.is-on {
    width: 20px;
    height: 20px;
    margin: -10px 0 0 -10px;
    background: var(--m-surface);
    border: 4px solid var(--m-brand);
    z-index: 2;
}
.m-gal__dart {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 0;
    height: 0;
    border-left: 3px solid transparent;
    border-right: 3px solid transparent;
    border-bottom: 9px solid var(--m-brand);
    /* ORDER MATTERS AND IT WAS WRONG. This was `rotate(X) translate(-50%,-140%)`,
       which rotates the coordinate frame FIRST and then applies the centring
       inside it — so the offset became directional and every dart flew off at
       its own bearing, detached from the pin it belongs to. Invisible with two
       north-ish pins; unmistakable with thirty-four. Centre, THEN rotate, THEN
       step out along the bearing: the last translate is the only one meant to
       be read in the rotated frame. */
    transform: translate(-50%, -50%) rotate(var(--dart, 0deg)) translateY(-11px);
}
.m-gal__openmaps { position: absolute; right: var(--m-s2); bottom: var(--m-s2); }
.m-gal__legend {
    display: flex;
    flex-wrap: wrap;
    gap: var(--m-s3);
    margin: var(--m-s2) 0 0;
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
}
.m-gal__key {
    display: inline-block;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    vertical-align: -1px;
    margin-right: 3px;
    background: var(--m-brand);
}
.m-gal__key--ring { background: transparent; border: 2px dashed var(--m-brand); }
.m-gal__key--dot  { background: var(--m-text-muted); }

/* ---- the selection bar ----------------------------------------------------
   Fixed to the bottom of the shell, above the tab bar, so it is reachable
   without scrolling back — the count is useless where you cannot act on it. */
.m-gal__selbar {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(var(--m-tabbar-h) + var(--m-safe-b));
    z-index: 25;
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s2) var(--m-s3);
    background: var(--m-surface);
    border-top: 1px solid var(--m-border);
    box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12);
}
.m-gal__count { flex: 1 1 auto; font-size: var(--m-fs-sm); font-weight: 600; }

/* ---- screen 5: what has been sent ----------------------------------------- */
.m-gal__histlink { margin: 0 0 var(--m-s2); }
.m-gal__hist { display: flex; flex-direction: column; }
.m-gal__share {
    padding: var(--m-s3) var(--m-s2);
    background: var(--m-surface);
    border-bottom: 1px solid var(--m-border);
}
.m-gal__sharetitle { margin: 0 0 3px; font-size: var(--m-fs-md); font-weight: 600; }
.m-gal__sharemeta {
    margin: 0 0 var(--m-s2);
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
    display: flex;
    flex-wrap: wrap;
    gap: 7px;
    align-items: center;
}
.m-gal__sharestate {
    border-radius: var(--m-r-pill);
    padding: 1px 8px;
    font-weight: 700;
    font-size: var(--m-fs-xs);
    text-transform: uppercase;
    letter-spacing: 0.04em;
}
/* Three states, not two: an expired link and a revoked one look identical to a
   client and completely different to whoever sent it. */
.m-gal__sharestate--live    { background: var(--m-success-bg); color: var(--m-success); }
.m-gal__sharestate--expired { background: var(--m-warn-bg); color: var(--m-warn); }
.m-gal__sharestate--revoked { background: var(--m-bg); color: var(--m-text-muted); }
.m-gal__shareacts { display: flex; flex-wrap: wrap; gap: 6px; }
.m-gal__shareacts .m-btn { padding: 6px 12px; font-size: var(--m-fs-xs); }

/* ---- the share sheet ------------------------------------------------------ */
.m-gal__picks { display: flex; gap: 6px; overflow-x: auto; padding: 2px 0 var(--m-s2); }
.m-gal__pick { position: relative; flex: 0 0 auto; width: 54px; height: 54px; }
.m-gal__pick img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: var(--m-r-sm);
    display: block;
    background: var(--m-bg);
}
.m-gal__drop {
    position: absolute;
    right: -4px;
    top: -4px;
    width: 20px;
    height: 20px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.72);
    color: #fff;
    font-size: 13px;
    line-height: 1;
    cursor: pointer;
}
.m-gal__sizes { display: flex; gap: 6px; }
.m-gal__size {
    flex: 1 1 0;
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-pill);
    background: var(--m-surface);
    color: var(--m-text-muted);
    font: inherit;
    font-size: var(--m-fs-sm);
    font-weight: 600;
    padding: 7px 4px;
    cursor: pointer;
}
.m-gal__size.is-on { background: var(--m-brand); border-color: var(--m-brand); color: var(--m-on-brand); }

.m-gal__routes { display: flex; flex-direction: column; gap: var(--m-s2); margin: var(--m-s3) 0; }
.m-gal__route {
    display: flex;
    gap: var(--m-s3);
    align-items: flex-start;
    width: 100%;
    padding: var(--m-s3);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    background: var(--m-surface);
    color: inherit;
    font: inherit;
    text-align: left;
    cursor: pointer;
}
.m-gal__route.is-on { border-color: var(--m-brand); background: var(--m-brand-wash); }
.m-gal__radio {
    flex: 0 0 auto;
    position: relative;
    width: 19px;
    height: 19px;
    margin-top: 2px;
    border: 2px solid var(--m-border);
    border-radius: 50%;
}
.m-gal__route.is-on .m-gal__radio { border-color: var(--m-brand); }
.m-gal__route.is-on .m-gal__radio::after {
    content: "";
    position: absolute;
    inset: 3px;
    border-radius: 50%;
    background: var(--m-brand);
}
.m-gal__routetext { min-width: 0; }
.m-gal__routename { display: block; font-size: var(--m-fs-md); font-weight: 600; }
.m-gal__routedesc {
    display: block;
    margin-top: 2px;
    font-size: var(--m-fs-xs);
    color: var(--m-text-muted);
    line-height: 1.45;
}

/* ==========================================================================
   Shortcuts & routines (Mobile App: SHORTCUTS) — view-shortcuts.html
   ========================================================================== */

.m-sc-lede {
    margin: 0 0 var(--m-s4);
    color: var(--m-text);
}

/* A copyable value beside its button. The code wraps rather than scrolls:
   a token is 47 characters and a URL is longer, and a horizontal scroller
   on a phone hides exactly the end a person needs to check. */
.m-sc-copy {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    min-height: var(--m-tap);
}

.m-sc-copy > code {
    flex: 1 1 auto;
    min-width: 0;
    padding: var(--m-s2) var(--m-s3);
    border-radius: var(--m-r-md);
    background: var(--m-surface-sunken);
    font-size: var(--m-fs-sm);
    line-height: 1.4;
    overflow-wrap: anywhere;
    word-break: break-all;
    user-select: all;
    -webkit-user-select: all;
}

.m-sc-copy--token > code {
    font-size: var(--m-fs-md);
    font-weight: 600;
}

.m-sc-copy > .m-btn {
    flex: 0 0 auto;
}

.m-sc-token-slot {
    color: var(--m-text-muted);
}

.m-btn--sm {
    min-height: 2.25rem;
    padding: 0 var(--m-s3);
    font-size: var(--m-fs-sm);
}

.m-sc-row-actions {
    display: flex;
    gap: var(--m-s2);
}

.m-row.is-revoked .m-row__title {
    color: var(--m-text-muted);
    text-decoration: line-through;
}

.m-sc-guide .m-field + .m-field {
    margin-top: var(--m-s4);
}

.m-sc-details {
    margin-top: var(--m-s4);
    border-top: 1px solid var(--m-border);
    padding-top: var(--m-s3);
}

.m-sc-details > summary {
    min-height: var(--m-tap);
    display: flex;
    align-items: center;
    font-weight: 600;
    cursor: pointer;
}

.m-sc-steps {
    padding-left: 1.25rem;
    margin: var(--m-s2) 0;
}

.m-sc-steps > li + li {
    margin-top: var(--m-s3);
}

.m-sc-catalogue {
    margin: var(--m-s2) 0;
}

.m-sc-catalogue > dt {
    font-weight: 600;
    margin-top: var(--m-s3);
}

.m-sc-catalogue > dd {
    margin: var(--m-s1) 0 0;
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
}

/* The builder's step and input rows: one card per row, its controls stacked,
   and a remove button at the top right where a thumb finds it. */
.m-sc-rows {
    display: flex;
    flex-direction: column;
    gap: var(--m-s3);
    margin-bottom: var(--m-s3);
}

.m-sc-row {
    position: relative;
    padding: var(--m-s3);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    background: var(--m-surface-sunken);
}

.m-sc-row__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--m-s2);
    margin-bottom: var(--m-s2);
    font-weight: 600;
}

.m-sc-row .m-field + .m-field {
    margin-top: var(--m-s2);
}

.m-sc-row .m-field__label {
    font-size: var(--m-fs-sm);
}

.m-sc-param--required .m-field__label::after {
    content: " *";
    color: var(--m-danger);
}

.m-sc-result {
    margin: 0;
    padding: var(--m-s3);
    border-radius: var(--m-r-md);
    background: var(--m-success-bg);
    color: var(--m-success);
}

.m-sc-result.is-error {
    background: var(--m-danger-bg);
    color: var(--m-danger);
}

.m-sc-candidates {
    margin: var(--m-s2) 0 0;
    padding-left: 1.25rem;
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
}

.m-sc-candidates:empty {
    display: none;
}

/* ==========================================================================
   RECEIVE PAYMENT — the desk's receipt session (2026-09-17)
   --------------------------------------------------------------------------
   static/scripts/mobile-receive.js draws it. Full height, because it is a
   sitting with steps rather than a quick form: a bottom sheet capped at 92dvh
   would put the rail and the foot on top of each other on a short phone.
   ========================================================================== */

.m-sheet--receive {
    max-width: var(--m-content-max);
    height: calc(100dvh - var(--m-kb, 0px));
    max-height: calc(100dvh - var(--m-kb, 0px));
    border-radius: 0;
    padding-top: var(--m-safe-t);
}

.m-rp-title {
    margin-right: auto;
    min-width: 0;
}

.m-rp-rail {
    display: flex;
    border-bottom: 1px solid var(--m-border);
    background: var(--m-surface);
}

.m-rp-rail button {
    flex: 1 1 0;
    min-height: var(--m-tap);
    border: 0;
    border-bottom: 3px solid transparent;
    background: none;
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
    cursor: pointer;
}

.m-rp-rail button[aria-current="step"] {
    border-bottom-color: var(--m-brand);
    color: var(--m-brand-deep);
    font-weight: 600;
}

.m-rp-rail button.is-done {
    color: var(--m-text);
}

.m-rp-rail button:disabled {
    opacity: 0.45;
    cursor: default;
}

.m-rp-grp,
.m-rp-card {
    margin-bottom: var(--m-s3);
    padding: var(--m-s3);
    border: 1px solid var(--m-border);
    border-radius: var(--m-r-md);
    background: var(--m-surface);
}

.m-rp-grp.is-current {
    border-color: var(--m-brand);
    box-shadow: 0 0 0 1px var(--m-brand);
}

.m-rp-grp.is-recorded,
.m-rp-card.is-recorded {
    background: var(--m-surface-alt);
}

.m-rp-grp__head {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
}

.m-rp-amt {
    font-size: var(--m-fs-lg);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    margin-right: auto;
}

.m-rp-amt-in {
    flex: 1 1 6rem;
    min-width: 0;
    font-variant-numeric: tabular-nums;
}

.m-rp-type {
    flex: 1 1 7rem;
    min-width: 0;
}

.m-rp-chip {
    padding: 2px var(--m-s2);
    border-radius: var(--m-r-pill);
    background: var(--m-brand-tint);
    color: var(--m-brand-deep);
    font-size: var(--m-fs-xs);
    font-weight: 600;
    letter-spacing: 0.04em;
}

.m-rp-x {
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    border: 0;
    border-radius: var(--m-r-pill);
    background: none;
    color: var(--m-text-muted);
    font-size: 1.4rem;
    line-height: 1;
    cursor: pointer;
}

.m-rp-grp__payer {
    margin-top: var(--m-s1);
    font-weight: 500;
    overflow-wrap: anywhere;
}

.m-rp-grp__src {
    margin: 2px 0 var(--m-s2);
    color: var(--m-text-muted);
    font-size: var(--m-fs-xs);
    overflow-wrap: anywhere;
}

.m-rp-hint {
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
    margin: var(--m-s2) 0;
}

.m-rp-hint.is-need {
    color: var(--m-warn);
    font-weight: 500;
}

.m-rp-note {
    margin: var(--m-s2) 0;
    padding: var(--m-s2) var(--m-s3);
    border-radius: var(--m-r-md);
    background: var(--m-warn-bg);
    color: var(--m-warn);
    font-size: var(--m-fs-sm);
}

.m-rp-note.is-bad {
    background: var(--m-danger-bg);
    color: var(--m-danger);
}

.m-rp-line {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s2) 0;
    border-top: 1px solid var(--m-border);
}

.m-rp-line__main {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-width: 0;
}

.m-rp-line__ref {
    font-weight: 500;
    font-variant-numeric: tabular-nums;
}

.m-rp-line__ctx {
    color: var(--m-text-muted);
    font-size: var(--m-fs-xs);
    overflow-wrap: anywhere;
}

.m-rp-line__older {
    color: var(--m-warn);
    font-size: var(--m-fs-xs);
}

.m-rp-line__in {
    flex: 0 0 6.5rem;
    width: 6.5rem;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

.m-rp-line__amt {
    font-variant-numeric: tabular-nums;
}

.m-rp-resid {
    padding-top: var(--m-s2);
    border-top: 1px solid var(--m-border);
    color: var(--m-warn);
    font-size: var(--m-fs-sm);
}

.m-rp-resid.is-zero {
    color: var(--m-success);
}

.m-rp-resid.is-over {
    color: var(--m-danger);
    font-weight: 500;
}

.m-rp-grp__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--m-s2);
    margin-top: var(--m-s2);
}

.m-rp-read {
    position: relative;
}

.m-rp-add {
    margin: var(--m-s2) 0 var(--m-s4);
}

.m-rp-pick__head {
    margin-bottom: var(--m-s2);
    font-weight: 500;
}

.m-rp-pool {
    width: 100%;
    text-align: left;
    border-top: 1px solid var(--m-border);
}

.m-rp-pool .m-row__title,
.m-rp-pool .m-row__meta {
    display: block;
}

/* The whole body scrolls here, so the picker's own 18rem cap would leave a short
   window of results above a screen of empty space. */
#m-rp-res {
    max-height: none;
    overflow: visible;
}

.m-rp-pool.is-taken {
    opacity: 0.55;
}

.m-rp-pool.is-member {
    padding-left: var(--m-s4);
}

.m-rp-set {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--m-s2);
    margin-top: var(--m-s3);
    padding: var(--m-s2) var(--m-s3);
    border-radius: var(--m-r-md);
    background: var(--m-brand-wash);
    font-size: var(--m-fs-sm);
}

.m-rp-f.is-filled .m-input,
.m-rp-f.is-filled .m-select {
    border-color: var(--m-brand);
}

.m-rp-f.is-low .m-input {
    border-color: var(--m-warn);
    background: var(--m-warn-bg);
}

.m-rp-low {
    color: var(--m-warn);
    font-weight: 500;
}

.m-rp-seg {
    display: flex;
    gap: var(--m-s2);
    margin-top: var(--m-s2);
}

.m-rp-seg .m-btn {
    flex: 1 1 0;
    border: 1px solid var(--m-control-border);
    background: var(--m-surface);
    font-size: var(--m-fs-sm);
}

.m-rp-seg__ok[aria-pressed="true"] {
    border-color: var(--m-success);
    background: var(--m-success-bg);
    color: var(--m-success);
    font-weight: 600;
}

.m-rp-seg__bad[aria-pressed="true"] {
    border-color: var(--m-danger);
    background: var(--m-danger-bg);
    color: var(--m-danger);
    font-weight: 600;
}

.m-rp-ok {
    margin-top: var(--m-s2);
    color: var(--m-success);
    font-weight: 500;
}

.m-rp-sum {
    margin-top: var(--m-s3);
}

.m-rp-sum__row {
    display: flex;
    justify-content: space-between;
    padding: var(--m-s1) 0;
    font-size: var(--m-fs-sm);
}

.m-rp-foot {
    flex-wrap: wrap;
}

.m-rp-foot__note {
    flex: 1 0 100%;
    color: var(--m-text-muted);
    font-size: var(--m-fs-xs);
}

.m-rp-foot__note:empty {
    display: none;
}

#m-rp-record > * + * {
    margin-top: var(--m-s3);
}

/* ==========================================================================
   REMINDERS (2026-09-17) — the desk's reminders on the phone
   --------------------------------------------------------------------------
   Rows are drawn by static/scripts/mobile-reminders.js and, for the first
   paint, by templates/_widget_reminders.html. Both produce this shape.
   ========================================================================== */

.m-rem {
    display: flex;
    align-items: flex-start;
    gap: var(--m-s2);
    padding: var(--m-s2) var(--m-s4);
    border-top: 1px solid var(--m-border);
}

.m-list > .m-rem:first-child {
    border-top: 0;
}

.m-rem__tick {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    margin-left: calc(var(--m-s3) * -1);
    border: 0;
    background: none;
    cursor: pointer;
}

/* A box, not a checkbox input: the whole row is a control and a real input
   inside a button would be a nested interactive element. aria-pressed on the
   button is what a screen reader reads. */
.m-rem__box {
    width: 1.25rem;
    height: 1.25rem;
    border: 2px solid var(--m-control-border);
    border-radius: var(--m-r-sm, 6px);
}

.m-rem.is-done .m-rem__box {
    border-color: var(--m-success);
    background: var(--m-success);
}

.m-rem__main {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-width: 0;
    gap: 2px;
    padding: var(--m-s2) 0;
    border: 0;
    background: none;
    text-align: left;
    color: inherit;
    cursor: pointer;
}

.m-rem__text {
    font-size: var(--m-fs-md);
    line-height: var(--m-lh-tight);
    color: var(--m-text);
    overflow-wrap: anywhere;
}

.m-rem.is-done .m-rem__text {
    color: var(--m-text-muted);
    text-decoration: line-through;
}

.m-rem__flag {
    color: var(--m-warn);
}

.m-rem__meta {
    font-size: var(--m-fs-sm);
    color: var(--m-text-muted);
}

.m-rem__snooze {
    flex: 0 0 auto;
    align-self: center;
    min-height: var(--m-tap);
    padding: 0 var(--m-s2);
    border: 0;
    background: none;
    color: var(--m-brand-deep);
    font-size: var(--m-fs-sm);
    font-weight: 600;
    cursor: pointer;
}

.m-rem__add {
    margin: var(--m-s2) var(--m-s4) var(--m-s3);
    width: auto;
}

.m-rem__next {
    margin-top: var(--m-s2);
    color: var(--m-text-muted);
    font-size: var(--m-fs-sm);
}

.m-rem__next:empty {
    display: none;
}

/* Brought level with the desk, 2026-10-01: the envelope on a mail-made row,
   the open count beside the titles (red while something is new), and the
   filter box above the list. */
.m-rem__email {
    flex: 0 0 auto;
    align-self: center;
    min-width: var(--m-tap);
    min-height: var(--m-tap);
    border: 0;
    background: none;
    color: var(--m-brand-deep);
    font-size: var(--m-fs-md);
    cursor: pointer;
}

.m-rem__count {
    margin-left: var(--m-s1);
    vertical-align: middle;
}

.m-rem__count.is-unseen {
    background: var(--m-danger-bg);
    color: var(--m-danger);
}

.m-rem__filter {
    padding: var(--m-s2) var(--m-s4) var(--m-s3);
}

#m-reminder-email,
#m-reminder-stop {
    margin-top: var(--m-s3);
}

/* The unfiled-email strip in Receive payment (2026-09-18). A warning, so it
   carries the warning surface — and it is removed as each row is dealt with. */
.m-rp-unfiled {
    margin-bottom: var(--m-s3);
    padding: var(--m-s2) var(--m-s3);
    border-radius: var(--m-r-md);
    background: var(--m-warn-bg);
    color: var(--m-warn);
}

.m-rp-unfiled__head {
    font-size: var(--m-fs-sm);
    font-weight: 600;
}

.m-rp-unfiled__row {
    display: flex;
    align-items: center;
    gap: var(--m-s2);
    padding: var(--m-s2) 0;
}

.m-rp-unfiled__row + .m-rp-unfiled__row {
    border-top: 1px solid color-mix(in srgb, var(--m-warn) 25%, transparent);
}

/* "Dismiss…" on an inbox card — the destructive one of the two ways to remove a
   card, so it reads as such beside "Leave in the inbox" (the × in the head). */
.m-rp-dismiss {
    color: var(--m-danger);
}
