/* VENDORED — do not edit.
   Copied verbatim from icarustoolbox/public/assets/style.css by
   scripts/sync-fieldguide-css.py. Edits belong upstream, in that repo, under
   its own design contract; an edit here is a fork and `--check` will fail.
*/
/* ==========================================================================
   Icarus Field Guide — field-terminal theme.
   Dark drop-pod HUD: deep space-slate panels, ICARUS orange accents,
   angular uppercase headings, subtle scanlines. Single stylesheet.
   ==========================================================================

   PAGE CONTRACT — every page follows this so the site reads as one system.
   (See CLAUDE.md for the prose version.)

     • Structure: page_header($title, $navKey) → content → page_footer()
       (inc/layout.php). Page files never emit their own <head>/nav.
     • One <h1> per page, inside .pagehead (title left, optional primary
       .btn right). Design exceptions: home .herostrip, auth .authbox.
     • Headings: .sech for sections — same size, colour and casing as a "##"
       inside .mdbody, so an authored heading and a generated one read as the
       same rank; .subhead / .subhead2 for sub-levels; .tierhead.tier-N for
       tier-scoped headings.
     • Tables: <table class="datatable"> inside <div class="tablescroll">;
       secondary columns get .col-opt so they fold away on phones.
     • Buttons: .btn / .btn-primary / .btn-ghost, grouped in .btnrow.
       Chips: .chip / .chiprow. Empty states: .empty. Muted text: .dim.
     • Stating a label and its value — anywhere, on any page — is
       .guidefacts / .fact, a rule-bounded strip of borderless cells. A
       mission's "Time limit", an item's "Damage", a build's "Points used"
       and the admin dashboard's "Views today" are all the same widget. Don't
       add a new box class for a number.
     • "Browse everything" pages render all rows once and filter client-side
       with state in the URL hash (items.php, tree.php, guides.php).
     • EVERY value comes from a :root token. Not "colour and spacing come from
       tokens where convenient" — every colour, every font-size, every radius
       and every padding/margin/gap below this block is a var(). The file used
       to carry 81 hex literals, 101 rgba literals, 23 font sizes, 20 radii and
       31 spacing lengths against 20 tokens, which is how six copies of one
       gradient and a 12.5px-beside-12px-beside-13px type ramp happen. The
       acceptance test is a grep: after :root there must be no hex, no rgba, no
       font-size/border-radius/padding/margin/gap literal.
       Three deliberate exceptions, and nothing else:
         - The 33 single-use palette definitions: the .cat- item categories
           and the .c- and .sb-c- sticky colours. Each appears exactly once,
           so each is already its own single definition point.
           (Careful writing this comment: a literal star-slash inside a CSS
           comment ends it. Spelling those selectors with globs closed this
           block early and silently unstyled the entire site.)
         - Lengths above 24px, which are layout dimensions (a 230px column, a
           130px art panel) rather than spacing steps.
         - 1px hairlines and 50% (which means "circle", not a radius step).
       A colour that is a variant of a token is written
       color-mix(in srgb, var(--token) N%, transparent) — never a fresh hex or
       rgba, or the variant stops following the token it came from. NB this
       needs Chrome 111+ / Safari 16.2+ (Mar 2023), which the file already
       required before the sweep.
     • Static styling lives here, not inline. Inline `style=` is only for
       per-element DATA — CSS custom properties (--accent), computed
       positions/sizes, progress widths — never for styling that could be a
       class.
     • ONE class per visual form. A page may not introduce a class that only
       it uses for appearance; page-specific classes are behaviour hooks
       (#itemtable, .itemsempty) or data carriers, nothing else. Collapsed in
       2026-08 and kept collapsed:
         - .chip is the ONLY filter control, with .chip--mono (tier chips) and
           .chip--lg (the talent category picker) as modifiers. .chip--mono and
           .chip--lg were the same control with different corners and type, and
           survive only as aliases in .chip's selector list until every page
           says .chip. .tabs is NOT one of these — it switches sections rather
           than filtering, and stays its own thing.
         - .cardlist is the ONLY grid of cards. .cardlist--map and .cardlist--tight
           differed from it in track width alone, which is now data
           (--card-min), not a second rule.
         - .toolbar is the ONLY strip of controls above a list. .toolbar and
           .toolbar were two names for it on eight pages each — and
           resource_log.php used both, at two different gaps.
         - .sortlist/.sortrow is the ONE drag-to-arrange list, and the one
           form here that only a single page currently renders. It is not a
           .datatable variant: a table cannot nest, and this list's whole
           point is that a row contains the rows inside it. Named for the
           form rather than for admin_navigation.php so the next sortable
           reuses it instead of adding a second.
     • NO OFFSET DECORATION on a box or a button. Nothing that frames or
       accents an element may sit outside its border box — no corner
       brackets, offset outlines, offset drop shadows, corner ticks or bevel
       edges at negative top/left. A mark a pixel proud of the border it
       frames reads as a misaligned second edge, not a frame: the box looks
       nudged up and left. (That is why the HUD corner brackets came off in
       2026-08 — see the note above .card/.tile/.mapcard.) Emphasis goes
       INSIDE the box: border-color, background, or a flush ring
       (box-shadow: 0 0 0 1px …, spread only, both offsets zero). Focus
       rings are the exception — outline/outline-offset is the platform's
       own affordance and is meant to be detached.
     • A BORDERED BOX HAS ONE BORDER WEIGHT on all four sides. The same
       defect comes from inside the border box when one edge is fatter than
       the rest (a 2px accent border-top, a 3px accent border-left) or a
       second rule is laid along one (inset 0 -2px under a 1px border): at
       the corners the heavy edge overshoots the light one and the box reads
       as sitting off its own outline. Carry an accent by tinting the WHOLE
       border — color-mix(in srgb, var(--accent) 38%, var(--line)) — as
       .subtree, .tiergroup and .itemtile now do. A one-sided rail is fine
       only with no other border to mismatch: .sech, .tierhead and .mdbody
       blockquote are rails on borderless text, and .tsheet's border-top is
       a full-bleed bar's only edge.
     • Mission guides render only the sections a mission has data for, so the
       set varies per page. Each section must therefore stand on its own
       without a fixed running order: .stepflow (walkthrough, numbered by CSS
       counter so it can't drift from row order), .difflevels (what each drop
       difficulty changes), .hintlist, and .gridref — the single treatment for
       a map cell wherever one appears. .sechnote on a .sech distinguishes the
       game's two difficulty axes: the facts row states how hard the mission
       IS ("Rated: Hard"), tables keyed by the level you PICK say "by drop
       difficulty".
     • .gridref HAS TWO FORMS AND THEY MUST NOT LOOK ALIKE: bare, it is a
       value; inside a.gridlink it is a control that opens that 504 m square
       on the interactive map. For the life of the site they rendered
       identically — `text-decoration: none` over the same chip — so the only
       thing advertising a mission guide's 23 clickable cells was a hover
       state and a cursor, both of which need the reader to have guessed
       first. The link form wears a pin inside its own border box and a
       tinted border/fill (never a fattened edge — see the one-border-weight
       rule above), and states it in WORDS too: ui_gridref() writes the
       title/aria-label, ui_gridref_hint() the one line above the section.
       Both forms are built by ui_gridref() (inc/ui.php) — a hand-written
       <a class="gridlink"> is what to grep for after any change here.
   ========================================================================== */

:root {
  --bg: #0c0f12;
  --bg2: #11161b;
  --panel: #161d24;
  --panel2: #1c242d;
  --line: #29343f;
  --text: #d7dee6;
  --dim: #8b98a5;
  --orange: #e8862a;
  --orange-hi: #ffa94d;
  --green: #7fc25c;
  --red: #e06052;
  --blue: #58a6d8;
  --t1: #8bc34a;
  --t2: #58a6d8;
  --t3: #b07fe0;
  --t4: #e8862a;
  --t5: #e0556f;
  --to: #ffd75e;
  --mono: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;
  /* .wrap's side padding. Shared so full-bleed furniture (.pinbar) and the
     .topbar header can match it exactly instead of hardcoding a value that
     drifts at other widths. */
  --wrap-pad: 18px;

  /* The X on the search field's clear button. A data: URI is the only way to
     hand ::-webkit-search-cancel-button a shape, and it is masked rather than
     drawn, so the colour comes from a token like everything else. */
  --x-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M1 1l10 10M11 1L1 11' stroke='%23000' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E");

  /* ---- surfaces and rules ------------------------------------------------
     The tokens above name the six things the site is MADE of. The ones below
     name the things it was previously spelling out by hand: before this, the
     stylesheet carried 81 distinct hex literals against 20 tokens, so "the
     chrome is too dark" meant finding six copies of one gradient and hoping
     you found all six. Every literal below appeared at least twice. */

  /* The chrome bar. One vertical gradient shared by .topbar, the footer,
     .pinbar, .usermenu-panel, .navgroup-panel and mapview's toolbar — it was
     written out identically in all six places. */
  --bar-1: #12181f;
  --bar-2: #0e1318;

  /* Rules, lightest to strongest. --line (above) is a box's own border;
     --line-soft is the hairline BETWEEN rows inside a box (datatable rows,
     cell dividers, task lists), which is deliberately darker than the border
     so a table doesn't read as a grid of boxes. */
  --line-soft: #1a222b;
  --line-faint: #2c3742;
  --line-hi: #384654;

  /* Wells — surfaces that sit BELOW the panel plane, not above it. */
  --void: #05070a;    /* deepest: the map canvas, icon outline shadows */
  --well: #0a0d10;    /* an icon's backing plate */
  --well-2: #0b1015;  /* talent tooltip / rank panel */
  --well-3: #10151a;  /* the gridded backdrop behind the tech tree */

  /* Stops for the two radial "lift" glows (body backdrop, .tnode hover). */
  --lift-1: #182029;
  --lift-2: #141c24;

  /* Text ramp. --text and --dim (above) are the two everyday weights; these
     are the step above --text (a lede) and the two below --dim (a watermark,
     and the grey that means "no value here"). */
  --text-2: #bfcad4;
  --text-faint: #45525f;
  --text-absent: #3a4652;

  /* Ink ON a warm fill — the dark brown used for text sitting on --orange,
     on --red and on the primary button. Brown rather than black so it reads
     as printed on the colour instead of punched out of it. The two differ by
     three RGB units and are collapse candidates; kept apart here only so this
     pass changes no pixel. */
  --ink-warm: #17110a;
  --ink-warm-2: #14100a;

  /* The primary button, which is a gradient plus a border plus a hover
     gradient — five literals across three rules before this. */
  --btn-1: #f09a3e;
  --btn-2: #d9781f;
  --btn-line: #b4641a;
  --btn-1-hi: #ffb35c;
  --btn-2-hi: #ea8b2c;

  /* The talent tree's brown-gold prerequisite wiring. .edge draws it and
     three other rules had to match it by eye — two of them said so in a
     comment, which is the tell that it wanted to be a token. */
  --wire: #6b5a2f;

  /* The sticky board is a deliberately LIGHT surface inside a dark site, so
     it carries its own small ramp instead of borrowing the page's. */
  --board-bg: #f4f6f8;
  --board-line: #e2e6eb;
  --board-ink: #57606b;
  --board-dim: #9aa4b0;
  --ink-paper: #33302a;  /* ink on a sticky note */

  /* ---- type scale --------------------------------------------------------
     216 font-size declarations used to name 23 different sizes, 56 of them on
     a half-step (12.5px next to 12px next to 13px). Nothing chose those
     half-steps deliberately — they are what happens when each page picks a
     size by eye. Eleven steps, and a page picks the step, not a number. */
  --fs-micro: 10px;   /* .cardtype, tier badges, the workshop flag */
  --fs-xs: 11px;      /* dense table meta, chip lettering          */
  --fs-sm: 12px;      /* captions, .dim runs — the commonest size  */
  --fs-md: 13px;      /* dense UI default                          */
  --fs-base: 15px;    /* body copy; matches the body rule          */
  /* 16px is load-bearing and NOT interchangeable with --fs-base: iOS Safari
     zooms the viewport when a focused input's text is under 16px, which is why
     .searchbox/.stickyedit/.bigsearch step up to it on small screens. Keep
     any input at or above this. */
  --fs-lg: 16px;
  --fs-xl: 17px;      /* .card h2/h3, .list-row h3, .avatar's initial */
  --fs-2xl: 20px;     /* .sech, session dates, route arrows        */
  --fs-3xl: 26px;     /* h1                                        */
  --fs-hero: 30px;    /* .pagehead h1 at wide widths               */
  --fs-ghost: 34px;   /* placeholder glyphs: the dashed "no image"
                         box, .avatar-lg's initial                 */

  /* ---- radius ------------------------------------------------------------
     Was 20 distinct values, including 5px beside 4px and 7px beside 6px. */
  --r-xs: 3px;
  --r-sm: 4px;
  --r-md: 6px;
  --r-lg: 8px;
  --r-xl: 16px;       /* the sticky board's soft corner — its own step,
                         because rounding it to a pill would round the board */
  --r-pill: 999px;    /* chips and toggles — was 11/20/22px by hand */

  /* ---- spacing -----------------------------------------------------------
     ~800 padding/margin/gap components named 31 different lengths. The design
     is really a 2px system — 6, 8, 10, 12, 14 and 16 carry 400 of those uses —
     with about 105 odd-numbered strays (3, 5, 7, 9, 11, 13, 15) that are just
     where somebody nudged a value once. The strays round up to the next step;
     the even ones are exact, so the site's density is unchanged.
     Lengths above 24px are layout dimensions rather than spacing steps and
     stay literal on purpose. */
  --sp-px: 1px;   /* hairline offsets, not a spacing step */
  --sp-1: 2px;
  --sp-2: 4px;
  --sp-3: 6px;
  --sp-4: 8px;
  --sp-5: 10px;
  --sp-6: 12px;
  --sp-7: 14px;
  --sp-8: 16px;
  --sp-9: 18px;
  --sp-10: 20px;
  --sp-11: 24px;

  /* The header's line box, in WHOLE PIXELS. Everything in .topbar is centred
     on one line, so every box in it has to be a whole number of pixels tall or
     nothing in it can land on the pixel grid: body copy is 1.55, which at the
     bar's 12px and 13px type gives line boxes of 18.6px and 20.15px, so the
     bar came out 49.59px tall, its centre line fell on 24.297 and every item
     rounded to whichever side of it the rasteriser preferred — the avatar 0.3
     up, the bell 0.27 down, and the whole thing changing with device pixel
     ratio. These two are the same heights to the nearest pixel (30px nav
     entries, a 32px Feedback button, a 38px Log in button), so the bar keeps
     the height it had; they are only made exact.
     --lh-bar-btn is the .btn one: 13px type in the .navuser button, which is
     what sets the logged-out bar's height. */
  --lh-bar: 18px;
  --lh-bar-btn: 20px;

  /* THE BAR IS NOT AS TALL AS ITS BOX, AND CENTRING IN THE BOX IS THEREFORE
     NOT CENTRING. .topbar paints TWO rows below its background — the 1px
     border, and the 1px accent the box-shadow lays under it (`0 1px 0`, in
     both themes) — and it paints nothing at all above, because the bar's top
     edge is the top of the window. Read off the screen a column at a time, the
     chrome runs y0…y49 on a 49px bar: 50 painted rows, whose middle is 25,
     while the content box centres everything on 24.
     So every item in the bar sat a pixel high, and the wordmark's caps — half
     a pixel high inside their own box on top of that — sat 1.5px high, which
     is where it stops being arithmetic and starts being visible. The content
     goes down by half the edge's weight: one hairline, added to the padding
     above and taken off the padding below, so the box height, --topbar-h and
     the measured breakpoints are all unchanged.
     Kill the accent line and this becomes 0. Add a top edge and it becomes 0
     too. It is half of (bottom edge − top edge), and it is measured off the
     PAINTED pixels, not off the box model. */
  --bar-edge: 1px;

  /* ---- the rest of the ramp ----------------------------------------------
     Six greys lived between --dim and --text-faint, in pairs one RGB unit
     apart (#7d8894 beside #6f7a86, #5b6670 beside #55626f, #4a5561 beside
     #4a5560). Nobody can see those differences; they are three steps. */
  --text-3: #7d8894;
  --text-4: #5b6670;
  --text-5: #4a5560;
  --text-bright: #c3ccd6;
  --ink-light: #fff;     /* text on a saturated fill */
  --ink-ok: #cdeab3;
  --ink-err: #f0b0a8;
  --done: #1e7d32;       /* a completed task's fill */
  --map-label: #f0e6d2;  /* place names burned onto map art */

  /* Black and white overlays — shadows, scrims and sheens. Was 20 black
     alphas and 10 white ones, which is one decision ("how dark is a shadow")
     spelled thirty ways. */
  --shade-1: rgba(0, 0, 0, .1);
  --shade-2: rgba(0, 0, 0, .15);
  --shade-3: rgba(0, 0, 0, .25);
  --shade-4: rgba(0, 0, 0, .35);
  --shade-5: rgba(0, 0, 0, .5);
  --shade-6: rgba(0, 0, 0, .6);
  --shade-7: rgba(0, 0, 0, .8);
  --shade-8: rgba(0, 0, 0, .95);
  --sheen-1: rgba(255, 255, 255, .015);
  --sheen-2: rgba(255, 255, 255, .04);
  --sheen-3: rgba(255, 255, 255, .16);
  --sheen-4: rgba(255, 255, 255, .35);
  --sheen-5: rgba(255, 255, 255, .55);

  --panel3: #262f39;  /* between --panel2 and --line: the tier group header */
  --dust: #796f57;    /* the warm haze over the field-journal backdrop       */
  --paper: #c7c0b1;
  --moss: #aeb477;
  --sand: #c79b62;
  --rule: color-mix(in srgb, var(--paper) 18%, transparent);

  /* The site's one caret, as an image. A <select> is a replaced element — it
     can hold neither a child <svg> nor a ::after — so .rateselect cannot use
     the <svg class="navcaret"> nav_links_html() emits, and a text glyph is
     ruled out for the reason recorded there: where a font puts ▾ inside its
     line box swings 2.1px across the five sans faces on this box. Same 6x6
     viewBox and the same path, so the header's caret and this one are one
     shape. It repeats --dim's value because a data URI cannot read a custom
     property; that is the whole reason it is defined up here rather than at
     its use site — one definition point, next to the token it copies. */
  --caret-dn: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6 6"><path d="M0 0h6L3 6z" fill="%238b98a5"/></svg>');
}

* { box-sizing: border-box; }
html {
  -webkit-text-size-adjust: 100%; scrollbar-gutter: stable; overflow-y: scroll; background: var(--bg);
  scrollbar-color: var(--line) var(--bg2);
  /* .topbar is position:sticky at top:0, so an in-page anchor jump landed the
     target UNDERNEATH it and the thing you clicked to reach was the one thing
     off screen. Site-wide rather than per-anchor: guides.php and tree.php's
     hash filters and unit.php's Settings links all jump, and the next one will
     too. Measured, not guessed — signed in the bar is 45px at 1099, 49px on a
     desktop, 53px at 768 and 51px on a phone; signed out it is 55px at its
     tallest, because the Log in button is bigger than the avatar. 64px clears
     that with a margin of breathing room above the target. A layout dimension
     over 24px, which is the stylesheet's second stated exception to the token
     rule.
     These used to be 45.6 and 49.6: the bar's height is now a whole number at
     every width, because everything in it is centred on one line and a bar
     whose centre falls on 24.297 cannot put anything on the pixel grid. See
     --lh-bar. Re-measure here if that changes. */
  scroll-padding-top: 64px;
}
body {
  margin: 0;
  min-height: 100vh;
  /* One restrained expedition-manual haze. Dense data pages stay calm rather
     than paying for terminal scanlines that the field-journal layer later
     replaced in the cascade. */
  background: radial-gradient(1000px 540px at 78% -15%, color-mix(in srgb, var(--dust) 13%, transparent), transparent 65%), var(--bg);
  background-size: auto;
  color: var(--text);
  font: 15px/1.55 system-ui, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--orange-hi); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre { font-family: var(--mono); background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r-sm); }
/* INLINE CODE HAS TO BE ALLOWED TO BREAK. A path, a URL or a JSON payload is one
   unbreakable "word", and prose has no scroll container to give it: on a phone a
   single `Icarus/Saved/PlayerBanList.txt` inside a <li> measured 303px in a 252px
   column, and a JSON example 505px in 322px — each pushing the PAGE wider than
   the window and giving the whole site a horizontal scrollbar.
   `anywhere` rather than `break-word`: only `anywhere` also shrinks the
   element's min-content contribution, and it is that contribution — not the
   painted overflow — that forces the ancestors wide. With `break-word` the
   min-content stays at the full unbroken width and the page still scrolls.
   <pre> is untouched: it keeps `white-space: pre`, under which no wrapping
   happens at all, so block code still reads as written and scrolls in its own
   box via the overflow-x below. */
code { padding: var(--sp-px) var(--sp-3); overflow-wrap: anywhere; }
/* Not in a table cell, though. A column already has .tablescroll for its width,
   and `anywhere` lowers min-content — which is what the table's own algorithm
   sizes columns from, so letting it apply here re-apportioned every column in
   the sheet rather than fixing anything. Cells keep the normal rules. */
td code, th code { overflow-wrap: normal; }
pre { padding: var(--sp-5) var(--sp-6); overflow-x: auto; }
h1, h2, h3 { line-height: 1.2; }
h1 { font-size: var(--fs-3xl); margin: var(--sp-3) 0 var(--sp-4); letter-spacing: .5px; }
img { max-width: 100%; }
mark { background: color-mix(in srgb, var(--orange) 35%, transparent); color: inherit; border-radius: var(--r-xs); }
::selection { background: color-mix(in srgb, var(--orange) 35%, transparent); color: var(--ink-light); }
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: var(--bg2); }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: var(--r-md); border: 2px solid var(--bg2); }
::-webkit-scrollbar-thumb:hover { background: var(--line-hi); }

/* ------------------------------------------------------------- topbar */
/* line-height is set here for the whole bar, not per entry: everything in it
   is centred on one line, and a line box that is not a whole number of pixels
   puts its own box — and therefore the bar, and therefore the bar's centre
   line — on a fraction, where every glyph inside rounds to whichever side the
   rasteriser picks. See --lh-bar. It inherits into the phone drawer and the
   dropdown panels on purpose; they are the same chrome. */
/* Skip link — the first thing in the tab order, jumping past everything
   .topbar renders (brand, eight nav entries, their menus, the account block)
   to <main id="main">. It borrows .btn/.btn-primary for appearance, so this
   rule carries only PLACEMENT and adds no new visual form.

   Off the top of the viewport until focused, and moved with `transform`
   rather than `top` so it reserves no space and shifts nothing when it
   appears. position: fixed keeps it out of .topbar's flex row entirely.
   This is not the offset decoration the design contract forbids — nothing
   here frames or accents a box; the element simply sits somewhere else until
   it is wanted. z-index clears the bar's 50 and stays under .modalscrim's
   200, where focus is trapped anyway. */
.skiplink {
  position: fixed; z-index: 100;
  left: var(--wrap-pad); top: var(--wrap-pad);
  transform: translateY(calc(-100% - var(--wrap-pad) * 2));
}
.skiplink:focus { transform: none; }
/* The skip TARGET takes focus programmatically (tabindex="-1"), so a ring on
   it would draw a box around the entire page content to announce a jump the
   reader just asked for. The bar-clearing scroll position is the confirmation;
   html{scroll-padding-top} supplies it. */
.wrap:focus { outline: none; }

.topbar {
  display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
  line-height: var(--lh-bar);
  /* 8px both ways, shifted down by --bar-edge — the padding is asymmetric so
     the CONTENT is not. See --bar-edge: the bar's bottom edge paints 2px and
     its top paints none. The pair still sums to 16px, so the bar's height is
     the one it was measured at. */
  padding: calc(var(--sp-4) + var(--bar-edge))
           max(var(--wrap-pad), calc((100% - 1200px) / 2 + var(--wrap-pad)))
           calc(var(--sp-4) - var(--bar-edge));
  background: linear-gradient(180deg, var(--bar-1), var(--bar-2));
  border-bottom: 1px solid var(--line);
  box-shadow: 0 1px 0 color-mix(in srgb, var(--sand) 22%, transparent), 0 5px 14px var(--shade-3);
  position: sticky; top: 0; z-index: 50;
}
/* No line-height:1 here. It made the wordmark's box its own 17px font-size —
   an ODD number inside the bar's even content box, so the one thing that reads
   as the site's name was the one thing centred on a half pixel, and its caps
   sat ~0.7px below every nav label beside them. The bar's --lh-bar covers it;
   .brand's height is the 30px mark either way. */
.brand { display: flex; align-items: center; gap: var(--sp-5); color: var(--orange); min-width: 0; }
.brand:hover { text-decoration: none; }
.brand-mark { display: block; width: 30px; height: 30px; flex: 0 0 30px; align-self: center; }
.brand-word { font-weight: 300; letter-spacing: 2.4px; font-size: var(--fs-xl); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.brand-word b { color: var(--orange); font-weight: 700; }
.mainnav { display: flex; flex-wrap: wrap; gap: var(--sp-3); }
.mainnav a {
  padding: var(--sp-3) var(--sp-3); color: var(--dim); border-radius: var(--r-sm);
  font-size: var(--fs-sm); letter-spacing: .7px; text-transform: uppercase; font-weight: 600;
}
.mainnav a:hover { color: var(--text); background: var(--panel); text-decoration: none; }
.mainnav a.active { color: var(--orange); border-bottom: 2px solid var(--orange); border-radius: var(--r-sm) var(--r-sm) 0 0; }
.navuser { display: flex; align-items: center; gap: var(--sp-5); margin-left: auto; }
/* The logged-out button is the tallest thing in the bar, so it is what makes
   the bar's height even and its centre line whole. 20px is its natural 20.15px
   line box to the nearest pixel — the button, and the bar, keep the height
   they had. Scoped to .btn-primary, not to .btn: .feedbackbtn is also a .btn
   in here, and it is 12px type that wants the bar's own 18px line box. */
.navuser .btn-primary { line-height: var(--lh-bar-btn); }
/* .feedbackbtn is a .btn variant and lives with the other ones, below —
   a rule up here would lose the cascade to .btn's own background/colour. */

/* nav dropdown groups (Items ▾, Ops ▾) — <details> disclosures */
.navgroup { position: relative; }
.navgroup > summary {
  list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-3); color: var(--dim); border-radius: var(--r-sm);
  font-size: var(--fs-sm); letter-spacing: .7px; text-transform: uppercase; font-weight: 600;
}
.navgroup > summary::-webkit-details-marker { display: none; }
.navgroup > summary:hover { color: var(--text); background: var(--panel); }
/* Same "you are here" mark as .mainnav a.active, because it means the same
   thing. A menu used to signal it with orange text alone, which was survivable
   while the menus held only secondary pages — once the alteration bench, builds
   and the crew tools moved inside, the mark you get for a page depended on
   whether its entry happened to be a link or a <summary>. */
.navgroup.active > summary {
  color: var(--orange); border-bottom: 2px solid var(--orange);
  border-radius: var(--r-sm) var(--r-sm) 0 0;
}
/* An SVG, not a glyph — see nav_links_html(). Sized in px because it is a
   mark rather than type: 6x6 is the ▾ it replaced (which inks 5.75x5.75 at
   --fs-micro and takes 5.7px of the row), and a box that is exactly the
   triangle centres on the bar's line with nothing to round. flex:0 0 auto so
   the summary's own flex can't stretch it. Literal lengths, like .avatar-sm's
   circle and .navtoggle's square — this is the size of a drawn thing, not a
   step on the spacing ramp. */
.navcaret { width: 6px; height: 6px; flex: 0 0 auto; opacity: .8; transition: transform .15s; }
.navgroup[open] .navcaret { transform: rotate(180deg); }
.navgroup-panel {
  position: absolute; left: 0; top: calc(100% + 6px); z-index: 60;
  min-width: 180px; display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-3);
  background: linear-gradient(180deg, var(--bar-1), var(--bar-2));
  border: 1px solid var(--line); border-radius: var(--r-md); box-shadow: 0 8px 22px var(--shade-5);
}
.navgroup-panel a {
  padding: var(--sp-4) var(--sp-6); border-radius: var(--r-sm); color: var(--dim);
  font-size: var(--fs-sm); letter-spacing: 1px; text-transform: uppercase; font-weight: 600; white-space: nowrap;
}
.navgroup-panel a:hover { color: var(--text); background: var(--panel); text-decoration: none; }
/* Third and last place the same mark appears: the open panel's own current
   entry. .mainnav a.active, .navgroup.active > summary and this rule are one
   mark on three widgets, so "you are here" doesn't change shape as you go a
   level down. Safe as a bottom border here because the panel gaps its rows —
   the 2px sits under the entry with space beneath it, not between two of them,
   which is the reading that made the drawer restate this back to a hairline. */
.navgroup-panel a.active {
  color: var(--orange); border-bottom: 2px solid var(--orange);
  border-radius: var(--r-sm) var(--r-sm) 0 0;
}

/* The one-row bar. The floor is 1105px, bisected with Playwright against the
   LIVE arrangement (2026-09-08): seven top-level entries, three of them menus,
   two of those created in admin_navigation.php. Logged-out is the wider case —
   its "Log in / Register" button outweighs the avatar plus Feedback — and it is
   the one the pair follows. See the 2026-09-08 note below for how this figure
   was derived, and for why it is ~200px more conservative than an honest
   measurement of this row would give.

   The historical note this paragraph used to carry, about the button wrapping
   to two lines and the floor being the width where it came back to one, is
   retired: .navuser is flex-shrink: 0 (see below), so it cannot wrap at any
   width. What the bisection measures now is the links alone. The principle it
   stood for is the one that still decides the number — a header that has
   quietly degraded has not fitted.

   MEASURE THE RIGHT FAILURE. Past the floor the nowrap links spill out of
   .mainnav and paint over .navuser; they do not wrap, because .topbar is
   flex-wrap:nowrap up here. So the test is `.mainnav.scrollWidth >
   clientWidth` and "last link's right edge past .navuser's left edge", and
   two other things that look like the failure are not:

     * .brand-word truncating is the DESIGNED degradation, not a break — it
       carries text-overflow:ellipsis and .brand carries min-width:0 precisely
       so that the wordmark is what gives and the links are not. Counting its
       ellipsis as a failure measures the wrong element and never converges.
     * .navuser sits ~4px off the row baseline at every width (its .btn is
       taller than a nav link), so a top-alignment check reports "wrapped" at
       1440px as readily as at 1000px.

   A bisection that only asks "is everything still on one line" passes straight
   through the real floor and reports ~1001px, which is 96px optimistic.

   Two further conditions, both inherited: measure with the drawer's OWN
   typography neutralised (below the breakpoint the block at the foot of this
   file gives .mainnav links drawer padding and a larger font, so a naive
   bisection measures the drawer and reports ~70px too wide), and on the LAYOUT
   width rather than window.innerWidth. Both figures assume the smaller
   .brand-word, which is why that rule keeps its own 1230px query below rather
   than moving with the drawer.

   The old floor was 1001px, set when the bar was six top-level entries; the
   arrangement has since grown to eight without the pair being re-measured, so
   between 1001 and 1098px the links painted over the account block. Before
   that it was 1231px against ~1192px measured, back when nine top-level
   entries ran Builds, Compare and the Alteration bench flat between the
   reference pages.

   admin_navigation.php's own "Row fits down to" figure says 1049px and is not
   wrong — it is the LOGGED-IN measurement, which is all that page can ever
   take, since you have to be signed in as an admin to be looking at it. The
   pair has to clear the logged-out case too, so it follows this number.
   Re-measure BOTH after adding or renaming a top-level entry. */
/* RE-MEASURED 2026-08-20: 1099/1098 -> 1113/1112. The pair went stale again,
   the same way and for the same reason the note above records — the top level
   is arranged at runtime in admin_navigation.php and the CSS cannot know it
   moved. Bisected signed-out (the wider case) against the arrangement in
   nav_order at the time: the row first fits at a viewport of 1113px, so
   1099-1112px was a 14px band where the wordmark ellipsised and the links
   overflowed the account block.

   It is a SMALLER failure than the one fixed alongside it, and worth telling
   apart. The account block used to absorb the overflow by wrapping its button,
   which made the BAR 75px tall at every width from 1280 to 1920 — .navuser is
   flex-shrink: 0 now (see below), so an over-full row can only ever spill the
   links, which is what this pair measures and what navorder.js reports.

   Re-measure after ANY change to the top level, in code or in nav_order, and
   in all five places: these two queries, the <noscript> fallback and the
   matchMedia guard in layout.php, and DRAWER_FLOOR in navorder.js. */
/* RE-MEASURED 2026-08-24: 1113/1112 -> 1181/1180, for a change in CODE this
   time rather than a drag — `uplink` joined nav_definition(), because that
   table is now the controller for the Uplink pages' header as well as this
   site's (see the entry's own note in inc/layout.php). One entry cost 68px, and
   68px of a bar this full is a band, not a nudge: 1113-1180 was where the links
   painted over "Log in / Register".

   Signed out 1181, signed in 1175, both bisected on the viewport — which is
   what a media query matches. That distinction is worth keeping straight,
   because the note above measures the LAYOUT width for admin_navigation.php's
   budget and the two differ by the scrollbar: at a viewport of 1181 the layout
   width is 1166. Set the queries in viewport terms, as this pair and every pair
   before it is.

   The cost lands on real windows — a 1152px laptop and a half-screen 1280 both
   get the drawer now, where they used to get the row. That is the arrangement's
   price, and it is refundable in admin_navigation.php: drag an entry into a
   menu or out of the bar and re-measure.

   RE-MEASURED 2026-09-08: 1181/1180 -> 1105/1104, and this time the refund was
   taken. Stickies moved into the Crew menu — 16 human views in 30 days, all 16
   signed in, fewer than about.php, against a top-level slot 69px wide plus its
   6px gap. Measured two independent ways that agreed on the saving: bisected
   against the real stylesheet with only the two breakpoint numbers patched in
   flight (982 -> 906 viewport), and summed from the laid-out boxes the way
   navorder.js does (1205 -> 1129 with the wordmark forced visible). 76px both
   times, so 1181 - 76 = 1105.

   AND THE PAIR HAS BEEN ~200PX CONSERVATIVE SINCE IT WAS FIRST SET, BECAUSE
   EVERY MEASUREMENT OF IT HAS ASSUMED A WORDMARK THAT IS NOT ON SCREEN. The
   max-width:1230px block below sets `.brand-word { display: none }`, so at
   every width this pair can possibly apply to, the brand is the 30px mark
   alone and not the 253px lockup. Bisected honestly — real stylesheet, real
   viewport, wordmark hidden as it actually is — the seven-entry row fits down
   to a viewport of 906 and the eight-entry row fitted to 982. The DELTA was
   applied rather than the absolute, deliberately: dropping the pair to 906
   would hand a 950px window a seven-entry desktop bar, which is a design
   decision about how narrow this row may get and not the "move one entry"
   this change was. Whoever wants that reduction should take it on purpose.
   Re-measure with the wordmark hidden and you will get 906, not 1105; that is
   not a regression, it is this note. */
