/* PluginsXIV - one stylesheet, shared by every page.
 *
 * Shared from the start, deliberately. The sibling site duplicates its CSS into each page's inline
 * <style> and has been carrying an open note to extract it ever since; there is no reason to repeat
 * that here, and a directory whose pages must stay visually consistent is the worst candidate for it.
 *
 * No build step. What is in this folder is what gets served.
 *
 * ---------------------------------------------------------------------------------------------
 * The system, so additions stay coherent:
 *
 *   SURFACES  Three levels only - page, panel, raised. More levels read as noise, and a directory
 *             needs its status colours to be the loudest thing on screen.
 *   COLOUR    One accent, used for interaction and focus. Never decoration. The four status colours
 *             carry meaning and must never be spent on ornament.
 *   SPACING   A 4px scale. Everything is a multiple, so vertical rhythm holds without measuring.
 *   DEPTH     Borders first, shadow second, and shadow only where something genuinely floats.
 * --------------------------------------------------------------------------------------------- */

:root {
    /* Surfaces. Cool near-black rather than pure black - pure #000 makes borders look like tears in
     * the page and gives OLED smearing on scroll. */
    --bg: #0a0c11;
    --bg-soft: #0e1117;
    --panel: #12161e;
    --raised: #171c26;

    --line: #212734;
    --line-strong: #2c3444;

    --ink: #e7ebf2;
    --ink-2: #a3adbe;
    --ink-3: #6d7788;

    /* One accent. --accent-ink is what goes ON the accent, and is dark on purpose: light text on a
     * mid-blue fails contrast at body sizes. */
    --accent: #4f8dff;
    --accent-hover: #6b9fff;
    --accent-dim: rgba(79, 141, 255, 0.14);
    --accent-ink: #061020;

    --ok: #3ddc84;
    --warn: #fbbf24;
    --bad: #ff6b6b;

    --s1: 4px;
    --s2: 8px;
    --s3: 12px;
    --s4: 16px;
    --s5: 24px;
    --s6: 32px;
    --s7: 48px;

    --r-sm: 6px;
    --r: 10px;
    --r-lg: 14px;

    --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -8px rgba(0, 0, 0, .5);
    --ring: 0 0 0 3px var(--accent-dim);

    --page: 1200px;
    --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, Arial, sans-serif;
    --mono: ui-monospace, SFMono-Regular, "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
}

/* ---- cross-document page transitions ----
 *
 * The declarative View Transitions API: no JavaScript, and the browser owns the timing. That matters
 * for back and forward navigation, where a hand-rolled "fade out, then navigate" script tends to
 * restore a page from bfcache still stuck at opacity zero.
 *
 * Both the page being left and the page being entered have to opt in, which is why this lives in the
 * shared stylesheet rather than per page. Browsers without support simply navigate instantly, which
 * is the correct fallback: nothing is broken, there is just no fade.
 *
 * The header is given its own transition name so it stays PUT while the body crossfades. Letting the
 * whole document fade would flicker the one element that is identical on both pages, which reads as
 * the site reloading rather than moving between pages. */
@view-transition { navigation: auto; }

/* `header.top`, NOT `.top`.
 *
 * A view-transition-name must be unique in the document or the browser abandons the entire
 * transition, silently - every navigation from the index simply reloaded. The index grid's cards each
 * contained a `<div class="top">` header row, which this selector was quietly naming as well, so five
 * cards meant six elements claiming `site-header`. Typing the selector to the element it means costs
 * nothing and makes the collision impossible rather than merely absent. */
header.top { view-transition-name: site-header; }

::view-transition-old(root) {
    animation: px-fade-out 140ms cubic-bezier(.4, 0, 1, 1) both;
}

::view-transition-new(root) {
    animation: px-fade-in 240ms cubic-bezier(0, 0, .2, 1) both;
}

/* The two durations do NOT add up. Outgoing and incoming snapshots animate concurrently, so the
 * transition lasts as long as the longer of the two. */
@keyframes px-fade-out {
    from { opacity: 1; }
    to { opacity: 0; }
}

@keyframes px-fade-in {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: none; }
}

* { box-sizing: border-box; }

html {
    -webkit-text-size-adjust: 100%;
    scroll-behavior: smooth;

    /* THE PAGE COLOUR LIVES HERE, not only on body.
     *
     * A background on body alone normally propagates to the viewport canvas, so the page looks right
     * and there seems to be no reason for this line. It stops being true during a view transition:
     * the root snapshot is lifted out to be animated, and what shows underneath is the browser's own
     * default canvas colour. Brave paints that grey, so every navigation flashed grey behind the
     * crossfade - which reads as the site breaking rather than moving.
     *
     * It also covers overscroll bounce, which had the same gap for the same reason. */
    background: var(--bg);

    /* Always reserve room for the scrollbar, whether or not the page needs one.
     *
     * Without it, a short page has no scrollbar and a long one does, so the usable width differs by
     * about fifteen pixels between them. Everything centred - the header, the logo, the whole content
     * column - lands in a different place on each, and moving between pages makes the site appear to
     * twitch sideways. The page that scrolls is not wrong and the page that does not is not wrong;
     * the difference between them is. */
    scrollbar-gutter: stable;
}

body {
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    font: 15px/1.65 var(--font);
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;

    /* A single soft wash behind the header. Enough to stop the page reading as a flat black
     * rectangle, faint enough that no content sits on a gradient. */
    background-image: radial-gradient(900px 400px at 50% -180px, rgba(79, 141, 255, .10), transparent 70%);
    background-repeat: no-repeat;
    min-height: 100vh;
}

/* The ambient background canvas, injected by site.js.
 *
 * z-index 0 with the content lifted to 1, NOT z-index -1.
 *
 * It was -1, which relies on a subtlety: a negative-z element paints behind its stacking-context
 * ancestor's background. That worked only while no ancestor formed a stacking context. Adding
 * `@view-transition` and a `view-transition-name` introduced them, the canvas dropped behind body's
 * background, and the animation vanished completely while still running perfectly - the most
 * confusing possible failure.
 *
 * Positive z-index for the canvas and an explicit layer for the content does not depend on any of
 * that, so it cannot be broken again by something unrelated creating a stacking context.
 *
 * pointer-events none so it can never eat a click. */
#lattice {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    z-index: 0;
    pointer-events: none;
}

/* Everything real sits above the canvas. The header already has its own higher layer. */
main,
footer { position: relative; z-index: 1; }

a {
    color: var(--accent);
    text-decoration: none;
    transition: color .12s ease;
}

a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 2px; }

code, .mono { font-family: var(--mono); font-size: .92em; }

::selection { background: rgba(79, 141, 255, .3); }

/* One focus style for everything, and it is never removed. Keyboard users navigate this site's forms
 * as much as anyone; :focus-visible means pointer users do not see a ring on every click. */
:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: var(--r-sm);
}

/* Horizontal padding set as two properties, NOT the `padding` shorthand.
 *
 * Every page uses `<main class="wrap">`, and `.wrap` outranks the bare `main` selector - so
 * `padding: 0 24px` here was silently zeroing the vertical padding `main` sets, which is why the
 * first heading sat hard against the header no matter what that rule said. Longhand properties only
 * touch the sides they name. */
.wrap { max-width: var(--page); margin: 0 auto; padding-left: var(--s5); padding-right: var(--s5); }

/* ---- header ---- */

/* The bar gets its own surface, a full-strength edge and a shadow.
 *
 * It used to be the page colour at 80% opacity over the page colour, which is a way of saying it had
 * no colour of its own: the only thing separating it from the content was a one-pixel line in the
 * faintest border tone, and the background lattice drifted straight through it. A header that has to
 * be looked for is not doing its job of telling you where the page starts.
 *
 * Still translucent enough to blur what scrolls under it, but opaque enough to read as a surface. */
