/* WanderPlan app-level component rules — design system v1
 *
 * Deliberately SEPARATE from tokens.css and rules.css, which are the
 * Claude Design handoff verbatim. Keeping our own rules here means a
 * future handoff revision can be dropped straight in without a merge.
 *
 * Load order: tokens.css -> rules.css -> app.css.
 */

/* ====================================================================
   0 · Tokens the handoff did not ship
   tokens.css covers surfaces, ink, status and party. It has nothing for
   the four things the templates keep reaching for a literal to get:
   a modal backdrop, a hover wash, a translucent panel over map tiles,
   and a shadow colour. Every one of those was written as rgba(0,0,0,x)
   or rgba(255,255,255,x), and a fixed black or white cannot follow a
   theme — which is exactly what made dark mode unreadable.

   These live here rather than in tokens.css because tokens.css is the
   Claude Design handoff verbatim and has to stay droppable-in. Each is
   built with color-mix() over tokens that ALREADY flip, so none of them
   contains a value of its own.

   THE ONE TRAP, recorded so nobody re-derives it: --wp-n-50..950 are
   NOT redefined in the dark block. A raw ramp token therefore keeps its
   light-mode lightness when the surface under it flips. That is a bug
   for text (n-700 on the dark raised surface is 1.9:1) but it is the
   whole point for a scrim: a backdrop must be near-black in BOTH
   themes, so --wp-n-950 is the correct base in light, and in dark the
   darkest token available is --wp-sunken (L 14%).
   ==================================================================== */