@media (min-width: 1105px) {
  .topbar { flex-wrap: nowrap; }
  .mainnav { flex: 1 1 auto; flex-wrap: nowrap; min-width: 0; align-items: center; }
  .mainnav a, .navgroup > summary { white-space: nowrap; }

  /* THE ACCOUNT BLOCK IS NOT COMPRESSIBLE, so an over-full bar degrades in one
     visible way instead of two silent ones.

     It had the flex default, shrink: 1. So once the top level grew past what
     the 1200px column holds, the row took its overflow out of .navuser — and
     the widest thing in there is the logged-out "Log in / Register" button,
     which wrapped to two lines and took the BAR from 49px to 75px. Measured
     2026-08-20 at eight top-level entries: 75px at 1920, 1600, 1440 and 1280,
     i.e. at every desktop width, because .topbar's content is capped at the
     1200px column and never gets wider. A half-height-again header on every
     page, and nothing reported it.

     That is the worst failure mode available here, because it does not look
     like "too many nav entries" — it looks like the header is broken. With
     shrink: 0 the links are what give instead, which is legible as what it is
     and is what navorder.js's figure on admin_navigation.php describes. */
  .navuser { flex-shrink: 0; }

  /* THE ACTIVE MARK IS RESERVED ON EVERY ENTRY AND TRADED AGAINST THE PADDING,
     never added on top of it. The 2px orange underline used to be extra height
     that only the current entry carried, and .mainnav is a flex row, so that
     one entry set the row's height and every other link STRETCHED to match it
     with its own text pinned at the top of the stretched box. The whole nav's
     labels therefore rode 1px above the bar's centre line — while the wordmark
     and the avatar, which are direct .topbar children and centred by its own
     align-items, did not. A header where the links sit a pixel higher than the
     brand next to them is the "nudged up and to the left" reading the corner
     brackets were removed for, arrived at from the other direction.

     A transparent border on every entry costs the same 2px whatever the state
     is, so the mark can no longer move anything, and taking those 2px out of
     padding-bottom keeps the entry the height it already was — the bar's own
     height is unchanged and --topbar-h, the 1099px floor and the measured
     scroll-padding all still hold.

     It also puts ONE MARK AT ONE HEIGHT. A <summary> is not the flex item its
     <details> is, so it never stretched with the links: an active menu drew
     the same 2px underline 2px higher than an active link did. Same mark, same
     meaning, two positions — the defect the three .active rules above exist to
     avoid. Both entries are now the same box, so both marks land on one line.

     align-items:center is what holds this if a future entry is genuinely
     taller: the others centre on it instead of stretching and top-pinning
     their text again. All of it is scoped to the one-row bar — below 1098px
     .mainnav is a stacked drawer that restates its own padding and states
     "you are here" with an inset rail rather than an underline. */
  .mainnav a, .navgroup > summary {
    padding-bottom: var(--sp-2); border-bottom: 2px solid transparent;
  }

  /* THE BRAND ALIGNS TO THE LINKS' BASELINE, NOT TO THE BAR'S CENTRE LINE.
     Everything else in the bar is centred and should be. The wordmark is the
     one place where two sizes of type sit side by side on one row — 17px (16px
     below 1231) beside the links' 12px — and centring each of them on the same
     line is precisely what puts their baselines in different places: a bigger
     font centred in the same line box has a lower baseline. Measured as ink,
     the wordmark's baseline lands 2.125px below the links', at BOTH its sizes.
     The eye reads a row of type as a row of type and aligns it on the
     baseline, so a wordmark whose capitals are perfectly centred still reads
     as sitting low next to the links — which is what it was doing, and what no
     amount of centring it better was ever going to fix.
     THE MARK DOES NOT RISE WITH IT. It is a picture, not a letter: it has no
     baseline to share, so the row's baseline says nothing about where it
     belongs, and the bar's centre line does. Lifted with the word it sat 2.5px
     above that line and read as floating. So the word takes the baseline and
     the mark takes the centre — the two rules that are each true of the thing
     they apply to.
     position/top, not a margin: this must move paint and nothing else. A
     margin would grow .brand's outer box past the 32px Feedback button, and
     the bar's height is what --topbar-h, scroll-padding-top and the measured
     breakpoints are all pinned to.
     Scoped to the one-row bar for the reason it exists: below 1099px the links
     are in the drawer, there is no row of type to align with, and the brand
     goes back to being centred like everything else. */
  .brand-word { position: relative; top: -2px; }
}

/* hamburger — desktop hides it; the phone block below takes over the nav */
.navtoggle {
  display: none; align-items: center; justify-content: center;
  width: 40px; height: 40px; margin-left: -8px; padding: 0;
  background: none; border: 1px solid transparent; border-radius: var(--r-md); cursor: pointer;
}
.navtoggle:hover { border-color: var(--line); }
.navtoggle-bars, .navtoggle-bars::before, .navtoggle-bars::after {
  display: block; width: 19px; height: 2px; border-radius: var(--r-xs);
  background: var(--text); transition: transform .18s ease, opacity .18s ease;
}
.navtoggle-bars { position: relative; }
.navtoggle-bars::before, .navtoggle-bars::after { content: ""; position: absolute; left: 0; }
.navtoggle-bars::before { top: -6px; }
.navtoggle-bars::after { top: 6px; }
.navopen .navtoggle-bars { background: transparent; }
.navopen .navtoggle-bars::before { transform: translateY(6px) rotate(45deg); background: var(--orange); }
.navopen .navtoggle-bars::after { transform: translateY(-6px) rotate(-45deg); background: var(--orange); }
.navscrim { display: none; }
.navsearch { display: flex; color: var(--dim); }
.navsearch:hover { color: var(--orange); }
/* Its own line box rather than the bar's, because .bellcount hangs off this
   element's corners: at --lh-bar the box would close in on the glyph and take
   the badge 3px down with it. 24px is the 24.8px box it already had, to the
   nearest whole pixel.
   The bottom pad is the same lever .avatar uses on its initial, for the same
   reason and with the same caveat — it is an observation, not arithmetic. A
   bell is bottom-heavy: the body fills the lower two thirds and the handle is
   a thin loop above it, so a glyph whose BOX is centred still reads low. Its
   ink box sat 0.56px below the bar's centre line and its ink CENTROID 1.65px
   below; 1px up (a 2px pad on a centred box) lands between the two, which is
   where the eye puts the centre of a shape like this. Measured on Noto Color
   Emoji, but every emoji set draws a bell the same way round, so the sign of
   this correction is not a local fact even though its size roughly is. */
.bell { position: relative; font-size: var(--fs-lg); line-height: var(--sp-11); padding-bottom: var(--sp-1); }
.bell.has { filter: drop-shadow(0 0 4px color-mix(in srgb, var(--orange) 80%, transparent)); }
.bellcount {
  position: absolute; top: -6px; right: -9px; background: var(--orange); color: var(--ink-warm-2);
  font-size: var(--fs-micro); font-weight: 700; border-radius: var(--r-lg); padding: 0 var(--sp-3);
}

/* user avatar dropdown */
.usermenu { position: relative; }
.usermenu > summary { list-style: none; cursor: pointer; display: flex; }
.usermenu > summary::-webkit-details-marker { display: none; }
/* Without a picture the circle holds the initial, so that letter IS the avatar
   and is sized as a fraction of the circle (~0.55 of the diameter at each size)
   rather than left at body copy. line-height:1 makes the centred line box the
   glyph's own em box — at 22px the inherited 1.6 line box was taller than the
   circle it was being centred in — and letter-spacing:0 keeps the tracking's
   trailing space from pushing the one letter off centre. */
.avatar {
  display: flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; border-radius: 50%;
  background: var(--panel2); border: 1px solid var(--line); color: var(--orange);
  font-size: var(--fs-xl); font-weight: 700; line-height: 1;
  letter-spacing: 0; text-transform: uppercase;
}
/* Centring the line box is not the same as centring the letter, and the
   difference is a whole pixel. A text baseline is snapped to the pixel grid,
   so the cap lands where that rounding puts it: measured against the circle's
   own edges at 8x device scale, the ink sat 0.9px BELOW centre in the 30px
   circle AND in the 72px one — small, but this glyph is alone inside a ring,
   which is the one place a pixel of asymmetry is legible.
   The bottom pad shortens the content box the letter is centred in, which is
   the only lever with sub-pixel reach here. It is per size and NOT
   proportional, because it only bites when it pushes the centring across the
   snap: at 72px the free space is already whole, so 1px changed nothing and
   2px was needed, where 30px moved on 1px. Measured after: 0.1px and 0.06px
   high, at device ratios 1, 2 and 8 alike. .avatar-sm is deliberately left at
   0 — 13px lettering sits only 0.4px low and the smallest nudge available
   overshoots it 0.6px the other way.
   Re-measure these if the type scale or the circle sizes move; they are
   observations, not arithmetic. Only the letter form is padded: with a picture
   the <img> fills the CONTENT box, so a pad would open a strip of circle
   background under it. */
.avatar:not(:has(img)) { padding-bottom: 1px; }
.avatar-lg:not(:has(img)) { padding-bottom: 2px; }
.avatar-sm:not(:has(img)) { padding-bottom: 0; }
.usermenu[open] > summary .avatar,
.usermenu > summary:hover .avatar { border-color: var(--orange); }
/* With an uploaded picture the circle holds an <img> instead of the initial.
   store_avatar() already writes a square, so object-fit is belt-and-braces for
   any file that predates it. overflow:hidden is what actually rounds it. */
.avatar { overflow: hidden; flex: 0 0 auto; }
.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* account.php's preview — same circle, bigger. */
.avatar-lg { width: 72px; height: 72px; font-size: var(--fs-ghost); }
/* unit.php roster — the avatar replaces the ◈ that used to prefix each name. */
.avatar-sm { width: 22px; height: 22px; font-size: var(--fs-md); }
.rostername { display: flex; align-items: center; gap: var(--sp-4); }
/* "by <Prospector>" bylines on build cards and pages. Inline-flex so the
   circle sits on the text baseline row inside .cardmeta's own flex. */
.byline { display: inline-flex; align-items: center; gap: var(--sp-3); }
/* Leaders on a public Unit profile. Not .chip: that one is an interactive
   filter control (cursor:pointer, hover state) and these are labels. The
   asymmetric padding lets the avatar sit flush in the pill's left end. */
.userchip {
  display: inline-flex; align-items: center; gap: var(--sp-4);
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-pill);
  color: var(--text); font-size: var(--fs-sm); padding: var(--sp-2) var(--sp-7) var(--sp-2) var(--sp-2);
}
.userchip .role { color: var(--dim); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .8px; }
.avatarrow { display: flex; align-items: flex-start; gap: var(--sp-9); flex-wrap: wrap; }
.avatarforms { flex: 1 1 260px; }
.avatarforms .toolbar { margin-top: var(--sp-4); }
.usermenu-panel {
  position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
  min-width: 160px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1);
  background: linear-gradient(180deg, var(--bar-1), var(--bar-2));
  border: 1px solid var(--line); border-radius: var(--r-md);
  box-shadow: 0 8px 22px var(--shade-5);
}
.usermenu-name {
  color: var(--dim); font-size: var(--fs-md); padding: var(--sp-3) var(--sp-5);
  border-bottom: 1px solid var(--line); margin-bottom: var(--sp-2);
}
.usermenu-panel a {
  color: var(--dim); font-size: var(--fs-md); padding: var(--sp-4) var(--sp-5); border-radius: var(--r-sm);
}
.usermenu-panel a:hover { color: var(--text); background: var(--panel); text-decoration: none; }
.usermenu-panel a.active { color: var(--orange); }

/* --------------------------------------------------------------- layout */
.wrap { max-width: 1200px; margin: 0 auto; padding: var(--sp-9) var(--wrap-pad) 60px; }
/* --text-3, not --text-4: this line is 11px, uppercase and letterspaced — the
   hardest shape to read on the site — and --text-4 measures 3.28:1 on --bg,
   under the 4.5:1 AA floor for text, on the footer of ~3,200 pages. --text-3 is
   5.33:1. The step the footer links rely on survives: they are --text at
   14.17:1, so the contrast BETWEEN them is 2.7:1 rather than 4.3:1, which is
   still a clear break and is now backed by an underline (see the design
   contract's note on .footlink) rather than by colour alone. */
.sitefoot {
  text-align: center; color: var(--text-3); font-size: var(--fs-xs); letter-spacing: 2px;
  text-transform: uppercase; padding: var(--sp-10); border-top: 1px solid var(--line);
}
.footstatus {
  display: inline-block; width: 6px; height: 6px; margin-right: var(--sp-4); border-radius: 50%;
  background: var(--green); box-shadow: 0 0 5px var(--green); vertical-align: 1px;
  animation: footpulse 2.6s ease-in-out infinite;
}
/* A LINK HAS TO LOOK LIKE ONE WITHOUT BEING POINTED AT. These were
   `color: inherit` with an :hover recolour and nothing else, sitting inside a
   run of interpuncts that also carries brand flavour text — so About the data,
   Changelog and Terms rendered identically to the words around them and were
   distinguishable only by hovering, which a touch device cannot do at all.
   Those three are the site's "who makes this and is it current" pages and the
   only route to them from any page.

   Colour AND an underline, because colour alone is not an affordance: the
   footer is --text-4 on --bg and lifting a link to --text is a contrast step a
   reader has to notice against neighbouring text to read as a difference. The
   underline is offset (--sp-2) clear of the 2px letter-spacing so it does not
   crowd the uppercase glyphs. */
.footlink {
  color: var(--text); text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--text) 45%, transparent);
  text-underline-offset: var(--sp-2);
}
.footlink:hover, .footlink:focus-visible {
  color: var(--orange-hi); text-decoration-color: currentColor;
}
@keyframes footpulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .footstatus { animation: none; } }
/* A .pagehead's action group carries no margin of its own — the head already
   sets its spacing, and .btnrow's default margin pushed the button a few
   pixels below the h1's optical line. Visible once ui_pagehead() started
   wrapping single actions in .btnrow the way the contract asks. */
.pagehead .btnrow { margin: 0; }
.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-7); flex-wrap: wrap; margin-bottom: var(--sp-6); }
/* The title block takes whatever the action group leaves, and no more. Its
   default flex-basis is auto, i.e. the max-content width of the sub — which a
   .pagesub-wide head has no measure to cap, so the head's hypothetical width
   blew past the column and flex-wrap dropped the button onto its own line at
   the LEFT margin (altbench.php's "+ New build"). Basis 0 means the block
   never forces that wrap, and min-width:0 lets it actually take the width it
   is given rather than its longest unbreakable word. */
.pagehead > div:first-child { flex: 1 1 0; min-width: 0; }
/* Intro line sitting directly under a .pagehead <h1>. */
.pagesub { margin: var(--sp-1) 0 0; }
.twocol { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
/* Grid/flex children default to min-width:auto, i.e. never narrower than their
   content's own minimum — a long <select>, item name or other unbroken text
   would otherwise force the column wider than the page and break every margin
   below it. min-width:0 lets them actually respect the column they're given. */
.twocol > * { min-width: 0; }
@media (max-width: 860px) { .twocol { grid-template-columns: 1fr; } }
/* Section headings read as headings: the same size, colour and casing a "##"
   gets inside .mdbody, so a generated section ("Objective manifest") and an
   authored one ("Hints & tips") on the same page look like the same rank
   instead of one shouting in 13px caps above the other. 24px is .mdbody h2's
   computed size — 1.5em of the 16px body — kept literal here because .sech
   sits outside .mdbody and would otherwise scale off a different parent. */
.sech {
  font-size: var(--fs-3xl); letter-spacing: .4px; color: var(--orange-hi);
  border-bottom: 1px solid var(--line); padding-bottom: var(--sp-3); margin: 26px 0 var(--sp-6);
}
.sech::before { content: "▸ "; opacity: .75; }
.tierhead {
  font-size: var(--fs-base); letter-spacing: 1.6px; text-transform: uppercase;
  border-left: 3px solid var(--orange); padding-left: var(--sp-5); margin: 26px 0 var(--sp-6);
}
.tierhead.tier-1 { border-color: var(--t1); }
.tierhead.tier-2 { border-color: var(--t2); }
.tierhead.tier-3 { border-color: var(--t3); }
.tierhead.tier-4 { border-color: var(--t4); }
.tierhead.tier-5 { border-color: var(--t5); }
.tierhead.tier-o { border-color: var(--to); }
/* The untiered set — world resources, which carry no tech tier at all. Same
   dim treatment tier_badge() and the tier chips already give it. */
.tierhead.tier-g { border-color: var(--dim); }

/* progression.php: one tier per section, prose under the tier's own rail. No
   border of its own, so nothing here can mismatch a border weight — the rail on
   .tierhead is a left edge on borderless text, which is the case the page
   contract explicitly allows. */
.progtier { margin: 0 0 28px; }
.progtier > p { max-width: 70ch; }

/* "Generated from the game's own data — last changed <date>" — the freshness
   line on item, creature, guide, map and pillar pages. A rule above it separates
   it from the content it is describing without boxing it. */
.updatedline {
  margin-top: 28px; padding-top: var(--sp-5); border-top: 1px solid var(--line-soft);
  font-size: var(--fs-sm);
}

/* guides.php's server-rendered missions-by-planet index. The chips are plain
   names in volume (170+), so they get a tighter row than the default .chiprow
   gap, which is tuned for a handful of facets. */
.missionindex { gap: var(--sp-3); margin-bottom: var(--sp-5); }
.missionindex-group { margin-bottom: var(--sp-9); }
.missionindex-more > summary {
  display: inline-block; color: var(--orange-hi); cursor: pointer;
  font-size: var(--fs-sm); letter-spacing: .4px;
}
.missionindex-more > summary:hover { color: var(--orange); }
.missionindex-more[open] > summary { margin-bottom: var(--sp-5); }
/* A sub-heading has to be nearer what it heads than what it follows, or the
   groups it is dividing run together. It sat at 16px above and 8px below, and
   once the forms under it stopped paying their spacing twice (see .stackform)
   the gap between two SECTIONS was no bigger than the gap between two fields
   inside one — ten settings groups reading as one undivided column. 24px above
   against 8px below binds the heading to what follows it. .sech gets that for
   free from its own border-bottom; .subhead carries no rule, so the space is
   the only signal it has. */
.subhead {
  font-size: var(--fs-sm); letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--dim); margin: var(--sp-11) 0 var(--sp-4);
}
.subhead2 {
  font-size: var(--fs-md); font-weight: 600; margin: var(--sp-6) 0 var(--sp-3);
}
.subhead2 .dim { font-weight: 400; font-size: var(--fs-sm); }
.dim { color: var(--dim); }
.empty { color: var(--dim); font-style: italic; padding: var(--sp-7) var(--sp-2); }
.lede { font-size: var(--fs-lg); color: var(--text-2); }
.spacer { flex: 1; }
.flash { padding: var(--sp-5) var(--sp-7); border-radius: var(--r-sm); margin-bottom: var(--sp-7); border: 1px solid; }
.flash-ok { background: color-mix(in srgb, var(--green) 12%, transparent); border-color: color-mix(in srgb, var(--green) 40%, transparent); color: #b9e2a3; }
.flash-err { background: color-mix(in srgb, var(--red) 12%, transparent); border-color: color-mix(in srgb, var(--red) 45%, transparent); color: var(--ink-err); }

/* Positioning context for the boxes' own absolutely-placed parts (.herostrip's
   starfield, .modalclose). Nothing decorative hangs off it.

   NO OFFSET DECORATION ON A BOX OR BUTTON. This used to carry "HUD corner
   brackets": 2px orange Ls pinned at top:-1px/left:-1px and
   bottom:-1px/right:-1px, so they sat a pixel PROUD of the border they were
   meant to frame. At every size and zoom that reads as a second, misaligned
   edge — the box looks nudged up and left rather than framed — and it landed
   on seven different box classes at once. A box's edge is its border. Don't
   reintroduce brackets, offset outlines, offset shadows or corner ticks that
   sit outside the border box; state emphasis with border-color, background or
   a flush ring (box-shadow: 0 0 0 1px, spread only, no offset). */
.authbox, .herostrip, .card, .tile, .mapcard, .heropanel, .modalbox { position: relative; }

/* --------------------------------------------------------------- forms */
input, select, textarea, button { font: inherit; color: var(--text); }
input, select, textarea {
  background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r-sm); padding: var(--sp-4) var(--sp-5);
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--orange); border-color: var(--orange); }
textarea { width: 100%; resize: vertical; font-family: var(--mono); font-size: var(--fs-md); }
label { display: block; margin: var(--sp-5) 0 var(--sp-2); color: var(--dim); font-size: var(--fs-md); }
label input, label select, label textarea { display: block; width: 100%; margin-top: var(--sp-2); color: var(--text); }
label.chk { display: inline-flex; align-items: center; gap: var(--sp-3); }
label.chk input { width: auto; display: inline; }
.formrow { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: var(--sp-6); }
.editform { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-8) var(--sp-9); }
/* A list of short values being edited — one row each, numbered, with a way to
   add one and a way to take one away. unit.php's application builder writes
   the choices of a "pick one" question with it (unit.js builds the rows over
   the plain textarea that works without a script).

   NAMED FOR THE FORM rather than for that page, the call .sortlist made above:
   the next editor that asks somebody for a handful of lines reuses this
   instead of adding a second one. The numbering is not decoration — the
   applicant answers this question through a <select>, and 1. 2. 3. says the
   options are an ordered list where a radio circle would promise a control
   they never see. */
.optlist { display: flex; flex-direction: column; gap: var(--sp-3); }
.optrow { display: flex; align-items: center; gap: var(--sp-5); }
/* The input carries the row; label's own `display: block; width: 100%` would
   otherwise push the number and the × onto lines of their own. */
.optrow input { flex: 1 1 auto; width: auto; margin: 0; }
.optnum { flex: 0 0 auto; color: var(--dim); font-size: var(--fs-sm); min-width: var(--sp-9); text-align: right; }
/* Names the group the way a `label` names a field, because it cannot BE one:
   a label has one control and this has as many as the crew asks for. */
.optlabel { color: var(--dim); font-size: var(--fs-md); margin-bottom: var(--sp-4); }
/* edit.php's objective grid-ref table: a sub-panel inside .editform, headed
   like a .sech so it reads as its own section of the form. The refs are the
   only part of the seeded objective manifest that is edited by hand. */
.objgrids { border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-2) var(--sp-7) var(--sp-6); margin: var(--sp-9) 0 0; }
.objgrids legend {
  font-size: var(--fs-md); letter-spacing: 2px; text-transform: uppercase; color: var(--orange); padding: 0 var(--sp-3);
}
.objgrids p { margin: var(--sp-4) 0 0; font-size: var(--fs-sm); }
.objgrids .datatable td { padding: var(--sp-2) var(--sp-5); }
.objgrids input { text-transform: uppercase; }
.btn {
  display: inline-block; cursor: pointer; padding: var(--sp-4) var(--sp-8);
  background: var(--panel2); border: 1px solid var(--line); color: var(--text);
  font-size: var(--fs-md); letter-spacing: .6px; text-transform: uppercase; font-weight: 600;
  /* Cut corner instead of a rounded one — reads as a console button, not a
     web button. Kept off .linkbtn/.chip/.pager, which want their own shapes. */
  clip-path: polygon(9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px);
  transition: filter .1s;
}
.btn:hover { border-color: var(--orange); color: var(--orange-hi); text-decoration: none; }
/* A disabled .btn looks disabled everywhere. This used to be scoped to
   .tsheet-acts, so the same attribute that greyed a button in the talent sheet
   did nothing at all in a table's action cell — an ↑ on the first row rendered
   exactly like a live control and only revealed itself as dead on click. */
.btn[disabled] { opacity: .4; pointer-events: none; }
.btn:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; }
.btn-primary {
  background: linear-gradient(180deg, var(--btn-1), var(--btn-2)); color: var(--ink-warm); border-color: var(--btn-line);
  text-shadow: none; box-shadow: 0 0 0 color-mix(in srgb, var(--orange) 0%, transparent);
}
.btn-primary:hover { background: linear-gradient(180deg, var(--btn-1-hi), var(--btn-2-hi)); color: var(--ink-warm); box-shadow: 0 0 16px color-mix(in srgb, var(--orange) 35%, transparent); }
.btn-ghost { background: transparent; }
/* The header's one non-navigation control: it asks the reader for something
   rather than taking them somewhere, so it reads a step warmer than the nav
   links without competing with the .btn-primary a logged-out visitor gets in
   the same slot. The accent is carried by tinting the WHOLE border plus a wash
   of the same colour — never a fattened edge or anything outside the border
   box; see the contract at the top of this file. */
.feedbackbtn {
  flex: 0 0 auto; white-space: nowrap; padding: var(--sp-3) var(--sp-5);
  font-size: var(--fs-sm); letter-spacing: .6px;
  background: color-mix(in srgb, var(--orange) 12%, transparent);
  border-color: color-mix(in srgb, var(--orange) 45%, var(--line));
  color: var(--orange-hi);
}
.feedbackbtn:hover { background: color-mix(in srgb, var(--orange) 20%, transparent); }
.btn-danger {
  background: linear-gradient(180deg, #e27764, #c9503c); color: var(--ink-light); border-color: #a93c2b;
}
.btn-danger:hover { background: linear-gradient(180deg, #f08a76, #d95f4a); color: var(--ink-light); box-shadow: 0 0 16px color-mix(in srgb, var(--red) 40%, transparent); }
.btnrow { display: flex; gap: var(--sp-4); align-items: center; flex-wrap: wrap; margin: var(--sp-5) 0; }
.linkbtn { background: none; border: none; color: var(--dim); cursor: pointer; font-size: var(--fs-sm); padding: var(--sp-1) var(--sp-2); }
.linkbtn:hover { color: var(--orange-hi); }
.linkbtn.danger:hover { color: var(--red); }
.inlineform { display: inline-flex; gap: var(--sp-3); align-items: center; }
/* Help text under a control or a section heading. Caption-sized, and held to
   the site's reading measure: at the full 1200px column a hint runs ~150
   characters to the line and reads as body copy arguing with the control it is
   describing. 760px is the measure .sortnotes and .feedbackbox already use
   (.lorepara did until 2026-08-24 — prose in a .post takes the column, per
   `.post .mdbody`). The margin is stated because a <p> otherwise takes the UA's
   1em, stacking a 15px gap under a heading that already supplies one. */
.formhint { font-size: var(--fs-sm); max-width: 760px; }
p.formhint { margin: var(--sp-2) 0 var(--sp-6); }
/* The narrow default for a toolbar's incidental filter inputs. It must not
   reach .searchbox: that is the page's PRIMARY search and wants 220px, but
   .toolbar input[type=search] (0,1,1) outranks .searchbox (0,1,0), so
   unifying .filterbar and .treetools silently shrank the home hero's search
   box and pulled its button up beside it. Re-stated at matching specificity
   rather than by weakening the default. */
.toolbar input[type=search], .toolbar input:not([type]) { min-width: 170px; }
.toolbar .searchbox { min-width: 220px; }
/* items.php: span the content column instead of huddling left, so the row's
   edges line up with the topbar's brand and avatar. Search eats the slack. */
.toolbar--full input[type=search] { flex: 1 1 260px; }
.toolbar--full select { flex: 0 1 auto; }
.addbar { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-5) var(--sp-6); }
.bigsearch input { flex: 1; font-size: var(--fs-lg); padding: var(--sp-6) var(--sp-7); }
/* ONE filter control. "A row of mutually exclusive buttons" used to exist as
   four unrelated classes with four different looks: .chip (pill, orange on),
   .chip--mono (mono, square, currentColor on), .chip--lg (big uppercase slab) and
   .tabs a. .tabs is a genuinely different control — it switches sections, it
   does not filter — and stays. The other three are this one, wearing a
   modifier. .chip--mono/.chip--lg remain in the selectors as aliases so existing
   markup keeps working; they come out once every page says .chip. */
.chip {
  display: inline-flex; align-items: center; gap: var(--sp-3);
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-pill);
  color: var(--dim); font-size: var(--fs-sm); padding: var(--sp-2) var(--sp-6); cursor: pointer;
}
.chip:hover { border-color: var(--orange); color: var(--orange-hi); text-decoration: none; }
.chip:focus-visible { outline-offset: 2px; text-decoration: none; }
.chip.on { border-color: var(--orange); color: var(--orange); background: color-mix(in srgb, var(--orange) 10%, transparent); }

/* Tier/monospace variant: square, mono, and it inks itself from currentColor
   so a tier chip carries its own tier colour. */
.chip--mono {
  font-family: var(--mono); font-size: var(--fs-xs); font-weight: 700; letter-spacing: 1px;
  border-radius: var(--r-sm); padding: var(--sp-2) var(--sp-5);
}
.chip--mono:hover { border-color: currentColor; color: inherit; }
.chip--mono.on {
  border-color: currentColor; color: inherit;
  background: color-mix(in srgb, currentColor 16%, var(--panel));
}

/* Slab variant: the talent-tree category picker, big enough to be the page's
   primary control rather than a refinement of one. */
.chip--lg {
  background: var(--bg2); border-radius: var(--r-md); padding: var(--sp-4) var(--sp-8);
  font-weight: 700; letter-spacing: .4px; text-transform: uppercase; gap: var(--sp-4);
}
.chip--lg:hover { color: var(--text); border-color: color-mix(in srgb, var(--orange) 40%, var(--line)); }
.chip--lg.on {
  color: var(--orange-hi); border-color: var(--orange);
  background: color-mix(in srgb, var(--orange) 16%, var(--panel));
}
.chiprow { display: flex; flex-wrap: wrap; gap: var(--sp-4); margin: var(--sp-4) 0; }

/* One category of a long chip list, collapsed. Deliberately NOT a .foldsec:
   that form is "a reference section that folds" and wears a dim uppercase
   summary to sit quieter than the .sech above it. These are peers of each
   other inside one section, and the thing a reader scans is the category name
   and its count, so the summary is ordinary text at the base size with the
   count dimmed beside it.
   No border, because a bordered box here would put a dozen outlines down the
   page for twelve rows of text — the rule stays on the .sech above, which is
   the one thing this block is inside. */
.chipgroup > summary {
  cursor: pointer; padding: var(--sp-3) var(--sp-2);
  color: var(--text); font-size: var(--fs-base);
  border-bottom: 1px solid var(--line-soft);
}
.chipgroup > summary:hover { color: var(--orange-hi); }
.chipgroup > summary::marker { color: var(--dim); }
.chipgroup[open] > summary { color: var(--orange); }
.chipgroup > .chiprow { margin: var(--sp-5) 0 var(--sp-7); }
.tag { background: color-mix(in srgb, var(--blue) 12%, transparent); border: 1px solid color-mix(in srgb, var(--blue) 35%, transparent); color: #9fd0ec; border-radius: var(--r-xs); font-size: var(--fs-sm); padding: var(--sp-px) var(--sp-4); }
.tagrow { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin: var(--sp-4) 0; }
.pager { display: flex; gap: var(--sp-3); margin: var(--sp-9) 0; }
.pager a { padding: var(--sp-2) var(--sp-6); border: 1px solid var(--line); border-radius: var(--r-sm); color: var(--dim); }
.pager a.cur { border-color: var(--orange); color: var(--orange); }
.tabs { display: flex; gap: var(--sp-2); border-bottom: 1px solid var(--line); margin: var(--sp-7) 0 var(--sp-10); flex-wrap: wrap; }
.tabs a { padding: var(--sp-4) var(--sp-8); color: var(--dim); text-transform: uppercase; font-size: var(--fs-sm); letter-spacing: 1px; font-weight: 600; }
.tabs a.on { color: var(--orange); border-bottom: 2px solid var(--orange); }
.tabs a.tabhub { border-right: 1px solid var(--line); margin-right: var(--sp-3); padding-right: var(--sp-7); opacity: .8; }
/* A pending count riding an admin tab (open content reports). Flush ring, no
   offset — see the page contract above. */
.tabcount {
  display: inline-block; margin-left: var(--sp-4); padding: 0 var(--sp-3); border-radius: var(--r-lg);
  background: var(--red); color: var(--ink-warm); font-size: var(--fs-micro); font-weight: 700; letter-spacing: 0;
  vertical-align: 1px;
}
/* ---- the admin section rail (every admin page, via admin_head) ---------
   The nine admin sections run DOWN THE LEFT of the page rather than across
   the top. As a .tabs bar they were nine uppercase entries on one line: at a
   laptop width that wrapped, so the entry you were in changed line as the
   window changed and the second row read as a second level of something —
   the exact confusion the old two-stacked-bars layout was killed for. A rail
   gives every section the same start x and one line each, and it sticks
   while the page beside it scrolls, which is the difference between seeing
   the section list and not on admin.php's 2.8k-row item table.

   It is a NAV, so it is built out of the header's nav vocabulary rather than
   a private one: the entries are .navgroup-panel a — dim, uppercase, --fs-sm
   at 1px tracking — they take the same hover (text colour plus a --panel
   plate), and the current one wears the site's single "you are here" mark,
   orange text over a 2px bottom border with the top corners rounded. That
   mark is already .mainnav a.active, .navgroup.active > summary and
   .navgroup-panel a.active; a left rail here would have made it four shapes
   for one meaning, and the class is .active for the same reason (.on belongs
   to .tabs). The rows are gapped, so the mark sits under its entry with air
   beneath it rather than between two of them — the condition
   .navgroup-panel already relies on.

   The rail's own separator is a hairline down its open edge, which is
   .tabs's border-bottom turned ninety degrees: borderless links with one
   rule, no bordered box for a second weight to mismatch.

   The grid is the whole admin page: the rail, then .adminmain holding the
   .pagehead and everything the page itself emits. page_footer() closes both,
   since a page only ever calls admin_head() and then writes content. */
.adminlayout {
  display: grid; grid-template-columns: 176px minmax(0, 1fr);
  gap: var(--sp-11); align-items: start; margin-top: var(--sp-7);
}
.adminmain { min-width: 0; }
.adminnav {
  display: flex; flex-direction: column; gap: var(--sp-1);
  border-right: 1px solid var(--line); padding-right: var(--sp-6);
  position: sticky; top: calc(var(--topbar-h, 52px) + var(--sp-8));
}
.adminnav a {
  padding: var(--sp-4) var(--sp-6); border-radius: var(--r-sm); color: var(--dim);
  font-size: var(--fs-sm); letter-spacing: 1px; text-transform: uppercase; font-weight: 600;
}
.adminnav a:hover { color: var(--text); background: var(--panel); text-decoration: none; }
.adminnav a.active {
  color: var(--orange); border-bottom: 2px solid var(--orange);
  border-radius: var(--r-sm) var(--r-sm) 0 0;
}

/* Narrow: the rail lies down above the page as a grid of tiles rather than a
   wrapping row of links. Ten entries in a row wrap to ragged lines at 390px —
   the section you are in changes line as the screen does, which is the defect
   the rail exists to fix, so reproducing it small would be no answer. A grid
   keeps all ten visible at once (no drawer to open, nothing parked off the
   side of a horizontal scroller), each on its own column edge, at a full
   thumb's height. auto-fit does the rest without a second breakpoint, and the
   lines are columns rather than wherever the text happened to run out:
   measured at 2 across (5 lines) at 320px, 3 on a phone, 5 at 600px and 9 by
   the top of the band.

   That top figure is the one thing adding a tenth section (Tickets, 2026-08)
   cost. .adminnav is 949px wide inside a 1000px window, which is nine 100px
   tracks plus gaps, so ten entries now finish on a second line holding one.
   A narrower track can't buy it back: "Navigation" renders 88px and the tile
   keeps --sp-3 either side, so 100px IS the content, and a smaller floor
   would clip the label rather than fit another column. Left as measured — the
   grid's order is fixed and every entry is on screen either way, which is
   what the form is for. An eleventh section should reconsider the shape
   rather than shave the track.

   1000px is the site's own narrow boundary, not a new one — it is where the
   header bar becomes the phone drawer (see the phone layout block below),
   so the whole of the site's chrome changes shape at one width. It also
   keeps the rail form honest: its narrowest content column is 750px, which
   is about what the admin tables were laid out against, where an 860px
   fallback would have handed them 610px.

   The 100px track is measured, not picked: the longest label ("Navigation")
   renders 88px at this size, and the tile keeps only --sp-3 of side padding
   so three of them plus the gaps fit the 347px a 390px phone actually leaves
   inside .wrap. Renaming a section to something longer, or adding one, means
   re-measuring it — the type stays at the nav's size, so the tile is what
   gives. The row is 47px tall, which is the tap target doing the deciding
   rather than the text. */
@media (max-width: 1000px) {
  .adminlayout { display: block; }
  .adminnav {
    position: static; display: grid; gap: var(--sp-3);
    grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
    border-right: 0; border-bottom: 1px solid var(--line);
    padding: 0 0 var(--sp-7); margin-bottom: var(--sp-9);
  }
  .adminnav a { padding: var(--sp-7) var(--sp-3); text-align: center; }
}

/* admin.php items tab: the row whose item the edit form above is holding, so
   after the anchor jump it's obvious which of 2.8k rows you're editing. */
.datatable tr.rowsel td { background: color-mix(in srgb, var(--orange) 9%, transparent); }
/* A datatable's trailing action cell (the admin lists' view/edit/delete). This
   used to be `<td class="btnrow">`, which set display:flex ON A TABLE CELL —
   the cell dropped out of the table layout, so the column stopped lining up
   with its header and every row grew tall enough for two wrapped lines of
   12px links. .btnrow is for a row of buttons in flow content; a cell that
   holds actions is this. */
.datatable td.rowacts { white-space: nowrap; text-align: right; vertical-align: middle; }
.datatable td.rowacts > * { vertical-align: middle; }
.datatable td.rowacts > * + * { margin-left: var(--sp-4); }
/* The admin CSV import forms. A file input's intrinsic width is wide and
   doesn't shrink, so as an .inlineform (inline-flex, nowrap) the pair pushed
   the whole page sideways on a phone. Wrap instead. */
.csvform { display: flex; flex-wrap: wrap; gap: var(--sp-4); align-items: center; }
.csvform input[type=file] { min-width: 0; max-width: 100%; }
/* ---- the sortable list (admin_navigation.php) --------------------------
   A nested, drag-to-arrange list: the header nav's own shape, rendered as
   rows you can pick up. Not a .datatable — a table cannot nest, and the
   whole point here is that a row can contain the rows inside a menu.

   .sortrow is a bordered box, so it carries ONE border weight on all four
   sides, and a menu tints the WHOLE border rather than fattening one edge of
   it (see the contract at the top of this file). Every state below does the
   same: border colour, background, or a FLUSH ring (spread only, both offsets
   zero). Nothing sits outside a row's border box.

   Rows are deliberately short. The bar is fifteen entries and the list is the
   thing you are dragging within, so a row that reads comfortably on its own
   but pushes half the list below the fold is the wrong trade: at ~38px the
   whole nav fits one screen and a drag never has to cross the fold.

   touch-action: none is on the HANDLE, not the row. It has to be somewhere —
   without it a touch drag scrolls the page instead of firing the pointermove
   events navorder.js listens for — but putting it on the row would mean a
   phone could not scroll a list that fills the screen by dragging the only
   thing on it. So a mouse can drag from anywhere on a row and a finger drags
   from the handle, which is also why the handle grows to a 44px target on
   small screens. */
.sortlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
/* The indent is the nesting. Padding on BOTH sides, so a child's border sits
   inside its menu's rather than flush against it — two borders on the same
   pixel column read as one misaligned edge, which is the defect the contract
   at the top of this file is about. */
.sortlist--sub {
  margin: 0 var(--sp-2) var(--sp-2) var(--sp-9);
  padding: 0 var(--sp-5); gap: var(--sp-2);
  border-left: 1px solid var(--line-soft); min-height: 30px; justify-content: center;
}
.sortrow { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); }
.sortrow--menu { border-color: color-mix(in srgb, var(--orange) 38%, var(--line)); background: var(--panel2); }
/* Wraps rather than overflows: on a phone the row folds into two lines
   instead of pushing the page sideways, which is the call .csvform makes
   above. */
.sortline {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--sp-3) var(--sp-5); padding: var(--sp-2) var(--sp-4); cursor: grab;
}
.sortform.is-sorting .sortline { cursor: grabbing; }
/* The handle is a real button — it is the keyboard's way in, and the whole
   row is the mouse's, so this only has to say "this moves" and take focus. */