.top {
    border-bottom: 1px solid var(--line-strong);
    background: rgba(18, 22, 30, .94);
    backdrop-filter: saturate(160%) blur(14px);
    position: sticky;
    top: 0;
    z-index: 20;

    /* A hairline highlight along the top edge and a soft drop beneath. Together they read as a panel
     * sitting above the page rather than a stripe painted onto it. */
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .04),
                0 10px 28px -16px rgba(0, 0, 0, .9);
}

.top .wrap {
    display: flex;
    align-items: center;
    gap: var(--s5);
    height: 62px;
}

.brand {
    font-weight: 700;
    font-size: 16px;
    letter-spacing: -.02em;
    color: var(--ink);
    white-space: nowrap;
    display: flex;
    align-items: center;
}

/* Margin on the image, NOT `gap` on the brand.
 *
 * `gap` applies between every flex item, and the wordmark is two of them: the bare text "Plugins"
 * becomes an anonymous flex item and `<span>XIV</span>` is another - so a gap meant to separate the
 * logo from the text also split the word into "Plugins XIV".
 *
 * Width and height are on the element too, so the header does not reflow when the image lands. */
.brand img { width: 26px; height: 26px; display: block; margin-right: var(--s2); }

.brand:hover { text-decoration: none; color: var(--ink); }
.brand span { color: var(--accent); }

.top nav {
    margin-left: auto;
    display: flex;
    /* Explicit rather than the default `stretch`. Under stretch, any item whose line box is taller
     * than the others sets the row height and the rest render their text at the top of a taller box -
     * which reads as one link sitting a pixel or two off its neighbours. */
    align-items: center;
    gap: var(--s1);
    flex-wrap: wrap;
}

.top nav a {
    color: var(--ink-2);
    font-size: 14px;
    font-weight: 500;
    padding: 6px 10px;
    border-radius: var(--r-sm);
    transition: color .12s ease, background .12s ease;
}

/* The account link is sized by its own text, exactly like its neighbours.
 *
 * It briefly had a min-width so that swapping "Sign In" for "Account" could not nudge anything. That
 * reserved the width of the LONGER label permanently, and the shorter one then sat centred in it -
 * which put about five extra pixels between it and "Policy" that existed nowhere else in the row.
 *
 * The two cannot both be had: reserving space for the wider label is what makes the gap uneven. Even
 * spacing wins, because it is on screen for everyone on every page, whereas the swap is a one-off
 * settle of a few pixels seen only by signed-in visitors on their first paint. */

.top nav a:hover {
    color: var(--ink);
    /* A light wash rather than a fixed surface colour, so it stays visible whatever the bar sits at.
     * `--raised` was nearly the same value as the new header background and vanished into it. */
    background: rgba(255, 255, 255, .07);
    text-decoration: none;
}

/* The current page indicator.
 *
 * Built from four layers rather than a flat fill, which is what separates a control that looks
 * pressed from a rectangle of tinted background:
 *
 *   1. a top-to-bottom gradient, lighter at the top, so it reads as lit from above like everything
 *      else on the page
 *   2. a one-pixel highlight along its top edge, which is what makes a raised surface look raised
 *   3. a crisp accent ring so the shape stays defined against the dark bar
 *   4. a soft accent glow underneath, so it sits ON the header rather than being cut into it
 *
 * Near-white text, not accent-coloured: against a fill this saturated, blue-on-blue loses contrast
 * exactly where the label matters most.
 *
 * `view-transition-name` is the interesting part - see the note below it. */
.top nav a[aria-current="page"] {
    color: #eaf1ff;
    background: linear-gradient(180deg, rgba(79, 141, 255, .30), rgba(79, 141, 255, .13));
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .20),
        inset 0 0 0 1px rgba(79, 141, 255, .48),
        0 2px 12px -3px rgba(79, 141, 255, .55);

    /* Exactly one element carries this name per page, which is what lets the browser treat the
     * indicator on the page being left and the one on the page being entered as THE SAME THING. It
     * then animates between their two positions, so the pill slides along the nav to whatever was
     * clicked instead of blinking out and reappearing somewhere else.
     *
     * Costs nothing where view transitions are unsupported: the property is ignored and the pill is
     * simply already in its new place. */
    view-transition-name: nav-current;
}

/* The slide itself. Slightly longer than the page crossfade and eased to settle rather than stop, so
 * the indicator is still arriving as the new page finishes fading in - which is what makes the two
 * read as one movement instead of two effects that happen to overlap. */
::view-transition-group(nav-current) {
    animation-duration: 280ms;
    animation-timing-function: cubic-bezier(.22, .8, .24, 1);
}

/* ---- generic ---- */

/* Generous above the first heading. A title that starts immediately under the header reads as a
 * continuation of it rather than as the beginning of the page. */
main { padding: var(--s7) 0 var(--s7); }

h1 {
    font-size: clamp(24px, 3.4vw, 30px);
    line-height: 1.2;
    margin: 0 0 var(--s2);
    letter-spacing: -.025em;
    font-weight: 680;
}

h2 {
    font-size: 19px;
    margin: var(--s6) 0 var(--s3);
    letter-spacing: -.015em;
    font-weight: 640;
}

h3 { font-size: 15px; margin: 0 0 var(--s1); font-weight: 620; letter-spacing: -.01em; }

.lede {
    color: var(--ink-2);
    margin: 0 0 var(--s5);
    max-width: 62ch;
    font-size: 15.5px;
}

.panel {
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
    padding: var(--s5);
}

.muted { color: var(--ink-2); }
.faint { color: var(--ink-3); font-size: 13px; }

/* ---- status pills ---- */

.pill {
    display: inline-flex;

    /* Baseline, not centre. See the dot below - this is half of one decision, not a default. */
    align-items: baseline;
    gap: 6px;
    padding: 5px 10px;
    border-radius: 999px;
    font-size: 11.5px;
    font-weight: 600;
    letter-spacing: .01em;
    border: 1px solid transparent;
    white-space: nowrap;
    vertical-align: middle;

    /* Tight, so the box is the text rather than the text floating inside an inherited 1.65 line box.
     * With a line box that tall, "centred" means centred in a lot of empty space, and the dot beside
     * it lands wherever that arithmetic falls. */
    line-height: 1;
}

/* A dot as well as a colour. Roughly one man in twelve cannot separate the green and the red on
 * hue alone, and "listed" versus "delisted" is the single most important distinction on this site. */
.pill::before {
    content: "";
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: currentColor;
    flex: 0 0 6px;

    /* Positioned from the BASELINE, not by centring the box.
     *
     * Centring was tuned against "Verified" and drifted the moment a label had a descender: the
     * content box grows downward for the `g` in "Pending", its centre moves with it, and a dot pinned
     * to that centre sinks while the capitals stay put. The eye reads the dot against the capitals,
     * which do not move, so anything measured from the box is measuring the wrong thing.
     *
     * An empty flex item baselines on its bottom edge, so this starts with the dot sitting ON the
     * baseline and lifts it to the middle of the cap height: caps are ~0.7em, half of that is ~4px at
     * 11.5px, and the dot's own radius is 3px, leaving 1px. Descenders never enter the arithmetic, so
     * every label lands identically whatever letters it happens to contain. */
    align-self: baseline;
    transform: translateY(-1px);
}

.pill-listed   { color: var(--ok);   border-color: rgba(61, 220, 132, .3); background: rgba(61, 220, 132, .09); }
.pill-changed  { color: var(--warn); border-color: rgba(251, 191, 36, .3); background: rgba(251, 191, 36, .09); }
.pill-rejected,
.pill-delisted { color: var(--bad);  border-color: rgba(255, 107, 107, .3); background: rgba(255, 107, 107, .09); }
.pill-pending  { color: var(--ink-2); border-color: var(--line-strong); background: var(--raised); }

/* ---- forms ---- */

/* A block, always. Left as inline these sit beside their control instead of above it, which is what
 * made the sign-in form look broken. */
label { display: block; font-weight: 600; font-size: 13.5px; margin-bottom: 6px; color: var(--ink); }