:root {
    /* ---- color-scheme -----------------------------------------------
       Nothing in this app declared `color-scheme` anywhere, in any
       stylesheet or template, and everything below is a consequence of
       that. It is invisible to a static check because none of it is
       expressed as a colour we author.

       · base.html sets `html { overflow-y: scroll }`, so EVERY page
         carries a permanent scrollbar. Without color-scheme the UA draws
         it in its light palette — a white gutter down the side of a dark
         page, on every screen.
       · The form-control baseline in section 1 deliberately excludes
         checkbox, radio, file, range and color inputs, and it cannot
         reach a <select>'s option popup or a native date picker at all:
         those are UA-rendered. `type="date"` on the board's start-date
         modal is exactly that case. All of them stayed light-on-light.
       · It is also what makes the UA pick a sane default caret,
         highlight and spin-button colour.

       Why `light` and not `light dark`: `light dark` tells the UA to
       follow the OS, but data-theme is a USER preference that can
       disagree with the OS ("system" is a third, explicit state — see
       the boot script in base.html). `light dark` would leave a user who
       chose light on a dark laptop with dark scrollbars on a light page.
       The resolved theme is the only correct input, so each branch names
       one scheme.

       Interaction with the pre-paint boot script — checked, and it is
       fine: the boot script is inline and synchronous in <head> BEFORE
       the first <link rel=stylesheet>, so data-theme is already on <html>
       by the time this file is even fetched. There is no frame in which
       the dark branch fails to match, and therefore no white-scrollbar
       flash. With JS off, the server-rendered data-theme seed on <html>
       is "light" and this :root branch is the correct answer anyway. If
       the theme is ever flipped AFTER parse (the toggle does this),
       color-scheme re-resolves with the attribute like any other
       selector — the UA repaints the scrollbar and controls live. */
    color-scheme: light;

    /* THE ONE BACKDROP. Modal, bottom sheet, and the board's mobile
       library DRAWER — one surface is being covered so another can be
       read, and that is the same job every time. Replaces
       `bg-black bg-opacity-50` and rgba(0,0,0,.3–.35). Also the right
       backing for a control that sits on top of a photo, since it is a
       dark veil in either theme.

       Generality checked against the drawer case rather than assumed,
       because board.html hand-mixes `var(--wp-n-950) 35%` for
       .library-backdrop and the two scrims disagreed. Panel-vs-backdrop
       separation, drawer at --wp-raised over the board ground:

           this token          5.29:1 light   1.13:1 dark
           n-950 @ 35%         2.43:1 light   1.09:1 dark

       So the hand-mix is worse in BOTH themes, badly so in light, and
       there is nothing about a drawer that wants a lighter veil than a
       modal — it covers more of the page, not less. .library-backdrop
       becomes `background: var(--wp-scrim);` with no other change.
       (board.html:1499 already reads this token for .board-modal-scrim;
       the drawer is the last holdout.) A drawer panel that also moves to
       `bg-overlay` + .wp-overlay-edge gains the same 1.25:1 and the
       hairline the modals get — the surface alone cannot do it in dark. */
    --wp-scrim: color-mix(in srgb, var(--wp-n-950) 60%, transparent);

    /* The foreground that goes ON --wp-scrim. NOT --wp-on-ink: on-ink
       flips to near-black in dark, and the scrim is dark in both themes,
       so on-ink over a scrim is 1.7:1 — the white-on-white bug wearing a
       different hat. --wp-n-0 is stable near-white by virtue of the ramp
       not being redefined, which here is the property we want. */
    --wp-on-scrim: var(--wp-n-0);

    /* Row / button hover and pressed washes. Mixed from --wp-ink, so the
       wash is a dark film in light mode and a light film in dark mode
       without a second definition. Paint on any surface.

       "Any surface" is the whole point and is why these exist alongside
       --wp-sunken, which was what the hovers below used to name. A named
       surface as a hover is a hover that is dead wherever the element
       already sits on that surface: `:hover { background: var(--wp-sunken) }`
       on a control inside a bg-sunken panel changes nothing at all, and
       three of the design system's own buttons were written that way.
       A wash is a DELTA, so it is visible on every ground:

           ink  6% over raised/canvas/sunken/overlay
                            1.125 / 1.124 / 1.123 / 1.125   light
                            1.159 / 1.134 / 1.108 / 1.176   dark
           ink 12%          1.273 / 1.270 / 1.267 / 1.273   light
                            1.384 / 1.340 / 1.291 / 1.410   dark

       Not a contrast requirement — a hover is a state change, not
       information — but it is never zero, which is what it was. */
    --wp-wash-hover:  color-mix(in srgb, var(--wp-ink) 6%, transparent);
    --wp-wash-active: color-mix(in srgb, var(--wp-ink) 12%, transparent);

    /* Translucent panel floating over content we do not control — map
       tiles, a photo. Replaces rgba(255,255,255,0.85). */
    --wp-veil: color-mix(in srgb, var(--wp-raised) 85%, transparent);

    /* Shadow COLOUR, not a whole shadow: keep the geometry the call site
       already has and swap only the rgba(0,0,0,x) for one of these.
       tokens.css's --wp-shadow-overlay stays the right answer for a
       popover; these two are for everything with bespoke geometry. */
    --wp-shadow-color:        color-mix(in srgb, var(--wp-n-950) 12%, transparent);
    --wp-shadow-color-strong: color-mix(in srgb, var(--wp-n-950) 22%, transparent);

    /* The hairline around an --wp-overlay panel. See the dark override for
       why this token has to exist at all; in light the panel already
       separates from the scrimmed backdrop at 5.29:1, so the edge is the
       app's ordinary hairline and nothing more. --wp-border is 3.45:1
       against the light scrimmed backdrop and 1.53:1 against the panel. */
    --wp-overlay-edge: var(--wp-border);
}