.sortgrip {
  flex: 0 0 auto; background: none; border: 1px solid transparent; border-radius: var(--r-sm);
  /* --text-4, not --text-5. The ⠿ is aria-hidden decoration inside a labelled
     button, so it is the visual affordance of a UI component and answers to
     the 3:1 non-text floor rather than the 4.5:1 text one — --text-5 is
     2.53:1 and misses it, --text-4 is 3.28:1. The ramp above it is untouched:
     row hover still lifts to --text-3 and the handle's own hover to --text. */
  color: var(--text-4); font-size: var(--fs-lg); line-height: 1;
  padding: var(--sp-1) var(--sp-2); cursor: grab; touch-action: none;
  -webkit-user-select: none; user-select: none;
}
/* A finger drags from the handle and nothing else, so on a phone the handle is
   a real tap target rather than a 24px glyph — and the row folds to two lines
   with the handle beside the name and everything else indented under it,
   rather than each part wrapping wherever it happens to run out of room. */
@media (max-width: 1000px) {
  .sortgrip { min-width: 44px; min-height: 44px; }
}
@media (max-width: 700px) {
  .sortline { row-gap: var(--sp-1); }
  .sortname { flex: 1 1 auto; min-width: 0; }
  .sortmeta { flex: 1 0 100%; padding-left: 50px; }
}
.sortrow:hover .sortgrip { color: var(--text-3); }
.sortgrip:hover { color: var(--text); border-color: var(--line); }
.sortgrip[aria-pressed="true"] { color: var(--orange); border-color: var(--orange); }
.sortname {
  flex: 0 0 auto; min-width: 160px; font-weight: 600; font-size: var(--fs-md);
  display: flex; align-items: center; gap: var(--sp-3);
}
/* A name is editable in place, so it has to look editable without looking like
   a form: text until you point at the row, then a field's outline and a pencil.
   The pencil answers on ROW hover rather than on the name's own — a control you
   can only discover by happening to cross eight characters of text is a control
   nobody finds. Everything stays inside the border box: the resting state is a
   transparent border of the same weight, so nothing shifts when it appears. */
.sortrename {
  display: inline-flex; align-items: center; gap: var(--sp-3);
  background: none; border: 1px solid transparent; border-radius: var(--r-sm);
  padding: var(--sp-1) var(--sp-3); margin: 0 calc(var(--sp-3) * -1);
  color: inherit; font: inherit; cursor: text; text-align: left;
}
.sortrename:hover { border-color: var(--line-hi); background: var(--bg2); }
.sortpen { color: var(--text-5); font-size: var(--fs-xs); opacity: 0; }
.sortrow:hover .sortpen, .sortrename:focus-visible .sortpen { opacity: 1; }
.sortrename:hover .sortpen { color: var(--text-3); }
.sortnameinput { width: 150px; font-weight: 600; }
.sortfallback input { width: 140px; }
/* "menu", set as a badge so a container reads as a different kind of row than
   the pages inside it even before you notice the tinted border. */
.sortkind {
  flex: 0 0 auto; font-size: var(--fs-micro); letter-spacing: 1px; text-transform: uppercase;
  color: var(--orange); border: 1px solid color-mix(in srgb, var(--orange) 38%, var(--line));
  border-radius: var(--r-pill); padding: 0 var(--sp-3);
}
/* Where it points + what is true of it, as one flex item so the pair folds
   onto the second line together when the row is too narrow to hold them. */
.sortmeta { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: var(--sp-5); }
.sortwhere {
  flex: 1 1 auto; min-width: 0; font-size: var(--fs-sm);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sortnote { flex: 0 0 auto; font-size: var(--fs-xs); }
.sortfallback { display: flex; align-items: center; gap: var(--sp-4); font-size: var(--fs-sm); }
/* Every row carries the controls for BOTH lists and the stylesheet picks —
   navorder.js moves rows between the lists without a reload, and a button
   rebuilt in JS is a button that can be rebuilt wrong. `.sortlist--out` or not
   is a question CSS cannot get wrong. */
.sorthide, .sortshow, .sortdel { flex: 0 0 auto; }
.sortshow, .sortdel { display: none; }
.sortlist--out .sorthide { display: none; }
.sortlist--out .sortshow, .sortlist--out .sortdel { display: inline-flex; }
/* A menu out of the bar is empty by construction, so its drop zone would be a
   container you can fill while it is nowhere. */
.sortlist--out .sortlist--sub { display: none; }
/* × is a quiet control until you look at the row — the same reasoning as the
   rename pencil. It keeps its space either way so nothing shifts, and it is
   always there on touch, where there is no hover to answer. */
.sorthide { opacity: 0; }
.sortrow:hover > .sortline > .sorthide,
.sortrow:focus-within > .sortline > .sorthide { opacity: 1; }
@media (hover: none) { .sorthide { opacity: 1; } }
/* Out of the bar: still real rows you can drag, read as set aside. */
.sortlist--out { margin-bottom: var(--sp-8); }
.sortlist--out > .sortrow { background: var(--bg2); border-style: dashed; }
.sortlist--out .sortname, .sortlist--out .sortwhere { color: var(--dim); }
/* An empty menu's drop zone. Dashed because it is a place to put something,
   not a thing — and it is the only dashed edge here for the same reason. */
.sortempty {
  color: var(--dim); font-size: var(--fs-sm); padding: var(--sp-3) var(--sp-5);
  border: 1px dashed var(--line-hi); border-radius: var(--r-md); text-align: center;
}
/* The row being dragged, and the menu it would land in. The target matters
   most: geometry alone can't tell you whether you are dropping INTO Reference
   or next to it. */
.sortrow.is-dragging, .sortrow.is-held {
  border-color: var(--orange);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--orange) 45%, transparent);
  background: color-mix(in srgb, var(--orange) 8%, var(--panel));
}
.sortrow.is-drop-target { border-color: var(--orange); }
.sortrow.is-drop-target > .sortline > .sortkind { background: color-mix(in srgb, var(--orange) 18%, transparent); }
.sortrow.is-drop-target > .sortlist--sub { border-left-color: var(--orange); }
.sortform.is-sorting { -webkit-user-select: none; user-select: none; }
.sortform.is-saving { cursor: progress; }
/* Rows slide to their new places rather than teleporting — navorder.js sets a
   transform and clears it, so this is the only rule that needs to exist for
   the FLIP to play. */
.sortrow { transition: transform 140ms ease, border-color 120ms ease, background 120ms ease; }
@media (prefers-reduced-motion: reduce) { .sortrow { transition: none; } }
/* The "New menu" form, in the .toolbar above the list rather than as a
   .pagehead action: it adds a row to the list below, which is what a toolbar
   is for, and as a 441px flex sibling of the page intro it squeezed that intro
   into half the column and left the other half empty under the buttons. */
.sortadd { display: flex; align-items: center; gap: var(--sp-4); }
/* What just happened, for the eye and for a screen reader (role=status). Sits
   at the far end of that toolbar, beside the thing it is about. It keeps its
   height so the row doesn't shift as messages come and go. */
.sortstatus {
  display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-4);
  min-height: 28px; font-size: var(--fs-sm); color: var(--dim); text-align: right;
}
/* The small print at the foot: a reading measure, because these are sentences
   to read rather than a row to scan. */
.sortnotes { max-width: 760px; margin: var(--sp-6) 0 0; }
.sortstatus.is-ok { color: var(--green); }
.sortstatus.is-err { color: var(--red); }
.sortstatus .btn { padding: var(--sp-1) var(--sp-4); font-size: var(--fs-xs); }
#sorthelp kbd {
  font-family: var(--mono); font-size: var(--fs-xs); color: var(--text-2);
  border: 1px solid var(--line); border-radius: var(--r-xs); padding: 0 var(--sp-2);
}
/* The measured bar width, filled in by navorder.js — red once the top level
   has outgrown the drawer breakpoint the row was measured against. */
[data-navwidth].over { color: var(--red); }
/* Pointing at a row in admin_navigation.php lights the link it IS, up in the
   real bar. Emphasis inside the box, as everywhere: a tint and a colour, never
   an offset mark. */
.mainnav [data-nav-key].is-lit,
.mainnav [data-nav-key].is-lit:hover {
  color: var(--orange); background: color-mix(in srgb, var(--orange) 16%, transparent);
}

.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-8) var(--sp-9); margin: var(--sp-7) 0; }
/* A heading at the top of a box sits at the box's padding. .sech's 26px top
   margin exists to separate it from the content above it, and inside a panel
   there is none — it was simply added to the padding, so every panel opened
   with 42px of nothing above its own title. */
.panel > .sech:first-child { margin-top: 0; }
/* inferred, not wiki-documented — set apart from the sourced bench line above it */
.benchderived { font-size: var(--fs-sm); color: var(--text-2); margin: var(--sp-5) 0 0; padding-top: var(--sp-5); border-top: 1px dashed var(--line); }

/* -------------------------------------------------------------- modal */
/* Centered dialog + dimming scrim. Generic rather than named after its one
   current caller (index.php's post-registration welcome popup, see
   consume_welcome_flag() in inc/helpers.php) — any future one-shot dialog
   can reuse it. The orange border and panel surface are what make it read as
   this terminal rather than a stock browser dialog. */
.modalscrim {
  position: fixed; inset: 0; z-index: 200; background: color-mix(in srgb, var(--void) 75%, transparent);
  display: flex; align-items: center; justify-content: center; padding: var(--sp-8);
}
/* The authModal instance (inc/layout.php) stays permanently in the DOM and
   toggles via the `hidden` attribute rather than being added/removed — the
   unconditional `display: flex` above has equal specificity to the `[hidden]`
   UA rule and would otherwise win, showing the scrim even while hidden. */
.modalscrim[hidden] { display: none; }
.modalbox {
  width: 420px; max-width: 100%; background: var(--panel); border: 1px solid var(--orange);
  border-radius: var(--r-lg); padding: var(--sp-11) 26px; box-shadow: 0 10px 40px var(--shade-6);
}
.modalbox h2 { margin: var(--sp-3) 0 var(--sp-5); letter-spacing: .5px; }
.modalbox p { margin: 0 0 var(--sp-5); }
.modalclose {
  position: absolute; top: 10px; right: 12px; background: none; border: 0;
  color: var(--dim); font-size: var(--fs-2xl); line-height: 1; cursor: pointer; padding: var(--sp-2);
}
.modalclose:hover { color: var(--orange-hi); }

/* --------------------------------------------------------------- auth */
.authbox { max-width: 420px; margin: 8vh auto; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 28px 30px; box-shadow: 0 0 40px color-mix(in srgb, var(--orange) 7%, transparent); }
.feedbackbox { max-width: 760px; margin-top: 5vh; }
.feedbackbox .formrow { align-items: start; }
.authbox h1 { letter-spacing: 1px; }
.authbox .btn { width: 100%; margin-top: var(--sp-7); padding: var(--sp-6); }
.botcatch { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
/* File inputs, wherever they appear (feedback screenshots, account.php's
   avatar, admin uploads): the browser's default button is a white OS chip that
   reads as a foreign object on a dark terminal, so it gets .btn's shape. */
input[type=file] { padding: var(--sp-5) var(--sp-5); color: var(--dim); font-size: var(--fs-md); cursor: pointer; }
input[type=file]::file-selector-button {
  margin-right: var(--sp-5); padding: var(--sp-3) var(--sp-6); border-radius: var(--r-sm); cursor: pointer;
  background: var(--panel2); border: 1px solid var(--line); color: var(--text);
  font: inherit; font-size: var(--fs-sm); letter-spacing: .6px; text-transform: uppercase;
}
input[type=file]::file-selector-button:hover { border-color: var(--orange); color: var(--orange-hi); }
/* feedback.php's picker sits outside its <label> (label input{width:100%}
   would stretch the browser's own button), so it needs the block layout and
   top margin the wrapped inputs get for free. */
.feedbackbox input[type=file] { display: block; width: 100%; margin-top: var(--sp-2); }
.uploadlist { margin: var(--sp-3) 0 0; font-size: var(--fs-sm); line-height: 1.5; min-height: 1em; }
.uploadlist-err { color: var(--red); }
/* Turnstile renders a fixed-size iframe (~300x65) that cannot be stretched, so
   the container gives it room and centres it. Left-aligned it read as a stray
   box hanging off the left edge of a form whose every other control is full
   width — worst on feedback.php's 920px column, where 300px of widget left
   two-thirds of the row empty. Centred on every form that carries one, not
   just that page: it is one widget and should sit the same way everywhere. */
.captcha { display: flex; justify-content: center; margin: var(--sp-9) 0 var(--sp-2); min-height: 65px; }
/* Admin-only "this form has no bot check" notice — see captcha_widget(). */
.captcha-off { margin: var(--sp-9) 0 var(--sp-2); padding: var(--sp-5) var(--sp-6); font-size: var(--fs-sm); line-height: 1.5;
  color: var(--ink-err); background: color-mix(in srgb, var(--red) 12%, transparent); border: 1px solid color-mix(in srgb, var(--red) 45%, transparent); border-radius: var(--r-sm); }
.captcha-off code { font-family: var(--mono); font-size: var(--fs-sm); color: var(--orange-hi); }
/* toggle switch (styled checkbox) */
label.toggle { display: flex; align-items: center; gap: var(--sp-5); margin-top: var(--sp-8); color: var(--text); font-size: var(--fs-base); cursor: pointer; }
label.toggle input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.toggle-track { flex: 0 0 auto; position: relative; width: 40px; height: 22px; border-radius: var(--r-pill); background: var(--line); border: 1px solid var(--line); transition: background .15s; }
.toggle-thumb { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--dim); transition: transform .15s, background .15s; }
label.toggle input:checked + .toggle-track { background: var(--orange); border-color: var(--orange); }
label.toggle input:checked + .toggle-track .toggle-thumb { transform: translateX(18px); background: var(--ink-light); }
label.toggle input:focus-visible + .toggle-track { outline: 2px solid var(--orange); outline-offset: 2px; }
.toggle-text { -webkit-user-select: none; user-select: none; }
/* label.toggle's margin-top is the gap login.php needs above a lone switch at
   the end of a form. In a row of controls it is dead space that drops the
   switch off the button's centre line, so the two contexts that put one in a
   row reset it — the same reason .stackform resets `label` margins wholesale
   two thousand lines below, and why that reset already covers a .toggle inside
   a stacked form. */
.btnrow label.toggle, .toolbar label.toggle { margin-top: 0; }

/* --------------------------------------------------------------- cards
   Every card/tile/mapcard/fact/heropanel on the site derives its surface from
   the single grouped rule below. Layout internals (flex vs block, gap,
   padding) are the only per-class differences. Edit the shared surface rule
   and every box on every page updates at once. */

/* Shared box surface — one edit changes every card */
.card, .tile, .mapcard, .heropanel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  color: var(--text);
}

/* Clickable boxes: shared padding, hover transition */
.card, .tile, .mapcard, .heropanel {
  padding: var(--sp-7) var(--sp-8);
  transition: border-color .15s, transform .15s;
}
.card:hover, .tile:hover, .mapcard:hover, .heropanel:hover, .itemtile:hover {
  border-color: var(--orange);
  text-decoration: none;
  transform: translateY(-1px);
}
.card:focus-visible, .tile:focus-visible, .mapcard:focus-visible, .heropanel:focus-visible, .itemtile:focus-visible {
  outline-offset: 4px; text-decoration: none;
}

/* Layout-only per-class differences */
.card        { display: block; position: relative; }
.tile        { display: flex; flex-direction: column; gap: var(--sp-1); }
.mapcard     { display: block; padding: 0; }
/* .catcard is NOT a box class: tree.php's category cards are .mapcard, the
   same box guides.php's map hub uses, so the surface, hover and the
   field-journal ink all come from that one place. This adds only what
   goes INSIDE a category card, which has no art band to sit under. */
.catcard     { display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-7) var(--sp-8); }
.heropanel   { display: flex; flex-direction: column; gap: var(--sp-2); }
/* (.fact is deliberately not in this family — it is a borderless label/value
   cell, defined once with .guidefacts in the guides section below.) */

/* ONE grid of cards. .cardlist, .cardlist--map and .cardlist--tight were three copies
   of the same rule differing only in track width, so a change to card spacing
   had to be made three times. Track width is now data (--card-min), which is
   what actually varies. The other two stay as aliases until every page says
   .cardlist. */
.cardlist {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--card-min, 300px), 1fr));
  gap: var(--sp-7); margin: var(--sp-7) 0;
}
.cardlist--map { --card-min: 240px; margin-bottom: 26px; }
.cardlist--tight { --card-min: 150px; gap: var(--sp-6); }
.card h2, .card h3 { margin: var(--sp-3) 0; font-size: var(--fs-xl); }
.card.slim h3 { font-size: var(--fs-base); }
.card.sel { border-color: var(--orange); box-shadow: 0 0 0 1px var(--orange); }
.cardtype {
  display: inline-block; font-size: var(--fs-micro); letter-spacing: 1.6px; text-transform: uppercase;
  color: var(--dim); border: 1px solid var(--line); border-radius: var(--r-xs); padding: var(--sp-px) var(--sp-4);
}
.cardtype.t-build { color: var(--t2); border-color: var(--t2); }
.cardtype.t-guide, .cardtype.t-mission { color: var(--orange); border-color: var(--orange); }
.cardtype.t-operation { color: var(--t3); border-color: var(--t3); }
.cardtype.t-map { color: var(--t2); border-color: var(--t2); }
.cardtype.t-system { color: var(--to); border-color: var(--to); }
.cardtype.t-active { color: var(--green); border-color: var(--green); }
.cardtype.t-planning { color: var(--blue); border-color: var(--blue); }
.cardtype.t-done { color: var(--dim); }
/* A world boss (D_BestiaryData.bIsBoss, 10 of 107 creatures). --red
   rather than the site accent: --orange already means t-guide/t-mission
   in this very component, and a second meaning for one colour inside one
   widget is how a badge stops reading as anything. */
.cardtype.t-boss { color: var(--red); border-color: var(--red); }
.cardmeta { display: flex; flex-wrap: wrap; gap: var(--sp-6); color: var(--dim); font-size: var(--fs-sm); margin-top: var(--sp-3); }
.cardmeta a { color: inherit; }
.progress { height: 5px; background: var(--bg2); border-radius: var(--r-xs); margin: var(--sp-5) 0 var(--sp-2); overflow: hidden; }
.progress div { height: 100%; background: linear-gradient(90deg, var(--orange), var(--orange-hi)); }

/* ---------------------------------------------------- admin_analytics.php
   Two small bar-chart components, built from plain divs (no charting lib) to
   match the rest of the site. Bar heights/widths are per-element data, so
   they're set inline — same rule .progress follows. The dashboard's headline
   numbers have no rules of their own: they are .guidefacts/.fact strips, the
   same label/value cell every other page states a number in. */
.dashsection { margin: 26px 0 var(--sp-4); }
.dashsection > p.dim { margin-top: -8px; }

/* Vertical bars, one per time bucket (day/week). */
.dvbars { display: flex; align-items: flex-end; gap: var(--sp-1); height: 70px; margin: var(--sp-5) 0 var(--sp-2); }
.dvbar { flex: 1 1 0; min-width: 3px; background: var(--orange); border-radius: var(--r-xs) var(--r-xs) 1px 1px; min-height: 2px; cursor: default; }
.dvbar:hover { background: var(--orange-hi); }
.dvbars.dv-blue .dvbar { background: var(--blue); }
.dvbars.dv-blue .dvbar:hover { background: var(--t2); filter: brightness(1.3); }
.dvbars-axis { display: flex; justify-content: space-between; color: var(--dim); font-size: var(--fs-micro); margin-bottom: var(--sp-8); }

/* Horizontal proportional bars, one row per label (e.g. top pages). A subject
   panel's label links to the thing it names; the value column carries the
   ranked figure plus a dim all-time one, so it's wider than a bare count. */
.dvhbars { display: grid; gap: var(--sp-4); margin: var(--sp-5) 0 var(--sp-9); }
.dvhbar-row { display: grid; grid-template-columns: 130px minmax(0, 1fr) 76px; align-items: center; gap: var(--sp-5); font-size: var(--fs-md); }
.dvhbar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dvhbar-label a { color: var(--text); text-decoration: none; }
.dvhbar-label a:hover { color: var(--orange); text-decoration: underline; }
.dvhbar-track { height: 8px; background: var(--bg2); border-radius: var(--r-sm); overflow: hidden; }
.dvhbar-fill { display: block; height: 100%; background: var(--orange); border-radius: var(--r-sm); min-width: 2px; }
.dvhbar-val { color: var(--dim); text-align: right; font-size: var(--fs-sm); white-space: nowrap; }

/* The "what people look up" panels: nine small rankings, two-up on desktop. */
/* People vs bots, on every pageview chart. The site stores no user-agent and no
   IP (terms.php §8), so the division is read from whether a client returned its
   own first-party cookie — see human_sql() in admin_analytics.php. PEOPLE TAKE
   THE ACCENT because they are the series a reader is actually following; bots
   take --dim so a crawler-dominated bar reads as noise instead of competing
   with the signal for attention. Two tokens, no third colour: this is a split,
   not a categorical palette. */
.dvbar--split { background: var(--line); display: flex; flex-direction: column; overflow: hidden; }
.dvbar-seg { display: block; width: 100%; }
.dvbar-people { background: var(--orange); }
.dvbar-bots { background: var(--dim); }
.dvbar--split:hover .dvbar-people { background: var(--orange-hi); }
.dvbar--split:hover .dvbar-bots { background: var(--text-2); }
.dvhbar-fill--split { background: none; display: flex; overflow: hidden; }
.dvhbar-seg { display: block; height: 100%; }
.dvhbar-people { background: var(--orange); }
.dvhbar-bots { background: var(--dim); }
/* The legend is the only place the two colours are named, so it carries no
   information the charts don't — it is a key, not a caption. */
.dvlegend { display: flex; align-items: center; color: var(--dim); font-size: var(--fs-sm); margin: 0 0 var(--sp-2); }
.dvkey { display: inline-block; width: var(--sp-5); height: var(--sp-5); border-radius: var(--r-xs); margin-right: var(--sp-3); }
.dvkey-people { background: var(--orange); }
.dvkey-bots { background: var(--dim); margin-left: var(--sp-8); }
.dashpanels { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 0 26px; }

/* --------------------------------------------------------------- hero */
.herostrip {
  margin-bottom: 26px; padding: var(--sp-10); overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--r-lg);
  background:
    radial-gradient(500px 220px at 88% -25%, color-mix(in srgb, var(--orange) 12%, transparent), transparent 65%),
    radial-gradient(420px 200px at -5% 115%, color-mix(in srgb, var(--blue) 9%, transparent), transparent 65%),
    linear-gradient(160deg, var(--panel2), var(--panel) 65%);
}
/* A handful of fixed "stars" behind the copy — cheap texture, not a photo,
   so it never risks being an actual (spoiler) game screenshot. Kept to this
   one calm hero rather than tiled globally, or it'd fight the item tables. */
.herostrip > * { position: relative; z-index: 1; }
.herostrip::before {
  content: ""; position: absolute; inset: 0; opacity: .8; pointer-events: none;
  background-image:
    radial-gradient(1.4px 1.4px at 18% 22%, var(--ink-light), transparent),
    radial-gradient(1px 1px at 34% 55%, var(--ink-light), transparent),
    radial-gradient(1.6px 1.6px at 62% 15%, var(--ink-light), transparent),
    radial-gradient(1px 1px at 76% 42%, var(--ink-light), transparent),
    radial-gradient(1.2px 1.2px at 91% 28%, var(--ink-light), transparent),
    radial-gradient(1px 1px at 52% 78%, var(--ink-light), transparent),
    radial-gradient(1.3px 1.3px at 8% 68%, var(--ink-light), transparent);
  background-repeat: no-repeat;
}
.hero-line1 { font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: 4px; color: var(--orange); opacity: .8; }
.herolinks { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--sp-6); margin-top: var(--sp-6); }
.heropanel b { font-size: var(--fs-xs); letter-spacing: 2px; text-transform: uppercase; color: var(--orange); }
.heropanel span { color: var(--dim); font-size: var(--fs-md); }

/* --------------------------------------------------------------- feed */
.feed { display: flex; flex-direction: column; }
.feedrow {
  display: flex; gap: var(--sp-6); align-items: baseline; padding: var(--sp-5) var(--sp-5);
  border-bottom: 1px solid var(--line-soft); color: var(--text); border-radius: var(--r-sm);
}
.feedrow:hover { background: var(--panel); text-decoration: none; }
.feedrow.unseen { background: color-mix(in srgb, var(--orange) 7%, transparent); }
.feedicon { flex: 0 0 24px; text-align: center; }
.feedwhat { flex: 1; min-width: 0; }
.feedwhat b { color: var(--orange-hi); font-weight: 600; }
.feedtime { color: var(--dim); font-size: var(--fs-sm); white-space: nowrap; }

/* --------------------------------------------------------------- home */
/* Spans the full content column, like every other search bar and the header
   above it — no narrower cap. Overrides .toolbar's own bottom margin (needed
   there so the toolbar clears page content below it) since this is the last
   thing in .herostrip — the card's own padding already provides that gap, so
   without this override the two stack and the bottom of the card reads taller
   than the top. Compound selector so it beats .toolbar on specificity
   regardless of declaration order. */
/* The home hero's search takes a full row of its own, with the button
   wrapping beneath it. That was implicit while .treetools and .filterbar were
   separate rules and became visible when they merged: on a phone the input
   dropped to the shared 170px floor, the button slid up beside it and the
   placeholder truncated mid-word. State it instead of relying on which of two
   toolbars a page happened to use. */
.toolbar.homesearch { margin: var(--sp-7) 0 0; }

/* Field guide tiles. Each carries a live count, so the tile itself shows the tool
   has something in it — these read off seeded tables and are never empty. */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--sp-6); margin: var(--sp-6) 0 var(--sp-8); }
.tilehead { display: flex; align-items: center; gap: var(--sp-5); margin-bottom: var(--sp-1); }
/* One generated line-icon per tile (home_tile_icon() in index.php) — same
   tinted-badge formula as .itemicon, so it reads as the site's own drawn
   glyph language rather than a dropped-in stock icon. */
.tileicon {
  display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
  width: 30px; height: 30px; border-radius: var(--r-md);
  background: color-mix(in srgb, currentColor 12%, var(--bg2));
  border: 1px solid color-mix(in srgb, currentColor 38%, var(--line));
}
.tileicon svg { width: 17px; height: 17px; }
.tile b {
  display: inline-block;
  font-size: var(--fs-micro); letter-spacing: 1.6px; text-transform: uppercase;
  color: var(--orange); border: 1px solid var(--orange);
  border-radius: var(--r-xs); padding: var(--sp-px) var(--sp-4);
}
.tilestat { font-family: var(--mono); font-size: var(--fs-3xl); line-height: 1.25; letter-spacing: -.5px; }
.tilesub { color: var(--dim); font-size: var(--fs-sm); }

/* Proportional read of the same $byTier data as the chips below — segment
   widths are per-request data (inline style), colours are static per tier. */
.tierbar { display: flex; height: 8px; border-radius: var(--r-sm); overflow: hidden; background: var(--bg2); margin: var(--sp-2) 0 var(--sp-5); }
.tierbar-seg { height: 100%; }
.tierbar-seg.tier-1 { background: var(--t1); }
.tierbar-seg.tier-2 { background: var(--t2); }
.tierbar-seg.tier-3 { background: var(--t3); }
.tierbar-seg.tier-4 { background: var(--t4); }
.tierbar-seg.tier-5 { background: var(--t5); }
.tierbar-seg.tier-o { background: var(--to); }
.tierbar-seg.tier-n { background: var(--dim); }

.tierchips { margin-bottom: var(--sp-11); }
.tierchips .chip.tier-1 { border-color: color-mix(in srgb, var(--t1) 50%, transparent); color: var(--t1); }
.tierchips .chip.tier-2 { border-color: color-mix(in srgb, var(--blue) 50%, transparent); color: var(--t2); }
.tierchips .chip.tier-3 { border-color: color-mix(in srgb, var(--t3) 50%, transparent); color: var(--t3); }
.tierchips .chip.tier-4 { border-color: color-mix(in srgb, var(--orange) 50%, transparent);  color: var(--t4); }
.tierchips .chip.tier-5 { border-color: color-mix(in srgb, var(--t5) 50%, transparent);  color: var(--t5); }
.tierchips .chip.tier-o { border-color: color-mix(in srgb, var(--to) 50%, transparent);  color: var(--to); }
.tierchips .chip:hover { border-color: currentColor; }