/* Every text-like input, by exclusion rather than by listing types.
 *
 * THE LIST WAS THE BUG. Typed selectors meant `type="email"` - which appears on exactly one page -
 * got no styling at all and collapsed to a browser default beside its label. Excluding the two types
 * that must not be full-width cannot rot the same way when a new input type turns up. */
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]),
textarea,
select {
    width: 100%;
    background: var(--bg-soft);
    border: 1px solid var(--line-strong);
    border-radius: var(--r);
    color: var(--ink);
    padding: 10px 13px;
    font: inherit;
    font-size: 14.5px;
    transition: border-color .12s ease, box-shadow .12s ease, background .12s ease;
}

input::placeholder, textarea::placeholder { color: var(--ink-3); }

/* ---- password reveal ----
 *
 * Edge draws its own reveal control inside password fields, in a fixed dark colour that is close to
 * invisible on this background, and Chrome and Firefox draw nothing at all. So the native one is
 * removed and site.js adds a real button instead: one appearance everywhere, themed like the rest of
 * the site, and present in every browser rather than only one.
 *
 * `::-ms-clear` goes too, or Edge shows a second control beside ours. */
input[type="password"]::-ms-reveal,
input[type="password"]::-ms-clear { display: none; }

.pw-wrap { position: relative; display: block; }

/* Room for the button, so a long password does not run underneath it. */
.pw-wrap input { padding-right: 44px; }

.pw-toggle {
    position: absolute;
    right: 5px;

    /* Centred with top/bottom/margin, NOT `transform: translateY(-50%)`.
     *
     * The global press effect is `button:active { transform: translateY(1px) }`, and transform is a
     * single property: pressing this replaced the centring transform outright and threw the button to
     * the top of the field. Overriding it back needed a more specific selector than
     * `button:active:not(:disabled)`, which is a specificity fight to keep winning forever.
     *
     * Centring by box model instead leaves `transform` entirely free, so the press is exactly the
     * same one-pixel dip every other button on the site gets. */
    top: 0;
    bottom: 0;
    margin: auto 0;
    width: 32px;
    height: 32px;

    background: none;
    border: 0;
    padding: 0;
    border-radius: var(--r-sm);
    color: var(--ink-3);
    cursor: pointer;
    display: grid;
    place-items: center;
    transition: color .12s ease, background .12s ease;
}

.pw-toggle:hover { color: var(--ink); background: rgba(255, 255, 255, .07); }

.pw-toggle svg {
    width: 18px;
    height: 18px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.7;
    stroke-linecap: round;
    stroke-linejoin: round;
}

input:not([type="checkbox"]):not([type="radio"]):focus,
textarea:focus,
select:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: var(--ring);
    background: var(--panel);
}

input:disabled, textarea:disabled { opacity: .55; cursor: not-allowed; }

textarea { resize: vertical; min-height: 96px; line-height: 1.6; }

select {
    appearance: none;
    padding-right: 34px;
    background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
                      linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
    background-position: calc(100% - 17px) 52%, calc(100% - 12px) 52%;
    background-size: 5px 5px, 5px 5px;
    background-repeat: no-repeat;
    cursor: pointer;
}

/* A field group: label, control, hint. The hint belongs to the control, so the gap above it is
 * smaller than the gap to the next field - grouping by proximity rather than by drawing boxes. */
.field { margin-bottom: var(--s4); }
.field:last-child { margin-bottom: 0; }
.field .hint { color: var(--ink-3); font-size: 12.5px; margin: 6px 0 0; }
.field .hint:first-of-type { margin-top: 6px; }

.check {
    display: flex;
    gap: 10px;
    /* Top-aligned so a label that wraps to two lines keeps its box beside the FIRST line rather than
     * drifting to the vertical middle of the block. */
    align-items: flex-start;
    font-size: 14px;
    line-height: 1.5;
    font-weight: 400;
    color: var(--ink-2);
    cursor: pointer;
    user-select: none;
}

.check strong { color: var(--ink); font-weight: 600; }

/* Custom checkbox, drawn on the input itself rather than on a substitute element.
 *
 * `appearance: none` strips the platform control while leaving a REAL checkbox in the page: it stays
 * focusable, toggles on space, is announced correctly by screen readers, and participates in the
 * form. The usual alternative - hiding the input and styling a sibling span - has to reimplement all
 * of that by hand and usually reimplements some of it wrongly. */
.check input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;

    width: 18px;
    height: 18px;
    flex: 0 0 18px;

    /* Centres the box on the first line of the label: half the difference between the line box
     * (1.5 x 14px) and the control. In em so it holds if the font size changes. */
    margin-top: .15em;

    border: 1px solid var(--line-strong);
    border-radius: 5px;
    background: var(--bg-soft);
    cursor: pointer;
    display: grid;
    place-content: center;
    transition: background .14s ease, border-color .14s ease, box-shadow .14s ease;
}

.check:hover input[type="checkbox"]:not(:checked) { border-color: var(--ink-3); }

.check input[type="checkbox"]:checked {
    background: linear-gradient(180deg, var(--accent-hover), var(--accent));
    border-color: var(--accent);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .3), 0 1px 8px -2px rgba(79, 141, 255, .7);
}

/* The tick is a clipped block, not a glyph or an image: it scales cleanly, inherits no font, needs no
 * network request, and is not subject to the content security policy the way a data URI would be. */
.check input[type="checkbox"]::before {
    content: "";
    width: 10px;
    height: 10px;
    background: var(--accent-ink);
    clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
    transform: scale(0);
    transition: transform .14s cubic-bezier(.2, .9, .3, 1.4);
}

.check input[type="checkbox"]:checked::before { transform: scale(1); }

.check input[type="checkbox"]:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* ---- buttons ---- */

button {
    background: var(--accent);
    color: var(--accent-ink);
    border: 1px solid transparent;
    border-radius: var(--r);
    padding: 10px 18px;
    font: inherit;
    font-size: 14.5px;
    font-weight: 640;
    cursor: pointer;
    transition: background .12s ease, transform .06s ease, box-shadow .12s ease;
    line-height: 1.3;
}

button:hover:not(:disabled) { background: var(--accent-hover); }

/* One pixel, on press only. Enough to feel like a button; not enough to shift the layout. */
button:active:not(:disabled) { transform: translateY(1px); }

button:disabled { opacity: .5; cursor: default; }

button.ghost {
    background: transparent;
    border-color: var(--line-strong);
    color: var(--ink);
}

button.ghost:hover:not(:disabled) { background: var(--raised); border-color: var(--ink-3); }