html[data-theme="dark"] {
    color-scheme: dark;

    /* Re-measured after the modal panels moved to --wp-overlay, which is
       what the previous note said the separation would have to come from.
       Numbers, on --wp-canvas (L 17.5%) with this scrim over it:

           panel bg-raised  vs scrimmed backdrop   1.13:1   (before)
           panel bg-overlay vs scrimmed backdrop   1.25:1   (now)

       and the ceiling for ANY scrim colour, with an --wp-overlay panel:

           this token   (sunken @ 80%)             1.251:1
           sunken @ 100% (darkest token available)  1.262:1
           a literal pure black @ 100%              1.331:1  — unreachable,
                                                    and forbidden anyway

       So the scrim is 0.011 off what the token vocabulary can reach and
       0.08 off physics. There is no adjustment worth making to the VALUE:
       going opaque buys 0.011 and costs the sense that a page exists
       behind the modal. The adjustment that was actually needed is
       --wp-overlay-edge below, which is what now carries the boundary. */
    --wp-scrim: color-mix(in srgb, var(--wp-sunken) 80%, transparent);

    /* Same physics: a dark shadow on a dark ground is nearly free of
       contrast (1.10:1 at the strong end). Raised alphas recover what
       little is available; a 1px --wp-border is the reliable edge. */
    --wp-shadow-color:        color-mix(in srgb, var(--wp-sunken) 50%, transparent);
    --wp-shadow-color-strong: color-mix(in srgb, var(--wp-sunken) 75%, transparent);

    /* THE ONE THING THAT SEPARATES A MODAL IN DARK.
       Surface and shadow are both spent (above): the panel can reach
       1.25:1 against the backdrop and no more, and --wp-shadow-overlay is
       a near-black shadow cast onto a near-black ground. That leaves the
       edge, and it has to be light enough to read from BOTH sides:

           --wp-border                          1.16:1 vs panel, 1.45 vs backdrop
           ink 30% + overlay                    2.50:1 vs panel, 3.13 vs backdrop
           ink 40% + overlay                    3.38:1 vs panel, 4.23 vs backdrop  <-
           --wp-muted                           6.36:1 vs panel, 7.96 vs backdrop

       40% is the first mix that clears 3:1 on both sides. It reads as a
       deliberate lighter hairline, which is the standard way dark UIs
       express elevation, and it is mixed from tokens so it follows any
       future change to either one. Do not swap it back to --wp-border:
       that is the value that made modal edges invisible in dark. */
    --wp-overlay-edge: color-mix(in srgb, var(--wp-ink) 40%, var(--wp-overlay));
}

/* The modal / sheet edge, as a class so the five board modals and every
   future overlay agree. Radius stays at the call site — panels differ.
   Pair with `bg-overlay` and `shadow-overlay` (both in base.html's
   Tailwind config); box-sizing is border-box under Preflight, so adding
   this does not move anything. */
.wp-overlay-edge { border: 1px solid var(--wp-overlay-edge); }

/* ====================================================================
   1 · Form controls
   96 visible controls live in the template tree and only 12 carry a
   class attribute, so an element-level baseline reaches almost all of
   them at once — and keeps future markup consistent without anyone
   having to remember a class.

   Low specificity on purpose: any Tailwind utility or bespoke class
   already on a control still wins.
   ==================================================================== */
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):not([type="color"]):not([type="submit"]):not([type="button"]),
select,
textarea {
    font-family: inherit;
    font-size: var(--wp-text-body);
    line-height: 1.3;
    color: var(--wp-ink);
    background: var(--wp-raised);
    border: 1px solid var(--wp-border);
    border-radius: var(--wp-radius-md);
    padding: var(--wp-space-1-5) var(--wp-space-2);
    min-height: var(--wp-control-h);
}

@media (pointer: coarse) {
    input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):not([type="color"]):not([type="submit"]):not([type="button"]),
    select,
    textarea {
        min-height: var(--wp-control-h-touch);
        /* 16px stops iOS Safari zooming the viewport on focus. */
        font-size: max(var(--wp-text-body), 1rem);
    }
}

textarea {
    min-height: 4.5rem;
    line-height: var(--wp-lh-body);
    resize: vertical;
}

/* Placeholders are neutral-500: legible enough to read, clearly not a
   value. Never used for real content — see tokens.css contrast notes. */