/* ------------------------------------------------------------ markdown */
.mdbody { max-width: 860px; }
.mdbody h1, .mdbody h2, .mdbody h3 { color: var(--orange-hi); letter-spacing: .4px; }
.mdbody blockquote { border-left: 3px solid var(--orange); margin-left: 0; padding-left: var(--sp-7); color: var(--dim); }
.mdbody table {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  margin: var(--sp-6) 0;
  font-size: var(--fs-base);
}
.mdbody th {
  text-align: left;
  font-size: var(--fs-xs);
  letter-spacing: 1.6px;
  text-transform: uppercase;
  color: var(--dim);
  border: 0;
  border-bottom: 2px solid var(--line);
  padding: var(--sp-4) var(--sp-5);
}
.mdbody td {
  border: 0;
  border-bottom: 1px solid var(--line-soft);
  padding: var(--sp-4) var(--sp-5);
  vertical-align: top;
}
.mdbody th:nth-child(2),
.mdbody th:nth-child(3),
.mdbody td:nth-child(2),
.mdbody td:nth-child(3) {
  text-align: center;
  white-space: nowrap;
}
.mdbody td:first-child {
  overflow-wrap: anywhere;
}
.mdbody tr:hover td { background: var(--sheen-1); }
.mdbody.flashed { animation: liveflash .8s; }
@keyframes liveflash { 0% { background: color-mix(in srgb, var(--orange) 15%, transparent); } 100% { background: transparent; } }
.mention { color: var(--orange); background: color-mix(in srgb, var(--orange) 12%, transparent); border-radius: var(--r-xs); padding: 0 var(--sp-2); font-weight: 600; }
.item-link { border-bottom: 1px dotted color-mix(in srgb, var(--orange-hi) 50%, transparent); }
.presence { color: var(--green); font-size: var(--fs-sm); }
.savestate { font-family: var(--mono); font-size: var(--fs-sm); color: var(--dim); }
.savestate.ok { color: var(--green); }
.savestate.err { color: var(--red); }
.conflict { border: 1px solid var(--red); background: color-mix(in srgb, var(--red) 8%, transparent); border-radius: var(--r-md); padding: var(--sp-7) var(--sp-9); margin: var(--sp-8) 0; }
.conflict pre { max-height: 300px; overflow: auto; }

/* The favourite toggle and the rating picker are ONE control sitting in a row
   of separate actions, so they cluster tighter than .btnrow's own 8px gap —
   4px inside against 8px between is what makes two boxes read as a pair
   rather than as two more things to press. They are not butted into a
   segmented control: a .btn's cut corners face each other across the join and
   the notch reads as damage. */
.favform { display: inline-flex; gap: var(--sp-2); align-items: center; }
/* The rating picker WEARS .btn (see ui_favform()), so everything that makes a
   button a button — the box, the cut corner, the 13px uppercase type, the
   ghost background, the hover — comes from the one definition and cannot drift
   from the star button beside it the way it had: 29px tall against 38px, 4px
   rounded corners against cut ones, a form well's --bg2 in a row of console
   buttons, and 12px type against 13px. Only the parts that are true of a
   <select> and nothing else are left here.
   align-self: stretch is what holds the heights equal at EVERY width, rather
   than a padding tuned to one. The block padding is zeroed so this element
   never drives the row's height — the star .btn does, the stretch copies it,
   and a <select> centres its own text in whatever box it is given. That
   matters because the phone query bumps this to 16px (iOS zooms the page when
   you focus a control under 16px) while .btn stays at 13px, so a height
   computed from this element's own type is wrong there by construction. It
   was: the phone rule below used to pad the difference out by hand, and the
   desktop case it was written beside was never fixed at all.
   Written as .favform .rateselect, and the descendant is load-bearing rather
   than tidy: .btn sets `padding` as a SHORTHAND, and the phone query restates
   it at var(--sp-5) var(--sp-8) — later in the file, same (0,1,0) specificity
   — so a bare .rateselect wins the padding on a desktop and silently loses it
   on a phone, which is exactly the 4.7px height mismatch this rule exists to
   kill. The extra class outranks .btn wherever it is declared. */
.favform .rateselect {
  appearance: none; border-radius: 0; align-self: stretch; padding-block: 0;
  /* Room for the caret: .btn's own 16px inline padding, plus the 6px mark and
     6px of air between it and the label. */
  padding-right: calc(var(--sp-8) + var(--sp-6));
  background-image: var(--caret-dn);
  background-repeat: no-repeat;
  background-position: right var(--sp-5) center;
  background-size: var(--sp-3) var(--sp-3);
}

/* --------------------------------------------------------------- tables */
.datatable { width: 100%; border-collapse: collapse; margin: var(--sp-6) 0; font-size: var(--fs-base); }
.datatable th {
  text-align: left; font-size: var(--fs-xs); letter-spacing: 1.6px; text-transform: uppercase;
  color: var(--dim); border-bottom: 2px solid var(--line); padding: var(--sp-4) var(--sp-5);
}
.datatable td { border-bottom: 1px solid var(--line-soft); padding: var(--sp-4) var(--sp-5); vertical-align: top; }
.datatable tr:hover td { background: var(--sheen-1); }
.datatable.totals { max-width: 480px; }
/* A TOTALS TABLE WITH A PROSE COLUMN TAKES THE PAGE COLUMN. The 480px cap is
   right for a short numeric table — craftlist.php's and item.php's workshop
   cost, both of which are a name and a number — and wrong for item.php's raw
   materials, whose third column is native_to_cell(): on Carbon Fiber the Wood
   row wrapped to SIX lines inside 480px while 720px of the 1200px column sat
   empty beside it. Capping a table whose content is a sentence does not make
   it tidy, it makes it a narrow paragraph next to nothing. Same reasoning as
   .overallcols' override below, pointed at content rather than at container
   width. 1,872 items render this table. */
.datatable.totals--prose { max-width: none; }

/* Sortable headers. The button carries the click and the focus ring so the
   control is real for a keyboard; <th aria-sort> carries the state, which is
   what the caret is drawn from — no second source of truth in a class. */
.datatable th.sortable { padding: 0; }
.datatable th.sortable button {
  display: flex; align-items: center; gap: var(--sp-3); width: 100%;
  background: none; border: 0; cursor: pointer;
  font: inherit; letter-spacing: inherit; text-transform: inherit; text-align: left;
  color: inherit; padding: var(--sp-4) var(--sp-5);
}
.datatable th.sortable button:hover { color: var(--orange-hi); }
/* The caret is the same SVG the selects use, so a sorted column and a dropdown
   point with one mark. Hidden until the column is the sorted one, at half
   strength on hover so the header advertises that it is clickable. */
.datatable th.sortable button::after {
  content: ''; width: var(--sp-3); height: var(--sp-3);
  background: var(--caret-dn) center / contain no-repeat;
  opacity: 0;
}
.datatable th.sortable button:hover::after { opacity: .5; }
.datatable th[aria-sort="ascending"], .datatable th[aria-sort="descending"] { color: var(--orange); }
.datatable th[aria-sort="ascending"] button::after,
.datatable th[aria-sort="descending"] button::after { opacity: 1; }
.datatable th[aria-sort="ascending"] button::after { transform: rotate(180deg); }

/* map.php spawn lists: one table per item category, name column carries the
   item icon so a row is scannable without reading it. */
.spawngroup { margin-bottom: 22px; }
.spawncat {
  font-size: var(--fs-md); letter-spacing: 1.6px; text-transform: uppercase;
  border-left: 3px solid currentColor; padding-left: var(--sp-5); margin: var(--sp-9) 0 var(--sp-2);
}
.countnote { font-size: var(--fs-sm); letter-spacing: 0; text-transform: none; }
.spawntable td:first-child { width: 34%; }
.spawntable td:nth-child(2) { width: 33%; }
.spawnname { display: inline-flex; align-items: center; gap: var(--sp-4); }
.spawnname .itemicon { flex: none; }
/* Tags sit inside a cell here, so drop .tagrow's block margins. */
.spawntable .tagrow { display: inline-flex; margin: 0; }
.pager {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
  margin: var(--sp-9) 0; max-width: 100%;
}
.pager a, .pager span {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 30px; height: 30px; padding: 0 var(--sp-4); border-radius: var(--r-sm);
  font-size: var(--fs-md); color: var(--text); border: 1px solid var(--line);
}
.pager a:hover { background: var(--panel); text-decoration: none; border-color: var(--orange); }
.pager a.cur { background: var(--orange); border-color: var(--orange); color: var(--ink-warm-2); font-weight: 700; }
.pager .pgnav { color: var(--dim); }
.pager .pgnav.disabled { opacity: .35; }
.pager .pgdots { border: none; color: var(--dim); min-width: auto; padding: 0 var(--sp-1); }
.matrix td.hit { color: var(--green); font-size: var(--fs-sm); }
.matrix td.miss { color: var(--text-absent); text-align: center; }
.tierbadge {
  display: inline-block; font-family: var(--mono); font-size: var(--fs-xs); font-weight: 700;
  border: 1px solid var(--line); border-radius: var(--r-xs); padding: var(--sp-px) var(--sp-4); letter-spacing: 1px;
}
.tierbadge.tier-1 { color: var(--t1); border-color: var(--t1); }
.tierbadge.tier-2 { color: var(--t2); border-color: var(--t2); }
.tierbadge.tier-3 { color: var(--t3); border-color: var(--t3); }
.tierbadge.tier-4 { color: var(--t4); border-color: var(--t4); }
.tierbadge.tier-5 { color: var(--t5); border-color: var(--t5); }
.tierbadge.tier-o { color: var(--to); border-color: var(--to); }
.tierbadge.tier-g { color: var(--dim); border-color: var(--dim); }

/* --------------------------------------------------------- recipe tree */
.recipetree { font-size: var(--fs-base); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-6) var(--sp-8); }
.treenode > summary { cursor: pointer; padding: var(--sp-2) 0; list-style: none; }
.treenode > summary::before { content: "▸ "; color: var(--orange); }
.treenode[open] > summary::before { content: "▾ "; }
.treekids { margin-left: 22px; border-left: 1px dashed var(--line-faint); padding-left: var(--sp-7); }
.treeleaf { padding: var(--sp-2) 0; }
.qty { color: var(--orange-hi); font-family: var(--mono); }
.craftmeta { color: var(--dim); font-size: var(--fs-sm); }
.rawmeta { color: var(--t1); font-size: var(--fs-xs); letter-spacing: 1px; text-transform: uppercase; }
.qtyform { display: flex; gap: var(--sp-4); align-items: end; }
.qtyform input { width: 90px; }
.foundon { margin: var(--sp-2) 0 var(--sp-6); }
.wayvia { margin: -4px 0 12px; font-size: var(--fs-md); color: var(--dim); }

/* ------------------------------------------------------- ways to craft
   One row per distinct recipe. Ingredients share a column across rows so the
   difference between two ways of making something is what the eye lands on;
   the bench column is secondary. Rows with documented inputs are links that
   re-drive the breakdown calculator (?recipe=). */
.recipeways { border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; background: var(--panel); }
.wayrow {
  display: grid; grid-template-columns: 22px minmax(0, 1fr) minmax(0, 15em);
  gap: var(--sp-2) var(--sp-6); align-items: center;
  padding: var(--sp-6) var(--sp-7); border-top: 1px solid var(--line);
  color: var(--text); font-size: var(--fs-md);
}
.wayrow:first-child { border-top: 0; }
.recipeways.single .wayrow { grid-template-columns: minmax(0, 1fr) minmax(0, 15em); }
a.wayrow:hover { background: var(--panel2); text-decoration: none; }
a.wayrow:hover .waymark { color: var(--orange-hi); }
.wayrow.sel { background: color-mix(in srgb, var(--orange) 7%, transparent); box-shadow: inset 3px 0 0 var(--orange); }
.waymark { color: var(--dim); font-size: var(--fs-md); text-align: center; }
.wayrow.sel .waymark { color: var(--orange); }

.wayings { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); min-width: 0; }
.waying { display: inline-flex; align-items: center; gap: var(--sp-3); white-space: nowrap; }
.waying .itemicon { border-radius: var(--r-xs); }
.waying b { color: var(--orange-hi); font-family: var(--mono); font-size: var(--fs-sm); }
.wayplus { color: var(--dim); font-size: var(--fs-sm); }
.waynote {
  color: var(--dim); font-size: var(--fs-micro); letter-spacing: 1px; text-transform: uppercase;
  border: 1px solid var(--line); border-radius: var(--r-xs); padding: var(--sp-px) var(--sp-3); white-space: nowrap;
}
.wayrow.sel .waynote { color: var(--orange); border-color: color-mix(in srgb, var(--orange) 50%, transparent); }
.wayundoc { color: var(--dim); font-style: italic; }
.waybench { color: var(--dim); font-size: var(--fs-sm); text-align: right; line-height: 1.45; }
.waybn { white-space: nowrap; }   /* wrap between bench names, never inside one */
.waysep { opacity: .5; }
.waymore { white-space: nowrap; color: var(--text-3); border-bottom: 1px dotted var(--text-5); cursor: help; }
/* inferred, never blended into the documented benches beside it (see schema.sql) */
.wayderived { display: block; color: var(--text-3); font-size: var(--fs-xs); border-top: 1px dashed var(--line); margin-top: var(--sp-2); padding-top: var(--sp-2); }
.wayshint { font-size: var(--fs-sm); margin: var(--sp-4) 0 0; }

@media (max-width: 700px) {
  /* On a phone the PRIMARY search owns its row and whatever follows it (a
     button, a category select) wraps beneath. Every page did this before the
     sweep, but only as a side effect of .treetools not carrying .filterbar's
     170px input floor. Merging the two toolbars made it explicit — which is
     the point: it is now one rule for every page instead of a property of
     which toolbar class a page happened to pick. */
  /* .cmpsearchwrap is the search-to-add box's positioning context, so IT is
     the toolbar's flex child there and the input inside it is not — the rule
     has to name both or compare/altbench keep their search on a shared row. */
  .toolbar .searchfield, .toolbar .searchbox, .toolbar .cmpsearchwrap { flex: 1 1 100%; }
  .wayrow, .recipeways.single .wayrow { grid-template-columns: 22px minmax(0, 1fr); }
  .recipeways.single .wayrow { grid-template-columns: minmax(0, 1fr); }
  .waybench { grid-column: 2; text-align: left; }
  .recipeways.single .waybench { grid-column: 1; }
}

/* ------------------------------------------------- item icons / pictures */
.itemicon {
  display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
  border-radius: var(--r-md); overflow: hidden;
  background: color-mix(in srgb, currentColor 12%, var(--bg2));
  border: 1px solid color-mix(in srgb, currentColor 38%, var(--line));
}
.itemicon.has-img { background: var(--well); border-color: var(--line); color: inherit; }
.itemicon img { width: 100%; height: 100%; object-fit: cover; display: block; }
.itemcell { display: inline-flex; align-items: center; gap: var(--sp-5); color: var(--text); min-width: 0; }
.itemcell:hover { text-decoration: none; }
.itemcell:hover .item-link { text-decoration: underline; }
.itemhero { display: flex; align-items: center; gap: var(--sp-8); }
.itemhero > .itemicon { border-radius: var(--r-lg); }
.itemhero h1 { margin: var(--sp-3) 0 var(--sp-3); }
.itemimg-field { display: flex; align-items: center; gap: var(--sp-7); margin: var(--sp-6) 0 var(--sp-2); }
.itemimg-field label { margin: 0; }

/* ---------------------------------------------------------- compare */
/* Items are columns, attributes are rows. The first column (labels) and the
   header row (item names) both stick, so you never lose track of which stat or
   which item a cell belongs to while scrolling a wide comparison. */
/* The search-to-add box is a .toolbar on both its pages (the <form> itself
   carries the class), so this only has to be the positioning context the
   dropdown hangs off and the flex child that eats the row's slack. There used
   to be a page-private .cmpadd wrapper here that centred its contents, which
   made compare.php the one page whose controls floated in the middle of the
   column instead of starting at the margin — and altbench's copy immediately
   overrode it back to flex-start, which is the tell. */
.cmpsearchwrap { position: relative; flex: 1 1 260px; }
.cmpsearchwrap .searchfield, .cmpsearchwrap input { width: 100%; }
/* Live matches for the "add an item" search, dropped below the input. Each
   match is a real submit button (name=add_id) styled like a feed row rather
   than a native <select>/datalist, so it can show a tier badge and never
   depends on a browser's own autocomplete rendering.
   Shared by compare.php and altbench.php's "Add a gear piece" — same
   search-to-add widget, so one set of rules rather than a near-copy each. */
.cmpresults {
  position: absolute; top: 100%; left: 0; right: 0; z-index: 6; margin-top: var(--sp-2);
  display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-2);
  background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r-lg);
  max-height: 320px; overflow-y: auto; box-shadow: 0 8px 20px var(--shade-5);
}
/* Same reason as .attopt[hidden] above: .cmpresults sets its own `display`,
   which otherwise beats the [hidden] attribute the search script toggles. */
.cmpresults[hidden] { display: none; }
button.cmpresult { background: none; border: none; font: inherit; text-align: left; cursor: pointer; width: 100%; }
.cmpresult.active, .cmpresult:hover { background: var(--panel); }
/* altbench's rows carry a gear class between the name and the tier badge. */
.cmpresult .dim { font-size: var(--fs-sm); white-space: nowrap; }
/* Footer line when a query matches more items than the list renders. Not a
   .cmpresult, so the keyboard handler never lands on it. */
.cmpmore { font-size: var(--fs-sm); padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--line); }
/* table-layout: fixed + an unsized <col> per item column (compare.php's
   colgroup) splits the remaining width evenly across however many items are
   in the set (max COMPARE_MAX = 4) — otherwise the browser sizes each column
   to its own content and columns end up lopsided next to each other, which
   defeats the point of a side-by-side comparison. The table's own min-width
   (inline style, ~210px/item) is a floor under that even split so columns
   never get fixed-layout-squeezed on a phone; below it .tablescroll's
   overflow-x takes over instead, same as any other wide table here. */
.cmptable { font-variant-numeric: tabular-nums; table-layout: fixed; }
.cmpcornercol { width: 190px; }
.cmptable th, .cmptable td { border-right: 1px solid var(--line-soft); }
.cmptable th:last-child, .cmptable td:last-child { border-right: 0; }
/* Not top-sticky: thead sits inside .tablescroll, whose own overflow-x:auto
   forces its computed overflow-y to auto too (CSS Overflow's "visible next to
   non-visible becomes auto" rule) — that makes .tablescroll, not the
   viewport, the containing block for any sticky descendant. A `top` sticky
   here would anchor to .tablescroll (which never scrolls independently) and
   just render permanently offset, overlapping the row below, without ever
   actually sticking during a real page scroll. Left-sticky (.cmpcorner,
   .cmplabel, .cmpsection th below) is unaffected — that axis genuinely is
   .tablescroll's own scroll axis. */
.cmptable thead th {
  background: var(--bg2); vertical-align: bottom; text-align: center;
}
.cmpcorner { position: sticky; left: 0; z-index: 4; }
.cmplabel {
  position: sticky; left: 0; z-index: 2;
  background: var(--bg2); text-transform: none; letter-spacing: .3px;
  font-size: var(--fs-sm); color: var(--dim);
}
.cmphead { min-width: 150px; }
.cmpitem { display: inline-flex; flex-direction: column; align-items: center; gap: var(--sp-3); color: var(--text); }
.cmpitem:hover { text-decoration: none; }
.cmpitem:hover .cmpname { text-decoration: underline; color: var(--orange-hi); }
.cmpname { font-size: var(--fs-md); letter-spacing: 0; text-transform: none; text-align: center; max-width: 160px; }
.cmphead > div { margin: var(--sp-3) 0 var(--sp-1); }
.cmpremove { margin: 0; }
.cmpremove .linkbtn { text-transform: uppercase; letter-spacing: .8px; font-size: var(--fs-micro); }
.cmptable td { text-align: center; }
.cmpsection th {
  background: var(--panel); color: var(--orange);
  font-size: var(--fs-xs); letter-spacing: 1.6px; border-bottom: 1px solid var(--line);
  position: sticky; left: 0;
}
/* Winning cell in a numeric row. */
.cmptable td.best {
  color: var(--ink-ok); font-weight: 700;
  background: color-mix(in srgb, var(--green) 14%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--green) 35%, transparent);
}

/* item.php intrinsic-stats panel — the same grouping as compare.php, read as a
   spec sheet. Only the grouping lives here: each group's stats are a plain
   .guidefacts/.fact strip, so an item's Damage reads exactly like a mission's
   Time limit instead of sitting in its own bordered cell. */
/* NOT a facts strip despite the old name .statpanel — it is the margin
   wrapper around item.php's group of fact sections. It was briefly folded
   into .guidefacts during the 2026-08 sweep on the assumption that it was a
   leftover stat box; it is not, and merging them truncated the section rules
   to the width of their headings. A wrapper that only carries spacing is
   allowed to exist. */
.factgroup { margin: var(--sp-6) 0 var(--sp-2); }
.statgroup { margin-bottom: var(--sp-7); }
.statgroup:last-child { margin-bottom: 0; }
.statgroup-h {
  margin: 0 0 var(--sp-4); color: var(--orange);
  font-size: var(--fs-xs); font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase;
}

/* items.php compare column + floating tray */
.col-cmp { width: 30px; text-align: center; padding-left: 4px !important; padding-right: 4px !important; }
.col-cmp input { width: 16px; height: 16px; accent-color: var(--orange); cursor: pointer; }
.cmptray {
  position: sticky; bottom: 14px; z-index: 20; margin: 16px auto 0; width: max-content; max-width: 100%;
  display: flex; align-items: center; gap: var(--sp-6); padding: var(--sp-5) var(--sp-7);
  background: linear-gradient(180deg, var(--bar-1), var(--bar-2));
  border: 1px solid var(--orange); border-radius: var(--r-lg);
  box-shadow: 0 8px 24px var(--shade-5);
}
.cmptray-n { font-size: var(--fs-sm); letter-spacing: 1px; text-transform: uppercase; color: var(--dim); }
.btn-disabled { opacity: .45; pointer-events: none; }
.filter-summary { display: none; }