button.danger { background: var(--bad); color: #2b0707; }
button.danger:hover:not(:disabled) { background: #ff8585; }

.actions { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }

/* ---- the auth pages ----
 *
 * Their own narrow, vertically-centred shell. A sign-in form stretched across a 1100px page is the
 * single most common way a site looks unfinished, and these pages have one job each. */

.auth {
    max-width: 440px;
    margin: 0 auto;
    padding: clamp(var(--s5), 7vh, var(--s7)) var(--s5) var(--s7);
}

/* Nested inside a wrap that already has padding, `.auth` would apply the page's rhythm a second
 * time: `main` supplies the vertical space and `.wrap` the horizontal, so all four sides double.
 * Used this way the class is only being asked for the centred column, not the page frame. */
.wrap > .auth { padding: 0; }

.auth h1 { text-align: center; }

.auth .lede {
    text-align: center;
    margin: 0 auto var(--s5);
    max-width: 38ch;
    font-size: 14.5px;
}

.auth .panel {
    padding: var(--s5);
    box-shadow: var(--shadow);
    border-radius: var(--r-lg);
}

.auth .actions { margin-top: var(--s5); }
.auth .actions button { width: 100%; justify-content: center; }

/* The links under an auth card. A flex row with a real gap, so a hidden link takes its separator
 * with it - the dot after "Forgot your password" was a literal middot in the markup, left stranded
 * whenever the link beside it was hidden. */
.authlinks {
    display: flex;
    justify-content: center;
    gap: var(--s4);
    flex-wrap: wrap;
    margin-top: var(--s4);
    font-size: 13.5px;
}

.authlinks a { color: var(--ink-2); }
.authlinks a:hover { color: var(--accent); }

/* A back link, with the arrow as part of the control rather than a character in the text. Inline SVG
 * inherits currentColor so it fades with the label, and it can be moved on hover, which a glyph
 * cannot be without shifting the words beside it. */
/* Scoped to the class, NOT to `.authlinks`, which is where it started life.
 *
 * An unsized inline SVG does not degrade gracefully: with no width, height or fill it renders at the
 * replaced-element default of 300x150 and paints the path solid, so the first use of this outside an
 * auth card put a black triangle the size of the viewport in the middle of the page. A component that
 * only works inside one parent is a trap for whoever reaches for it next. */
.back { display: inline-flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 13.5px; }

.back svg {
    width: 14px;
    height: 14px;
    flex: 0 0 14px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2.2;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: transform .16s cubic-bezier(.2, .8, .2, 1);
}

/* The arrow leads, the text stays put. Moving the whole link would nudge the layout; moving only the
 * arrow reads as the direction it points. */
.back:hover svg { transform: translateX(-3px); }
.back:hover { text-decoration: none; color: var(--accent); }

/* Standing alone above a page heading it needs room beneath it; inside an auth card the row already
 * has its own spacing. */
main > .back { margin-bottom: var(--s4); }

/* ---- status pages ----
 *
 * The one-off outcome screens: address confirmed, link expired, password set. Each is a moment
 * rather than a page someone browses, so it gets a single centred focal point and exactly one
 * obvious next action.
 *
 * The badge does the work a heading cannot: it says succeeded or failed before a word is read, which
 * matters because these screens are reached by clicking a link in an email and the first question is
 * always "did that work". */

.status { text-align: center; }

.status .badge {
    width: 72px;
    height: 72px;
    border-radius: 50%;
    display: grid;
    place-items: center;
    margin: 0 auto var(--s5);
    position: relative;
}

.status .badge svg {
    width: 32px;
    height: 32px;
    stroke-width: 2.5;
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* Ring, fill and outer glow in the status colour. The glow is what stops a coloured circle looking
 * like a sticker pasted onto the panel. */
.badge-ok {
    color: var(--ok);
    background: radial-gradient(circle at 50% 40%, rgba(61, 220, 132, .22), rgba(61, 220, 132, .05));
    box-shadow: inset 0 0 0 1px rgba(61, 220, 132, .4), 0 0 44px -10px rgba(61, 220, 132, .65);
}

.badge-bad {
    color: var(--bad);
    background: radial-gradient(circle at 50% 40%, rgba(255, 107, 107, .20), rgba(255, 107, 107, .05));
    box-shadow: inset 0 0 0 1px rgba(255, 107, 107, .4), 0 0 44px -10px rgba(255, 107, 107, .5);
}

.status h1 { margin-bottom: var(--s3); }

.status p {
    color: var(--ink-2);
    margin: 0 auto var(--s4);
    max-width: 44ch;
}

.status .actions { justify-content: center; margin-top: var(--s5); }

/* The badge draws itself: the ring expands, then the mark strokes on. Two short, staged movements
 * read as a confirmation; everything arriving at once reads as a page load. */
.status .badge { animation: px-badge-in 340ms cubic-bezier(.2, .9, .25, 1) both; }
.status .badge svg path { stroke-dasharray: 48; stroke-dashoffset: 48; animation: px-draw 380ms cubic-bezier(.4, 0, .2, 1) 160ms both; }

@keyframes px-badge-in {
    from { opacity: 0; transform: scale(.82); }
    to { opacity: 1; transform: none; }
}

@keyframes px-draw {
    to { stroke-dashoffset: 0; }
}

/* What to do next, numbered. Only shown where there genuinely is a next step - a list of one is a
 * sentence wearing a costume. */
.next {
    text-align: left;
    max-width: 420px;
    margin: var(--s5) auto 0;
    padding: var(--s4) var(--s4) var(--s4) 0;
    border-top: 1px solid var(--line);
    list-style: none;
    counter-reset: step;
}

.next li {
    counter-increment: step;
    display: grid;
    grid-template-columns: 26px 1fr;
    gap: var(--s3);
    align-items: baseline;
    color: var(--ink-2);
    font-size: 14px;
    margin-bottom: var(--s3);
}

.next li:last-child { margin-bottom: 0; }

.next li::before {
    content: counter(step);
    display: grid;
    place-items: center;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    background: var(--raised);
    border: 1px solid var(--line-strong);
    color: var(--ink);
    font-size: 11.5px;
    font-weight: 650;
}

@media (prefers-reduced-motion: reduce) {
    .status .badge, .status .badge svg path { animation: none; }
    .status .badge svg path { stroke-dashoffset: 0; }
}

/* ---- plugin cards ---- */

.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: var(--s3); }

/* A COLUMN, not a row.
 *
 * The icon and the heading share a row inside it, but the card itself stacks - which is what lets the
 * blurb take the slack and the metrics sit on the floor of every card at the same height. Laid out as
 * one row, the footer floated wherever the text above it ended and a grid of cards had its numbers
 * scattered at four different heights. */
.card {
    position: relative;
    display: flex;
    flex-direction: column;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
    padding: var(--s5);
    overflow: hidden;
    transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease;
}

/* The plugin's hue survives only as the hover border. There WAS a tinted hairline along the top edge
 * as well; at rest it read as a stray line emerging from the icon rather than as an edge belonging to
 * the card, which is a decoration doing the opposite of its job. */
.card:hover {
    transform: translateY(-3px);
    border-color: color-mix(in srgb, var(--pc, var(--accent)) 40%, var(--line));
    box-shadow: var(--shadow);
}

/* The header row. min-height pins it to the icon so every card's header is the same height whatever
 * the text beside it does, and the icon is top-aligned so nothing that wraps can move it. */
/* The minimum matches the icon, so every card's header is the same height whatever the text beside
 * it does, and the blurb below starts on the same line across a row. */
.card .head { display: flex; align-items: flex-start; gap: var(--s3); min-height: 56px; }
.card .who { min-width: 0; flex: 1; }

/* The whole card is the click target, not just the title.
 *
 * Done by stretching the title's own anchor over the card rather than wrapping everything in a link:
 * the accessible name stays the plugin name instead of becoming the entire card read aloud, and the
 * markup keeps working if the anchor is ever absent, which is exactly the case on the listing page
 * where these same cards are not links at all. */
.card h3 a::after { content: ""; position: absolute; inset: 0; z-index: 1; border-radius: inherit; }
.card:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
.card:focus-within h3 a { outline: none; }

.card img {
    width: 56px;
    height: 56px;
    border-radius: 13px;
    background: var(--raised);
    flex: 0 0 56px;
    object-fit: cover;

    /* An inset hairline instead of a border. A border would push the icon off the 4px scale by a
     * pixel on each side; this draws the same edge inside the box the icon already occupies. */
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06);
}

.card h3 { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; font-size: 18px; }
/* The name does not change colour on hover. The card already answers "this is interactive" by lifting,
 * tinting its border and brightening its top edge, and recolouring the title on top of that competes
 * with the status pill beside it for the eye. */
.card h3 a { color: var(--ink); }
.card h3 a:hover { color: var(--ink); text-decoration: none; }

/* Author and version, directly under the name. One line, and the version is kept out of the heading
 * row on purpose: it is the element most likely to wrap, and anything that wraps up there changes the
 * header's height and drags the icon out of line with the neighbouring cards. */
.card .sub {
    margin: 3px 0 0;
    font-size: 12.5px;
    color: var(--ink-3);
    display: flex;
    align-items: center;
    gap: var(--s2);
    flex-wrap: wrap;
    min-width: 0;
}

/* Smaller here than anywhere else. This pill sits on a 12.5px metadata line rather than beside a
 * heading, and at its normal size it was the loudest thing on the card - louder than the name. */
.card .sub .pill { font-size: 10.5px; padding: 3px 8px; }
.card .sub .pill::before { width: 5px; height: 5px; flex-basis: 5px; }


.card .sub .who-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.card p {
    margin: var(--s4) 0 0;
    color: var(--ink-2);
    font-size: 13.5px;
    line-height: 1.55;

    /* Takes the slack, so every card's metrics row sits on the same line across a grid row. */
    flex: 1;

    /* Three lines, then ellipsis. Punchlines in real manifests run from empty to a paragraph, and
     * letting them size the card gives a grid of wildly uneven boxes. */
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.card .meta { margin-top: var(--s3); display: flex; gap: var(--s3); flex-wrap: wrap; align-items: center; }

/* ---- one plugin's own page ----
 *
 * Two columns on a desk, one on a phone. The description is the long-form thing somebody came to read
 * and gets the wide column; everything they might ACT on - the install URL, the numbers, where the
 * source is - is collected into a panel that stays with them as they scroll. Stacked in one column,
 * the install line was above the description and off-screen by the time anyone had read enough to
 * want it. */

.phero {
    position: relative;
    display: flex;
    align-items: flex-start;
    gap: var(--s5);
    padding: var(--s5) 0 var(--s5);
    margin-bottom: var(--s6);
    border-bottom: 1px solid var(--line);
}

/* The plugin's hue, once, as light rather than paint. It sits behind the icon and fades out well
 * before any text, so the page picks up the plugin's identity without colouring anything that carries
 * meaning. */
.phero::before {
    content: "";
    position: absolute;
    left: -40px;
    top: -30px;
    width: 320px;
    height: 220px;
    background: radial-gradient(closest-side, var(--pc, transparent), transparent);
    opacity: .13;
    pointer-events: none;
}

.phero img {
    position: relative;
    width: 96px;
    height: 96px;
    border-radius: 22px;
    flex: 0 0 96px;
    object-fit: cover;
    background: var(--raised);
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07), 0 18px 40px -18px rgba(0, 0, 0, .9);
}

.pinfo { position: relative; min-width: 0; flex: 1; }

.pinfo h1 {
    display: flex;
    align-items: center;
    gap: var(--s3);
    flex-wrap: wrap;
    margin-bottom: var(--s2);
    font-size: clamp(26px, 3.4vw, 34px);
}

.pinfo .lede { margin: 0 0 var(--s3); max-width: 58ch; font-size: 16px; }

.pmeta {
    display: flex;
    gap: var(--s2) var(--s4);
    flex-wrap: wrap;
    align-items: baseline;
    font-size: 13px;
    color: var(--ink-3);
}

.pmeta strong { color: var(--ink-2); }

.tags { display: flex; gap: var(--s2); flex-wrap: wrap; margin-top: var(--s4); }

.tag {
    font-size: 11.5px;
    font-weight: 500;
    padding: 4px 11px;
    border-radius: 999px;
    background: var(--raised);
    border: 1px solid var(--line);
    color: var(--ink-2);
}

.pbody { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: var(--s6); align-items: start; }

.pmain > h2:first-child { margin-top: 0; }

/* Sticky, offset by the header's own 62px so it never slides underneath it. */
.pside { position: sticky; top: 86px; display: grid; gap: var(--s3); }

.pside .panel { padding: var(--s4); }
.pside h3 { font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-3); margin-bottom: var(--s3); }