::placeholder { color: var(--wp-n-500); opacity: 1; }

/* An invalid control says so before it is submitted, not after. Scoped
   to :not(:placeholder-shown) so an untouched empty required field is
   not shouting at the user on first paint. */
input:user-invalid:not(:placeholder-shown),
textarea:user-invalid:not(:placeholder-shown) {
    border-color: var(--wp-status-error);
}

/* ====================================================================
   2 · Buttons
   rules.css already gives every button its hit area and focus ring.
   What it cannot do is make the app's ~40 bespoke button classes agree
   on family, radius and cursor, so those are set at element level and
   the bespoke classes keep their own padding and colour.
   ==================================================================== */
button, [role="button"] {
    font-family: inherit;
    cursor: pointer;
}
button:disabled, [aria-disabled="true"] { cursor: not-allowed; }

/* The design-system button. New markup should use these; the bespoke
   classes stay until their surfaces are rebuilt. */
.wp-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--wp-space-1-5);
    font-size: var(--wp-text-body);
    font-weight: 600;
    letter-spacing: -0.005em;
    line-height: 1.2;
    padding: 0 var(--wp-space-4);
    min-height: var(--wp-control-h);
    border: 1px solid transparent;
    border-radius: var(--wp-radius-md);
    background: var(--wp-raised);
    color: var(--wp-ink);
    text-decoration: none;
}
@media (pointer: coarse) { .wp-btn { min-height: var(--wp-control-h-touch); } }

/* There is no brand hue — every hue is spoken for, so emphasis is
   carried by weight and contrast and the primary action is ink. */
.wp-btn--primary   { background: var(--wp-ink); color: var(--wp-on-ink); }
.wp-btn--secondary { background: var(--wp-raised); border-color: var(--wp-border); }
.wp-btn--danger    { background: transparent; color: var(--wp-status-error);
                     border-color: color-mix(in srgb, var(--wp-status-error) 45%, transparent); }
/* --wp-muted, not --wp-n-700. The neutral ramp is deliberately NOT
   redefined in the dark block of tokens.css — only the semantic surfaces
   are — so a raw --wp-n-* used as text keeps its light-mode lightness when
   the surface under it flips. n-700 (L 40%) on the dark raised surface
   (L 21.5%) is about 1.6:1: a tertiary button whose label you cannot read.
   --wp-muted is the token that means "quiet text" in whichever theme is on. */
.wp-btn--tertiary  { background: transparent; color: var(--wp-muted); border-color: transparent; }
/* Hovers. --wp-wash-hover, not --wp-sunken: see the token's note above —
   a surface-named hover is dead on an element already sitting on that
   surface, and secondary/tertiary buttons live inside bg-sunken panels
   all over this app. The wash is a delta, so it reads on every ground. */
.wp-btn--secondary:hover { background: var(--wp-wash-hover); }
.wp-btn--tertiary:hover  { background: var(--wp-wash-hover); }
.wp-btn--danger:hover    { border-color: var(--wp-status-error); }
.wp-btn--secondary:active,
.wp-btn--tertiary:active { background: var(--wp-wash-active); }

/* The primary button had NO hover rule at all, and the templates that
   hand-rolled it wrote `bg-indigo-600 hover:bg-indigo-700` — base.html
   maps indigo 500 through 950 onto --wp-ink, so that pair is two names
   for one colour and the app's most important control had no feedback.
   --wp-wash-hover cannot help here: the ground IS --wp-ink, so an
   ink wash on it is the same collapse again. The wash has to come from
   the other end of the pair, which flips with the theme for free:
   lifting ink toward --wp-on-ink is LIGHTER in light and DARKER in dark.

       mix(ink 88%, on-ink) vs ink   1.43 light / 1.28 dark
       the --wp-on-ink label on it  11.63 light / 12.66 dark

   88% matches the value already shipped inline on the Open-ticket link
   in components/_sheet_tab_booking.html, so the two agree; new markup
   should use this class rather than repeat the color-mix. */