/* per-category accent (drives icon tint + tile edge + chip highlight) */
.cat-material   { --cc: #9aa7b4; }
.cat-tool       { --cc: #58a6d8; }
.cat-weapon     { --cc: #e0556f; }
.cat-ammo       { --cc: #e8862a; }
.cat-armor      { --cc: #b07fe0; }
.cat-bench      { --cc: #7fc25c; }
.cat-building   { --cc: #c9a06a; }
.cat-deployable { --cc: #5bc0c0; }
.cat-farming    { --cc: #8bc34a; }
.cat-food       { --cc: #ffd75e; }
.cat-furniture  { --cc: #d98cb3; }
.cat-medical    { --cc: #e06052; }
.cat-storage    { --cc: #b98f5a; }
.cat-livestock  { --cc: #9c6b43; }

/* ------------------------------------------------------------ tech tree */
/* ONE toolbar — the controls above a list. .toolbar (8 pages) and
   .toolbar (8 pages) were two names for it, and resource_log.php used both.
   .toolbar is the name; --split is the only real difference (search pushed
   apart from the actions, rather than everything packed left). */
.toolbar {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--sp-6); margin: var(--sp-7) 0 var(--sp-6);
}
.toolbar--split { justify-content: space-between; }
/* ONE search box. Every place the site offers a text field to narrow or look
   something up wears this: the browse-page filters (guides, items, tree,
   creatures, maps, resources, alt bench), the home hero and search.php, the
   pickers on compare/alt bench, mapview's spawn filter and admin's three table
   filters. It used to be called .treesearch and named tree.php while serving
   eleven pages; three more places wrote no class at all (units.php, admin's
   three) and creatures.php wrote `.drillsearch`, which HAS NO RULE ANYWHERE —
   so those five fell back to the bare `input` and rendered 41px tall against
   this 45px, without the magnifier the other eleven carried. A control that is
   the same control has to be the same size. Features still differ freely (live
   filter vs submit, extra selects, a picker dropdown); the box does not. */
.searchbox { flex: 1; min-width: 220px; font-size: var(--fs-base); padding: var(--sp-5) var(--sp-7); }
/* The field's furniture. .searchfield is the positioning context so the icon
   and the key hint sit INSIDE the input's border box — nothing here may sit
   proud of it, which is the no-offset-decoration rule the corner brackets were
   removed for. The input carries the border and the focus ring exactly as
   before; these two are painted on top of its own padding. */
.searchfield { position: relative; flex: 1; min-width: 220px; display: flex; align-items: center; }
.searchfield .searchbox { width: 100%; padding-left: 38px; }
.searchfield-ico {
  position: absolute; left: 13px; width: 16px; height: 16px;
  pointer-events: none; color: var(--dim); transition: color .12s;
}
/* :focus-within rather than a class — the state belongs to the input and the
   browser already tracks it, so no script is involved in the icon lighting up. */
.searchfield:focus-within .searchfield-ico { color: var(--orange); }
/* The "/" hint. Sized off the same tokens as .chip--mono so a keycap and a
   filter pill read as the same family. It goes away on focus: once the caret
   is in the box the shortcut has done its job, and a key hint sitting beside
   text somebody is typing reads as part of the value. */
.searchfield-kbd {
  position: absolute; right: 10px; padding: 1px 7px;
  font-family: var(--mono); font-size: var(--fs-sm); color: var(--dim);
  border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--bg);
  pointer-events: none; transition: opacity .12s;
}
.searchfield--kbd .searchbox { padding-right: 34px; }
.searchfield:focus-within .searchfield-kbd { opacity: 0; }
/* ...and once there is a VALUE, whether or not the field still has focus: the
   browser puts its own clear button in exactly this spot, so a field somebody
   typed in and then clicked away from showed a keycap and an X on top of each
   other. Sibling selector off :placeholder-shown rather than :has(), which
   this needs no part of — the hint follows the input in source order. */
.searchfield .searchbox:not(:placeholder-shown) ~ .searchfield-kbd { opacity: 0; }

/* THE BROWSER'S OWN CLEAR BUTTON, RECOLOURED. Chrome draws
   ::-webkit-search-cancel-button in the UA accent — it came out BLUE, which is
   the identical defect the emoji magnifier had: the one mark in the bar that
   no token reaches, appearing the moment somebody types. It cannot be given a
   colour directly, so the glyph is a mask and `background-color` is what the
   token paints. Keeping it (rather than hiding it) preserves a real
   affordance: one click empties the filter and restores the full list. */
.searchbox::-webkit-search-cancel-button {
  -webkit-appearance: none; appearance: none;
  width: 12px; height: 12px; cursor: pointer; background-color: var(--dim);
  -webkit-mask: var(--x-glyph) center / contain no-repeat;
          mask: var(--x-glyph) center / contain no-repeat;
}
.searchbox::-webkit-search-cancel-button:hover { background-color: var(--text); }
/* A touch device has no "/" to press, and the hint would be pure decoration
   eating the width the placeholder needs — the field is 347px on a phone. */
@media (max-width: 700px) { .searchfield-kbd { display: none; } }

/* the display rules below (grid/flex) would otherwise beat the [hidden]
   attribute the JS toggles to filter and switch views — re-assert it.
   .datatable rows are here for items.php, which filters the same way.
   .chip is here for item.php's "Used in" filter: .chip is inline-flex, so
   until this line a filtered-out chip stayed on screen and the filter looked
   broken while doing exactly what it was told. .chip--lg had its own copy of
   this rule further down for the same reason — that is the tell that the base
   class was missing one. */
.drillbar[hidden], #panel[hidden], .btnrow[hidden], .cmptray[hidden],
.tiergroup[hidden], .benchblock[hidden], .itemtile[hidden],
.treeloading[hidden],
.maplayer[hidden], .mapgrid-overlay[hidden], .mapmeasure[hidden],
.spawnlist li[hidden], .chip[hidden], .chipgroup[hidden],
.datatable tbody tr[hidden] { display: none; }

/* Shown only while a category/search panel is actually being fetched — a
   cached one swaps in without it, so this should rarely be seen. */
.treeloading { color: var(--dim); font-style: italic; padding: var(--sp-7) var(--sp-2); }

/* NB: `table-layout: fixed` looks like the obvious fix for this table (the
   browser measures all 2801 rows to size six columns), but it was measured at
   only ~9% off the load — the cost is DOM construction, not column sizing —
   and it collapses on a phone, where the two col-opt columns drop out and the
   rest get squeezed until names break one letter per line. Left on auto. */

/* items.php reuses the tree's toolbar: search + selects pinned on top, tier
   chips and the live count in the drill bar under it. The selects sit next to
   the box rather than stretching, so the search keeps the width it earns. */
.toolbar select { flex: 0 1 auto; }
/* .tierfilter already claims margin-left:auto; without this the second auto
   margin on .drillact would split the gap and strand the chips mid-bar. */
.drillbar .tierfilter + .drillact { margin-left: 0; }
/* .btnrow's own 10px block margin would push the tree's expand/collapse pair
   below the chips it sits beside — the bar aligns its rows itself. */
.drillbar .btnrow { margin: 0; }

/* category hub — the landing grid of clickable category cards. The grid is
   .cardlist--map and the cards are .mapcard (see .catcard above); what is left here
   is the contents of one card, sized to read like a map card's caption. */
.catcard-top { display: flex; align-items: center; gap: var(--sp-5); }
.catcard-name { font-size: var(--fs-lg); font-weight: 600; }
.catcard-count { font-size: var(--fs-sm); color: var(--dim); margin-top: var(--sp-1); }
.catcard-bar { display: flex; height: 5px; border-radius: var(--r-sm); overflow: hidden; background: var(--bg2); margin: var(--sp-4) 0 var(--sp-3); }
.catcard-bar .seg { display: block; height: 100%; }
.catcard-tiers { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); font-family: var(--mono); font-size: var(--fs-xs); }
.catcard-tiers .tt { color: var(--dim); }
.seg.tier-1 { background: var(--t1); } .tt.tier-1, .chip--mono.tier-1 { color: var(--t1); }
.seg.tier-2 { background: var(--t2); } .tt.tier-2, .chip--mono.tier-2 { color: var(--t2); }
.seg.tier-3 { background: var(--t3); } .tt.tier-3, .chip--mono.tier-3 { color: var(--t3); }
.seg.tier-4 { background: var(--t4); } .tt.tier-4, .chip--mono.tier-4 { color: var(--t4); }
.seg.tier-5 { background: var(--t5); } .tt.tier-5, .chip--mono.tier-5 { color: var(--t5); }
.seg.tier-o { background: var(--to); } .tt.tier-o, .chip--mono.tier-o { color: var(--to); }
.seg.tier-g { background: var(--dim); } .tt.tier-g, .chip--mono.tier-g { color: var(--dim); }

/* drill toolbar shown when inside a category / search view */
.drillbar { display: flex; align-items: center; gap: var(--sp-6); flex-wrap: wrap; margin: var(--sp-2) 0 var(--sp-7); padding-bottom: var(--sp-6); border-bottom: 1px solid var(--line); }
.drilltitle { font-size: var(--fs-xl); font-weight: 600; letter-spacing: .3px; }
/* The count of what the panel is showing, beside its title — same weight as
   the per-tier counts inside the panel, so the two don't compete. */
.drillcount { font-size: var(--fs-sm); margin-left: -4px; }
.tierfilter { display: flex; gap: var(--sp-3); flex-wrap: wrap; margin-left: auto; }

/* Tier colour at ONE border weight on all four sides — a 3px left edge against
   1px on the other three reads as the box sitting off its own outline. Same
   rule as .subtree and .itemtile.

   The COLOUR below is the base layer and is not what ships: the field journal
   refresh near the end of this file repaints border-color to --rule and puts
   --tclr on border-left-color alone. That is a rail down the left again, but at
   EQUAL weight — no corner overshoot, which is the defect this rule exists to
   prevent — and it is the deliberate current look, matching .itemtile's
   category colour. Edit the refresh layer, not this, or the two disagree and
   only one of them is on screen. */
.tiergroup {
  border: 1px solid color-mix(in srgb, var(--tclr, var(--orange)) 38%, var(--line));
  border-radius: var(--r-lg); margin: var(--sp-6) 0; overflow: hidden; background: var(--panel);
}
.tiergroup.tier-1 { --tclr: var(--t1); }
.tiergroup.tier-2 { --tclr: var(--t2); }
.tiergroup.tier-3 { --tclr: var(--t3); }
.tiergroup.tier-4 { --tclr: var(--t4); }
.tiergroup.tier-5 { --tclr: var(--t5); }
.tiergroup.tier-o { --tclr: var(--to); }
.tiergroup.tier-g { --tclr: var(--dim); }
.tiergroup-head {
  display: flex; align-items: center; gap: var(--sp-6); padding: var(--sp-6) var(--sp-8); cursor: pointer;
  -webkit-user-select: none; user-select: none;
  background: linear-gradient(180deg, var(--panel2), var(--panel));
}
.tiergroup-head:hover { background: var(--panel2); }
.tiergroup-head:focus-visible { outline: 1px solid var(--orange); outline-offset: -2px; }
.tiergroup-head h2 { margin: 0; font-size: var(--fs-lg); letter-spacing: 1px; text-transform: uppercase; }
.tiergroup-head .tg-count { margin-left: auto; font-size: var(--fs-sm); }
.tg-caret { color: var(--dim); transition: transform .15s; font-size: var(--fs-sm); }
.tiergroup.collapsed .tg-caret { transform: rotate(-90deg); }
.tiergroup.collapsed .tiergroup-body { display: none; }
.tiergroup-body { padding: var(--sp-1) var(--sp-8) var(--sp-8); }

.benchblock { margin-top: var(--sp-6); }
.benchname {
  font-size: var(--fs-xs); letter-spacing: 1.5px; text-transform: uppercase; color: var(--orange);
  margin: var(--sp-8) 0 var(--sp-5); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--line);
}
.itemtiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(185px, 1fr)); gap: var(--sp-4); }
.itemtile {
  display: flex; align-items: center; gap: var(--sp-5); min-width: 0; padding: var(--sp-4) var(--sp-6);
  /* Category colour tints the whole 1px border rather than fattening the left
     edge to 3px — a mismatched side reads as the tile sitting off its outline.
     Same rule as .tiergroup and .subtree. */
  background: var(--bg2); border: 1px solid color-mix(in srgb, var(--cc, var(--line)) 38%, var(--line));
  border-radius: var(--r-md); color: var(--text); transition: border-color .12s, transform .12s, background .12s;
  /* The tech tree ships every item as a tile (~2800 of them), so drilling a
     big category or searching can lay out hundreds at once. Skip layout and
     paint for the ones scrolled out of view. "auto" in contain-intrinsic-size
     means the browser remembers each tile's real height after it has been
     rendered once, so the 56px estimate (40px icon + padding + border) only
     ever applies to a tile that has not been on screen yet — scroll height
     stays honest. Tiles remain in the DOM and keep matching
     querySelectorAll('.itemtile'), which is what the filter in tree.php
     drives off, and in-page find still reaches them. */
  content-visibility: auto;
  contain-intrinsic-size: auto 56px;
}
.itemtile-name { font-size: var(--fs-md); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* marks a tier item that is ALSO sold at the Orbital Workshop */
.wsflag {
  margin-left: auto; flex: 0 0 auto; font-family: var(--mono); font-size: var(--fs-micro); font-weight: 700;
  letter-spacing: .5px; color: var(--to); border: 1px solid var(--to); border-radius: var(--r-xs); padding: 0 var(--sp-2);
}

/* guide.php's Mission requirements grid reuses .itemtile so a required item
   looks the same here as on the tech tree. Two additions: the count the quest
   asks for, and a non-link variant for the game's generic asks ("Any Axe"),
   which have no item row and so nowhere to click through to. There are at most
   a few dozen per guide, so the tech tree's content-visibility deferral is
   turned back off — at this count it only costs a scroll-height estimate. */
/* The categories flow along one line and wrap as whole groups, rather than each
   starting a row of its own — a mission with three materials, one weapon and
   one generic ask used three stacked rows to show five tiles, most of the page
   empty beside each. Tiles keep the size they have on the tech tree; the row
   only breaks once there genuinely isn't room for the next group.
   (Not stretching the tiles to fill: a lone "Any Head Vestige" spanning the
   page is worse than a short row.) */
.reqgroups { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-10); align-items: flex-start; }
.reqgroup { min-width: 0; }
.reqtiles { margin-bottom: var(--sp-7); display: flex; flex-wrap: wrap; gap: var(--sp-4); }
/* Fixed width, so a group of one and a group of five use the same tile and the
   whole area reads as one set of things rather than three layouts.
   No category stripe here: .itemtile's left edge carries --cc, the tech tree's
   colour code for an item's category. On that page it is doing work — it is how
   you pick a category out of hundreds of tiles at a glance. Here the tiles are
   already grouped under the category's own heading, so the stripe restates the
   heading and just reads as the boxes being nudged right.
   Matched to the other three sides instead — through --cc, so the shared :hover
   rule can still light all four sides orange. The field journal refresh at the
   end of this file has to neutralise the left edge a SECOND time: it repaints
   the other three to --rule while the left keeps color-mix(--cc 38%, --line),
   so --cc alone can no longer reach them and the rail came back at
   rgb(51,60,68) against their rgb(75,79,81). See .reqtiles .itemtile there. */
.reqtiles .itemtile {
  content-visibility: visible; flex: 0 0 auto; width: 185px;
  --cc: var(--line); border-left-width: 1px;
}
.reqqty {
  margin-left: auto; flex: 0 0 auto; font-family: var(--mono); font-size: var(--fs-md);
  font-weight: 700; color: var(--text);
}
.itemtile.generic { cursor: default; }
.itemtile.generic:hover { border-color: var(--line); transform: none; }
.itemtile.generic .itemtile-name { color: var(--dim); font-style: italic; }

/* --------------------------------------------------------------- maps */
.mapcard-body { padding: var(--sp-5) var(--sp-7); }
/* Clip the art to the card's rounded top corners on the holder itself, so the
   card doesn't have to clip its whole box to keep the image inside the radius.
   The 1px inset is the border it sits against — an outer radius clipped at its
   own value leaves a hairline of art in each corner. */
.mapcard .mapholder { border-radius: calc(var(--r-md) - 1px) calc(var(--r-md) - 1px) 0 0; overflow: hidden; }
.mapcard-body h2 { margin: var(--sp-1) 0 var(--sp-3); font-size: var(--fs-lg); }
.mapcard-body p { font-size: var(--fs-sm); margin: var(--sp-3) 0 var(--sp-1); }
.mapholder {
  height: 130px; position: relative; display: flex; align-items: center; justify-content: center;
  color: var(--text-faint); font-size: var(--fs-md); letter-spacing: 2px; text-transform: uppercase;
  background:
    linear-gradient(color-mix(in srgb, var(--orange) 4%, transparent) 1px, transparent 1px),
    linear-gradient(90deg, color-mix(in srgb, var(--orange) 4%, transparent) 1px, transparent 1px), var(--bg2);
  background-size: 24px 24px;
}
.mapholder .mapscene, .mapholder img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.mapholder.big { height: 340px; border: 1px solid var(--line); border-radius: var(--r-md); margin: var(--sp-6) 0; }

/* map.php's way in to mapview.php. It sits over the curated scene art, so it
   carries its own scrim rather than trusting the image underneath to be dark
   enough — the whole holder is the click target, the button just anchors the
   eye. */
.mapenter {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-4);
  background: radial-gradient(ellipse at center, color-mix(in srgb, var(--bg) 34%, transparent), color-mix(in srgb, var(--bg) 80%, transparent));
  transition: background .15s;
  text-decoration: none;
}
.mapenter:hover { background: radial-gradient(ellipse at center, color-mix(in srgb, var(--bg) 18%, transparent), color-mix(in srgb, var(--bg) 72%, transparent)); }
.mapenter-cta {
  background: linear-gradient(180deg, var(--btn-1), var(--btn-2)); color: var(--ink-warm);
  border: 1px solid var(--btn-line); border-radius: var(--r-sm);
  padding: var(--sp-6) 26px; font-size: var(--fs-base); font-weight: 700;
  letter-spacing: 1.4px; text-transform: uppercase;
  box-shadow: 0 4px 20px color-mix(in srgb, var(--orange) 35%, transparent);
  transition: transform .15s, box-shadow .15s;
}
.mapenter:hover .mapenter-cta { transform: translateY(-1px); box-shadow: 0 6px 26px color-mix(in srgb, var(--orange) 50%, transparent); }
.mapenter:focus-visible .mapenter-cta { outline: 2px solid var(--orange-hi); outline-offset: 3px; }
.mapenter-sub { font-size: var(--fs-sm); color: var(--text-bright); letter-spacing: 1px; text-transform: uppercase; }

/* Coordinate overlay — the game's own 16x16 A-P/1-16 map grid (see
   map_grid_overlay() in inc/helpers.php, which sets --grid-n as data).
   Borders sit on the top/left edge of every cell so there is no trailing edge
   to strip: the old :nth-child(8n)/:nth-child(n+57) trick was hardcoded to an
   8-wide grid and strips the wrong cells at any other size. */
.mapgrid-overlay {
  position: absolute; inset: 0; display: grid;
  grid-template-columns: repeat(var(--grid-n, 16), 1fr);
  grid-template-rows: repeat(var(--grid-n, 16), 1fr);
  pointer-events: none;
}
.mapgrid-cell {
  position: relative;
  border-top: 1px solid var(--sheen-3);
  border-left: 1px solid var(--sheen-3);
}
/* 16x16 is 256 labels — unreadable mush at fit-to-viewport, so they fade in
   only once mapview.js says we are zoomed in far enough for them to mean
   something. The rules themselves always show. */
.mapgrid-label {
  position: absolute; top: 2px; left: 3px;
  font-size: var(--fs-xs); font-weight: 700; letter-spacing: .5px;
  color: var(--ink-light); background: color-mix(in srgb, var(--well) 72%, transparent); border-radius: var(--r-xs); padding: 0 var(--sp-2); line-height: 1.5;
  opacity: 0; transition: opacity .12s;
  /* Counter-scale the canvas transform so the pill is the same size on screen
     at every zoom level (mapview.js publishes --zoom-inv = 1/scale). */
  transform: scale(var(--zoom-inv, 1)); transform-origin: 0 0;
}
.mapviewport.showlabels .mapgrid-label { opacity: 1; }

/* -------------------------------------------------------------- planner */
/* No .plannerlayout grid: the planner is one column. It was `1fr 300px`, the
   map beside the drop's checklist in a .plannerside aside; the checklist went
   with drop_tasks in 2026-08 (seed/migration_drops_cut_lists.sql) and a
   two-column grid holding one child does not collapse — it keeps paying for the
   empty track, which the map-only ?map=N view had been doing all along. */
.plannertoolbar {
  display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  background: var(--panel); border: 1px solid var(--line); border-bottom: none;
  border-radius: var(--r-md) var(--r-md) 0 0; padding: var(--sp-4) var(--sp-6);
}
.symchoice input { display: none; }
/* Uniform boxed icon buttons, same footprint whether selected or not — an
   always-visible border so the row reads as one button group (like
   .legendrow's chips) instead of loose emoji that only gain a box once picked. */
.symchoice span {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px;
  border: 1px solid var(--line); background: var(--bg2);
  border-radius: var(--r-sm); cursor: pointer;
}
.symchoice span img { width: 18px; height: 18px; }
.symchoice:hover span { border-color: var(--orange); }
.symchoice input:checked + span { border-color: var(--orange); background: color-mix(in srgb, var(--orange) 15%, transparent); }

/* Same radio-behind-a-label pattern as .symchoice, but the "icon" is just the
   colour itself — round like .swatch (stickies' own colour picker) rather
   than boxed, so the two pickers stay visually distinct at a glance. */
.colorchoice input { display: none; }
.colorchoice span {
  display: inline-block; width: 22px; height: 22px; border-radius: 50%;
  background: var(--swatch-color); border: 2px solid var(--line); cursor: pointer;
  box-shadow: 0 0 0 1px var(--shade-5);
}
.colorchoice:hover span { border-color: var(--orange); }
.colorchoice input:checked + span { border-color: var(--orange-hi); box-shadow: 0 0 0 1px var(--shade-5), 0 0 0 3px color-mix(in srgb, var(--orange) 35%, transparent); }

/* Same swatch look as .colorchoice, as a plain button instead of a radio —
   the marker-edit popup (#mp-color) drives selection with a JS .active class
   the way stickies.php's own .swatch picker already does. Own class rather
   than reusing .sdot/.swatch: those size their colour from a fixed .c-name
   class, and this one needs an arbitrary --swatch-color per pin colour. */
.pinswatch {
  width: 20px; height: 20px; border-radius: 50%; padding: 0;
  background: var(--swatch-color); border: 2px solid var(--line); cursor: pointer;
  box-shadow: 0 0 0 1px var(--shade-5);
}
.pinswatch:hover { border-color: var(--orange); }
.pinswatch.active { border-color: var(--orange-hi); box-shadow: 0 0 0 1px var(--shade-5), 0 0 0 3px color-mix(in srgb, var(--orange) 35%, transparent); }

/* panzoom.js's cooperative-scroll hint — shared by .mapviewport and
   .plannerviewport, appended into whichever viewport a bare (non-ctrl) wheel
   fires over. Fades in/out rather than popping, so a wheel-scrolling user
   sees it as a passing nudge, not a dialog blocking the page scroll it just let through. */
.pz-zoomhint {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  z-index: 4; pointer-events: none;
  padding: var(--sp-4) var(--sp-8); border-radius: var(--r-md); white-space: nowrap;
  background: color-mix(in srgb, var(--void) 85%, transparent); border: 1px solid var(--line);
  color: var(--text); font-size: var(--fs-md); letter-spacing: .3px;
  opacity: 0; transition: opacity .2s ease;
}
.pz-zoomhint.show { opacity: 1; }

.plannerviewport {
  position: relative; overflow: hidden; height: 68vh; min-height: 380px;
  background: var(--bg2); border: 1px solid var(--line); border-radius: 0 0 var(--r-md) var(--r-md);
  cursor: crosshair; -webkit-user-select: none; user-select: none;
  /* the JS drives pan/pinch itself — stop the browser also scrolling the page */
  touch-action: none;
}
.plannercanvas { position: absolute; transform-origin: 0 0; }
/* Direct-child selector: #markers .pin img.pinface is nested one level
   deeper and sizes via .pinface instead, not the 1:1 map-image rule. */
.plannercanvas > img { display: block; max-width: none; width: 1000px; }
.plannergrid {
  width: 1000px; height: 1000px; display: flex; align-items: center; justify-content: center;
  color: var(--text-faint); letter-spacing: 2px; text-transform: uppercase; font-size: var(--fs-md); text-align: center;
  background:
    linear-gradient(color-mix(in srgb, var(--orange) 6%, transparent) 1px, transparent 1px),
    linear-gradient(90deg, color-mix(in srgb, var(--orange) 6%, transparent) 1px, transparent 1px), var(--well-3);
  background-size: 50px 50px;
}
#markers { position: absolute; inset: 0; }
.pin { position: absolute; transform: translate(-50%, -90%); cursor: grab; text-align: center; touch-action: none; pointer-events: auto; }
.pin:active { cursor: grabbing; }
/* The contrast outline, so a pin reads over snow, sand, rock or night alike.
   Two things decide how it's built:
   - It hangs on .pin, not on the .pinface glyph it outlines, because browsers
     apply `filter` BEFORE masking — a drop-shadow on the masked face is drawn
     and then clipped away by that same mask, which is why the shadow this rule
     replaces never actually rendered. The parent is unmasked, so it works.
   - It's four zero-blur drop-shadows, one per direction, rather than a border
     or outline: a pin is a silhouette, not a box, and only a shadow follows
     the glyph's own edge. They chain (each shadows the already-widened result)
     so the diagonals fill in. A blurred shadow reads as a halo, not an edge.
   Dark, because every marker colour is light (see marker_colors()) — the rim
   carries a pin over pale ground, its own brightness carries it over dark.
   Opaque, because overlapping translucent rims would mottle. Kept in a
   variable so the selected/focus rule, which replaces `filter` wholesale,
   can't lose the rim along with the shadow. Crew logs opt out: theirs is a
   504 m cell box, not a glyph, and its unmasked <img> face rims itself. */
.pin { --pin-rim:
    drop-shadow(1px 0 0 var(--void)) drop-shadow(-1px 0 0 var(--void))
    drop-shadow(0 1px 0 var(--void)) drop-shadow(0 -1px 0 var(--void)); }
.pin:not(.crewlog) { filter: var(--pin-rim) drop-shadow(0 2px 4px var(--shade-5)); }
/* A pin's glyph is fine to click but too small to reliably hit with a thumb.
   Pad the hit area out on touch without moving the pin's point or growing the
   glyph: everything is border-box, so the padding has to come out of a bigger
   box, and the mask/background have to clip to the content box or they would
   simply paint over the padding and the glyph would grow with the target. */
@media (hover: none) {
  .pinface {
    width: 38px; height: 38px; padding: var(--sp-3); margin: -6px;
    background-clip: content-box;
    -webkit-mask-origin: content-box; -webkit-mask-clip: content-box;
    mask-origin: content-box; mask-clip: content-box;
  }
}
/* Dropped pins are a <span>, tinted via mask rather than an <img> src, so one
   white silhouette PNG can render in whatever colour the Prospector picked
   (--pin-color) — see map_pin_html() / mapview.js / planner.js. The crew-log
   marker is a plain <img class="pinface"> instead (not user-colourable): its
   own bitmap paints normally since --pin-icon/--pin-color are simply unset
   there, which is valid — an unset var() falls mask-image back to `none`.
   The face carries no shadow of its own — see .pin's --pin-rim above. */
.pinface {
  display: inline-block; width: 26px; height: 26px;
  background-color: var(--pin-color, var(--ink-light));
  -webkit-mask-image: var(--pin-icon); -webkit-mask-size: contain;
  -webkit-mask-repeat: no-repeat; -webkit-mask-position: center;
  mask-image: var(--pin-icon); mask-size: contain;
  mask-repeat: no-repeat; mask-position: center;
}
.pinlabel {
  display: block; font-size: var(--fs-micro); background: color-mix(in srgb, var(--well) 85%, transparent); border: 1px solid var(--line);
  border-radius: var(--r-xs); padding: 0 var(--sp-3); white-space: nowrap; margin-top: -3px;
}
.legend { display: flex; flex-wrap: wrap; gap: var(--sp-6); color: var(--dim); font-size: var(--fs-sm); margin-top: var(--sp-4); align-items: center; }
.legend img { width: 14px; height: 14px; vertical-align: -3px; }
.markerpop {
  position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 90; width: 260px;
  background: var(--panel); border: 1px solid var(--orange); border-radius: var(--r-md); padding: var(--sp-6);
  box-shadow: 0 10px 40px var(--shade-6);
}
.markerpop input, .markerpop textarea { width: 100%; margin-bottom: var(--sp-4); }
.notesbox { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-5) var(--sp-8); margin-bottom: var(--sp-7); }
.notesbox summary { cursor: pointer; color: var(--orange); font-size: var(--fs-md); letter-spacing: 1px; text-transform: uppercase; }

/* -------------------------------------------------------------- stickies */
/* A bright, airy note board — its own light surface inside the dark terminal. */
.stickyboard {
  background: var(--board-bg); border: 1px solid var(--board-line); border-radius: var(--r-xl); overflow: hidden;
  box-shadow: 0 1px 2px var(--shade-4), 0 14px 34px var(--shade-4);
}
.boardhead {
  display: flex; align-items: baseline; gap: var(--sp-5); padding: var(--sp-7) var(--sp-10);
  border-bottom: 1px solid var(--board-line);
  font-size: var(--fs-sm); letter-spacing: 1.6px; text-transform: uppercase; font-weight: 700; color: var(--board-ink);
}
.boardhead span { font-weight: 500; letter-spacing: .2px; text-transform: none; color: var(--board-dim); font-size: var(--fs-sm); }
.boardhead.boardtabs { padding: var(--sp-5) var(--sp-6); gap: var(--sp-4); }
.boardtab {
  appearance: none; background: none; border: 1px solid transparent; border-radius: var(--r-lg);
  display: inline-flex; align-items: baseline; gap: var(--sp-4); padding: var(--sp-3) var(--sp-7); cursor: pointer;
  font: inherit; font-size: var(--fs-sm); letter-spacing: 1.6px; text-transform: uppercase; font-weight: 700; color: var(--board-dim);
}
.boardtab:hover { color: var(--board-ink); }
.boardtab.active {
  background: var(--ink-light); border-color: var(--board-line); color: var(--board-ink);
  box-shadow: 0 1px 2px var(--shade-1);
}
.boardarea { position: relative; min-height: max(640px, calc(100vh - 330px)); touch-action: none; }
.boardconn {
  margin: 0; padding: var(--sp-3) var(--sp-10); background: color-mix(in srgb, var(--red) 7%, transparent); color: var(--red);
  font-size: var(--fs-sm); letter-spacing: .3px;
}
.boardempty {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  text-align: center; color: var(--board-dim); padding: 0 34px; margin: 0; font-size: var(--fs-base);
}

.sticky {
  position: absolute; display: flex; flex-direction: column;
  padding: var(--sp-6) var(--sp-8); border-radius: var(--r-lg); color: var(--ink-paper);
  font-size: var(--fs-base); line-height: 1.5; cursor: grab;
  box-shadow: 0 1px 2px var(--shade-2), 0 4px 10px var(--shade-1);
  transition: box-shadow .14s ease, transform .14s ease;
}
.sticky:hover { box-shadow: 0 4px 10px var(--shade-2), 0 12px 26px var(--shade-2); }
.sticky.dragging { cursor: grabbing; transform: scale(1.03); box-shadow: 0 16px 34px var(--shade-3); }
.sticky.pinned { box-shadow: 0 1px 2px var(--shade-2), 0 4px 10px var(--shade-1), 0 0 0 2px var(--orange); cursor: default; }

.stickybody { flex: 1 1 auto; white-space: normal; word-wrap: break-word; overflow-wrap: anywhere; overflow: hidden; }
/* The editor's lists are built by execCommand and carry no class of their own,
   so these match on the element: a list typed into a note has to look like the
   list it commits to. Scoped to .sblist they left a task item in the editor as
   a plain list item — marker still showing, checkbox jammed against the text. */
.stickybody .sblist, .stickyedit ul, .stickyedit ol { margin: var(--sp-1) 0; padding-left: var(--sp-9); }
.stickybody .sblist li, .stickyedit li { margin: 0; }
.stickybody .sblist li::marker, .stickyedit li::marker { color: color-mix(in srgb, var(--ink-paper) 55%, transparent); }
/* A task item draws its own box, so the list's marker indent is dead space in
   front of it — pull it back to the body's left edge. Done per item, not by
   unpadding the list, so bullet siblings in a mixed list keep their indent. */
.stickybody li.task, .stickyedit li.task {
  list-style: none; display: flex; align-items: flex-start; gap: var(--sp-4);
  margin-left: calc(-1 * var(--sp-9));
}
.taskbox {
  --taskbox-size: 15px;
  /* Centred on the FIRST LINE BOX, not on the item: a task that wraps keeps
     its box beside the line it starts on, which is why the row above is
     align-items: flex-start and this is a nudge rather than centring. The
     plain value is the fallback for an engine without the lh unit. */
  margin-top: var(--sp-2);
  margin-top: calc((1lh - var(--taskbox-size)) / 2);
  flex: 0 0 auto; width: var(--taskbox-size); height: var(--taskbox-size); padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1.5px solid color-mix(in srgb, var(--ink-paper) 45%, transparent); border-radius: var(--r-sm);
  background: var(--sheen-5); cursor: pointer; color: var(--ink-light);
}
.taskbox svg { display: block; }
li.task.done .taskbox { background: var(--done); border-color: var(--done); }
li.task.done .tasktext { text-decoration: line-through; opacity: .7; }
.stickyph { color: color-mix(in srgb, var(--ink-paper) 40%, transparent); }
.stickyedit {
  flex: 1 1 auto; width: 100%; min-height: 70px; margin: 0; padding: 0;
  box-sizing: border-box; background: var(--sheen-4); color: var(--ink-paper);
  border: none; border-radius: 0; font-family: inherit; font-size: var(--fs-base);
  line-height: 1.5; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word;
  outline: none; caret-color: var(--ink-paper);
}
.stickyedit:focus { outline: 2px solid var(--shade-2); }
.stickyedit:empty::before { content: attr(data-ph); color: color-mix(in srgb, var(--ink-paper) 40%, transparent); }
.stickyeditwrap { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; position: relative; }
.stickybar { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; padding: var(--sp-1) 0 var(--sp-2); }
.sbullet, .snumb, .sbtool {
  display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 22px;
  padding: 0; border: none; border-radius: var(--r-sm); background: none; cursor: pointer;
  color: color-mix(in srgb, var(--ink-paper) 65%, transparent);
}
.sbullet svg, .snumb svg, .sbtool svg { display: block; }
.sbullet:hover, .snumb:hover, .sbtool:hover { background: var(--shade-1); color: var(--ink-paper); }
.sbtool { width: 22px; font-size: var(--fs-base); }
.sbtool.on { color: var(--orange); }
.sbcolorpop {
  position: absolute; top: 30px; left: 0; z-index: 6;
  display: flex; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-lg);
  background: var(--ink-light); border: 1px solid var(--line); box-shadow: 0 6px 18px var(--shade-2);
}
.sbcolorpop[hidden] { display: none; }
.sbcolor { width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--shade-3); padding: 0; cursor: pointer; }
.sbcolor:hover { transform: scale(1.15); }
.sb-al-left { display: block; text-align: left; }
.sb-al-center { display: block; text-align: center; }
.sb-al-right { display: block; text-align: right; }
/* The six inks a Prospector can pick for their own sticky text. Darkened
   2026-08-21 so every one clears 4.5:1 on every paper: these sit on the pastel
   .c-* notes below, NOT on --bg, and measured against the darkest of them
   (.c-purple #e5d6f6) five of the six failed — orange worst at 3.03, then green
   3.79, red 3.96, blue 4.18, gray 4.27. Hue and saturation are preserved and
   only lightness moved, so a note written last year still reads as the colour
   it was written in. Purple was already 5.97 and is untouched.

   MEASURE THESE AGAINST THE PAPER, NOT THE PAGE. Read against --bg they look
   fine-to-marginal and the ranking is wrong — gray comes out worst there and is
   in fact the second best here, which is how it got reported as the only
   failure in the first place.

   .sb-c-green takes a literal rather than var(--done): that token is a
   completed task's FILL (li.task.done .taskbox) and has no contrast duty as
   text. Same value in two places was a coincidence, not a relationship.

   COUPLED TO stickies.php's COLORMAP, which paints the picker swatches from
   its own copy of these hex values. Change one and the swatch stops matching
   the ink it applies — edit both in the same commit. */
.sb-c-red { color: #ae3427; }
.sb-c-orange { color: #a34100; }
.sb-c-green { color: #1b6e2c; }
.sb-c-blue { color: #145eb4; }
.sb-c-purple { color: #7b1fa2; }
/* A literal like its five siblings, not var(--text-4). That token is tuned for
   the DARK page — it is deliberately 3.28:1 there, which is the non-text floor
   the drag handle answers to — and reusing it here silently tied a sticky ink
   to a decision about a button. On paper it measured 4.27; this is 4.61. */
.sb-c-gray { color: #56616a; }

/* The action toolbar lives in the sticky's header: the title sits flush with
   the body text, the colour dots/pin/delete/collapse sit in a pill flush
   with the right edge, so everything shares one padding grid. */
.stickymeta { display: none; }
.stickyhead {
  display: flex; align-items: center; gap: var(--sp-3);
  margin: 0 0 var(--sp-4);
}
.stickycontrols {
  flex: 0 0 auto; display: flex; align-items: center; gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-3); border-radius: var(--r-pill);
  background: var(--sheen-5); box-shadow: 0 1px 3px var(--shade-1);
}
.stickytitle {
  flex: 1 1 auto; min-width: 0; font-size: var(--fs-sm); font-weight: 700; letter-spacing: .3px;
  color: color-mix(in srgb, var(--ink-paper) 65%, transparent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sticky .stickytitleinput {
  flex: 1 1 auto; min-width: 0; height: 21px; margin: 0;
  font: inherit; font-size: var(--fs-sm); font-weight: 700; letter-spacing: .3px;
  color: var(--ink-paper); -webkit-text-fill-color: var(--ink-paper); caret-color: var(--ink-paper);
  background: transparent; border: none; border-radius: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--ink-paper) 35%, transparent); padding: 0 var(--sp-px);
}
.sticky .stickytitleinput::placeholder { color: color-mix(in srgb, var(--ink-paper) 40%, transparent); -webkit-text-fill-color: color-mix(in srgb, var(--ink-paper) 40%, transparent); }
.sticky .stickytitleinput::selection { color: var(--ink-paper); background: color-mix(in srgb, var(--ink-paper) 20%, transparent); }
.sticky .stickytitleinput:focus { outline: none; border-bottom-color: color-mix(in srgb, var(--ink-paper) 60%, transparent); }
/* Narrow notes: the colour dots give way so the title stays readable;
   pin, collapse and delete are always kept. */
.sticky.squeezed .sdots { display: none; }
.sdots { display: flex; gap: var(--sp-3); flex: 0 0 auto; }
.sdot { width: 15px; height: 15px; border-radius: 50%; border: 1px solid var(--shade-3); cursor: pointer; padding: 0; }
.sdot.active { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ink-paper) 55%, transparent); }
.spin, .sdel, .scoll {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center; width: 19px; height: 19px;
  padding: 0; border: none; border-radius: var(--r-sm); background: none; cursor: pointer; color: color-mix(in srgb, var(--ink-paper) 75%, transparent);
}
.spin svg, .sdel svg, .scoll svg { display: block; }
.spin:hover, .scoll:hover { color: var(--ink-paper); background: var(--shade-1); }
.sdel:hover { color: var(--red); background: color-mix(in srgb, var(--red) 12%, transparent); }
.spin.on { color: var(--orange); }
.sticky.collapsed { min-height: 0; }
.sticky.collapsed .stickybody, .sticky.collapsed .sresize { display: none; }
.sticky.collapsed .stickyhead { margin-bottom: 0; }
.sresize {
  position: absolute; right: 3px; bottom: 3px; width: 14px; height: 14px; cursor: nwse-resize;
  opacity: 0; transition: opacity .12s ease;
  background: linear-gradient(135deg,
    transparent 46%, color-mix(in srgb, var(--ink-paper) 35%, transparent) 46%, color-mix(in srgb, var(--ink-paper) 35%, transparent) 54%,
    transparent 54%, transparent 70%, color-mix(in srgb, var(--ink-paper) 35%, transparent) 70%, color-mix(in srgb, var(--ink-paper) 35%, transparent) 78%, transparent 78%);
}
.sticky:hover .sresize { opacity: .6; }
.sticky:focus-visible { outline: 2px solid color-mix(in srgb, var(--ink-paper) 35%, transparent); outline-offset: 2px; }
.sresize:focus-visible { opacity: 1; outline: 2px solid color-mix(in srgb, var(--ink-paper) 35%, transparent); outline-offset: 1px; }

/* new-note colour picker in the page header (sits on the dark bar) */
.swatchpick { display: inline-flex; gap: var(--sp-4); align-items: center; }
.swatch { width: 22px; height: 22px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; box-shadow: 0 0 0 1px var(--sheen-3); }
.swatch:hover { border-color: var(--sheen-5); }
.swatch.active { border-color: var(--orange); box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 35%, transparent); }

/* soft pastel note colours (also used by the dots + swatches) */
.c-amber  { background: #ffe6a3; }
.c-green  { background: #d4edbc; }
.c-blue   { background: #c8e4f6; }
.c-pink   { background: #fbd4e0; }
.c-purple { background: #e5d6f6; }

/* -------------------------------------------------------------- guides */
.post { width: 100%; min-width: 0; }
.post .mdbody { max-width: none; }
/* ------------------------------------------------- the facts strip (sitewide)
   .guidefacts/.fact is the ONE way this site states a label and its value —
   named for the mission guide it started on, but used by every page that has
   headline numbers: guide.php and creature.php head a detail page with it,
   altbench.php sums a loadout in it, build.php's talent bar counts points in
   it, item.php lays out a spec sheet in it and admin_analytics.php builds its
   whole dashboard from it. Anything new that reads "LABEL / value" belongs
   here rather than in a fresh box class — three of those (.statchip,
   .stattile, .statrow) is what this replaced, and they made the same kind of
   fact look like a different kind of thing on each page.

   A wrapping row of cells, each sized to its own text. The strip used to be
   flex-wrap:nowrap with .fact{flex:1 1 0}, which forced eight cells onto one
   line by squeezing each one narrower than its (white-space:nowrap) value —
   so the text spilled out past the edge instead of the cell growing. Cells
   size to content now and wrap to the next line when the row is full: nothing
   is ever compressed and no value wraps mid-phrase. */
.guidefacts {
  display: flex; gap: var(--sp-5); flex-wrap: wrap;
  width: 100%; margin: var(--sp-6) 0;
}
/* Grow to share the row evenly, but never shrink below the text — min-width
   fit-content is what stops the old squeeze-and-spill. A cell that no longer
   fits wraps to the next line instead. Label and value both start at the
   cell's left edge, so each pair reads as one column; centring them made a
   short label float over the middle of a long value.
   Containment is deliberately absent: the strip's own rules (or the panel
   hosting it) do that job, per the field-journal contract. The surface reset
   is repeated in that section near the end of the file, which is where the
   label takes its moss ink.
   A cell is sometimes a <button> (build.php's mission-bonus toggle), so the
   type is reset here rather than per consumer — a browser's button font
   otherwise lands one size and one family off the cells beside it. */
.fact {
  display: flex; flex-direction: column; gap: var(--sp-2);
  flex: 1 1 auto; min-width: fit-content; max-width: 100%;
  font: inherit; font-size: var(--fs-md); color: var(--text); text-align: left;
  background: none; border: 0; padding: 0;
}
/* .fact sets its own display, which would otherwise beat the [hidden]
   attribute build.php and talents.js put on the inactive pool's cells. */
.fact[hidden] { display: none; }
/* Label above value, so a long value gets the cell's full width for its one
   line rather than sharing it with the label. */
.fact b {
  font-size: var(--fs-micro); letter-spacing: 1.4px; text-transform: uppercase; color: var(--dim);
  white-space: nowrap;
}
/* Values are already shortened to a phrase by the guide_compact_* helpers, with
   the full text on the cell's title=. The ellipsis is the last-resort guard for
   a narrow screen — it keeps an over-long value on one line inside its column
   instead of wrapping or overflowing. Figures line up column-wise, which is
   what a spec sheet or a dashboard row is read for. */
.fact > span {
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  color: var(--text); font-variant-numeric: tabular-nums;
}
/* Second line under a value — the dashboard's "of 412 prospectors" gloss. */
.fact .factsub { font-size: var(--fs-sm); color: var(--dim); }
.fact .factsub.up { color: var(--green); }
/* The one figure a section leads on. */
.fact.accent > span { color: var(--orange); }
/* Hazards read as a warning, in ink rather than in a box: the tint this used
   to carry was a background, which the borderless treatment below has been
   cancelling ever since the field-journal refresh — so the class had been
   silently doing nothing on every guide that sets it. (Two classes deep so it
   outranks the moss label ink that section sets on .guidefacts .fact b.) */
.guidefacts .fact.hazard b { color: var(--red); }
/* Objectives that state no quantity, so they belong to no single difficulty
   column — listed once above the grid with the crew's cell. Deliberately a
   list and not a fourth table: a table with one populated column reads as a
   difficulty the mission does not have. */
.objshared { list-style: none; margin: var(--sp-6) 0 0; padding: 0; display: grid; gap: var(--sp-3); }
/* The name is the flexible item and the ONLY one allowed to wrap internally.
   It was an unshrinkable box in a wrap container, so on a phone a long
   objective ("Travel through the cave to reach the Shattered Barrens") could
   not fit beside the marker and was pushed to the next line whole — leaving a
   bare orange ▸ alone on a row above it, which reads as a bullet for nothing.
   align-items is baseline rather than center for the same reason: once the
   name is two lines, centring puts the marker halfway down them. */
/* AND THE ROW WRAPS, because an objective can wear a dozen squares. The cells
   are flex: 0 0 auto (below) in a nowrap row, so Ashlands' twelve ran straight
   off the side and took the whole DOCUMENT with them: 742px of scroll in a
   390px viewport, on a page that otherwise obeys the no-horizontal-scroll
   rule. Pre-dates the 2026-09 filter that decides which rows render here — it
   just only ever showed on the handful of guides where one objective is
   placed in many cells, which are exactly the rows that filter keeps. */
.objshared li { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-4); }
.objshared li::before { content: "▸"; color: var(--dim); flex: 0 0 auto; }
.objsharedname { color: var(--text); flex: 1 1 auto; min-width: 0; }
.objshared li .gridref, .objshared li .gridlink { flex: 0 0 auto; }
.objectivegrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: var(--sp-8); margin: var(--sp-6) 0; }
.objectivecol h3 {
  font-size: var(--fs-md); text-transform: uppercase; letter-spacing: 1.4px;
  color: var(--dim); margin: 0 0 var(--sp-3);
}
.objectivetable {
  table-layout: fixed;
  min-width: 300px;
  margin: 0;
}
.objectivetable .obj-grid { width: 58px; }
.objectivetable .obj-qty { width: 56px; }
.objectivetable th:nth-child(2),
.objectivetable th:nth-child(3),
.objectivetable td:nth-child(3) {
  text-align: center;
  white-space: nowrap;
}
/* Grid cells hold merged refs ("L14, O11") — let them wrap so the table never
   overflows its rail and no horizontal scroll is needed. */
.objectivetable td:nth-child(2) {
  text-align: center;
  overflow-wrap: anywhere;
}
.objectivetable td:first-child {
  overflow-wrap: anywhere;
}
.objectivetable .grouprow {
  text-align: left;
  font-weight: 700;
  color: var(--orange);
  background: color-mix(in srgb, var(--orange) 8%, transparent);
  border-top: 1px solid color-mix(in srgb, var(--orange) 24%, transparent);
  padding-top: 10px !important;
}
.grouprow { font-weight: 700; color: var(--orange); background: var(--sheen-1); padding-top: 12px !important; }
.guidegroup { margin-bottom: var(--sp-4); }

/* ---- mission guide sections -------------------------------------------
   A guide renders only the sections its mission actually has data for, so the
   set varies from page to page. Everything below therefore has to look like
   one system on its own, without relying on a fixed running order: same
   heading rhythm, same rails, same grid-ref treatment wherever a cell appears.

   .sechnote disambiguates the two difficulty axes the game overloads. The
   facts row says "Rated: Hard" (how hard the mission IS); the manifest and
   effects tables say "by drop difficulty" (the level you PICK). Without the
   note they read as one scale. */
.sechnote {
  font-size: var(--fs-xs); letter-spacing: 1.2px; text-transform: uppercase;
  color: var(--dim); font-weight: 400; margin-left: var(--sp-4);
}

/* A cell reference, wherever it appears — step, location chip or table. One
   treatment so a grid ref is recognisable as the same kind of thing
   everywhere, and monospaced so K6 and M10 line up rather than jitter. */
.gridref {
  display: inline-flex; align-items: center; gap: var(--sp-2);
  font-family: var(--mono); font-size: var(--fs-sm);
  padding: var(--sp-px) var(--sp-3); border-radius: var(--r-xs);
  background: var(--panel2); border: 1px solid var(--line); color: var(--blue);
  white-space: nowrap;
}
/* A LINKABLE CELL AND A PLAIN ONE ARE TWO THINGS AND MUST NOT LOOK ALIKE.
   They did, for the life of the site: `text-decoration: none` on the anchor
   over an identical chip, so a control that opens the interactive map on that
   504 m square was drawn exactly like a value in a table, and the only signal
   was a hover state and a cursor — both of which need the reader to have
   already guessed. A mission guide carries up to 23 of them.

   The pin sits INSIDE the chip's border box, which is the no-offset-decoration
   rule; it is what carries the difference at a glance, and the tint on the
   border and fill is the flush-ring form of the same emphasis (one border
   weight on all four sides — an accent is a tinted border, never a fattened
   edge). The WORDS are in the anchor's title/aria-label and in
   ui_gridref_hint()'s line above the section; a mark alone says nothing to a
   screen reader, which is the .substeptag lesson one component over. */
a.gridlink { text-decoration: none; }
a.gridlink .gridref {
  border-color: color-mix(in srgb, var(--blue) 42%, var(--line));
  background: color-mix(in srgb, var(--blue) 12%, var(--panel2));
}
.gridpin { width: 8px; height: 11px; flex: 0 0 auto; opacity: .85; }
a.gridlink:hover .gridref,
a.gridlink:focus-visible .gridref {
  border-color: var(--blue); color: var(--orange-hi);
  background: color-mix(in srgb, var(--blue) 22%, var(--panel2));
}
a.gridlink:hover .gridpin, a.gridlink:focus-visible .gridpin { opacity: 1; }
/* The sentence that states the affordance in words. It sits directly under the
   section heading and above the cells it describes, so it is read before them
   rather than found after. */
.gridhint { margin: calc(-1 * var(--sp-2)) 0 var(--sp-4); font-size: var(--fs-sm); }

/* The walkthrough. Steps are numbered by the list itself so the numbering can
   never drift from the order rows come out of the database. */
.stepflow { list-style: none; counter-reset: step; margin: var(--sp-6) 0; padding: 0; }
.stepflow > .step {
  counter-increment: step;
  position: relative;
  padding: 0 0 var(--sp-7) 34px;
  border-left: 1px solid var(--line);
  margin-left: var(--sp-6);
}
.stepflow > .step:last-child { border-left-color: transparent; padding-bottom: 0; }
.stepflow > .step::before {
  content: counter(step);
  position: absolute; left: -11px; top: 0;
  width: 22px; height: 22px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--mono); font-size: var(--fs-xs); font-weight: 700;
  background: var(--panel2); border: 1px solid var(--line); color: var(--orange);
}
.stephead { display: flex; align-items: baseline; gap: var(--sp-4); flex-wrap: wrap; min-height: 22px; }
.steptitle { font-weight: 600; color: var(--text); }
/* A SUB-STEP IS A ROW WITH COLUMNS, not a flex line that wraps.
   It was the second: instruction, then whatever chips the row happened to
   carry, wrapping. So the count and the grid ref sat at a different x on
   every row and there was no column to run an eye down — which is what made
   a run of them read as a paragraph. Twenty parent steps carry sixteen or
   more sub-steps and the worst carries twenty-seven, so this is the block
   that decides whether the page can be scanned at all.
   The last two tracks are `auto` and therefore collapse to nothing on the
   rows that have no count and no cell, so a walkthrough of pure travel steps
   pays nothing for the alignment the mixed ones need. */
.substeps { list-style: none; margin: var(--sp-4) 0 0; padding: 0; }
.substeps > li {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto auto;
  align-items: baseline; column-gap: var(--sp-5);
  padding: var(--sp-2) 0 var(--sp-2) var(--sp-7);
  position: relative; color: var(--dim);
  border-bottom: 1px solid var(--sheen-2);
}
.substeps > li:last-child { border-bottom: 0; }
.substeps > li::before {
  content: "·"; position: absolute; left: 2px; top: var(--sp-2);
  color: var(--line); font-weight: 700;
}
/* The instruction is the only part allowed to wrap, and it wraps INSIDE its
   own track, so a long one never pushes the count or the cell out of line. */
.substeptext { min-width: 0; }
.substeptitle { color: var(--text); }
/* THE GAME'S OWN HINT FOR A STEP (D_Quests.InfoText, 242 rendered steps).
   One class for both levels, because it is one kind of thing: a line the game
   writes beside an objective saying how to do it ("Some Cave Walls can be Mined
   through with a Pickaxe"). It is never a restatement of the step — 0 of the
   242 equal their own Description — so it earns its own line rather than a
   title attribute nobody on a phone can reach.
   `display: block` because on a sub-step it is a <span> inside .substeptext's
   single grid track: it has to break the line without leaving that track, or a
   long hint would push the count and the cell out of the columns the grid above
   exists to keep. On a top-level step it is already a <p>. */
.stepnote {
  display: block;
  margin: var(--sp-1) 0 0;
  color: var(--dim);
  font-size: var(--fs-sm);
  line-height: 1.5;
}

/* The count and the cell keep their tracks whether or not the row fills them,
   which is the whole point — an empty cell that still occupies its column is
   what makes the filled ones line up. */
.substeptags, .substepqty, .substepcell { justify-self: end; white-space: nowrap; }
/* The tag takes a track of its own rather than trailing the instruction: with
   fourteen supply rows in a column (Potshot), names of different lengths put
   the same word at fourteen different x positions, which is the ragged-chip
   problem this grid exists to fix, reintroduced one column over. */

/* SUBGRID, BECAUSE A GRID PER ROW IS NOT A COLUMN.
   `display: grid` on the <li> makes every row its own grid container, and a
   track only sizes across the container it belongs to — so the count track was
   3 characters wide on a row reading "100" and 1 on a row reading "1", and the
   tag beside it moved by the difference. Fourteen supply rows wobbled by ~29px,
   which is the exact raggedness this grid was added to remove, one column over
   and harder to see. The tracks are declared once on the <ul> and each row
   opts into them.
   The per-row `grid-template-columns` above stays as the fallback and is not
   dead: without subgrid this block degrades to what it did before — aligned
   within a row, wobbling between rows — rather than to four stacked spans. */
@supports (grid-template-columns: subgrid) {
  .substeps {
    display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto;
    column-gap: var(--sp-5);
  }
  .substeps > li { grid-column: 1 / -1; grid-template-columns: subgrid; }
}

/* SUPPLY IS A DIFFERENT FACT, AND IT IS SAID IN A WORD.
   objective_kind separates 1,721 plain instructions from 567 item asks, 155
   supply lines, 35 kill counts and 19 either/or nodes, and the page rendered
   all five identically. For supply that is a correctness bug rather than a
   styling one: a supply line is what the mission HANDS you — a pod, a range
   crate, a reward — which is why gamedata_requirements.php keeps those nodes
   out of guide_requirements. Rendered in the same voice as an ask, it tells a
   reader to go and fetch something the mission is about to give them.
   The mark is the tag, not the colour: a glyph or a column position says
   nothing to a screen reader, nothing inside a horizontal scroller, and
   nothing to anybody who has not learned the convention, so the fact has to
   survive the styling. .substeptag is the word; the dimmed instruction is
   only the echo of it. */
.substep--supply .substeptitle { color: var(--dim); }
.substeptag {
  font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 1px;
  color: var(--dim); border: 1px solid var(--line-soft);
  border-radius: var(--r-sm); padding: 0 var(--sp-2);
  white-space: nowrap;
}
/* An ask carries the weight the surrounding travel steps do not: these are the
   rows somebody is actually counting off. */
.substep--item .substeptitle, .substep--creature .substeptitle { color: var(--text); }
/* Quantities are the number a reader scans for, so they get the emphasis the
   surrounding prose does not. */
.stepqty {
  font-family: var(--mono); font-size: var(--fs-sm); font-weight: 700; color: var(--green);
}

.placelist { margin: var(--sp-6) 0; }
/* A mission's operating area is up to 155 cells, so they wrap as a dense block
   of refs rather than one chip per line. */
.boundscells { gap: var(--sp-2); margin: var(--sp-5) 0; }
.boundscells .gridref { font-size: var(--fs-xs); padding: var(--sp-px) var(--sp-2); }
.chip.place { display: inline-flex; align-items: center; gap: var(--sp-3); }
.exoticnote { font-size: var(--fs-sm); margin-top: var(--sp-3); }

/* A REFERENCE SECTION THAT FOLDS — the one disclosure form for content that
   is on a page because it is TRUE, not because it is what anybody opened the
   page for. guide.php's difficulty tables are the case that named it: the
   same 22-row block hangs off 154 of the 169 mission guides that have one,
   because those numbers are properties of the difficulty you pick when you
   drop and not of the contract you picked it for. Open by default it was most
   of a short guide's page, above that guide's own walkthrough.

   QUIET BY CONSTRUCTION. The summary is --dim uppercase, deliberately NOT a
   .sech: a fold that shouts as loudly as the sections above it is a section
   that should not have folded, and the whole point of the rank is that the
   eye can tell tier 1 from tier 3 without reading either.

   The heading stays a real <h2> INSIDE the <summary> — the summary content
   model allows exactly one heading element — so the document outline, the
   one-h1 rule and guide.php's Article schema see the structure they saw when
   these were ordinary open sections. Putting the text in a bare <span> and
   the <h2> inside the body would have printed the title twice.

   No offset decoration and no second border weight: one hairline along the
   top edge, which is a rail on borderless content, the exemption .sech
   already has. */
.foldsec { border-top: 1px solid var(--line-soft); }
.foldsec + .foldsec { border-top-color: var(--line-soft); }
.foldsec > summary { cursor: pointer; list-style: none; padding: var(--sp-5) 0; }
.foldsec > summary::-webkit-details-marker { display: none; }
.foldsec > summary h2 {
  display: flex; align-items: center; gap: var(--sp-3); margin: 0;
  font-size: var(--fs-sm); letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--dim); font-weight: 700;
}
/* The marker is drawn from the summary, not from the h2's own box, so the
   heading text starts at the same x whether the section is open or shut. */
.foldsec > summary h2::before { content: "\25B8"; color: var(--orange); font-size: var(--fs-md); }
.foldsec[open] > summary h2::before { content: "\25BE"; }
.foldsec > summary:hover h2,
.foldsec > summary:focus-visible h2 { color: var(--orange-hi); }
.foldsec-body { padding-bottom: var(--sp-7); }
/* The first thing inside a fold sits directly under the summary's own padding,
   so its own top margin would double the gap. */
.foldsec-body > :first-child { margin-top: 0; }

/* What each drop difficulty changes: one column per level, so a reader
   compares across levels by eye instead of holding two tables in their head. */
.difflevels {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--sp-8); margin: var(--sp-6) 0;
}
.difflevel {
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-md); padding: var(--sp-5) var(--sp-7);
}
.effectlist { list-style: none; margin: 0; padding: 0; }
.effectlist > li {
  display: flex; justify-content: space-between; gap: var(--sp-6);
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--sheen-2);
  font-size: var(--fs-md);
}
.effectlist > li:last-child { border-bottom: 0; }
.effname { color: var(--dim); }
.effval { font-family: var(--mono); color: var(--text); white-space: nowrap; }

.hintlist { list-style: none; margin: var(--sp-6) 0; padding: 0; }
.hintlist > .hint {
  padding: var(--sp-4) var(--sp-6) var(--sp-4) 32px; margin-bottom: var(--sp-3); position: relative;
  background: var(--panel); border: 1px solid var(--line);
  border-left-width: 3px; border-radius: var(--r-sm);
}
.hint::before { position: absolute; left: 11px; top: 8px; }
.hint.h-tip     { border-left-color: var(--green); }
.hint.h-tip::before { content: "▸"; color: var(--green); }
.hint.h-warning { border-left-color: var(--orange); }
.hint.h-warning::before { content: "!"; color: var(--orange); font-weight: 700; }
.hint.h-pitfall { border-left-color: var(--red); }
.hint.h-pitfall::before { content: "×"; color: var(--red); font-weight: 700; }

.briefing { color: var(--dim); }
.briefing h2 { font-size: var(--fs-md); text-transform: uppercase; letter-spacing: 1.4px; color: var(--dim); }


/* ---- bestiary ----------------------------------------------------------
   The browse grid is deliberately art-led: a creature is recognised by its
   silhouette long before its name is read, so the portrait carries the card
   and the text sits under it. */
.card--creature {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: var(--sp-2); padding: var(--sp-6) var(--sp-4); text-decoration: none;
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md);
}
.card--creature:hover { border-color: var(--orange); text-decoration: none; }
.creatureart {
  width: 96px; height: 96px; object-fit: contain;
  /* The game's bestiary art is a light silhouette on transparency; on a dark
     panel it needs no treatment, but the fallback glyph does. */
}
.creatureart.noart {
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-ghost); color: var(--line); border: 1px dashed var(--line); border-radius: var(--r-md);
}
.creaturename { font-weight: 600; color: var(--text); }
.card--creature .cardmeta { font-size: var(--fs-xs); justify-content: center; }

.cardfacets {
  margin: var(--sp-2) 0 0; font-size: var(--fs-xs);
  /* The facet line is supporting detail under the summary, not a second
     heading — it must not compete with the guide name for attention. */
  opacity: .85;
}

/* The bestiary portrait, in the .pagehead's .itemhero beside the name — the
   same lockup item.php gives an item icon, one size up because on a creature
   page the art IS the subject. It carried .creaturehead/.creaturefacts (a flex
   row that nested the facts strip in a 944px right column) until 2026-08-24;
   see creature.php for why a strip has to be bounded by the page column.
   Sized so the box matches the height of the eyebrow + <h1> beside it. */
.creatureportrait {
  width: 128px; height: 128px; object-fit: contain; flex: 0 0 auto;
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-4);
}
.traitrow { margin-top: var(--sp-5); }
.chip.trait { border-color: var(--line); }
/* A bestiary lore paragraph, inside the Field notes fold.
   NO MEASURE: it is prose in a .post, and the rule for that is already written
   two hundred lines up as `.post .mdbody { max-width: none }` — a guide body
   runs the page column. At 760px against a 1164px column these paragraphs
   stopped ~400px short of the full-width rule of their own heading, which is
   the "reads as a margin mistake, not as a column" defect .pagesub-wide exists
   for, sitting under a section rule that made the mismatch impossible to miss.
   .loretier came off with it (see creature.php: the tier was invented). */
.lorepara { margin: var(--sp-4) 0; }
.linklist { list-style: none; margin: var(--sp-5) 0; padding: 0; }
.linklist > li { padding: var(--sp-2) 0; display: flex; gap: var(--sp-5); align-items: baseline; flex-wrap: wrap; }
.cardtype.t-creature { color: var(--green); border-color: var(--green); }

.searchhits { margin-bottom: var(--sp-5); }
/* Same reason as the tech tree's rule: .tabs/.cardlist/.card set a display,
   which would otherwise beat the [hidden] the guide filter toggles. */
.tabs[hidden], .guidegroup[hidden], .guideblock[hidden],
.cardlist[hidden], .cardlist > .card[hidden],
.journal-list[hidden], .journal-list > .list-row[hidden] { display: none; }
/* push the full-text fallback to the far end of the toolbar, like .tierfilter */
.drillbar .drillact { margin-left: auto; }

/* Five classes lived here and nothing but schedule.php used any of them —
   .sessioncard, .sessiondate, .sessionbody, .rsvprow and .pastbox (below),
   plus the two-line mobile override at the 700px breakpoint. That page is
   drops.php now, built from .journal-list / .list-row like every other browse
   list on the site — a two-column grid that already collapses at 720px, which
   is the whole responsive story — so this block came out whole (2026-08) and
   the rebuild added no CSS at all. .newbox stays: it is the "+ New …" disclosure
   shared by admin.php, altbench.php, builds.php, missions.php and craftlist.php. */
.newbox { margin: var(--sp-5) 0 var(--sp-10); }
.newbox summary { list-style: none; display: inline-block; }
.newbox summary::-webkit-details-marker { display: none; }
.newbox form { margin-top: var(--sp-6); }
/* The "+ New …" disclosure sitting in a .pagehead's action slot: a button on
   the right while closed, a full-width panel once open — and while it is open
   the button is not there at all (see below). Its own flex-basis was written
   when ui_pagehead() emitted actions as direct children of .pagehead — nested
   in .btnrow, 100% is 100% of the button, so the form laid out inside ~126px.
   The row claims the head's width instead. */
.pagehead-newbuild { margin: 0; flex: 0 0 auto; }
.pagehead .btnrow:has(> .pagehead-newbuild[open]) { flex: 1 1 100%; }
.pagehead-newbuild[open] { flex-basis: 100%; display: flex; flex-direction: column; }
/* Open, the revealed form is a PANEL, not loose controls on the page
   background: .addbar's box, the one the site already puts round an "add a
   row" form on admin.php and resource_log.php. Written here rather than as a
   class on each page's markup so all three disclosures (altbench, builds,
   drops) cannot drift — and because the box is a property of the open state,
   which only this rule knows about. Without it the strip and the button that
   opened it were a thousand pixels apart with nothing tying them together. */
.pagehead-newbuild[open] form {
  margin-bottom: 0;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-md); padding: var(--sp-5) var(--sp-6);
}
/* THE "+ NEW BUILD" BUTTON GOES AWAY WHILE YOU ARE MAKING ONE. Open, it named
   the thing already on the screen, and it sat there in the site's one accent
   colour beside the form's own Create — two controls each claiming to be THE
   thing to press, one of them for a form the reader is already looking at.
   The way out moves into the panel next to Create, where the pair reads as
   "do it / don't", and this hides the trigger.

   That way out is a LINK to the page, not a button, and that is the whole
   reason it can live inside the form at all: a <details> is closed by its own
   <summary> and by nothing else, and a <summary> has to be the element's first
   child — so nothing inside the form can ever toggle it without script. A
   plain <a href="thispage.php"> needs none: the page comes back with the
   disclosure closed, which is exactly what cancelling means, and it still
   works with JS off, which a wired-up <button> would not. */