/* The install row, stacked rather than side by side: at 330px a URL and a button on one line leaves
 * about nine characters for the URL. */
.pinstall code {
    display: block;
    background: var(--bg-soft);
    border: 1px solid var(--line);
    border-radius: var(--r);
    padding: 10px 12px;
    font-size: 12px;
    word-break: break-all;
    color: var(--ink-2);
    margin-bottom: var(--s2);
}

.pinstall button { width: 100%; justify-content: center; }

/* The two figures, given room to be read as figures. On the card they are a footnote; this is the
 * page about this one plugin, so here they are the headline they could not be there. */
.pnums { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s3); }
.pnums div { text-align: center; padding: var(--s3) var(--s2); background: var(--raised); border-radius: var(--r); }
.pnums b { display: block; font-size: 20px; font-weight: 680; color: var(--ink); font-variant-numeric: tabular-nums; }
.pnums span { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-3); }

/* The contact editor on the dashboard. A raised block rather than another collapsed row, because it
 * was one and nobody found it: sat among identical disclosure triangles, a form is only discovered by
 * somebody who already knows it is there. */
.contact-edit {
    background: var(--raised);
    border: 1px solid var(--line);
    border-radius: var(--r);
    padding: var(--s4);
    margin-bottom: var(--s4);
}

.contact-edit h4 {
    font-size: 13px;
    margin: 0 0 var(--s3);
    color: var(--ink);
    display: flex;
    gap: var(--s2);
    align-items: baseline;
    flex-wrap: wrap;
}

.contact-edit h4 .faint { font-weight: 400; font-size: 12px; }
.contact-edit .actions { margin-top: var(--s3); }

/* Contact links. Rows rather than a paragraph of links, because there are at most two and each one is
 * a separate destination somebody chooses between. */
.contact { display: grid; gap: var(--s2); }
.contact h3 { margin-bottom: var(--s1); }

.contact a, .contact .handle {
    display: flex;
    align-items: center;
    gap: var(--s2);
    padding: 9px 11px;
    border-radius: var(--r);
    background: var(--raised);
    border: 1px solid var(--line);
    font-size: 13px;
    color: var(--ink-2);
    min-width: 0;
}

.contact a:hover { border-color: var(--accent); color: var(--ink); text-decoration: none; }

/* A handle is not a link and must not pretend to be one: no hover state, and a text cursor, because
 * the only thing to do with it is select and copy it. */
.contact .handle { cursor: text; user-select: all; }

.contact span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.contact svg {
    width: 15px;
    height: 15px;
    flex: 0 0 15px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.6;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.pfacts { display: grid; gap: var(--s3); font-size: 13px; }
.pfacts > div { display: grid; gap: 2px; min-width: 0; }
.pfacts dt, .pfacts .k { color: var(--ink-3); font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; }
.pfacts .v { color: var(--ink-2); overflow-wrap: anywhere; }

@media (max-width: 900px) {
    .pbody { grid-template-columns: 1fr; gap: var(--s5); }

    /* Unstuck once it is no longer beside anything - a sticky block in a single column follows you
     * down the page for no reason and covers the text underneath it.
     *
     * And moved ABOVE the description. Beside it, the install URL is visible the whole time; stacked
     * underneath, it sits past a screen or more of prose, so the one action on the page is the last
     * thing a phone reaches. Identity, then how to get it, then the detail. */
    .pside { position: static; order: -1; }
}

@media (max-width: 560px) {
    .phero { gap: var(--s4); }
    .phero img { width: 68px; height: 68px; flex-basis: 68px; border-radius: 16px; }
}

/* ---- loading skeleton ----
 *
 * WHY THIS EXISTS, because "a spinner would do" is the obvious objection and it would not.
 *
 * These pages fetch before they can draw anything, so their first paint was the word "Loading." on an
 * empty page. That is fine on its own, but it is also the frame the cross-document view transition
 * crossfades INTO: the outgoing page dissolved into a blank one, the content then appeared after the
 * animation had already finished, and the whole thing read as a reload rather than a move. The
 * transition was working perfectly; it had nothing to land on.
 *
 * A silhouette of the page that is coming fixes that, because the fade now ends on something
 * page-shaped and the real content replaces it in place. */
.skeleton { display: grid; gap: var(--s4); }

.skeleton i {
    display: block;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
}

.skeleton .sk-title { height: 38px; width: min(280px, 60%); border-radius: var(--r); }
.skeleton .sk-line { height: 15px; width: min(460px, 90%); border-radius: var(--r-sm); }
.skeleton .sk-panel { height: 170px; }
.skeleton .sk-tall { height: 260px; }

/* Barely there, and off entirely for anyone who asked for less motion. A skeleton that pulses hard is
 * more distracting than the blank page it replaced. */
@media (prefers-reduced-motion: no-preference) {
    .skeleton i { animation: sk-pulse 1.6s ease-in-out infinite; }
    .skeleton i:nth-child(2) { animation-delay: .12s; }
    .skeleton i:nth-child(3) { animation-delay: .24s; }
}

@keyframes sk-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: .55; }
}