.wp-btn--primary:hover   { background: color-mix(in srgb, var(--wp-ink) 88%, var(--wp-on-ink)); }
.wp-btn--primary:active  { background: color-mix(in srgb, var(--wp-ink) 80%, var(--wp-on-ink)); }

/* Inline click-to-edit trigger — for a value that sits INSIDE a line of
   text (an activity's date or time), where .wp-editable's block treatment
   and the global 36px control height would break the line.

   Still a <button>: the point of the exercise is a keyboard path, and the
   alternative is reimplementing focus, role and Enter/Space on a span and
   getting one of them wrong. WCAG 2.5.5 exempts targets inline in a
   sentence from the 44px minimum, which is exactly this case; the vertical
   padding on coarse pointers is what makes it thumbable anyway. */
.wp-inline-edit {
    min-height: 0;
    display: inline;
    padding: 0 0.125rem;
    border: 0;
    /* The dashed underline IS the affordance — it is the only thing that
       says this word is editable — so it has to clear the 3:1 UI floor in
       both themes. It was var(--wp-n-400), which is 2.45:1 on --wp-raised
       in LIGHT: under the floor before dark is even considered, and the
       ramp is not redefined in dark, so the same literal grey then sat at
       7.06:1. currentColor is the right base instead of any fixed token,
       because this control is inline INSIDE a line of text and inherits
       that line's colour: the underline tracks whatever it is underlining,
       ink or muted, in either theme. At 55% it measures 3.80:1 light /
       5.38:1 dark against --wp-raised, and stays a hairline rather than a
       second solid rule competing with the word above it. */
    border-bottom: 1px dashed color-mix(in srgb, currentColor 55%, transparent);
    background: transparent;
    font: inherit;
    color: inherit;
    text-align: inherit;
}
/* --wp-wash-hover, not --wp-sunken: this control is inline in body text
   that is frequently already on a sunken panel, where the old rule was a
   no-op. See the token note at the top of this file. */
.wp-inline-edit:hover { background: var(--wp-wash-hover); }
@media (pointer: coarse) {
    .wp-inline-edit { min-height: 0; padding: 0.375rem 0.25rem; }
}

/* ====================================================================
   3 · Feedback that had nowhere to land
   45 error messages are composed server-side and then dropped, because
   HTMX ignores 4xx/5xx bodies by default and nothing rendered them.
   These are the classes those responses swap into #toasts in base.html.
   ==================================================================== */
#toasts {
    position: fixed;
    z-index: 1000;
    top: var(--wp-space-4);
    right: var(--wp-space-4);
    display: flex;
    flex-direction: column;
    gap: var(--wp-space-2);
    pointer-events: none;
}
#toasts > * { pointer-events: auto; }

/* On a phone a toast docks to the bottom: a thumb can reach it, and a
   top toast lands under the notch. */
@media (max-width: 640px) {
    #toasts {
        top: auto;
        left: var(--wp-space-3);
        right: var(--wp-space-3);
        bottom: calc(var(--wp-space-4) + env(safe-area-inset-bottom, 0px));
    }
}

.wp-toast {
    display: flex;
    align-items: center;
    gap: var(--wp-space-2);
    padding: var(--wp-space-2) var(--wp-space-3);
    border-radius: var(--wp-radius-md);
    background: var(--wp-ink);
    color: var(--wp-on-ink);
    font-size: var(--wp-text-body);
    font-weight: 550;
    box-shadow: var(--wp-shadow-overlay);
}
/* --wp-on-ink, not --wp-n-0. The ramp is not redefined in dark but
   --wp-status-error IS: it lightens from L 55% to L 70% so the ring
   stays visible on a dark surface. Near-white text on the light-mode
   red is 5.30:1; on the dark-mode red it is 2.84:1 — an error message
   you cannot read. --wp-on-ink flips to near-black and gives 6.54:1
   there while keeping 5.30:1 in light. */