.pagehead-newbuild[open] > summary { display: none; }
/* NB these forms were a .toolbar ROW (name, visibility, Create side by side)
   and are a labelled .stackform now that the dialog is the primary form. The
   row needed rules to stop it laying out three different control heights and
   huddling at the left of a full-width panel; a column needs none of it, so
   they came out with the row rather than being left to match nothing. */

/* -------------------------------------------------------------- misc */
@media (max-width: 700px) {
  .pagehead { flex-direction: column; }
}



/* ============================================================ talent builds */
.buildsummary { color: var(--dim); margin: var(--sp-2) 0 var(--sp-5); font-size: var(--fs-base); }
.carddesc { color: var(--dim); font-size: var(--fs-md); margin: var(--sp-2) 0 var(--sp-3); }
.bigtitle {
  font-size: var(--fs-3xl); font-weight: 700; background: var(--panel2);
  border: 1px solid var(--line); border-radius: var(--r-md); color: var(--text);
  padding: var(--sp-2) var(--sp-5); width: 100%; max-width: 520px; margin: var(--sp-1) 0;
}
.buildmeta { margin: var(--sp-5) 0 var(--sp-2); }
.buildmeta .grow { flex: 1 1 240px; }
.pt-total { font-family: var(--mono); color: var(--orange-hi); }
.notesarea {
  width: 100%; background: var(--panel2); border: 1px solid var(--line);
  color: var(--text); border-radius: var(--r-md); padding: var(--sp-5) var(--sp-6); font: inherit;
}

/* how-to strip -------------------------------------------------------------- */
.talent-howto {
  display: flex; flex-wrap: wrap; gap: var(--sp-3) var(--sp-8); align-items: center;
  margin: var(--sp-5) 0; padding: var(--sp-5) var(--sp-7); background: var(--bg2);
  border: 1px solid var(--line); border-radius: var(--r-lg); font-size: var(--fs-sm); color: var(--dim);
}
.talent-howto b { color: var(--text); font-weight: 600; }
.talent-howto span { position: relative; padding-left: var(--sp-8); }
.talent-howto span::before { content: "\25B8"; position: absolute; left: 2px; color: var(--orange); }
/* the mouse and touch gestures are different lines; the touch media query at
   the foot of this section swaps which pair is shown */
.talent-howto .howto-touch { display: none; }

/* talent control bar: point facts · category tabs · Talents/Solo toggle ----- */
.tal-top {
  display: flex; align-items: center; gap: var(--sp-6) var(--sp-8); flex-wrap: wrap;
  margin: var(--sp-4) 0 var(--sp-8); padding: var(--sp-5) var(--sp-6);
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
}
/* The point counters are the sitewide .guidefacts strip, not a private set of
   chips — a build's "Points used" now reads like a mission's "Time limit".
   The bar is already a panel, so the strip drops the rules, the full width and
   the margins it carries when it heads a page on its own. */
.tal-top .tal-stats {
  width: auto; margin: 0; padding: 0; border-block: 0; gap: var(--sp-3) var(--sp-10);
}
/* would otherwise beat the [hidden] attribute the pool toggle sets on the
   inactive pool's tabs (the facts get the same guard from .fact[hidden]) */
.chip--lg[hidden] { display: none; }
/* Each pool inks its own count, so Talents and Solo are told apart at a glance
   without a second label; running out turns it into a warning. */
.tal-stats .fact { --accent: var(--green); }
.availpool > span { color: var(--accent); }
.availpool.none > span { color: var(--orange-hi); }
/* The bonus is a toggle, not a reading — most characters haven't earned it. It
   keeps a control's affordance without a box: the value sits dim under a
   dashed rule until it's switched on, then it takes the pool's ink. */
.bonustoggle { cursor: pointer; }
/* align-self so the rule is only as wide as "+18" — a .fact's value is a
   stretched flex item, and left to itself the dash ran the label's full width
   and read as a divider rather than as something you can press. */
.bonustoggle > span { align-self: start; color: var(--dim); border-bottom: 1px dashed var(--line); }
.bonustoggle:hover > span { color: var(--text); border-bottom-color: var(--accent); }
.bonustoggle.on > span { color: var(--accent); border-bottom: 1px solid var(--accent); }

.cattabs { display: flex; gap: var(--sp-3); flex-wrap: wrap; margin: 0 auto; }
.cat-glyph { width: 16px; height: 16px; object-fit: contain; }

.poolswitch { display: inline-flex; border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; }
.pswitch {
  cursor: pointer; background: var(--bg2); color: var(--dim);
  border: none; padding: var(--sp-4) var(--sp-8); font-size: var(--fs-sm); font-weight: 700; letter-spacing: .3px;
}
.pswitch:hover { color: var(--text); }
.pswitch + .pswitch { border-left: 1px solid var(--line); }
.pswitch.on { background: color-mix(in srgb, var(--orange) 24%, var(--panel)); color: var(--orange-hi); }

/* category page: several sub-tree columns side by side ---------------------- */
.catpanes {
  border: 1px solid var(--line); border-radius: var(--r-lg);
  background:
    repeating-linear-gradient(0deg, transparent 0 3px, var(--sheen-1) 3px 4px),
    radial-gradient(130% 90% at 50% -20%, var(--lift-2), var(--panel));
  padding: var(--sp-9) var(--sp-8) 26px;
}
.catpane { display: none; }
.catpane.on { display: block; }
/* Each column is sized by its tree's fixed pixel canvas (the game's own grid).
   A wide category (Combat has 4 trees) can exceed the pane, so talents.js
   scales the whole row down uniformly to fit (see fitPanes) rather than
   wrapping the last tree onto a second line or scrolling — a scroll container
   (overflow) would clip the node tooltips that float above the edge tiles.
   align-items:stretch keeps every column the same height as the tallest. */
.subtrees {
  display: flex; gap: var(--sp-7); align-items: stretch; flex-wrap: nowrap; justify-content: center;
}
/* The tree's colour tints the whole border at the same 1px as the other three
   sides. It used to be a 2px accent rail on border-top alone, which stood a
   pixel proud of the sides it met and read as a bar laid across the column
   rather than as its top edge. The .st-name heading and .subfill bar carry the
   same accent inside the box, so the identity doesn't rest on the border. */
.subtree {
  --accent: var(--blue);
  flex: 0 0 auto; width: var(--boxw);
  background: var(--sheen-1);
  border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--line));
  border-radius: var(--r-lg); padding: var(--sp-6) var(--sp-7) var(--sp-8);
}
/* the trees are wide — on big screens let the talent screen span the viewport
   instead of the 1200px page column, like the in-game full-screen layout */
@media (min-width: 1280px) {
  .talents { width: min(1980px, calc(100vw - 44px)); margin-left: 50%; transform: translateX(-50%); }
}
.subtree-head { margin-bottom: var(--sp-6); }
.st-title { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-4); }
.st-namewrap { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.st-icon { width: 18px; height: 18px; object-fit: contain; flex: 0 0 auto; border-radius: var(--r-sm); }
.st-name { font-size: var(--fs-sm); font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--accent); }
.st-count { font-family: var(--mono); font-size: var(--fs-xs); color: var(--text); }
.st-count .dim { color: var(--dim); }
.subbar { height: 5px; background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r-xs); overflow: hidden; margin-top: var(--sp-3); }
.subfill { display: block; height: 100%; width: 0; background: var(--accent); transition: width .16s ease; }

/* node canvas inside a sub-tree column — talents sit at the game's own
   [row, col] pixel coordinates (inline left/top), so fractional columns and
   the real branching web render exactly like the in-game screen. */
.subgrid { position: relative; overflow: visible; margin: 2px auto 0; }
.subgrid .tnode { position: absolute; }
.edges { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: visible; }
.edge { fill: none; stroke: var(--wire); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.edge.live { stroke: var(--orange); stroke-width: 3; filter: drop-shadow(0 0 5px color-mix(in srgb, var(--orange) 75%, transparent)); }

/* square talent tile — icon only, name lives in the hover tooltip (like the
   in-game screen), so tiles pack tight and branch lines stay clean ---------- */
.tnode {
  position: relative; z-index: 1;
  display: grid; place-items: center; cursor: default;
}
.talents[data-edit="1"] .tnode { cursor: pointer; }

.tn-tile {
  position: relative; width: 52px; height: 52px; border-radius: var(--r-lg);
  border: 1px solid var(--wire); /* same brown-gold as .edge, the prerequisite lines */
  background: var(--bg2);
  display: grid; place-items: center; transition: transform .1s ease, filter .15s ease, background .15s ease, border-color .15s ease;
}
.tn-glyph { width: 25px; height: 25px; color: var(--dim); transition: color .15s ease; }
.tn-icon { display: grid; place-items: center; border-radius: var(--r-md); overflow: hidden; }
.tn-icon.has-img img { width: 100%; height: 100%; object-fit: contain; display: block; filter: saturate(.55) brightness(.85); transition: filter .15s ease; }
.tnode.avail .tn-icon.has-img img, .tnode.on .tn-icon.has-img img { filter: none; }
/* The rank counter sits INSIDE the tile, flush into its top-left corner. It
   used to be pinned at top/left: -7px — a bordered box hanging entirely
   outside the tile it labels, which is the offset decoration the design
   contract bans. On a grid of 52px tiles it was the exact failure that note
   describes: a second edge up and to the left of every tile, reading as if
   each tile were nudged down-and-right rather than as a badge on it. Inset, it
   needs no border of its own — the tile's own edge is the frame, and the
   corner radius matches the tile's inner curve (9px tile − 1px border). */
.tn-rank {
  position: absolute; top: 0; left: 0; z-index: 3;
  min-width: 17px; height: 14px; padding: 0 var(--sp-2); border-radius: var(--r-lg) 0 var(--r-sm) 0;
  background: color-mix(in srgb, var(--panel2) 88%, transparent); color: var(--dim);
  font-family: var(--mono); font-size: var(--fs-micro); font-weight: 700; line-height: 1;
  display: flex; align-items: center; justify-content: center;
}
.tnode-name { display: none; }

/* branch junction (D_Talents TalentType "Reroute") — the game draws these
   where two branches merge: no icon, no name, no rank, nothing to spend. It
   keeps a full tile slot so the edges route through its centre like every
   other node, but shows only a dot on the wire. */
.tnode.reroute { width: 52px; height: 52px; cursor: default; pointer-events: none; }
.tn-join {
  width: 11px; height: 11px; border-radius: 50%;
  background: var(--wire);   /* same brown-gold as .edge at rest */
  transition: background .15s ease, box-shadow .15s ease;
}
.tnode.reroute.on .tn-join {
  background: var(--orange);
  box-shadow: 0 0 6px color-mix(in srgb, var(--orange) 75%, transparent);
}

/* node states --------------------------------------------------------------- */
.talents[data-edit="1"] .tnode.avail .tn-tile { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); }
.talents[data-edit="1"] .tnode.avail .tn-glyph { color: color-mix(in srgb, var(--accent) 80%, var(--text)); }
.talents[data-edit="1"] .tnode.avail:hover .tn-tile { transform: translateY(-2px) scale(1.05); }

.tnode.on .tn-tile { background: color-mix(in srgb, var(--accent) 22%, var(--bg2)); border-color: var(--accent); filter: drop-shadow(0 0 7px color-mix(in srgb, var(--accent) 55%, transparent)); }
.tnode.on .tn-glyph { color: var(--ink-light); }
.tnode.on .tn-rank { background: var(--accent); color: var(--ink-warm-2); }
.tnode.maxed .tn-tile { background: color-mix(in srgb, var(--accent) 34%, var(--bg2)); filter: drop-shadow(0 0 12px var(--accent)); }

/* Dim only the tile/rank chrome, not the whole .tnode — .tnode-tip is a
   sibling that pops up on hover, and CSS opacity on an ancestor caps every
   descendant's max opacity, so putting it on .tnode made locked nodes' own
   tooltips render semi-transparent and hard to read. */
.tnode.locked .tn-tile, .tnode.locked .tn-rank { opacity: .5; }
.tnode.locked .tn-glyph { color: var(--text-5); }
.tnode.locked .tnode-name { color: var(--dim); }
.tnode.locked .tn-tile::after {
  content: "\1F512"; position: absolute; top: 1px; right: 3px; font-size: var(--fs-micro); opacity: .8;
}

/* the talent the touch sheet is currently showing — the sheet covers the foot
   of the screen, so the tile needs to say which node it is talking about */
.tnode.selected .tn-tile { box-shadow: 0 0 0 2px var(--orange-hi); }

.tnode.nope .tn-tile { animation: hex-nope .4s; }
.tnode.nope::after {
  content: attr(data-msg); position: absolute; top: -22px; left: 50%; transform: translateX(-50%);
  background: var(--red); color: var(--ink-light); font-size: var(--fs-micro); padding: var(--sp-px) var(--sp-3); border-radius: var(--r-xs); white-space: nowrap; z-index: 6;
}
@keyframes hex-nope { 0%,100%{transform:translateX(0);} 25%{transform:translateX(-3px);} 75%{transform:translateX(3px);} }

/* hover tooltip ------------------------------------------------------------- */
.tnode-tip {
  position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
  width: 210px; background: var(--well-2); border: 1px solid var(--accent); border-radius: var(--r-md);
  padding: var(--sp-5) var(--sp-6); z-index: 30; display: none; text-align: left;
  box-shadow: 0 10px 28px var(--shade-5);
}
.tnode-tip::after {
  content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
  border: 6px solid transparent; border-top-color: var(--accent);
}
.tnode:hover .tnode-tip, .tnode:focus-within .tnode-tip { display: block; }
.tnode-tip b { display: block; color: var(--text); font-size: var(--fs-sm); margin-bottom: var(--sp-2); }
.tip-eff { display: block; color: var(--dim); font-size: var(--fs-xs); line-height: 1.35; margin-bottom: var(--sp-3); }
.tip-meta { display: block; color: var(--accent); font-family: var(--mono); font-size: var(--fs-micro); }
.rank-badge { width: 14px; height: 14px; object-fit: contain; vertical-align: -3px; }

/* touch detail sheet -------------------------------------------------------
   Stands in for .tnode-tip (no hover) and for shift/right-click (no refund
   gesture) on touch: talents.js clones the tapped node's tooltip in here and
   the two buttons spend and refund. Hidden until a tap opens it, so it costs
   a mouse user nothing. */
.tsheet {
  --accent: var(--orange);
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
  background: var(--well-2); border-top: 2px solid var(--accent);
  box-shadow: 0 -12px 30px var(--shade-5);
  padding: 12px 16px calc(14px + env(safe-area-inset-bottom));
}
.tsheet[hidden] { display: none; }
.tsheet-tree {
  color: var(--accent); font-size: var(--fs-micro); font-weight: 700;
  text-transform: uppercase; letter-spacing: .7px; margin-bottom: var(--sp-2);
}
/* the cloned tooltip markup, sized up from the 210px hover box to a panel you
   read at arm's length */