/* ---- a repository's own page ----
 *
 * Same two-column shape as a plugin's page, because it answers the same shape of question: what is
 * this, and what do I do about it. The left column is what the repository CONTAINS and what has been
 * decided about it; the right is the install line and the record, which are the things a reader acts
 * on or checks. */

.rhero { padding: var(--s2) 0 var(--s5); margin-bottom: var(--s6); border-bottom: 1px solid var(--line); }

.rhero h1 {
    display: flex;
    align-items: center;
    gap: var(--s3);
    flex-wrap: wrap;
    font-size: clamp(28px, 3.6vw, 36px);
    margin-bottom: var(--s2);
}

.rhero .lede { margin: 0; max-width: 60ch; }

/* The slug and the forge path, together, small. Both identify the listing and neither is its name, so
 * they sit under the heading rather than in it. */
.rsub { display: flex; gap: var(--s2) var(--s4); flex-wrap: wrap; align-items: baseline;
        margin-top: var(--s3); font-size: 12.5px; color: var(--ink-3); }

.timeline { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s3); }

.timeline li {
    position: relative;
    padding: var(--s4);
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--r);
    border-left: 3px solid var(--line-strong);
}

.timeline li.is-listed { border-left-color: var(--ok); }
.timeline li.is-rejected, .timeline li.is-delisted { border-left-color: var(--bad); }

.timeline .head { display: flex; gap: var(--s3); align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
.timeline .verdict { font-weight: 650; color: var(--ink); text-transform: capitalize; }
.timeline .reason { color: var(--ink-2); font-size: 13.5px; }

/* Rendered developer copy. Given a readable measure and real list styling, because the whole reason
 * this page exists is that the description used to be dumped as preformatted text with its own
 * markdown showing as punctuation. */
.prose { max-width: 72ch; }
.prose p { margin: 0 0 var(--s4); color: var(--ink-2); line-height: 1.7; }
.prose p:last-child, .prose ul:last-child { margin-bottom: 0; }
.prose ul { margin: 0 0 var(--s4); padding-left: 22px; color: var(--ink-2); }
.prose li { margin-bottom: 7px; line-height: 1.65; }

/* A bold run on its own line is how developers write a subheading in a plugin description, so it is
 * treated as one rather than as loud body text. */
.prose p > strong:only-child {
    display: block;
    color: var(--ink);
    font-size: 15px;
    letter-spacing: -.01em;
    margin-top: var(--s2);
}

.prose strong { color: var(--ink); }

/* ---- card metrics ---- */

/* THE COMPONENT IS SCOPED TO ITS OWN CLASS, and the card only adds spacing.
 *
 * This was written as `.card .stats` and then reused on the plugin page, where it lost every rule at
 * once - including the icon sizing, so an unsized inline SVG painted a filled 300x150 triangle across
 * the page. That is the second time the same mistake landed today (see `.back`), so the rule now is:
 * anything reusable is styled by its own class, and a parent may add context but never define it. */
.stats {
    display: flex;
    gap: var(--s4);
    align-items: center;
    flex-wrap: wrap;
    font-size: 12px;
    color: var(--ink-3);
}

.stats span { display: inline-flex; align-items: center; gap: 6px; }

.stats svg {
    width: 13px;
    height: 13px;
    flex: 0 0 13px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.7;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* Tabular figures, so a column of cards lines its numbers up instead of shimmering by a pixel per
 * digit. The value is brighter than its label because the digit is the thing being read. */
.stats b {
    font-weight: 600;
    color: var(--ink-2);
    font-variant-numeric: tabular-nums;
}

/* On a card they sit at the bottom behind a rule, because they are the only thing there that is not a
 * claim about what the plugin IS. Mixed into the author row they read as another attribute; below a
 * line they read as measurements, which is what they are. */
.card .stats {
    margin-top: var(--s3);
    padding-top: var(--s3);
    border-top: 1px solid var(--line);
}

/* The developer's longer description. Not clamped like the punchline above it - somebody wrote this
 * to be read, and a card on a detail page has room for it. */
.card .detail {
    -webkit-line-clamp: unset;
    display: block;
    margin-top: var(--s2);
    white-space: pre-wrap;
    font-size: 13px;
    color: var(--ink-3);
}

/* ---- controls row ---- */

/* ONE HEIGHT for everything in this row, stated rather than inherited.
 *
 * Left to themselves a text input and a select do not agree: identical padding and font gave 45.9px
 * and 51.9px, because a select's intrinsic content height is not the same as an input's. Both were
 * top-aligned in the row, so the six pixels came off the bottom and the dropdowns sat visibly lower
 * than the search field.
 *
 * Centring alone would have hidden it rather than fixed it - two controls of different heights side
 * by side still read as a mistake. */
.controls {
    display: flex;
    align-items: center;
    gap: var(--s2);
    flex-wrap: wrap;
    margin-bottom: var(--s4);
}

.controls input[type="search"] { flex: 1 1 260px; width: auto; }
.controls select { flex: 0 0 auto; width: auto; min-width: 170px; }
/* Every direct child, not just the fields inside them. The search field is wrapped in a label, so
 * setting the height on the input alone left the label sizing itself from content while the selects
 * were sized explicitly - the two then sat three pixels apart inside a taller line. */
.controls > * { height: 46px; }
.controls .search input[type="search"] { height: 100%; }

/* The search field carries its own icon, drawn as a background rather than sat in a wrapper element.
 * A wrapper would need its own focus styling to avoid the ring appearing around the input while the
 * box around it stayed dark, which is the usual way this detail goes wrong. */
.controls .search {
    position: relative;
    flex: 1 1 280px;
    display: flex;

    /* The global `label` rule adds `margin-bottom: 6px`, which is right for a label sitting ABOVE its
     * input and wrong here, where the label IS the field's wrapper. That 6px made this item's margin
     * box 52px tall in a row of 46px controls, so the flex line grew to 52 and the two selects
     * centred themselves three pixels below the search box. Measured, not guessed: the label reported
     * `margin: 0px 0px 6px` while every control reported height 46. */
    margin: 0;
}

/* Typed on the attribute deliberately. The base input rule is three `:not([type=...])` clauses, which
 * is a specificity of (0,3,1) - higher than `.controls .search input`, so the plain class selector
 * loses and the icon ends up sitting underneath the placeholder. */
.controls .search input[type="search"] { flex: 1; padding-left: 40px; }

.controls .search svg {
    position: absolute;
    left: 13px;
    top: 0;
    bottom: 0;
    margin: auto 0;
    width: 16px;
    height: 16px;
    fill: none;
    stroke: var(--ink-3);
    stroke-width: 1.8;
    stroke-linecap: round;
    pointer-events: none;
}

.controls .search input:focus ~ svg { stroke: var(--accent); }

/* ---- hero ---- */

.hero { margin-bottom: var(--s6); }

.hero h1 {
    font-size: clamp(28px, 4vw, 40px);
    letter-spacing: -.035em;
    line-height: 1.12;
    margin-bottom: var(--s3);

    /* Wide enough that the explicit <br> is the only break. Measured against the LONGER of the two
     * lines, not the first one: at 26ch the first line fitted and the second wrapped, leaving "Them"
     * stranded on a line of its own. Below this width it wraps properly rather than badly, which is
     * the right behaviour on a phone. */
    max-width: 36ch;
}

/* The second line of the title, dimmed rather than coloured. An accent here would compete with the
 * status pills further down the page, and those are the only thing on this site that must win. */
.hero h1 em { font-style: normal; color: var(--ink-3); }

.hero .lede { max-width: 62ch; font-size: 15.5px; margin: 0; }

/* ---- headline figures ---- */

.figures {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s5);
    margin: var(--s5) 0 var(--s6);
    padding: var(--s4) var(--s5);
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
}

.figures div { display: flex; flex-direction: column; gap: 2px; min-width: 84px; }

.figures b {
    font-size: 22px;
    font-weight: 680;
    letter-spacing: -.02em;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
}

.figures span {
    font-size: 11.5px;
    text-transform: uppercase;
    letter-spacing: .07em;
    color: var(--ink-3);
    font-weight: 600;
}

/* ---- tabs ---- */

.tabs {
    display: flex;
    gap: var(--s1);
    border-bottom: 1px solid var(--line);
    margin-bottom: var(--s5);
}

.tabs button {
    background: none;
    border: 0;
    color: var(--ink-2);
    font-size: 14.5px;
    font-weight: 600;
    padding: 10px 14px;
    border-radius: var(--r-sm) var(--r-sm) 0 0;
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--s2);
    transition: color .12s ease, background .12s ease;
}