.wp-toast--error { background: var(--wp-status-error); color: var(--wp-on-ink); }

/* Dismiss. An error toast stays until it is acknowledged, so it needs a way
   out that is reachable by thumb and by keyboard. */
.wp-toast-close {
    margin-left: var(--wp-space-1);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 1.75rem;
    min-height: 1.75rem;
    border: 0;
    border-radius: var(--wp-radius-sm);
    background: transparent;
    color: inherit;
    opacity: 0.75;
}
/* currentColor, not a white literal: a toast is --wp-ink in light (dark
   surface, white wash reads) and --wp-status-error when it is an error, and
   in dark mode --wp-ink is near-white so a white wash on it disappears
   entirely. The close button already inherits the readable foreground, so
   mixing from it gives a hover that works on all three backgrounds. */
.wp-toast-close:hover { opacity: 1; background: color-mix(in srgb, currentColor 15%, transparent); }
.wp-toast-close:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; box-shadow: none; }
@media (pointer: coarse) { .wp-toast-close { min-width: 2.75rem; min-height: 2.75rem; } }

.wp-banner {
    display: flex;
    align-items: flex-start;
    gap: var(--wp-space-2);
    padding: var(--wp-space-2) var(--wp-space-3);
    border: 1px solid;
    border-left-width: 3px;
    border-radius: var(--wp-radius-md);
    font-size: var(--wp-text-body);
}
.wp-banner--error {
    color: var(--wp-status-error);
    border-color: color-mix(in srgb, var(--wp-status-error) 40%, transparent);
    background: color-mix(in srgb, var(--wp-status-error) 7%, var(--wp-raised));
}
.wp-banner--warn {
    color: var(--wp-status-warn);
    border-color: color-mix(in srgb, var(--wp-status-warn) 40%, transparent);
    background: color-mix(in srgb, var(--wp-status-warn) 8%, var(--wp-raised));
}

/* An empty screen is an invitation to act, not an apology. */
.wp-empty {
    text-align: center;
    padding: var(--wp-space-8) var(--wp-space-5);
    border: 1px dashed var(--wp-border);
    border-radius: var(--wp-radius-lg);
}
.wp-empty h3 { font-size: var(--wp-text-section); font-weight: 600; margin: 0 0 var(--wp-space-1); }
.wp-empty p  { font-size: var(--wp-text-body); color: var(--wp-muted); margin: 0 0 var(--wp-space-4); }

/* ====================================================================
   4 · Icon sizing
   Lucide is drawn on a 24px grid with a 2px stroke — 8.3% of the glyph.
   Every call site here hardcoded stroke-width="2" regardless of size, so
   at 12px that stroke was 16.7% and at 10px it was 20%: two and a half
   times the weight the glyph was drawn for. The strokes converge, the
   interior fills in, and it reads as a blob. That, not glyph complexity,
   was the main reason icons were illegible on the board.

   Icons are now >= 24px everywhere (the floor), so stroke 2 is fine. This
   scales it down for anything that slips below, because a CSS rule beats
   a presentation attribute and can therefore fix a call site without
   editing it.

   BOTH RULES ARE GLOBAL AND MUST STAY GLOBAL. claude.md's Icons section
   records the failure they avoid: an icon rule scoped to a parent class
   (`.some-panel svg { ... }`) silently does nothing to the same icon used
   anywhere else, so unscoped instances render at full SVG size — or, here,
   at full stroke weight. Audited 2026-08-12: these are the only icon-sizing
   or icon-stroke rules in this file and neither is parent-scoped. If you add
   one, key it off the element or a class ON the icon, never off an ancestor.
   ==================================================================== */
svg[class*="w-4"], svg[class*="w-3"], svg[class*="w-2"] { stroke-width: 1.5; }
svg[width="12"], svg[width="14"], svg[width="16"] { stroke-width: 1.5; }