.tsheet-body b { display: block; color: var(--text); font-size: var(--fs-lg); font-weight: 700; margin-bottom: var(--sp-2); }
.tsheet-body .tip-eff { display: block; color: var(--dim); font-size: var(--fs-md); line-height: 1.45; margin-bottom: var(--sp-3); }
.tsheet-body .tip-meta { display: block; color: var(--accent); font-family: var(--mono); font-size: var(--fs-sm); }
.tsheet-msg { margin-top: var(--sp-4); color: var(--red); font-size: var(--fs-md); }
.tsheet-acts { display: flex; gap: var(--sp-4); margin-top: var(--sp-6); }
.tsheet-acts .btn { flex: 1 1 0; text-align: center; padding: var(--sp-7) var(--sp-5); font-size: var(--fs-base); }
.tsheet-save {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-5);
  margin-top: var(--sp-5); padding-top: var(--sp-5); border-top: 1px solid var(--line);
}
.tsheet-save[hidden] { display: none; }
.tsheet-savestate { font-family: var(--mono); font-size: var(--fs-sm); color: var(--dim); }
.tsheet-save .btn { flex: 0 0 auto; padding: var(--sp-6) var(--sp-8); }
.tsheet-close {
  position: absolute; top: 6px; right: 8px; width: 38px; height: 38px;
  background: none; border: 0; color: var(--dim); font-size: var(--fs-lg); cursor: pointer;
}
.tsheet-close:hover { color: var(--text); }

.build-errors {
  margin: var(--sp-7) 0 0; border: 1px solid var(--red); border-radius: var(--r-md);
  background: color-mix(in srgb, var(--red) 12%, var(--panel)); padding: var(--sp-4) var(--sp-6);
  color: var(--red); font-size: var(--fs-md);
}
.build-errors div { margin: var(--sp-1) 0; }
.dangerzone { margin: 26px 0 0; padding-top: var(--sp-7); border-top: 1px solid var(--line); }
.linkbtn.danger { color: var(--red); }

/* ---------------------------------------------------------- stacked trees
   Below this width fitPanes' shrink-to-fit stops being a fit and starts being
   a squint: Combat's four 638px columns measure ~2600px, so at 800px they land
   at zoom .28 — 14px tiles, smaller than the phone layout renders them. Stack
   the category instead, one tree per row with its header always in view. Keep
   the 1024px figure in sync with fitPanes() in talents.js, which bails out at
   the same width rather than fighting this. */
@media (max-width: 1024px) {
  .catpanes { padding: var(--sp-6) var(--sp-5) var(--sp-9); }
  .subtrees { flex-direction: column; gap: var(--sp-6); }
  /* the shared uniform box width is a desktop alignment device — with the
     trees stacked it is up to 340px of dead padding on a narrow tree */
  .subtree { width: auto; }
  .subgridwrap {
    overflow-x: auto; -webkit-overflow-scrolling: touch;
    padding: 0 var(--sp-4); /* room for the rank badge overhanging the leftmost tile */
    /* Centre the canvas when it fits, but `safe` falls back to flex-start when
       it doesn't — plain centring (or the auto margins .subgrid uses on
       desktop) would push the left edge of a wide tree out of scroll reach. */
    display: flex; justify-content: center; justify-content: safe center;
    /* Same scroll-rail hint as .tablescroll, but the tree's accent instead of a
       black shadow — the pane is already near-black, so a dark edge reads as
       nothing. The `local` fade layers scroll with the canvas and cover the
       mark at that end, so it shows only while there is more tree that way. */
    background:
      linear-gradient(90deg, var(--panel) 40%, color-mix(in srgb, var(--panel) 0%, transparent)) left / 26px 100% no-repeat,
      linear-gradient(90deg, color-mix(in srgb, var(--panel) 0%, transparent), var(--panel) 60%) right / 26px 100% no-repeat,
      linear-gradient(90deg, color-mix(in srgb, var(--accent) 34%, transparent), transparent) left / 18px 100% no-repeat,
      linear-gradient(270deg, color-mix(in srgb, var(--accent) 34%, transparent), transparent) right / 18px 100% no-repeat;
    background-attachment: local, local, scroll, scroll;
  }
  .subgrid { flex: 0 0 auto; margin-inline: 0; }
}

@media (max-width: 760px) {
  .tal-top { gap: var(--sp-5); padding: var(--sp-5) var(--sp-5); }
  /* The three labels together are wider than a 390px screen, so the strip has
     to wrap (2 + 1) rather than spill past the viewport edge — .tal-top itself
     already wraps, this just lets the facts row do the same, at a tighter
     column gap so the pair on the first row isn't flung apart. */
  .tal-top .tal-stats { flex-wrap: wrap; gap: var(--sp-3) var(--sp-7); }
  .cattabs { margin: var(--sp-1) 0 0; order: 3; width: 100%; justify-content: center; }
  .chip--lg { padding: var(--sp-4) var(--sp-5); font-size: var(--fs-xs); }
  /* the pool switch is the widest control left on its row — let it use it */
  .poolswitch { flex: 1 1 auto; }
  .pswitch { flex: 1 1 0; padding: var(--sp-6) var(--sp-6); }
}

/* Touch interaction, independent of width — a tablet with no mouse needs this
   at 1024px too, and a mouse at 800px does not. The hover tooltip can never be
   summoned and there is no shift/right-click, so both jobs move to .tsheet
   (see talents.js, which arms itself off the same two conditions). */
@media (max-width: 760px), (hover: none) {
  .tnode:hover .tnode-tip, .tnode:focus-within .tnode-tip { display: none; }
  .talent-howto .howto-mouse { display: none; }
  .talent-howto .howto-touch { display: inline; }
  /* the sheet covers the foot of the screen; scrollIntoView must clear it */
  .tnode { scroll-margin-bottom: 230px; }
}

/* ============================================================ phone layout
   Two breakpoints, because they answer two questions.

   1230px is where the bar stops having room to be generous: the page's own
   1200px column has run out, so the brand wordmark drops to the smaller size.
   The nav still renders as a row here — a 1024px tablet or a half-screen
   1280px window gets the real bar, not a hamburger.

   1104px is where the row is called done — bisected with Playwright, logged
   out and logged in, against the live seven-entry arrangement; see the
   min-width:1105px query above for how, and for the three ways the naive
   version of that measurement lies. Past the floor the nowrap links spill out
   of .mainnav and paint over .navuser instead of wrapping. Below the
   breakpoint they move into a drawer, which also keeps --topbar-h honest — the
   drawer is fixed, so it never counts toward the bar's measured height.
   Add or rename a top-level nav entry and this pair has to be re-measured, not
   nudged; it was 1000/1001 while the bar grew from six entries to eight, which
   is how a 96px band of overlapping links went unnoticed.

   THIS COMMENT IS PART OF THE PAIR AND WENT STALE ON ITS OWN (fixed
   2026-08-25). The 2026-08-24 re-measurement updated both queries, both
   layout.php sites and navorder.js — and left this block naming 1098 and
   pointing at a "min-width:1099px query above" that had not existed for four
   days, directly over the rule it describes. The five places to change are
   listed at the query above; this block is inside one of them, so it does not
   add a sixth — but a comment that names a number is a place that number can
   be wrong.

   THIS IS NO LONGER THE SITE'S ONE CHROME BOUNDARY. .adminnav's rail still
   lies down at 1000px (see its own block above), so between 1001 and 1104px
   the header is a drawer while the admin rail is still a rail. That split is
   deliberate: both numbers are independently measured against different
   content, and dragging the admin rail up to 1104 to preserve a tidy sentence
   would hand the tile grid ~100px of laptop width where the rail is the better
   form. The band tracks the bar's own width — 98px at eight entries, 180px at
   nine, and 104px since Stickies moved into Crew on 2026-09-08. If the bar
   ever shrinks back under 1000px they should be re-joined.
   ========================================================================= */
@media (max-width: 1230px) {
  /* 6px both ways, carrying --bar-edge the same way the base rule does. */
  .topbar {
    gap: var(--sp-5); flex-wrap: nowrap;
    padding: calc(var(--sp-3) + var(--bar-edge)) var(--wrap-pad) calc(var(--sp-3) - var(--bar-edge));
  }
  /* The full nav owns this middle width. Keep the mark, but do not squeeze
     the wordmark into it; the full label returns with the roomy desktop bar. */
  .brand-word { display: none; }
}

@media (max-width: 1104px) {
  .navtoggle { display: flex; }

  .mainnav {
    position: fixed; left: 0; right: 0; top: var(--topbar-h, 52px);
    display: none; flex-direction: column; gap: 0; z-index: 55;
    max-height: calc(100vh - var(--topbar-h, 52px)); overflow-y: auto;
    padding: var(--sp-3) 0 var(--sp-5);
    background: linear-gradient(180deg, var(--bar-1), var(--bar-2));
    border-bottom: 1px solid var(--line);
    box-shadow: 0 14px 28px var(--shade-5);
    overscroll-behavior: contain;
  }
  .navopen .mainnav { display: flex; }
  .mainnav a {
    padding: var(--sp-7) var(--sp-10); border-radius: 0; font-size: var(--fs-md);
    border-bottom: 1px solid color-mix(in srgb, var(--line) 50%, transparent);
  }
  .mainnav a.active {
    color: var(--orange); border-bottom: 1px solid color-mix(in srgb, var(--line) 50%, transparent);
    background: color-mix(in srgb, var(--orange) 8%, transparent); box-shadow: inset 3px 0 0 var(--orange);
  }
  /* Nav groups become accordions inside the drawer instead of dropdowns. */
  .navgroup { position: static; width: 100%; }
  .navgroup > summary {
    justify-content: space-between; width: 100%;
    padding: var(--sp-7) var(--sp-10); border-bottom: 1px solid color-mix(in srgb, var(--line) 50%, transparent); border-radius: 0;
  }
  /* border-bottom is restated, not inherited: the desktop rule's 2px orange
     underline outweighs .navgroup > summary's 1px separator above, and in a
     stacked drawer that reads as a rule between rows rather than a mark on
     one. The drawer says "you are here" with the inset rail, exactly as
     .mainnav a.active does two rules up. */
  .navgroup.active > summary {
    color: var(--orange); border-bottom: 1px solid color-mix(in srgb, var(--line) 50%, transparent);
    background: color-mix(in srgb, var(--orange) 8%, transparent); box-shadow: inset 3px 0 0 var(--orange);
  }
  .navgroup-panel {
    position: static; min-width: 0; padding: 0; gap: 0;
    background: var(--shade-3); border: 0; border-radius: 0; box-shadow: none;
  }
  .navgroup-panel a { padding-left: 38px; }
  .navopen .navscrim {
    display: block; position: fixed; inset: 0; z-index: 54;
    top: var(--topbar-h, 52px); background: var(--shade-5);
  }
  .navopen { overflow: hidden; }
}

@media (max-width: 700px) {
  :root { --wrap-pad: 14px; }
  .wrap { padding-top: var(--sp-8); padding-bottom: 48px; }
  h1 { font-size: var(--fs-2xl); }
  /* the 8vh vertical centering on desktop becomes dead space on a phone —
     pull the auth box close to the top and let the page foot hold the bottom */
  .authbox { margin: 0 auto; }
  .lede { font-size: var(--fs-base); }

  /* iOS Safari zooms the whole page when you focus a control under 16px.
     Ends up wider than the viewport and needs a manual pinch back out. The
     class selectors are listed because each sets its own smaller size and
     would otherwise outrank a bare element rule. */
  input, select, textarea,
  .searchbox, .rateselect, .stickyedit, .bigtitle, .notesarea { font-size: var(--fs-lg); }
  /* Form controls and inline admin actions are the last common source of a
     page-wide horizontal rail: their intrinsic width is allowed to shrink and
     action groups wrap inside the table rail instead of widening the body. */
  input, select, button { max-width: 100%; }
  .inlineform { flex-wrap: wrap; }
  .btn { padding: var(--sp-5) var(--sp-8); }
  .btnrow .btn { flex: 1 1 auto; text-align: center; }
  /* an input's default intrinsic width (~20 chars) plus a button beats a
     320px screen — let it shrink, and wrap if it still can't fit */
  .qtyform { flex-wrap: wrap; }

  /* thumb-sized hit areas — the audit found 9-115 targets under 32px a page */
  .chip, .chip--mono { padding: var(--sp-4) var(--sp-7); }
  .tabs a { padding: var(--sp-6) var(--sp-7); }
  .pager a, .pager span { min-width: 38px; height: 38px; }
  /* No .rateselect height patch here any more. This used to pad it out by hand
     because "at its desktop 4px padding it came out 8px shorter than the
     button next to it" — true, and true on the desktop it was measured beside,
     where nothing was ever done about it. It stretches to the star button's
     height at every width now, so the only thing left to say on a phone is the
     16px font above, and a padding here would put the mismatch back by
     overriding padding-block: 0. */
  .linkbtn { padding: var(--sp-4) var(--sp-3); }
  .usermenu-panel a { padding: var(--sp-6) var(--sp-6); }
  .navuser { gap: var(--sp-8); }
  .bell { display: flex; align-items: center; justify-content: center; width: 34px; height: 40px; }
  .usermenu > summary { align-items: center; min-height: 40px; }
  .swatch { width: 30px; height: 30px; }
  .sdot { width: 20px; height: 20px; }

  /* Roomier rows: the item name in a table cell is the tap target for the
     whole row, and at 19px tall it was a coin-flip to hit. Padding the anchor
     itself grows the hit box without moving the text off its baseline —
     these read as row targets, not the prose links WCAG 2.5.8 exempts. */
  .datatable td { padding: var(--sp-6) var(--sp-5); }
  .datatable th { padding: var(--sp-5) var(--sp-5); }

  /* A roster or other action-heavy table is not useful as a thin slice of
     desktop columns on a phone. Keep its table semantics, but let each row
     read as a compact field record: identity + role, then the related actions
     in one visible group. Other tables retain the normal scroll-rail form. */
  .datatable--mobile-cards thead { display: none; }
  .datatable--mobile-cards,
  .datatable--mobile-cards tbody { display: block; width: 100%; }
  .datatable--mobile-cards tr {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--sp-5);
    width: 100%; padding: var(--sp-6) 0; border-bottom: 1px solid var(--line-soft);
  }
  .datatable--mobile-cards td { display: block; border: 0; padding: 0; }
  .datatable--mobile-cards .col-opt { display: none; }
  .datatable--mobile-cards td:first-child { grid-column: 1; font-size: var(--fs-lg); font-weight: 600; }
  .datatable--mobile-cards td:nth-child(2) { grid-column: 2; align-self: center; }
  .datatable--mobile-cards .cell-activity {
    display: block; grid-column: 1 / -1; margin-top: var(--sp-2); font-size: var(--fs-sm);
  }
  .datatable--mobile-cards .cell-activity span {
    color: var(--text-faint); font-size: var(--fs-xs); letter-spacing: .8px; text-transform: uppercase;
  }
  .datatable--mobile-cards .rowacts {
    grid-column: 1 / -1; margin-top: var(--sp-4); padding: 0;
    text-align: left; white-space: normal;
  }
  .datatable--mobile-cards .rowacts .inlineform {
    display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-3); width: 100%;
  }
  .datatable--mobile-cards .rowacts .linkbtn { min-height: 38px; text-align: left; }
  .datatable--mobile-cards .rowacts:has(> .dim:only-child) { display: none; }
  /* .itemcell is already inline-flex (icon beside name) — pad it without
     touching display, or the name unwraps beneath its own icon. */
  .spawnname a, .datatable td > a:not(.itemcell) { display: inline-block; }
  .spawnname a, .datatable td > a { padding-block: 7px; }

  /* Secondary columns fold away rather than being scrolled to. A wrapped
     description column stretches every row to several times its height, which
     costs more than the column is worth — the detail pages carry it all. */
  .col-opt { display: none; }

  /* stacked reading order beats squeezed columns */
  .objectivegrid, .formrow { grid-template-columns: 1fr; }
  /* .cardlist is NOT stacked flat, because --card-min already says how wide
     this particular grid's cards need to be and a blanket 1fr threw that away.
     It cost creatures.php 107 cards at one per row: 23,520px on a 390px phone,
     which is 2.5x the whole 2,847-row item table. min(--card-min, 100%) is
     what lets the 300px default still collapse to one column on a 347px
     screen, so the wide grids (guides, maps, builds) are byte-identical to the
     blanket rule while .cardlist--tight's 150px gets its two columns back. */
  .cardlist { grid-template-columns: repeat(auto-fill, minmax(min(var(--card-min, 300px), 100%), 1fr)); }
  /* On a phone the row is only ~2 boxes wide, so fit-content would let a long
     value (an armor set, three mission types) push the strip past the screen.
     Drop back to a fixed basis and let .fact > span's ellipsis do the trimming;
     the full text is on the box's title= either way. */
  .fact { flex: 1 1 140px; min-width: 0; }
  .itemhero { gap: var(--sp-6); }
  .mapholder.big { height: 220px; }
  /* (.mapholder.big.mapview keeps its square full-width box — the whole point
     of that view is seeing the map.) A phone-width cell is only ~40px, so the
     full-size coordinate pill would swallow the terrain under it. */
  .mapgrid-label { font-size: var(--fs-micro); padding: 0 var(--sp-1); top: 2px; left: 2px; }
  .plannerviewport { height: 60vh; min-height: 300px; }

  /* the marker editor is a centered 260px box — on a phone let it span the
     width of the screen instead, still centered vertically */
  .markerpop { left: 12px; right: 12px; width: auto; transform: translateY(-50%); }

  /* the sticky's action toolbar is always visible in its header, so the
     old touch-only reveal rules are gone; the corner handle still needs a
     resting opacity on touch */
  .sresize { opacity: .6; }
  .boardarea { min-height: max(460px, calc(100vh - 300px)); }
}

@media (max-width: 520px) {
  /* 5px both ways, carrying --bar-edge — the bar's bottom edge is the same
     two rows on a phone as it is on a desktop. */
  .topbar { gap: var(--sp-4); padding-block: calc(5px + var(--bar-edge)) calc(5px - var(--bar-edge)); }
  .navtoggle { width: 36px; height: 36px; margin-left: -6px; flex: 0 0 auto; }
  /* Brand occupies the space between the hamburger and the user area. */
  .brand { gap: var(--sp-4); flex: 1 1 auto; min-width: 0; }
  .brand-mark { width: 26px; height: 26px; flex: 0 0 26px; }
  /* The login/register buttons and avatar never get squeezed by the brand. */
  .navuser { gap: var(--sp-4); flex: 0 0 auto; }
  .navuser .btn { padding: var(--sp-4) var(--sp-5); font-size: var(--fs-xs); white-space: nowrap; }
  /* Back to the bar's own line box. --lh-bar-btn is the whole-pixel form of
     what 13px type gives; this button is --fs-xs down here, so keeping 20px
     would not be rounding its height, it would be adding 2px to the phone
     header. Both are whole numbers, which is all the centring needs. */
  .navuser .btn-primary { line-height: var(--lh-bar); }
  /* Narrower padding so the wordmark keeps its room, but the same lettering as
     the other .navuser button — this one is orange on a phone too, and 10px
     uppercase under a coloured border read as a badge rather than a control. */
  .navuser .feedbackbtn { padding-inline: 8px; }
  .cmptray { width: calc(100% - 28px); flex-wrap: wrap; justify-content: flex-end; }
  .cmptray-n { flex: 1 1 100%; }
}

@media (max-width: 360px) {
  .navtoggle { width: 32px; height: 32px; margin-left: -4px; }
  .brand { gap: var(--sp-3); }
  .brand-mark { width: 24px; height: 24px; flex: 0 0 24px; }
  .navuser { gap: var(--sp-2); }
  .navuser .btn { padding: var(--sp-3) var(--sp-4); font-size: var(--fs-micro); }
}

/* Wide data tables scroll inside their own rail rather than pushing the page
   sideways — a horizontally scrolling body makes every other page feel broken.
   .tablescroll is the wrapper; the shadow hints there is more to the right. */
.tablescroll {
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  background:
    linear-gradient(90deg, var(--bg) 30%, color-mix(in srgb, var(--bg) 0%, transparent)) left / 22px 100% no-repeat,
    linear-gradient(90deg, color-mix(in srgb, var(--bg) 0%, transparent), var(--bg) 70%) right / 22px 100% no-repeat,
    radial-gradient(farthest-side at 0 50%, var(--shade-5), transparent) left / 11px 100% no-repeat,
    radial-gradient(farthest-side at 100% 50%, var(--shade-5), transparent) right / 11px 100% no-repeat;
  background-attachment: local, local, scroll, scroll;
}
.tablescroll > .datatable { margin-top: 0; }

/* ------------------------------------- pinned search bars (items, tree) */
/* Search furniture pins under the topbar so filters stay reachable while
   scrolling a long list. --topbar-h is published by layout.php because the
   topbar wraps to 2-3 rows on phones; the fallback covers a no-JS load. The
   negative margin lets the opaque backing span .wrap's side padding, so rows
   scrolling underneath don't peek out at the edges. Declared last because
   .toolbar/.toolbar set `margin` shorthand, which would otherwise win. */
.pinbar {
  position: sticky; top: var(--topbar-h, 52px); z-index: 30;
  margin-inline: calc(-1 * var(--wrap-pad)); padding: 10px var(--wrap-pad);
  /* THE PAGE'S OWN COLOUR, flat — the band must not read as a lighter box
     around the search field. It used to wear .topbar's gradient, which put it
     at rgb(17,23,30) against a page at rgb(12,15,18): a visible slab, and
     within one unit of the INPUT'S OWN fill (--bg2, rgb(17,22,27)), so the
     field it contained barely read as a field. The border was doing all the
     work of saying "this is a box".

     An older note here claimed a flat --bg "reads as a dark patch once pinned"
     because the body's radial glow is viewport-fixed. Measured rather than
     assumed: that glow is a 1000x540 ellipse anchored at 78% -15%, and in the
     band's own rows it contributes NOTHING — sampled across six x positions
     while pinned, band and page below it both read rgb(12,15,18) exactly, at
     1100px and at 1600px. The patch does not happen.

     It still has to be OPAQUE: pinned, it is what the page scrolls under. */
  background: var(--bg);
  /* ...which leaves nothing to mark where the pinned bar ends and the moving
     content begins, so the seam is stated. --line-soft is the between-rows
     hairline, deliberately quieter than a box's --line: at rest it reads as a
     rule under the search row, not as the edge of a panel. */
  border-bottom: 1px solid var(--line-soft);
}
/* A page whose band is the FIRST thing under the header (items.php, where the
   search sits above the <h1> rather than below it) pins with no travel at all:
   .wrap's own top padding is cancelled, so the bar is already at its sticky
   offset on first paint and the catalogue scrolls under a search box that
   never moves. The gap that padding was providing is handed to the content
   below the band instead.

   A modifier rather than :first-child — page_trail() and jsonld() emit
   <script> elements ahead of it, which occupy no space but are real first
   children, so the structural selector matches nothing. */
/* .pinbar is ALWAYS a wrapper around the search row, never a class on the row
   itself. guides.php used to put it on its own <form>, which meant the child
   rules below silently didn't apply there — the form kept .toolbar's own block
   margin on top of the band's padding, so the one page the inconsistency was
   first reported on had a visibly taller bar than items.php and tree.php.
   Wrapping is now the one shape on all eight pinned pages.

   Where .pinbar wraps a search row AND its drill toolbar (items.php, tree.php),
   the two rows pin together as one unit, so the tier chips, category/map
   selects and live count stay reachable while scrolling a long list — not just
   the search box. The inner rows drop their own outer margins, and the drillbar
   loses its own divider, so the .pinbar's single border is the only seam. The
   drillbar's top margin is the GAP BETWEEN two stacked rows, so it is only
   applied when a row precedes it — creatures.php's drillbar is the first thing
   in its band and would otherwise sit lower than every other page's search. */
.pinbar > .toolbar { margin: 0; }
.pinbar > .drillbar { margin: 0; padding-bottom: 0; border-bottom: 0; }
.pinbar > * + .drillbar { margin-top: var(--sp-5); }
.mobilefiltertoggle { display: none; }
/* On phones the full pinned toolbar would swallow most of the screen, so trim
   it to essentials: the search, selects and tier chips stay pinned, the chips
   drop onto one horizontally-scrollable row, and the redundant bulk goes —
   Expand/Collapse all (each tier header is tappable) and the Search button on
   items.php, guides.php and item.php alike (Enter in the box already submits
   the form). .drillact is what asks for that, and it is a request rather than
   a place: item.php's button wears it from inside a .toolbar, not a .drillbar,
   so it takes the hide below and neither of the two layout rules above. */
@media (max-width: 700px) {
  .pinbar > .drillbar { gap: var(--sp-4); }
  .pinbar > * + .drillbar { margin-top: var(--sp-4); }
  .pinbar .tierfilter { margin-left: 0; width: 100%; flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .pinbar .tierfilter .chip--mono { flex: 0 0 auto; }
  .pinbar .btnrow, .pinbar .drillact { display: none; }
  /* Search is the persistent affordance on dense reference pages. The rest of
     the filter set is intentionally opt-in, so a phone reader sees results
     instead of a two-row chip wall before they have expressed a need. */
  .mobilefiltertoggle { display: inline-flex; }
  .filter-summary { display: block; margin: var(--sp-4) 0 0; color: var(--orange-hi); font-size: var(--fs-sm); }
  #itemFilters.filter-ready:not(.filters-open) #catFilter,
  #itemFilters.filter-ready:not(.filters-open) #mapFilter,
  #itemFilters.filter-ready:not(.filters-open) #tierFilter,
  #creatureFilters.filter-ready:not(.filters-open) #mapchips,
  #creatureFilters.filter-ready:not(.filters-open) #biomechips,
  #spawnFilters.filter-ready:not(.filters-open) #kindchips { display: none; }
  #creatureFilters.filters-open #mapchips,
  #creatureFilters.filters-open #biomechips,
  #spawnFilters.filters-open #kindchips { width: 100%; }
}

/* ----------------------------------------------------- alteration bench */
.altbase { display: flex; align-items: center; gap: var(--sp-6); margin: var(--sp-3) 0 var(--sp-7); }
.altattcard { display: flex; align-items: center; gap: var(--sp-5); margin: var(--sp-3) 0 var(--sp-7); padding: var(--sp-4) var(--sp-5);
  background: var(--panel2); border: 1px solid var(--line); border-radius: var(--r-md); }
.altattcard > div { flex: 1; min-width: 0; }
/* Attachment picker: every compatible attachment is its own clickable card
   (icon + name + granted perk) inside one shared form — clicking re-fits
   immediately (button value = att_id), no separate select-then-Update step,
   and the perk is visible before you pick rather than after. Capped height
   with its own scroll so a 20+-option class (knives, sledgehammers) can't run
   away with the column. */
.attgrid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: var(--sp-4); margin: var(--sp-5) 0 var(--sp-9); max-height: 380px; overflow-y: auto; padding: var(--sp-1) var(--sp-1) var(--sp-1) 0;
}
.attopt {
  display: flex; align-items: center; gap: var(--sp-4); text-align: left; min-width: 0;
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md);
  padding: var(--sp-4) var(--sp-5); color: var(--text); cursor: pointer;
}
/* Same reason as .cardlist > .card[hidden] above: .attopt sets its own
   `display`, which otherwise beats the [hidden] attribute the picker filter
   script toggles — without this, "hidden" items stay rendered. */
.attopt[hidden], .basegroup-head[hidden] { display: none; }
.attopt:hover { border-color: var(--orange); }
.attopt.sel { border-color: var(--orange); box-shadow: 0 0 0 1px var(--orange); background: color-mix(in srgb, var(--orange) 8%, transparent); }
.attopt-empty { color: var(--dim); font-style: italic; justify-content: center; }
.attopt-body { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; }
.attopt-name { font-size: var(--fs-sm); font-weight: 600; }
/* New-build base-item picker reuses .attgrid/.attopt (same icon+name card,
   same one-click-submits shape) at a taller cap — it's browsing the full
   ~225-item gear catalogue instead of one class's handful of attachments —
   grouped under a full-width gear_class heading per group. */
.basegrid { max-height: 460px; }
.basegroup-head {
  grid-column: 1 / -1; margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--line);
  font-size: var(--fs-xs); letter-spacing: 1.2px; text-transform: uppercase; color: var(--dim);
}
.basegroup-head:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.attopt-perk { font-size: var(--fs-xs); color: var(--orange-hi); }
/* Gallery card icon strip: the build's base pieces at a glance, so a card is
   recognisable as "the heavy armour one" before you open it. */
.cardicons { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2); margin: var(--sp-4) 0 var(--sp-1); }
/* No rules for the build's quick-glance strip (pieces, fitted count, weight,
   craft cost): it is a plain .guidefacts/.fact strip, the same widget
   guide.php and creature.php head a detail page with. It used to be its own
   .altsummary row of bordered boxes borrowed from the talent tree, which made
   one detail view's header look unlike every other one's — the talent tree
   itself has since moved to the same strip. */
.stackform { display: flex; flex-direction: column; gap: var(--sp-5); margin-top: var(--sp-6); }
/* :not(.chk):not(.toggle) is load-bearing, and EVERY BESIDE-STYLE LABEL HAS TO
   BE LISTED HERE. `label.chk` (line 679) and `.stackform label` have EQUAL
   specificity, so the later one won and every checkbox inside a stacked form
   rendered as a centred box with its text underneath instead of the inline-flex
   row .chk asks for. It was mild while two crew checkboxes hit it; the 2026-08
   rebuild put the "I've read this and I'll play by it" agree control into the
   same shape, and a stacked, centred agree box on an agreement is not a control
   anybody trusts. The stackform's column layout is for a label ABOVE its input;
   a checkbox label is a label BESIDE one.

   `.toggle` was added to the exclusion when the crew's Listing and Recruiting
   switches moved into stacked forms — and it fails WORSE than .chk did, not
   better: `.stackform label:not(…)` beats `label.toggle` on specificity
   outright, so the switch and its text stacked AND `label.toggle`'s own
   `align-items: center` then centred them in the column, leaving a lone pill
   floating over a line of text in the middle of a left-aligned form. Adding the
   markup is not enough; a new beside-style label class must be excluded here in
   the same commit. */
.stackform label:not(.chk):not(.toggle) { display: flex; flex-direction: column; gap: var(--sp-2); font-size: var(--fs-md); }
/* THE SPACING WAS BEING PAID TWICE. The column's own gap is what separates one
   field from the next and the label's gap is what separates a label from its
   control — but `label` also carries margin: 10px 0 4px and `label input` a 4px
   margin-top, and a margin inside a flex container does not collapse with
   anything. Every field therefore sat 24px from the next and 8px from its own
   input, which is most of why a settings form read as a page of air. Reset here
   rather than weakening the base rules, which are right for a label in ordinary
   flow. */
.stackform label { margin: 0; }
.stackform label > input, .stackform label > select, .stackform label > textarea { margin-top: 0; }
/* A submit is as wide as its own label. .btn is inline-block, but as a direct
   child of a flex column it stretches to the column — which rendered "Save
   settings" as a 1200px orange bar. Buttons that belong together are still a
   .btnrow; its own block margin would be added to the gap rather than
   collapsed into it, so it is restated here for this context. */
.stackform > .btn { align-self: start; margin-top: var(--sp-2); }
.stackform > .btnrow { margin: var(--sp-2) 0 0; }
/* Attachment bonus value: no good/bad colour, since a negative number here can
   be the desirable direction (-50% weapon weight) as often as not — only the
   label says which. */
.altbonus { color: var(--orange-hi); font-weight: 700; }
/* Per-source breakdown under a combined bonus — always shown when two pieces
   grant the same effect, so the total is never the only figure on the page. */
.bonussources { font-weight: 400; font-size: var(--fs-sm); margin-top: var(--sp-1); }
/* The Overall section runs the bonuses and craft-cost tables side by side in a
   .twocol: two equal halves of the same width the section heading spans, each
   table filling its half. Both sit in a .tablescroll so neither carries a
   width cap or a stray top margin the other lacks — see altbench.php.
   Trailing prose keeps the gap the tables already end with, rather than
   adding its own on top.
   Unlike a generic .twocol, this pair holds its 50/50 split below the 860px
   fold (declared later, so it outranks that rule): both tables are narrow
   label+number lists, and stacking them turns the section into two
   screens of scroll on a half-screen window. Only a real phone width folds
   them. */
.overallcols { grid-template-columns: 1fr 1fr; }
@media (max-width: 640px) { .overallcols { grid-template-columns: 1fr; } }
/* A half-width column is already narrow, so no table in here takes a width cap
   of its own — .datatable.totals' 480px would leave the craft-cost table
   ending short of the section rule while the bonuses table beside it filled
   its half. altbench.php simply omits that class; this makes the split
   hold even if it comes back. */
.overallcols .datatable { max-width: none; }
/* Both tables are label + number, so both take the SAME fixed split rather
   than letting content decide: auto layout sized the bonuses table's label
   column to ~90% and the craft-cost table's to ~71%, which put the two value
   columns at different offsets and left the pair looking misaligned across the
   gutter.
   The split is 68/32, not 50/50. Everything in the value column is short — a
   modifier ("+40%") or a material count ("1,759") — while a bonus label is a
   whole phrase ("Storm stamina regen — Composite Head Armor"). At half and
   half every one of those wrapped to two lines beside a mostly-empty number
   cell, and because only the left table wrapped, the two tables' rows drifted
   further apart the further down the section you read. 68% clears the longest
   label on a full-width page, so the rows stay level across the gutter and the
   numbers still line up at the same offset in each half. Narrower viewports
   wrap again, which is what the wrap is for. */
.overallcols .datatable { table-layout: fixed; }
.overallcols .datatable th, .overallcols .datatable td { width: 68%; }
.overallcols .datatable th + td, .overallcols .datatable td + td { width: 32%; }
.overallcols > div > p:last-child { margin-bottom: 0; }
/* Closing line of the Overall section: the bench list. Full width rather than
   inside a column, so a long bench list runs on one line instead of wrapping
   three times in half a page. It was a two-item row — benches left, "Send
   pieces to a drop" right — until the drop manifest was cut in 2026-08; the
   flex row is kept because it is what places and spaces the line, and it lays
   out the same with one child as it did with two. */
.overallfoot {
  display: flex; flex-wrap: wrap; align-items: center;
  justify-content: space-between; gap: var(--sp-4) 22px; margin-top: var(--sp-7);
}
.overallfoot > p { margin: 0; }
.overallfoot .btnrow { margin: 0; }
/* Each equipped piece is its own card, three-up on desktop under an Armor /
   Weapons / Tools heading, so a seven-piece loadout is one screen instead of
   seven full-width stacked panels. Fixed tracks rather than auto-fill, so the
   Armor row and the Tools row line up their columns instead of each reflowing
   to its own count. */
.loadoutgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-7); margin-bottom: var(--sp-9); }
.loadoutgrid > .panel { margin: 0; }
/* A card at a third of the page gives its picker 341px of track, which the
   default 190px minimum turns into one lonely column. 150px is measured
   against that width (two 165px columns plus the gap). */
.loadoutgrid .attgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }

/* Below the wrap's own 1230px breakpoint the page stops being a full 1200px, so
   a third-width card no longer leaves its picker room for two columns — measured
   at 302px of track, i.e. one. Drop to two cards there rather than let the
   picker degrade. */
@media (max-width: 1230px) {
  .loadoutgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* Phones: the pickers' 190px track leaves one lonely column on a 360px screen,
   so tighten it to two-up, and stack the loadout cards. 132px is measured, not
   guessed: a picker inside a loadout card at a 360px viewport gets 277px of
   track, so the two columns plus the 8px gap have to fit inside that. */
@media (max-width: 700px) {
  .attgrid, .loadoutgrid .attgrid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); max-height: 320px; }
  .loadoutgrid { grid-template-columns: 1fr; }
}

/* ------------------------------------------------------ interactive map */
/* mapview.php. Controls sit ABOVE the map, not beside it, so the map gets the
   full content width. The source image is a 1536x1536 square (.mapcanvas), so
   the viewport is forced to that same 1:1 ratio instead of a wide rectangle —
   otherwise panzoom.js's fit() scales the square canvas to whichever side is
   smaller and letterboxes the rest as dead space. Stays inside .wrap's normal
   column (same left/right margins as the header content and every other page)
   rather than breaking out full-bleed. The viewport clones .plannerviewport's
   gesture setup (the JS drives pan/pinch, so the browser must not also scroll
   the page under it). */
.mapstage {
  background: var(--void); border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden;
}
/* Positioning context for .mapinspect — the viewport's box alone, so the panel
   floats over the map rather than over the toolbar above it. */
.mapviewwrap { position: relative; }
.mapviewport {
  position: relative; overflow: hidden;
  width: 100%; aspect-ratio: 1 / 1;
  background: var(--void); cursor: grab;
  -webkit-user-select: none; user-select: none;
  touch-action: none;
}
.mapviewport:active { cursor: grabbing; }
.mapviewport.measuring { cursor: crosshair; }
.mapviewport:focus-visible { outline: 2px solid var(--orange); outline-offset: -2px; }
/* Native size: the source PNGs are 1536px square, so the deepest zoom is
   pixel-crisp rather than an upscale. panzoom.js fits it to the viewport.
   Direct-child selector so this doesn't also catch the game-icon <img>s
   nested inside .maplayer .pin (those size via .pinface instead). */
.mapcanvas { position: absolute; transform-origin: 0 0; width: 1536px; height: 1536px; }
.mapcanvas > img { display: block; max-width: none; width: 1536px; height: 1536px; }
.mapcanvas .empty { color: var(--dim); padding: 40px; }
/* Each layer is a full-canvas stack (crew under mine under unit) so a later,
   mostly-empty layer would otherwise swallow clicks meant for a pin in an
   earlier one — pointer-events:none here plus :auto on .pin lets clicks fall
   through empty space to whatever pin (or the bare map) is actually under
   the cursor. */
.maplayer { position: absolute; inset: 0; pointer-events: none; }

/* Same counter-scale as the grid labels: a pin marks a point, so it should
   stay the same size on screen and keep its tip on that point at any zoom. */
.mapcanvas .pin { transform: translate(-50%, -90%) scale(var(--zoom-inv, 1)); transform-origin: 50% 90%; }
/* A crew log's honest position is also the whole 504 m cell, not a point
   inside it — resource_log.php only ever validates a grid ref, never an exact
   spot, so this gets a box treatment rather than the tip-anchor transform
   (which would drop the pick icon at one arbitrary point in the cell,
   claiming precision nobody reported). */
.mapcanvas .pin.crewlog {
  transform: none;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--blue) 16%, transparent);
  border: 1px solid color-mix(in srgb, var(--blue) 55%, transparent);
  border-radius: var(--r-xs);
  cursor: pointer;
}
/* The crew-log face is a plain unmasked <img>, so unlike a dropped pin's glyph
   it can carry its own rim and glow (see .pin's --pin-rim). */
.mapcanvas .pin.crewlog .pinface { transform: scale(var(--zoom-inv, 1)); filter: var(--pin-rim) drop-shadow(0 0 4px color-mix(in srgb, var(--blue) 90%, transparent)); }
.mapcanvas .pin.crewlog .pinlabel { transform: scale(var(--zoom-inv, 1)); }
.pin.crewlog:hover, .pin.crewlog.sel, .pin.crewlog:focus-visible {
  background: color-mix(in srgb, var(--blue) 30%, transparent); border-color: color-mix(in srgb, var(--blue) 90%, transparent); outline: none;
}
/* Same reason as --pin-rim: on the masked .pinface this glow was clipped away
   by the mask, so it lives on the pin itself. */
.pin.sel:not(.crewlog), .pin:not(.crewlog):focus-visible {
  filter: var(--pin-rim) drop-shadow(0 0 5px var(--orange-hi)) drop-shadow(0 0 3px var(--orange-hi));
}
.pin:focus-visible { outline: none; }

/* Named-region labels (Grove, Oasis, ...) — plain text, no icon, not
   clickable (inherits .maplayer's pointer-events:none). Same counter-scale
   as pins so the text stays a constant on-screen size at any zoom. */
.arealabel {
  position: absolute; transform: translate(-50%, -50%) scale(var(--zoom-inv, 1));
  font-size: var(--fs-sm); font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase;
  color: var(--map-label); text-shadow: 0 1px 3px var(--shade-8), 0 0 8px var(--shade-7);
  white-space: nowrap; pointer-events: none;
}

/* Measure overlay. viewBox 0 0 100 100 + preserveAspectRatio:none means the
   JS can draw straight in map-percent coordinates. */
.mapmeasure { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.mapmeasure line { stroke: var(--orange-hi); stroke-width: .25; stroke-dasharray: 1 .6; }
.mapmeasure circle { fill: var(--orange-hi); }

/* Sits in .maptop, above the Pin/Key rows — a .maptoprow itself (see below),
   so it wraps and spaces the same way instead of needing its own layout. */
.mapzoombar .btn { padding: var(--sp-3) var(--sp-6); font-size: var(--fs-sm); }
.mapzoombar .btn.on { border-color: var(--orange); color: var(--orange); }
/* NOT a .searchbox, and deliberately so: this is a go-to-a-coordinate jump
   field, not a filter over a list — it takes five characters ("M14") and sits
   in the map's dense control strip beside the zoom buttons, which it is sized
   to match. .searchbox's 220px floor would push that strip onto two rows. The
   background/border/radius come from the base `input` rule, same as every
   other field on the site; only the size is its own. */
.gridjump { color: var(--text); padding: var(--sp-3) var(--sp-5); font-size: var(--fs-sm); width: 92px; }
.coordread { font-family: var(--mono); font-size: var(--fs-sm); white-space: nowrap; }

/* Zoom bar, pin picker and key, stacked above the map. All three rows wrap
   rather than scrolling, so they cost the map as little height as possible. */
.maptop { background: var(--panel); border-bottom: 1px solid var(--line); }
.maptoprow {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3) var(--sp-5);
  padding: var(--sp-2) var(--sp-5);
}
.maptoprow + .maptoprow { border-top: 1px solid var(--line-soft); }
.maptoplabel {
  flex: none; width: 46px;
  font-size: var(--fs-micro); letter-spacing: 1.6px; text-transform: uppercase;
}
.mapsidenote { font-size: var(--fs-sm); margin: var(--sp-3) 0; }
.mapnotes { font-size: var(--fs-sm); margin: var(--sp-4) var(--sp-1); }

/* The key doubles as the legend: swatch + label + count, inline chips so the
   whole thing is one wrapped row instead of a column beside the map. */
.maplegend { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3) var(--sp-3); }
.legendrow {
  display: inline-flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-5) var(--sp-2) var(--sp-4); border-radius: var(--r-pill); cursor: pointer; font-size: var(--fs-sm);
  border: 1px solid var(--line); background: var(--bg2);
}
.legendrow:hover { border-color: var(--orange); color: var(--orange-hi); }
/* An unchecked layer reads as off without needing a second control. */
.legendrow:has(input:not(:checked)) { opacity: .45; }
/* These rows are <label>s, so the generic `label input { display:block;
   width:100% }` rule up top would stretch the checkbox across the row and
   crush the text beside it. Opt back out. */
.legendrow input[type=checkbox] {
  display: inline-block; width: auto; flex: none; margin: 0;
}
.legendrow .countnote { font-size: var(--fs-xs); }
.legendswatch {
  width: 11px; height: 11px; flex: none; border-radius: var(--r-xs);
  background: var(--swatch, var(--dim));
  box-shadow: 0 0 0 1px var(--shade-5);
}
.spawnlist .legendlabel, .legendrow .legendlabel { flex: none; }

/* Floats over the top-right of the map — a sibling of the viewport, so
   interacting with it never reaches the pan/pin handlers underneath. */
.mapinspect {
  position: absolute; top: 10px; right: 10px; z-index: 5; width: 262px;
  font-size: var(--fs-sm); background: var(--panel);
  border: 1px solid var(--orange); border-radius: var(--r-md); padding: var(--sp-5) var(--sp-6);
  box-shadow: 0 10px 40px var(--shade-6);
}
.mapinspect h3 { margin: 0 var(--sp-7) var(--sp-2) 0; }
.mapinspect p { margin: var(--sp-2) 0; }
.mapinspect-x {
  position: absolute; top: 4px; right: 6px;
  background: none; border: none; color: var(--dim);
  cursor: pointer; font-size: var(--fs-md); line-height: 1; padding: var(--sp-1) var(--sp-2);
}
.mapinspect-x:hover { color: var(--orange-hi); }

/* Collapsed by default: these rows have no coordinates, so the panel cannot
   interact with the map — it is a reference list, and map.php already has the
   full tables. Open by default it would be the page's biggest clutter. */
.spawnpanel { margin-top: var(--sp-8); border-top: 1px solid var(--line); padding-top: var(--sp-5); }
.spawnpanel > summary {
  cursor: pointer; font-size: var(--fs-sm); letter-spacing: 1.4px;
  text-transform: uppercase; color: var(--dim);
}
.spawnpanel > summary:hover { color: var(--orange-hi); }
.spawnpanel .searchfield { width: 100%; margin: var(--sp-4) 0; }
.spawnlist { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; }
.spawnlist li {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-2) var(--sp-1); border-bottom: 1px solid var(--line-soft); font-size: var(--fs-sm);
}
.spawnlist li .dim { margin-left: auto; text-align: right; font-size: var(--fs-xs); }

/* Same opt-out as .legendrow: this is a <label>, so the select would go
   display:block and drop under its own caption. */
.mapvis { display: inline-flex; align-items: center; gap: var(--sp-3); }
.mapvis select {
  display: inline-block; width: auto; margin-top: 0;
  background: var(--bg2); border: 1px solid var(--line); color: var(--text);
  border-radius: var(--r-sm); padding: var(--sp-2) var(--sp-3); font-size: var(--fs-sm);
}
.markerpop.hidden { display: none; }
.markerpop select { width: 100%; margin-bottom: var(--sp-4); }
/* Colour picker inside the pin popup (mapview.php), sat with the pin's name
   instead of in a toolbar row above the map. Eight swatches plus a caption
   don't fit the popup's width on one line, so the row wraps rather than
   squeezing the swatches or widening the popup. */
.mp-colour { display: flex; align-items: center; gap: var(--sp-3) var(--sp-4); flex-wrap: wrap; margin-bottom: var(--sp-4); }
.mp-colour .swatchpick { flex-wrap: wrap; gap: var(--sp-3); }
.mp-caption { flex: none; font-size: var(--fs-micro); letter-spacing: 1.6px; text-transform: uppercase; }
/* Save / Delete / Close stay on one line: three default-padded .btn don't fit
   the popup's 260px, and a wrapped Close reads like a second, separate action.
   Trimming the side padding is enough — the labels keep their full text. */
.markerpop .btnrow { flex-wrap: nowrap; gap: var(--sp-3); margin-bottom: 0; }
.markerpop .btnrow .btn { padding-inline: 10px; }

@media (max-width: 640px) {
  /* The floating inspector would cover most of a phone screen — dock it to the
     bottom edge full-width instead. (.markerpop stays centered but goes
     full-width — see its own mobile rule above.) */
  .mapinspect { top: auto; bottom: 0; left: 0; right: 0; width: auto; border-radius: 0; border-width: 1px 0 0; }
  /* Stacked above the map the three control rows are compact on a desktop
     column, but wrapped across a phone they run to ~700px — the viewport
     started below the fold, so opening the interactive map showed no map at
     all. Put the viewport first and let the controls follow it; the border
     that separated them flips to the other edge to match.
     Every child needs an explicit order — .mapnotes and .spawnpanel live in
     .mapstage too, and left at the default 0 they would jump ahead of the
     map instead of staying under it. */
  .mapstage { display: flex; flex-direction: column; }
  .mapviewwrap { order: 1; }
  .maptop { order: 2; border-bottom: 0; border-top: 1px solid var(--line); }
  .mapnotes { order: 3; }
  .spawnpanel { order: 4; }
  .maptoplabel { width: 100%; }
  .mapenter-cta { font-size: var(--fs-md); padding: var(--sp-5) var(--sp-9); letter-spacing: 1px; }
  .mapenter-sub { font-size: var(--fs-micro); }
  .coordread { display: none; }
}

/* ====================================================== field journal refresh
   The terminal remains the site identity, but reference pages now read as an
   expedition manual: hierarchy comes from type, imagery and divider rules;
   containment is reserved for actions and genuinely separate tools. */
/* Page hierarchy and reading flow */
.pagehead { padding-bottom: var(--sp-7); margin-bottom: var(--sp-9); }
/* A section heading with a control beside it (item.php's craft quantity form).
   It used to borrow .pagehead, which is the page's ONE <h1> strip — so that
   block had an <h2> inside a .pagehead and no <h1>, quietly contradicting the
   contract at the top of this file. Its own name, and no page-head margins. */
/* A section heading sharing its row with a control (item.php's crafting
   breakdown + its quantity form). THE RULE BELONGS TO THE ROW, NOT TO THE
   HEADING: .sech carries a border-bottom, so left on the h2 it stopped at the
   heading's own box — 241px short of the column on a 1440px page — while the
   facts strip directly above it and every other .sech below it ran edge to
   edge. One section rule at a different length reads as a broken rule, which
   is the same defect class as an inset facts strip. */
.sechrow {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-7); flex-wrap: wrap;
  border-bottom: 1px solid var(--rule); margin: 30px 0 var(--sp-6);
}
.sechrow > .sech { margin: 0; border-bottom: 0; padding-bottom: 0; flex: 1 1 auto; }
.pagehead h1 { font-size: clamp(28px, 3vw, 38px); font-weight: 600; letter-spacing: .1px; }
.pagesub, .lede { max-width: 760px; }
/* The one place the reading measure is wrong: an admin_head() intro sits over
   a full-width tab bar and table, so 760px left its right edge short of the
   column everything under it aligns to. */
.pagesub-wide { max-width: none; }
/* Softer divider and more air above, but the heading keeps the base rule's
   orange: recolouring these to moss is what made a generated section read as
   a quieter kind of thing than the "##" headings in the body beside them. */
.sech { border-bottom-color: var(--rule); margin-top: 30px; }
.sech::before { content: ''; }
.content-section { margin: 30px 0; }
.content-section > .section-intro { max-width: 720px; color: var(--dim); margin: -5px 0 14px; }
.inset { background: color-mix(in srgb, var(--panel2) 58%, transparent); border: 1px solid var(--rule); padding: var(--sp-7) var(--sp-8); }
/* The strip is rule-bounded, not boxed — containment is reserved for actions.
   Cells sit left-packed at their own width with one gap between them, so each
   label sits directly above the start of its value and the pairs read as
   columns. row-gap matters: with eight facts this wraps on a laptop, and at
   0 the second row's labels collided with the first row's values. */
.meta-strip, .guidefacts { display: flex; flex-wrap: wrap; gap: var(--sp-6) 26px; border-block: 1px solid var(--rule); padding: var(--sp-5) 0; align-items: start; }
/* flex-grow so the cells share the leftover width and the row reaches the
   right-hand rule, instead of packing left and leaving the last third of the
   bar empty under a full-width border. Growing beats justify-content here:
   space-between would fling a wrapped last row to both margins. */
.meta-strip > *, .guidefacts .fact { border: 0; background: none; border-radius: 0; padding: 0; flex: 1 1 auto; min-width: 0; white-space: normal; }
.guidefacts .fact b { color: var(--moss); }
.mdbody { font-size: var(--fs-lg); line-height: 1.7; }

/* Mission guide sections in the field-journal theme: same structure, the
   theme's own rules and inks. Only colour and rule weight change here — the
   layout above is shared, so a guide keeps identical bones in both themes. */
.sechnote { color: var(--dim); }
.gridref { background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); color: var(--sand); }
a.gridlink .gridref {
  border-color: color-mix(in srgb, var(--sand) 48%, var(--rule));
  background: color-mix(in srgb, var(--sand) 12%, transparent);
}
a.gridlink:hover .gridref, a.gridlink:focus-visible .gridref {
  border-color: var(--sand); color: var(--paper);
  background: color-mix(in srgb, var(--sand) 22%, transparent);
}
.stepflow > .step { border-left-color: var(--rule); }
.stepflow > .step::before {
  background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); color: var(--sand);
}
.steptitle, .substeptitle { color: var(--paper); }
.stepqty { color: var(--moss); }
.substeps > li::before { color: var(--rule); }
.difflevel { background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); }
.effectlist > li { border-bottom-color: var(--rule); }
.effval { color: var(--paper); }
.hintlist > .hint { background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); }
.hint.h-tip { border-left-color: var(--moss); }
.hint.h-tip::before { color: var(--moss); }
.hint.h-warning { border-left-color: var(--sand); }
.hint.h-warning::before { color: var(--sand); }
.briefing h2 { color: var(--moss); }
.card--creature { background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); }
.card--creature:hover { border-color: var(--sand); }
.creaturename { color: var(--paper); }
.creatureportrait { background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); }
.creatureart.noart { border-color: var(--rule); color: var(--rule); }
.cardtype.t-creature { color: var(--moss); border-color: var(--moss); }
/* The boss badge keeps --red in the refresh layer too: it is the one
   thing on a creature page that is a warning rather than a category, and
   the refresh repaints .t-creature one line above, so a t-boss left out
   of this layer would be the only cardtype still wearing the base
   palette next to a repainted sibling. */
.cardtype.t-boss { color: var(--red); border-color: var(--red); }

/* The tech tree's results panel — and the item tiles guide.php reuses for a
   mission's requirements — in the theme's inks. Same bones as before (tier
   section, bench blocks, tiles); only the surface and rule weight change, to
   the ones .difflevel, .hint and .card--creature already use, so a drilled
   category is made of the same material as every other page. What stays lit
   is the data: the tier colour on the section's left edge and the category
   colour on each tile's. */
.tiergroup { background: color-mix(in srgb, var(--panel2) 58%, transparent); border-color: var(--rule); border-left-color: var(--tclr, var(--sand)); }
.tiergroup-head { background: linear-gradient(180deg, color-mix(in srgb, var(--panel3) 50%, transparent), color-mix(in srgb, var(--panel2) 25%, transparent)); }
.tiergroup-head:hover { background: color-mix(in srgb, var(--panel3) 55%, transparent); }
.benchname { color: var(--sand); border-bottom-color: var(--rule); }
/* Three sides, not the shorthand: border-left carries --cc, the item's
   category colour, and a `border-color` here would repaint it. */
.itemtile {
  background: color-mix(in srgb, var(--panel2) 58%, transparent);
  border-top-color: var(--rule); border-right-color: var(--rule); border-bottom-color: var(--rule);
}
.itemtile:hover { border-color: var(--sand); }
.itemtile.generic:hover { border-color: var(--rule); }
/* --cc no longer reaches the left edge on its own here, because the three
   rules above repaint their sides to --rule while the left is still the base
   rule's color-mix(--cc 38%, --line). Stated outright so the four sides match,
   which is what .reqtiles has always been for. */
.reqtiles .itemtile { --cc: var(--rule); border-left-color: var(--rule); }

/* Compact visual masthead. Generated terrain is deliberately decorative only:
   all page meaning remains available in text and normal navigation. */
.field-hero {
  min-height: 178px; padding: var(--sp-9) 22px; margin-bottom: 22px;
  background-image: linear-gradient(90deg, color-mix(in srgb, var(--well) 68%, transparent) 0%, color-mix(in srgb, var(--well) 42%, transparent) 52%, color-mix(in srgb, var(--well) 14%, transparent)), url('field-journal-hero.jpg');
  background-size: cover; background-position: center 56%;
  border-color: color-mix(in srgb, var(--sand) 32%, transparent);
}
.field-hero h1 { font-size: clamp(29px, 4vw, 44px); font-weight: 600; margin: var(--sp-1) 0 var(--sp-2); }
.field-hero .hero-copy { margin: 0; max-width: 630px; }
.field-hero .homesearch { max-width: 700px; margin-top: var(--sp-6); }

/* The home page follows the composition preview: map-led routes on the left,
   a quiet crew column on the right, then reference tools below. */
.home-dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(250px, .38fr); gap: 36px; align-items: start; }
.home-main { min-width: 0; }
.home-dashboard .sech, .home-prepare .sech { border-bottom: 0; padding-bottom: 0; margin-bottom: var(--sp-5); }
.expedition-list { border-top: 1px solid var(--rule); }
.expedition-row { display: grid; grid-template-columns: 230px minmax(0, 1fr) auto; gap: var(--sp-9); align-items: center; padding: var(--sp-5) 0; border-bottom: 1px solid var(--rule); color: var(--text); }
.expedition-row:hover { color: var(--text); text-decoration: none; background: linear-gradient(90deg, color-mix(in srgb, var(--sand) 8%, transparent), transparent); }
.expedition-art { display: block; height: 112px; overflow: hidden; background: var(--bg2); }
.expedition-art img, .expedition-art .mapscene { display: block; width: 100%; height: 100%; object-fit: cover; }
.expedition-copy { display: grid; gap: var(--sp-3); }
.expedition-copy b { font-size: var(--fs-2xl); font-weight: 600; }
.expedition-copy span { color: var(--dim); font-size: var(--fs-md); }
.section-link { float: right; color: var(--orange); font-size: var(--fs-xs); letter-spacing: 1px; font-weight: 500; }
.home-fieldnotes { padding: 0 0 var(--sp-9) var(--sp-4); min-width: 0; }
.home-fieldnotes .sech { margin-top: 26px; }
.home-fieldnotes .section-link { font-size: var(--fs-micro); }
.home-fieldnotes .list-row { padding: var(--sp-6) 0; }
.home-fieldnotes .list-row h3 { font-size: var(--fs-lg); }
.home-fieldnotes .list-row p { font-size: var(--fs-sm); line-height: 1.4; }
.home-notes .list-row { padding: var(--sp-6) 0; }
.home-prepare { max-width: none; }
.home-prepare .home-routes { grid-template-columns: repeat(3, minmax(0, 1fr)); }

/* Home tools become a route board rather than a grid of equal dashboard widgets. */
.home-section { margin: 26px 0; }
.home-routes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; }
.home-routes .tile {
  position: relative; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--sp-4) var(--sp-6);
  min-height: 76px; padding: var(--sp-7) var(--sp-1); border: 0; border-radius: 0; border-bottom: 1px solid var(--rule); background: transparent;
}
.home-routes .tile:hover { transform: none; border-color: var(--sand); background: linear-gradient(90deg, color-mix(in srgb, var(--sand) 7%, transparent), transparent); }
.home-routes .tilehead { margin: 0; grid-row: span 2; }
.home-routes .tilehead b { border: 0; padding: 0; color: var(--text); font-size: var(--fs-base); letter-spacing: .2px; text-transform: none; }
.home-routes .tileicon { border-radius: 50%; width: 34px; height: 34px; }
.home-routes .tilesub { grid-column: 2; font-size: var(--fs-md); }
.route-arrow { grid-column: 3; grid-row: 1 / span 2; color: var(--orange); font-size: var(--fs-2xl); }
.home-section-crew .home-routes { grid-template-columns: 1fr; max-width: 760px; }

/* Device-local planner snapshot confirmation. It sits below the briefing so it
   cannot be read as a shared crew update or part of the map controls. */
.offlinebrief-status { margin: calc(-1 * var(--sp-5)) 0 var(--sp-7); }

/* ---- the support ticket form (feedback.php) ---------------------------
   The one page a visitor reaches when something is already wrong, and the
   last one still wearing the pre-refresh flat panel: eleven identical dim
   labels over eleven identical wells in a 760px column, with nothing to
   say which three are the requirement. Nothing was misaligned — it was
   uniform, which on a long form reads as a wall.

   It keeps .authbox's containment, because a form IS an action and that is
   what this theme reserves containment for. Everything else comes from the
   journal vocabulary already on every other page: .sech rules do the
   grouping, labels take the micro-caps the nav wears, and the
   accent lives on the control under the cursor rather than being painted
   on the box. No new box, no second border weight, nothing outside a
   border box — the pop is state, not decoration. */
/* Wider, and the box's own edge tinted to the warm rule its contents now use —
   the whole border, not one side of it, which is how an accent is carried
   here. At --line it was a cool blue-grey frame around a warm form. */
.feedbackbox { max-width: 920px; border-color: color-mix(in srgb, var(--sand) 26%, transparent); }
.feedbackbox > .cardtype { display: inline-block; color: var(--sand); border-color: color-mix(in srgb, var(--sand) 45%, transparent); }
.feedbackbox h1 { margin: var(--sp-4) 0 var(--sp-3); }
.feedbackbox > .pagesub { margin: 0 0 var(--sp-8); font-size: var(--fs-md); line-height: 1.6; }
/* Sized and inked as a form's section marker rather than an article's: the
   heading is the smallest thing that can still divide, so the fields under it
   stay the loudest thing in their group. */
.feedbackbox .sech {
  font-size: var(--fs-md); letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--sand); margin: 30px 0 var(--sp-5);
}
.feedbackbox .sech:first-of-type { margin-top: var(--sp-6); }
.feedbackbox .sechnote { text-transform: none; letter-spacing: 0; font-size: var(--fs-sm); }
.feedbackbox label { font-size: var(--fs-xs); letter-spacing: 1px; text-transform: uppercase; margin-top: var(--sp-7); }
/* State, on the control you are touching. Hover warms the edge; focus keeps
   the site's orange ring (the platform affordance, and the one detached mark
   the contract allows) and adds a flush halo — spread only, both offsets
   zero — so the active field reads at a glance down a tall form. */
.feedbackbox :is(input, select, textarea) { border-color: var(--rule); transition: border-color .12s, box-shadow .12s; }
.feedbackbox :is(input, select, textarea):hover { border-color: color-mix(in srgb, var(--sand) 42%, transparent); }
.feedbackbox :is(input, select, textarea):focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 15%, transparent); }
/* Priority is the one field whose VALUE is a signal, so it wears it —
   feedback.js writes --accent from the selection, the same way every other
   data-driven colour on the site arrives as a custom property. */
.feedbackbox select[name=priority] { border-color: var(--accent, var(--rule)); }
/* The screenshot field as a drop well: dashed on all four sides (one weight,
   uniformly), and lit on dragover so the drop has an answer. */
.feedbackbox input[type=file] {
  border: 1px dashed color-mix(in srgb, var(--sand) 32%, transparent); background: transparent;
  padding: var(--sp-7) var(--sp-6); text-align: left;
}
.feedbackbox input[type=file]:hover { border-color: color-mix(in srgb, var(--sand) 55%, transparent); }
.feedbackbox input[type=file].dropping {
  border-style: solid; border-color: var(--orange);
  background: color-mix(in srgb, var(--orange) 8%, transparent);
}
.feedbackbox .uploadlist { margin-top: var(--sp-4); }
/* The captcha and the submit close the form, so they sit under a rule of
   their own rather than floating after the last field. */
.feedbackbox .captcha, .feedbackbox .captcha-off { border-top: 1px solid var(--rule); padding-top: var(--sp-8); margin-top: 30px; }

/* ---- the ticket conversation (tickets.php, admin_tickets.php) ---------
   One form, two pages: the submitter's view and the admin's view render the
   same thread from feedback_thread_html(), so it is styled once here rather
   than twice with a page prefix.

   A left rail on borderless text, which is the one place a single-sided
   accent is allowed — there is no box border for a heavier edge to overshoot,
   and it is the shape .stepflow and .mdbody blockquote already use for "this
   block is a unit of something longer". Staff replies take the rail in
   --sand, so who is speaking is legible down the margin without a badge on
   every entry. No bubbles and no alternating alignment: this is a record of
   correspondence, not a chat window. */
.thread { list-style: none; margin: 0 0 var(--sp-9); padding: 0; display: grid; gap: var(--sp-8); }
.threadmsg { border-left: 2px solid var(--rule); padding-left: var(--sp-7); }
.threadmsg--staff { border-left-color: var(--sand); }
.threadwho { margin: 0 0 var(--sp-3); font-size: var(--fs-xs); letter-spacing: 1px; text-transform: uppercase; color: var(--dim); }
.threadmsg--staff .threadwho { color: var(--sand); }
.threadbody { margin: 0; line-height: 1.6; }
/* The reply box closes the thread, so it sits under the thread's own rule
   rather than floating after it. */
.replyform { border-top: 1px solid var(--rule); padding-top: var(--sp-7); margin-bottom: var(--sp-9); }
.replyform label { font-size: var(--fs-xs); letter-spacing: 1px; text-transform: uppercase; }
.replyform .btnrow { margin-top: var(--sp-6); }

/* Browsing lists retain their information but stop looking like stacked boxes. */
.journal-list { display: grid; gap: 0; border-top: 1px solid var(--rule); }
.list-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-6); align-items: center; padding: var(--sp-7) 0; border-bottom: 1px solid var(--rule); color: var(--text); }
.list-row:hover { color: var(--text); background: linear-gradient(90deg, color-mix(in srgb, var(--sand) 7%, transparent), transparent); text-decoration: none; }
.list-row h2, .list-row h3, .list-row p { margin: 0; }
.list-row h3 { font-size: var(--fs-xl); font-weight: 600; }
.list-row .cardmeta { justify-content: flex-end; margin: 0; font-size: var(--fs-sm); }
.list-row .cardtype { margin-bottom: var(--sp-3); }
.missioncard .btnrow { justify-content: flex-end; margin: 0; }
.cardlist--map { gap: var(--sp-9); }
/* Edges are builds.php's: the shared --r-md corner, the --line border and the
   orange hover from the .card/.tile/.mapcard surface rule. Only the fill is
   this page's own. */
.mapcard { background: transparent; }
/* Text keeps a buffer off the card's own border on all four sides. */
.mapcard-body { padding: var(--sp-7) var(--sp-7) var(--sp-8); }
.mapcard-body h2 { font-weight: 600; }
.mapsection + .mapsection { margin-top: var(--sp-10); }
.mapsection > .sech { margin-top: 0; }
.mission-meta { display: flex; flex-wrap: wrap; gap: var(--sp-4); margin: var(--sp-3) 0 0; color: var(--text-faint); font-size: var(--fs-xs); letter-spacing: .7px; text-transform: uppercase; }
.mission-meta span + span::before { content: '·'; margin-right: var(--sp-4); color: var(--dim); }

@media (max-width: 720px) {
  .field-hero { padding: var(--sp-8); background-position: 58% center; }
  .home-dashboard { grid-template-columns: 1fr; gap: 0; }
  .home-fieldnotes { padding: 0; }
  .expedition-row { grid-template-columns: 118px minmax(0, 1fr) auto; gap: var(--sp-6); }
  .expedition-art { height: 82px; }
  .expedition-copy b { font-size: var(--fs-2xl); }
  .home-prepare { max-width: none; }
  .home-prepare .home-routes { grid-template-columns: 1fr; }
  .home-routes { grid-template-columns: 1fr; }
  /* THE TILE STOPS BEING THREE COLUMNS. Its desktop grid is
     `auto 1fr auto` — icon+name, description, arrow — with the description in
     a track of its own beside the name. That is a wide-row form: at 390px it
     left the name and a three-line description fighting over ~250px with the
     arrow still holding a track, so all three tiles read as a cramped table
     rather than as three things you can tap.

     Stack instead: name row, description under it, arrow held to the right
     across both. Two columns, and the description gets the full width. */
  .home-routes .tile { grid-template-columns: minmax(0, 1fr) auto; }
  .home-routes .tilehead { grid-row: 1; grid-column: 1; }
  .home-routes .tilesub { grid-row: 2; grid-column: 1; }
  .home-routes .route-arrow { grid-column: 2; grid-row: 1 / span 2; }
  .pagehead h1 { font-size: var(--fs-hero); }
  .list-row { grid-template-columns: 1fr; }
  .list-row .cardmeta { justify-content: flex-start; }
}

/* ------------------------------------------------------------ touch targets
   Controls sized for a mouse cursor are not reliably hittable with a thumb.
   Scoped to `hover: none` — the same gate .pinface already uses — so the dense
   desktop layout keeps its compact controls and only touch devices pay the
   extra height. Nothing here changes a control's look, only its hit box.

   The checkboxes are the sharp end: at the browser default 13px (and 16px for
   the compare column) they sit well under any thumb-target guidance, and they
   gate real actions — picking items to compare, toggling map layers, flipping
   a Unit's privacy settings. */
@media (hover: none) {
  .col-cmp input,
  .legendrow input[type=checkbox],
  label.chk input[type=checkbox] { width: 20px; height: 20px; }

  /* Map toolbar: zoom/measure buttons, the grid-jump box and the pin-visibility
     select all sat at 27–31px. Padding rather than a fixed height so a wrapped
     label still grows the control instead of overflowing it. */
  .mapzoombar .btn { padding: var(--sp-5) var(--sp-7); }
  .gridjump { padding: var(--sp-5) var(--sp-5); }
  .mapvis select { padding: var(--sp-4) var(--sp-4); }

  /* "Browse all →" was a 12px-tall floated link. The negative margin keeps it
     optically where it was while the padding gives it a thumb-sized box. */
  .section-link { padding: var(--sp-5) var(--sp-1); margin: -10px 0; }

  /* The wordmark is the way back to the home page from every page on the site;
     26px tall is a mean target for the one link that is always there. */
  .brand { min-height: 40px; }

  /* The spawn list's disclosure was a 19px-tall summary. It is full-width, so
     only the height needed helping. */
  .spawnpanel > summary { padding: var(--sp-4) 0; }
}