.tabs button:hover { color: var(--ink); background: rgba(255, 255, 255, .05); }
.tabs button[aria-selected="true"] { color: var(--ink); }

/* The underline sits ON the container's border rather than beside it, so the selected tab reads as
 * joined to its panel instead of as a button that happens to be highlighted. */
.tabs button[aria-selected="true"]::after {
    content: "";
    position: absolute;
    left: 10px;
    right: 10px;
    bottom: -1px;
    height: 2px;
    border-radius: 2px 2px 0 0;
    background: var(--accent);
    box-shadow: 0 0 10px -1px rgba(79, 141, 255, .8);
}

/* The underline is a shared element, exactly like the nav indicator: only the selected tab has this
 * pseudo-element, so the browser morphs it from the old tab's position to the new one and it slides
 * instead of blinking across. */
.tabs button[aria-selected="true"]::after { view-transition-name: tab-underline; }

::view-transition-group(tab-underline) {
    animation-duration: 260ms;
    animation-timing-function: cubic-bezier(.22, .8, .24, 1);
}

/* A tab switch is not a navigation, so it gets a plainer crossfade than one.
 *
 * The page-level transition lifts the incoming document by eight pixels, which reads as arriving
 * somewhere new. Doing that when only a panel changed makes the whole page appear to jump for a
 * change that happened in one corner of it. The class is applied for the duration of the switch and
 * removed afterwards, so navigations keep the fuller movement. */
html.tab-switch::view-transition-old(root) { animation: px-fade-out 110ms linear both; }
html.tab-switch::view-transition-new(root) { animation: px-fade-flat 200ms cubic-bezier(0, 0, .2, 1) both; }

@keyframes px-fade-flat {
    from { opacity: 0; }
    to { opacity: 1; }
}

.tabs .count {
    font-size: 11.5px;
    font-weight: 650;
    min-width: 20px;
    padding: 1px 6px;
    border-radius: 999px;
    background: var(--raised);
    border: 1px solid var(--line-strong);
    color: var(--ink-2);
}

.tabs button[aria-selected="true"] .count {
    background: var(--accent-dim);
    border-color: rgba(79, 141, 255, .4);
    color: var(--accent);
}

/* ---- stacked sections ----
 *
 * One panel divided by rules, rather than a separate card per setting. Four bordered boxes down a
 * page reads as four unrelated features; one panel with dividers reads as one thing with parts,
 * which is what an account page is. */

.stack { padding: 0; overflow: hidden; }

.stack > section {
    padding: var(--s5);
    border-top: 1px solid var(--line);
}

.stack > section:first-child { border-top: 0; }

.stack h3 { font-size: 14.5px; margin-bottom: var(--s1); }
.stack .lead { color: var(--ink-2); font-size: 13.5px; margin: 0 0 var(--s4); max-width: 68ch; }

/* The destructive section is tinted rather than boxed off. It stays part of the same panel - it is
 * an account setting like the others - while never being mistaken for one. */
.stack > section.danger {
    background: rgba(255, 107, 107, .045);
    border-top-color: rgba(255, 107, 107, .25);
}

/* Fields and their button on one line where there is room, wrapping to their own rows when there is
 * not. Keeps a two-field form to a single line instead of a stack three rows tall. */
.rowform { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: flex-end; }

/* Grows to fill a narrow row, then STOPS.
 *
 * Without the cap a single-field row stretched the input across the whole panel, so an account name
 * of eight characters got a box wide enough for a paragraph. An input should be about as wide as the
 * longest thing anyone will type in it: a field far wider than its content reads as a mistake, and
 * on a wide monitor it puts the label and the button an absurd distance apart. */
.rowform .field { flex: 1 1 200px; max-width: 300px; margin-bottom: 0; }
.rowform .field .hint { margin-bottom: 0; }
.rowform > button { flex: 0 0 auto; }

/* Forces a line break in a wrapping flex row. Zero height, full width: it occupies the rest of the
 * current line and nothing else, so whatever follows starts a new one.
 *
 * Used to put a second field under the first rather than beside it, which keeps every form on the
 * page the same width instead of the two-field one running noticeably wider than its neighbours. */
.rowform .break { flex: 1 0 100%; height: 0; margin: 0; }
.rowform .said { flex: 1 1 100%; color: var(--ink-3); font-size: 13px; margin: 0; }

/* ---- key/value rows ---- */

.rows { display: grid; grid-template-columns: max-content 1fr; gap: var(--s2) var(--s5); align-items: baseline; }
.rows dt { color: var(--ink-3); font-size: 12.5px; font-weight: 500; }
.rows dd { margin: 0; min-width: 0; overflow-wrap: anywhere; font-size: 14px; }

/* ---- callouts ---- */

/* A plain note is a plain box: one border weight, one border colour, all four sides.
 *
 * It used to carry a 3px left border in a lighter grey, so that the warn and bad variants could
 * recolour it. That left every neutral note wearing a stripe which meant nothing - visibly heavier
 * and a different shade from its other three sides, which reads as something rendering wrong rather
 * than as a design. The stylesheet's own opening rule says weight and colour are for meaning and
 * never for ornament; this was ornament.
 *
 * The stripe now belongs to the variants that have something to say with it. */
.note {
    border: 1px solid var(--line);
    padding: var(--s4);
    background: var(--panel);
    border-radius: var(--r);
    margin: var(--s4) 0;
}

/* The 2px the thicker border adds is taken back out of the padding, so body text starts at the same
 * x in all three variants. Without it, a warn note stacked under a plain one shifts its text
 * rightwards by two pixels - individually invisible, obvious the moment they sit together. */
.note-warn,
.note-bad {
    border-left-width: 3px;
    padding-left: calc(var(--s4) - 2px);
}

.note-warn { border-left-color: var(--warn); background: rgba(251, 191, 36, .04); }
.note-bad  { border-left-color: var(--bad);  background: rgba(255, 107, 107, .04); }
.note h3 { margin-bottom: var(--s1); }
.note p { color: var(--ink-2); font-size: 14px; }
.note p:first-of-type { margin-top: 0; }
.note p:last-child { margin-bottom: 0; }

/* ---- copy-to-clipboard field ---- */

.copyrow { display: flex; gap: var(--s2); align-items: stretch; }

.copyrow code {
    flex: 1;
    background: var(--bg-soft);
    border: 1px solid var(--line-strong);
    border-radius: var(--r);
    padding: 10px 13px;
    overflow-x: auto;
    white-space: nowrap;
    min-width: 0;
    color: var(--ink-2);
    display: flex;
    align-items: center;
}

/* ---- document layout ----
 *
 * A wide page is not an excuse for wide text. Past roughly 80 characters the eye loses its place
 * returning to the start of the next line, which is why a stretched paragraph reads as harder work
 * even though nothing about it looks wrong.
 *
 * So the extra width goes to STRUCTURE rather than to line length: a contents rail that makes a
 * policy page scannable, and a measure widened to a comfortable maximum rather than to the viewport. */

.doc { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s6); }

@media (min-width: 1000px) {
    .doc { grid-template-columns: 220px minmax(0, 1fr); gap: var(--s7); align-items: start; }
}

.toc {
    position: sticky;
    /* Header height plus a little air. Without this the first item hides under the sticky header. */
    top: 86px;
    align-self: start;
    max-height: calc(100vh - 120px);
    overflow-y: auto;
    font-size: 13.5px;
    border-left: 1px solid var(--line);
    padding-left: var(--s4);
}

.toc h2 {
    font-size: 11.5px;
    text-transform: uppercase;
    letter-spacing: .08em;
    color: var(--ink-3);
    margin: 0 0 var(--s3);
    font-weight: 650;
}

.toc a {
    display: block;
    color: var(--ink-2);
    padding: 5px 0;
    line-height: 1.4;
    border-left: 2px solid transparent;
    margin-left: -18px;
    padding-left: 16px;
    transition: color .12s ease, border-color .12s ease;
}

.toc a:hover { color: var(--ink); text-decoration: none; }
.toc a.here { color: var(--accent); border-left-color: var(--accent); }

@media (max-width: 999px) {
    .toc {
        position: static;
        max-height: none;
        border-left: 0;
        border-bottom: 1px solid var(--line);
        padding: 0 0 var(--s4);
    }
    .toc a { margin-left: 0; padding-left: 0; border-left: 0; }
    .toc a.here { border-left: 0; }
}

/* ---- per-plugin editor ----
 *
 * A <details> per plugin, collapsed by default. A repository with six plugins would otherwise open as
 * six full forms and bury the listing's own status above them; collapsed, the set reads as a list of
 * what is in the repository, which is the useful view most of the time. */

.plugin-edit {
    border: 1px solid var(--line);
    border-radius: var(--r);
    background: var(--bg-soft);
    margin-bottom: var(--s2);
    overflow: hidden;
}

.plugin-edit summary {
    display: flex;
    align-items: center;
    gap: var(--s3);
    padding: 10px var(--s4);
    cursor: pointer;
    list-style: none;
    transition: background .12s ease;
}

/* The default triangle is replaced by a chevron drawn with a border, so it can rotate on open and
 * match the rest of the site rather than the platform. */
.plugin-edit summary::-webkit-details-marker { display: none; }

.plugin-edit summary::after {
    content: "";
    width: 7px;
    height: 7px;
    margin-left: auto;
    border-right: 2px solid var(--ink-3);
    border-bottom: 2px solid var(--ink-3);
    transform: rotate(45deg) translate(-2px, -2px);
    transition: transform .18s ease;
}

.plugin-edit[open] summary::after { transform: rotate(225deg) translate(-2px, -2px); }
.plugin-edit summary:hover { background: rgba(255, 255, 255, .035); }

.plugin-edit summary img {
    width: 28px;
    height: 28px;
    border-radius: 6px;
    background: var(--raised);
    object-fit: cover;
    flex: 0 0 28px;
}

.plugin-edit .who { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; }
.plugin-edit .who strong { color: var(--ink); font-size: 14px; }

.plugin-edit .body {
    padding: var(--s4);
    border-top: 1px solid var(--line);
}

.plugin-edit .body .field { max-width: 560px; }

/* ---- step rail ----
 *
 * Reuses the contents rail's position so a form and a document share one page shape. It is progress
 * AND explanation: somebody arriving can see the whole process before starting it, including that a
 * person reads the source at the end. Discovering that at step three would be the worse surprise. */

.steps ol { list-style: none; margin: 0; padding: 0; }

.steps li {
    display: grid;
    grid-template-columns: 20px 1fr;
    gap: var(--s3);
    align-items: center;
    padding: 7px 0;
    color: var(--ink-3);
    font-size: 13.5px;
    transition: color .15s ease;
}

.steps .dot {
    width: 9px;
    height: 9px;
    border-radius: 50%;
    justify-self: center;
    background: var(--raised);
    box-shadow: inset 0 0 0 1px var(--line-strong);
    transition: background .18s ease, box-shadow .18s ease, transform .18s ease;
}

.steps li.done { color: var(--ink-2); }
.steps li.done .dot { background: var(--ok); box-shadow: inset 0 0 0 1px var(--ok); }

.steps li.here { color: var(--ink); font-weight: 600; }

.steps li.here .dot {
    background: var(--accent);
    box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 4px var(--accent-dim);
    transform: scale(1.1);
}

/* ---- prose ---- */

.prose { max-width: 80ch; }

/* Anchored headings need to clear the sticky header when jumped to, or the target lands underneath
 * it and looks like the link went to the wrong place. */
.prose h2, .prose h3 { scroll-margin-top: 86px; }

/* A rule above each section, so the page reads as parts rather than as one column of text. Drawn
 * with a border on the heading rather than an <hr>, which keeps it attached when sections move. */
.prose h2 {
    border-top: 1px solid var(--line);
    padding-top: var(--s5);
    margin-top: var(--s6);
}

.prose h2:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.prose h3 { margin-top: var(--s5); color: var(--ink); }

/* The opening paragraph of a document, set slightly larger. One only - a page where everything is
 * emphasised has emphasised nothing. */
.prose > p:first-of-type { font-size: 16.5px; line-height: 1.6; color: var(--ink-2); }
.prose p, .prose li { color: var(--ink-2); }
.prose strong { color: var(--ink); font-weight: 620; }
.prose ul, .prose ol { padding-left: var(--s5); }
.prose li { margin: var(--s1) 0; }
.prose code { background: var(--raised); padding: 2px 6px; border-radius: var(--r-sm); color: var(--ink); }

.prose table {
    border-collapse: separate;
    border-spacing: 0;
    width: 100%;
    margin: var(--s4) 0;
    border: 1px solid var(--line);
    border-radius: var(--r);
    overflow: hidden;
}

.prose th, .prose td { border-bottom: 1px solid var(--line); padding: 10px 13px; text-align: left; font-size: 14px; }
.prose tr:last-child td { border-bottom: 0; }
.prose th { background: var(--raised); color: var(--ink); font-weight: 600; }

/* ---- footer ---- */

footer {
    border-top: 1px solid var(--line);
    padding: var(--s5) 0 var(--s7);
    color: var(--ink-3);
    font-size: 13px;
    line-height: 1.8;
}

footer a { color: var(--ink-2); }
footer a:hover { color: var(--accent); }

/* ---- narrow screens ---- */

@media (max-width: 640px) {
    .wrap { padding: 0 var(--s4); }
    .top .wrap { height: auto; padding-top: var(--s3); padding-bottom: var(--s3); flex-wrap: wrap; gap: var(--s2); }
    .top nav { margin-left: 0; width: 100%; }
    .top nav a { padding: 6px 8px; }
    .rows { grid-template-columns: 1fr; gap: 0; }
    .rows dt { margin-top: var(--s3); }
    .rows dd { margin-bottom: var(--s1); }
    main { padding-top: var(--s5); }
    .auth { padding-top: var(--s5); }
}

/* Respect a stated preference for less motion. Everything above is a transform or a colour fade, so
 * removing them costs nothing and prevents real discomfort for people who ask. */
@media (prefers-reduced-motion: reduce) {
    * { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
    .card:hover { transform: none; }

    /* Reduce motion means no movement, but a plain fade is gentler than a hard cut - so the
     * translate is dropped rather than the whole transition. Re-declared after the blanket rule
     * above, which would otherwise flatten this to nothing. */
    ::view-transition-new(root) {
        animation-duration: 140ms !important;
        animation-name: px-fade-plain !important;
    }

    @keyframes px-fade-plain {
        from { opacity: 0; }
        to { opacity: 1; }
    }
}

/* `hidden` is only a user-agent rule and loses to any author `display`, so anything given a display
 * value elsewhere in this file would still draw while hidden. Costs one line; the sibling site
 * shipped an empty stub pill on five pages without it, and a DOM test could not catch it because the
 * property was set perfectly correctly. */
[hidden] { display: none !important; }

