/* Inter, self-hosted. Two subsets, 130KB together, served from this app's own origin: a font
   loaded from a third party is a request to somebody else on every visit, and this thing is meant
   to work on a phone in a spot with one bar of signal.
   Variable, so 400 through 700 come out of one file per subset rather than four static weights.
   The latin subset covers everything Dutch needs; latin-ext carries the accents that turn up in a
   pasted filename. Kept in the order Google serves them, ranges and all, so a browser only fetches
   the second file if a page actually uses a character from it.
   font-display: swap — the fallback below shows immediately and Inter replaces it. Never invisible
   text while a font loads. */
@font-face {
    font-family: "Inter";
    font-style: normal;
    font-weight: 400 700;
    font-display: swap;
    src: url("/fonts/inter-latin-ext.woff2") format("woff2");
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
    font-family: "Inter";
    font-style: normal;
    font-weight: 400 700;
    font-display: swap;
    src: url("/fonts/inter-latin.woff2") format("woff2");
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/*
  A phone app that happens to be served as HTML: opened one-handed, from a home screen, often on a
  slow connection. No framework, no utility classes, no webfont — the system stack is SF on the
  hardware most of these parents hold, and downloading a typeface to imitate it would be slower and
  worse.

  The structure is Apple's inset-grouped list: a quiet ground, white cards floating on it, hairline
  separators inside them. The palette (Slate) is deliberately cool and near-neutral rather than
  hue-biased: the thumbnails are worksheets, storybook covers, photos — already colourful — and a
  tinted neutral would compete with them instead of framing them. Colour is spent in exactly one
  place, the accent, and nowhere else.
*/

/* ---------------------------------------------------------------- tokens -- */

:root {
    color-scheme: light dark;

    /* Modern Minimalist (theme-factory): Charcoal, Slate Gray, Light Gray, White, verbatim as
       ink/accent/hairline/surface. Every other token here is derived from those four at the same
       hue (~207°, the shared cool cast of Charcoal and Slate Gray) rather than invented — a
       filled-in version of the named theme, not a different palette wearing its name. */
    --bg: #f7f7f8;
    --surface: #ffffff;
    --surface-sunk: #f2f2f3;
    --surface-press: #e4e6e7;

    --ink: #36454f;
    --ink-2: #607280;
    --ink-3: #949fa8;

    --hairline: #d3d3d3;
    --hairline-strong: #b8b8b8;

    /* The theme's own Slate Gray swapped for this specific blue — everything else in Modern
       Minimalist (ink, hairline, surface, type) stays as specified. */
    --accent: #3a4e7a;
    --accent-press: #2e3e62;
    --accent-ink: #ffffff;
    --accent-wash: #e7eaef;

    /* The mark in the banner, and nothing else. Three colours rather than one, because the glyph
       is drawn in two parts: a pale canvas disc, the house in near-black, and the book inside it in
       the accent blue. Owner's choice, replacing an orange disc with a white glyph — it holds its
       own against the dark band without introducing a hue the rest of the app never uses. */
    --mark-canvas: #f7f7f6;
    --mark-house: #18181b;
    --mark-book: #3a4e7a;

    /* Gold, and the only warm hue in the whole palette. Spent on exactly one thing: the "+N" that
       rises when somebody has just added something to the library. It is up for about a second and
       then gone, which is what lets it be the one loud note in an otherwise cool, near-neutral
       interface — a permanent gold element would fight every thumbnail on the page.
       No dark-mode variant: the pill carries its own fill and its own dark ink, so it reads the
       same against a white page and a near-black one. */
    --gold: #e0aa2e;
    --gold-light: #f7d774;
    --gold-deep: #c2871a;
    --gold-ink: #3d2a05;

    /* The favourite heart, and nothing else. Its own token rather than --danger: red means
       "careful" everywhere else in this app, and a filled heart means the opposite of that. */
    --heart: #d92b4b;
    --heart-press: #b71f3c;
    --heart-wash: #fbe9ed;

    --danger: #b3261e;
    /* What is legible on --danger. White in light mode; in dark the danger colour itself is a
       light coral, and white on it comes out at 2.7:1. */
    --danger-ink: #ffffff;

    /* Only used to say "this edit is ready to save" on the rename control. Green is the
       one signal that reads as go without a word of text next to it. */
    --ok: #2c9633;
    --ok-ink: #ffffff;


    /* Age deliberately carries no colour of its own — plain theme chips/tags, the same
       surface/hairline/accent language as every other untinted control — so that subject, below,
       is the one place on the page colour means something. (An earlier version gave age its own
       five-hue palette; removed on request in favour of this plainer look.)
       Subject is an open, per-group vocabulary, so it can't be keyed by value the way a fixed
       enum could — --subject-0 .. --subject-9 are assigned by each tag's position in the group's
       own vocabulary instead (see LibraryModel.SubjectPaletteSize), cycling if a group ever seeds
       more than ten.
       Each index is a PAIR: --subject-N is a pale tint used as a fill, --subject-N-ink the deep
       version of the same hue used for the text on it and for the dot on a file's card. An
       earlier version was ten fully saturated hues with white text; these tints replace them at
       the owner's choice. Three roles per index, because two were not enough once the chip fill
       went back to neutral: -N is the accent (the border, the dot on a card, and the fill once
       the chip is on), -N-ink writes on the neutral chip, -N-on writes on that accent fill.
       In light mode -ink and -on are the same deep colour — dark text works on white and on a
       pale tint alike. Dark mode is where they part: on a dark chip the text has to be the light
       tint, while on a tint-filled chip it has to go dark again.
       Measured, so that nobody re-derives it from the look: muting a palette moves its colours
       closer together, and colour-vision deficiency then collapses them faster. Ten colliding
       pairs under deuteranopia here against five for the old vivid set — Muziek and Talen land
       0.6 ΔE apart, which is to say identical. What makes the change an improvement anyway is
       that the chip now carries its own label on top of the tint, so colour stopped being the
       only channel there. The one place it is still the only channel is the dot on a file's card
       — so that dot must never become the sole way to tell two subjects apart, and if a subject's
       identity has to survive on a card, it needs a second channel. */
    --subject-0: #e7c9cf;
    --subject-0-ink: #8a2136;
    --subject-0-on: #8a2136;
    --subject-1: #dcebc2;
    --subject-1-ink: #3f6210;
    --subject-1-on: #3f6210;
    --subject-2: #c8dff5;
    --subject-2-ink: #12457f;
    --subject-2-on: #12457f;
    --subject-3: #f6dfc0;
    --subject-3-ink: #8a5205;
    --subject-3-on: #8a5205;
    --subject-4: #d9d2ef;
    --subject-4-ink: #472a8a;
    --subject-4-on: #472a8a;
    --subject-5: #f4d2e6;
    --subject-5-ink: #8a2168;
    --subject-5-on: #8a2168;
    --subject-6: #efe7b4;
    --subject-6-ink: #645a08;
    --subject-6-on: #645a08;
    --subject-7: #f8d3c6;
    --subject-7-ink: #8f3418;
    --subject-7-on: #8f3418;
    --subject-8: #dedcd8;
    --subject-8-ink: #4f4b45;
    --subject-8-on: #4f4b45;
    --subject-9: #bfe3db;
    --subject-9-ink: #0d5248;
    --subject-9-on: #0d5248;

    /* Apple's radii are large and consistent; small ones read as cheap. */
    --r-card: 16px;
    --r-inner: 12px;
    --r-pill: 999px;
    /* The file card's own corner, tuned apart from --r-inner. It was sharing that 12px with the
       form fields and the panels, and at the size a card actually is in a three-column grid on a
       phone that read as far too round. One token, because three things have to agree on it: the
       card, the footer row that rounds its own bottom corners underneath it, and the selection
       overlay that sits on top. */
    --r-tile: 8px;

    --tap: 44px;

    /* Where the floating add button sits, and how big it is. Shared with the two options that open
       above it — see .add-dial. */
    --fab-right: max(16px, calc(env(safe-area-inset-right) + 12px));
    /* Written down once: the floating button, the add sheet's dial, and the foot of every page all
       have to clear the navigation bar, and three copies of this sum would drift apart the first
       time the bar changed height. */
    --head-height: 45px;
    --tabs-height: 40px;
    --nav-height: 58px;
    --nav-total: calc(var(--nav-height) + env(safe-area-inset-bottom));
    --fab-bottom: calc(var(--nav-total) + 14px);
    --fab-size: 50px;
    /* The two round options that open above it. Smaller than the button they came out of, so the
       button stays the one that is obviously in charge. */
    --dial-size: 44px;

    /* Two shadows: one hairline for edge definition, one wide and very soft for lift. Tinted by
       ink rather than pure black, so the lift reads as part of this palette. */
    --lift: 0 0 0 0.5px rgb(54 69 79 / 6%), 0 1px 2px rgb(54 69 79 / 5%), 0 8px 24px rgb(54 69 79 / 6%);

    --ease: cubic-bezier(0.32, 0.72, 0, 1);
}

@media (prefers-color-scheme: dark) {
    :root {
        /* Modern Minimalist has no dark variant of its own — presentation themes rarely do — so
           this extends it the same way the rest of this app's dark mode was built: Charcoal's own
           hue darkens further for the canvas, and the accent lightens for contrast rather than
           being replaced with an unrelated colour. */
        --bg: #1d252a;
        --surface: #2a3237;
        --surface-sunk: #363f45;
        --surface-press: #424c52;

        --ink: #f2f2f3;
        --ink-2: #a6aeb5;
        --ink-3: #6d7983;

        /* Solid borders don't scale across arbitrary dark surfaces the way they do against a
           fixed white card, so dark mode goes back to a translucent line. */
        --hairline: rgb(255 255 255 / 10%);
        --hairline-strong: rgb(255 255 255 / 18%);

        /* A deep red disappears into a dark card, so the heart lightens the same way the accent
           does — same hue, lifted until it reads. */
        --heart: #ff5f7a;
        --heart-press: #ff8496;
        --heart-wash: #43262d;

        /* The accent lightens for contrast against a dark ground — the same blue, not a
           different hue — and its ink flips dark to sit on top of it. */
        --accent: #7e93c4;
        --accent-press: #93a6d1;
        --accent-ink: #121826;
        --accent-wash: #232a3b;

        /* The disc stays pale in dark mode: it is a canvas the glyph is drawn on, and inverting
           it would put a dark mark on a light band, which is the one arrangement that stops
           reading as a badge. */
        --mark-canvas: #f2f2f0;
        --mark-house: #18181b;
        --mark-book: #3a4e7a;

        --danger: #ff6f62;
        --danger-ink: #2b0a06;

        --ok: #57b85e;
        --ok-ink: #12210f;


        /* The same ten pairs with their roles swapped for a dark ground: the fill is the deep
           hue dropped onto the dark card, the text is the pale tint. Because both modes keep the
           same three roles, so every rule below is written once and works in both. Unselected
           text and the border are the same light tint here; -on is a deep version of the hue for
           the moment that tint becomes the fill. Text clears 7.7:1 on all ten, borders 8.4:1. */
        --subject-0: #e7c9cf;
        --subject-0-ink: #e7c9cf;
        --subject-0-on: #522b37;
        --subject-1: #dcebc2;
        --subject-1-ink: #dcebc2;
        --subject-1-on: #334627;
        --subject-2: #c8dff5;
        --subject-2-ink: #c8dff5;
        --subject-2-on: #203a55;
        --subject-3: #f6dfc0;
        --subject-3-ink: #f6dfc0;
        --subject-3-on: #523f22;
        --subject-4: #d9d2ef;
        --subject-4-ink: #d9d2ef;
        --subject-4-on: #362f5a;
        --subject-5: #f4d2e6;
        --subject-5-ink: #f4d2e6;
        --subject-5-on: #522b4c;
        --subject-6: #efe7b4;
        --subject-6-ink: #efe7b4;
        --subject-6-on: #424323;
        --subject-7: #f8d3c6;
        --subject-7-ink: #f8d3c6;
        --subject-7-on: #54332a;
        --subject-8: #dedcd8;
        --subject-8-ink: #dedcd8;
        --subject-8-on: #3a3c3d;
        --subject-9: #bfe3db;
        --subject-9-ink: #bfe3db;
        --subject-9-on: #1e3f3e;

        --lift: 0 0 0 0.5px rgb(255 255 255 / 6%), 0 8px 24px rgb(0 0 0 / 40%);
    }
}

/* ------------------------------------------------------------------ base -- */

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

html {
    -webkit-text-size-adjust: 100%;
    /* Nothing in this app is ever wider than the screen. */
    overflow-x: hidden;
}

body {
    margin: 0;
    overflow-x: hidden;
    background: var(--bg);
    color: var(--ink);
    /* Inter, self-hosted above. The earlier note here said self-hosting was not worth ~1.4MB of
       font files; that was about shipping two static weights of DejaVu. One variable subset is
       47KB, which changes the answer. The fallback is the system UI stack, which is what Inter is
       modelled on anyway, so the swap when it lands is barely visible. */
    font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    /* Apple's own body size is 17px; dialed back one notch because this app leans denser than a
       typical iOS screen — a 4-across grid and compact list rows throughout. */
    font-size: 16px;
    line-height: 1.47;
    letter-spacing: -0.011em;
    -webkit-font-smoothing: antialiased;
    /* The browser's grey flash on tap looks like a bug next to a real press state. */
    -webkit-tap-highlight-color: transparent;
    padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

main {
    max-width: 680px;
    margin: 0 auto;
    /* The top is roomier than it looks like it needs to be: the brand disc hangs out of the band
       above and lands in this space. */
    padding: 16px 16px 56px;
}

/* With no band above, that allowance is paying for a disc that is not there. Trimmed rather than
   removed: a title flush against the top of the window reads as cut off, and on a phone this is
   where the clock and the notch are. The saved height is what the page gains. */
main.no-top-bar {
    padding-top: max(10px, env(safe-area-inset-top));
}

/* And starts below the bar at the top, on the one screen that has one. The window's own top inset
   is already paid for by the padding on <body>, so this is the bar's drawn height and nothing
   more — adding the inset again would leave a gap the width of the notch under the bar. */
body:has(.library-head) main {
    /* The bar's height plus a little air, so the search field is not glued to the line under it.
       The gap has to live here rather than as a margin on the first thing in the page: what sits
       directly under the bar differs per state — the search row normally, a toast when a handler
       has just said something. */
    padding-top: calc(var(--head-height) + 10px);
}

/* With the two tabs under the bar, the page starts under those instead. */
body:has(.library-tabs) main {
    padding-top: calc(var(--head-height) + var(--tabs-height) + 10px);
}

/* Every page ends above the navigation bar — but only on the pages that have one. Keyed off the
   bar actually being in the document rather than off the flag: signed out there are no tabs, and
   reserving room for them would leave a strip of nothing under the sign-in form. Pages with a
   floating button need more still; see the .fab rules further down. */
body:has(.bottom-nav) main {
    padding-bottom: calc(var(--nav-total) + 24px);
}

h1 {
    margin: 0;
    /* Scales with the viewport instead of being a fixed size tuned on a desktop. Small enough on
       a 360px phone to sit under the site header without a jarring size jump between the two. */
    font-size: clamp(18px, 5vw, 22px);
    line-height: 1.15;
    font-weight: 700;
    letter-spacing: -0.02em;
    text-wrap: balance;
}

/* The small uppercase label above a grouped section, straight out of iOS Settings. */
h2 {
    margin: 0 0 8px;
    font-size: 12px;
    line-height: 1.3;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ink-3);
}

p {
    margin: 0;
}

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

a:hover {
    text-decoration: underline;
    text-underline-offset: 3px;
}

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

.small {
    font-size: 13px;
    line-height: 1.4;
}

:where(a, button, input, label):focus-visible {
    outline: 2.5px solid var(--accent);
    outline-offset: 2px;
    border-radius: 6px;
}

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

button,
.button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: var(--tap);
    padding: 0 20px;
    border: none;
    border-radius: var(--r-inner);
    background: var(--accent);
    color: var(--accent-ink);
    font: inherit;
    font-size: 14px;
    font-weight: 600;
    letter-spacing: -0.01em;
    white-space: nowrap;
    cursor: pointer;
    transition: transform 140ms var(--ease), background-color 140ms var(--ease);
}

button:hover,
.button:hover {
    background: var(--accent-press);
    text-decoration: none;
}

/* The slight give on press is most of what makes a web page feel like an app. */
button:active,
.button:active {
    transform: scale(0.96);
}

/* A button that has just been pressed and is waiting for the answer. Same look as any other
   disabled button — the point is only that it is visibly no longer pressable, so nobody taps it a
   second time while the phone thinks. See submit-once.js. */
[data-locked] {
    cursor: default;
}

button:disabled {
    opacity: 0.4;
    cursor: default;
    transform: none;
}

/* Sits beside a title without dominating it. Still 38px tall, comfortably tappable. */
.button.compact,
button.compact {
    min-height: 38px;
    padding: 0 16px;
    font-size: 13px;
}

.link-button {
    min-height: 32px;
    padding: 0 12px;
    background: var(--surface-sunk);
    color: var(--ink-2);
    font-size: 13px;
    font-weight: 500;
}

.link-button:hover {
    background: var(--surface-press);
}

.link-button.danger {
    color: var(--danger);
}

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

.stack {
    display: flex;
    flex-direction: column;
    gap: 22px;
    margin-top: 28px;
}

/* ------------------------------------------------------- aanmelden/inschrijven -- */

/* Two radios drive which panel is on screen. No script: the same checkbox-and-label trick the
   filter row and the tag chips already use, so it works before a single byte of JavaScript has
   loaded — which on the one screen you cannot get past is worth something. */
.auth-mode {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.auth-tabs {
    display: flex;
    gap: 4px;
    margin: 26px 0 0;
    padding: 3px;
    border-radius: var(--r-pill);
    background: var(--surface-sunk);
}

.auth-tabs label {
    flex: 1 1 0;
    padding: 9px 10px;
    border-radius: var(--r-pill);
    text-align: center;
    font-size: 14px;
    font-weight: 600;
    color: var(--ink-2);
    cursor: pointer;
    transition: background-color 140ms var(--ease), color 140ms var(--ease);
}

#auth-signin:checked ~ .auth-tabs label[for="auth-signin"],
#auth-register:checked ~ .auth-tabs label[for="auth-register"] {
    background: var(--surface);
    color: var(--ink);
    box-shadow: var(--lift);
}

/* The radio itself is a pixel in the corner, so its focus ring would land nowhere. Put it on the
   label the keyboard is actually pointing at — same as .same-labels-toggle does. */
.auth-mode:focus-visible ~ .auth-tabs label[for="auth-signin"],
.auth-mode:focus-visible ~ .auth-tabs label[for="auth-register"] {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.auth-panel {
    display: none;
    margin-top: 20px;
}

#auth-signin:checked ~ .auth-panel-signin,
#auth-register:checked ~ .auth-panel-register {
    display: flex;
}

.field {
    display: flex;
    flex-direction: column;
    gap: 7px;
}

.field > span:first-child {
    padding-inline-start: 2px;
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.01em;
    color: var(--ink-2);
}

/* textarea was missing here, which is why every one in the app came out in the browser's default
   monospace with its own border — the two description fields looked like a different app. */
input[type="text"],
input[type="search"],
input[type="email"],
input[type="number"],
textarea,
.field input {
    width: 100%;
    max-width: 100%;
    min-height: 42px;
    padding: 0 16px;
    border: none;
    border-radius: var(--r-inner);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink);
    font: inherit;
    transition: box-shadow 140ms var(--ease);
}

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

input:focus,
textarea:focus {
    outline: none;
    box-shadow: inset 0 0 0 2px var(--accent);
}

/* A single-line rule cannot serve a box of text: the padding has to be on both axes, and a fixed
   height would fight the rows attribute. */
textarea {
    min-height: 0;
    padding: 10px 16px;
    line-height: 1.45;
}

input[type="search"] {
    /* Safari draws its own search-field chrome (a rounded lozenge with a baked-in cancel icon)
       that fights whatever shape is set here — turn it off everywhere search inputs appear. */
    -webkit-appearance: none;
    appearance: none;
}

.error,
.errors {
    color: var(--danger);
    font-size: 14px;
}

.errors:empty {
    display: none;
}

.errors ul {
    margin: 0;
    padding-inline-start: 18px;
}

/* --------------------------------------------------------------- library -- */

/* The bar at the top of a school, in the shape a phone user knows from a chat: back, a round mark,
   the name, one action. Fixed, so it survives a flick through a long grid — the name of the school
   you are in and the way out of it are exactly the two things you want when you are lost in it.
   Opaque, like the bar at the foot: a translucent strip over a grid of thumbnails makes the title
   a guess. */
.library-head {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    /* Under the toasts (60), above everything the page itself draws. */
    z-index: 6;
    background: var(--surface);
    box-shadow: inset 0 -1px 0 var(--hairline);
    /* The bar is measured from the top of the window, not from the body, so the notch is its own
       problem to solve — the same arrangement the navigation bar makes at the other end. */
    padding-top: env(safe-area-inset-top);
}

/* The bar reaches both edges; what is in it lines up with the grid underneath. */
.library-head-inner {
    display: flex;
    align-items: center;
    gap: 9px;
    max-width: 680px;
    height: var(--head-height);
    margin: 0 auto;
    padding: 0 14px;
}

.library-head h1 {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 15.5px;
}

/* Drawn small, tapped large: 22px of ink inside a 40px target. */
.head-back,
.head-action {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 19px;
    height: 19px;
}

.head-back::after,
.head-action::after {
    content: "";
    position: absolute;
    inset: -11px;
    border-radius: 50%;
}

/* Both header actions in the app's one interactive colour. Grey on the gear beside a blue arrow
   read as two different kinds of thing, when they are the same kind: the two ways out of the grid. */
.head-back,
.head-action {
    color: var(--accent);
}

.head-back {
    margin-inline-start: -4px;
}

.head-back svg,
.head-action svg {
    width: 100%;
    height: 100%;
}

.head-back:active,
.head-action:active {
    opacity: 0.55;
}

/* The same round mark the list of schools uses, one size down: there it is the row, here it sits
   beside a title that has to stay the thing you read first. */
.head-mark {
    display: grid;
    place-items: center;
    width: 27px;
    height: 27px;
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 10.5px;
    font-weight: 700;
}

.head-mark-heart {
    background: var(--heart-wash);
    color: var(--heart);
}

.head-mark-heart svg {
    width: 15px;
    height: 15px;
}

/* Small on purpose: it sits beside the group's name, which is the thing to read on this screen,
   and a settings control is visited once in a while rather than every visit. */
.icon-button {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--surface-sunk);
    color: var(--ink-2);
}

/* The button is drawn smaller than the words beside it, but a thumb still needs something to hit.
   This invisible ring puts the tappable area back at 40px without changing what is on screen. */
.icon-button::after {
    content: "";
    position: absolute;
    inset: -10px;
    border-radius: 50%;
}

.icon-button:hover {
    background: var(--surface-press);
    text-decoration: none;
}

.icon-button svg {
    /* block, or the svg sits on the text baseline and leaves a descender's worth of space under it
       - which reads as a glyph nudged up and to the left inside its circle. */
    display: block;
    width: 12px;
    height: 12px;
}

.filters {
    margin-bottom: 16px;
}

/* Always visible, never a separate expandable panel — narrowing the grid costs one tap, not
   open-then-pick. Clipped to a fixed height rather than left to scroll off-screen with no sign
   there was more, with a "meer" chip that reveals the rest. Age and subject are two stacked
   groups rather than one wrapping line — age (short, fixed at six chips including "Geen") reads
   as its own row, subject (a longer, per-group list) wraps below it, and which dimension a chip
   belongs to is which row it's in rather than a thin divider that could land mid-wrap depending
   on viewport width and vocabulary size. */
/* Shorter than the fields on the forms: this one sits above a grid you are scanning, not in a
   form you are filling in, so it should take less of the screen.
   The text stays at 16px on purpose. Anything smaller and iOS Safari zooms the page in the moment
   the field takes focus, and the reader has to pinch back out to see the results they just
   narrowed. Height and padding do the shrinking instead. */
.filters input[type="search"] {
    min-height: 36px;
    padding: 0 14px;
}

/* Hidden until the switch above it is on. Not display:none on the group inside — the whole block
   goes, label and all, so nothing is left hanging under the search field. */
.filter-toggle {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

/* The switch sits on the same line as the search field, to its right, but deliberately not
   against it. A control welded to the right edge of a search box is read as that box's submit
   button, and this one does the opposite of searching — it narrows the grid below. The gap is
   what keeps them two separate controls; they match in height, so the row reads as one line
   rather than a tall thing with a small thing floating beside it. */
.search-line {
    display: flex;
    align-items: center;
    gap: 12px;
}

.search-line input[type="search"] {
    /* min-width:0 because a flex item will not shrink past its intrinsic width without it, and on
       a narrow phone the button would be pushed off the row instead. */
    flex: 1 1 auto;
    min-width: 0;
}

.filter-button {
    position: relative;
    display: inline-flex;
    flex: 0 0 auto;
    align-items: center;
    gap: 6px;
    /* Exactly the search field's height (.filters input[type="search"]). Both are border-box with
       no border — an inset shadow draws the outline — so the two numbers are directly comparable
       and any change to one has to be made to the other. */
    min-height: 36px;
    padding: 0 14px;
    border-radius: var(--r-pill);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink-2);
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    user-select: none;
}

.filter-button:hover {
    background: var(--surface-sunk);
}

.filter-button svg {
    width: 13px;
    height: 13px;
    flex: 0 0 auto;
}

/* On, so the switch says so as well as showing it. */
.filter-toggle:checked ~ .filters .chip-row {
    display: flex;
}

/* A count in the corner of the switch, and only while the chips are out of sight. Once they are
   on screen they show which ones are on, and two answers to one question is one too many. */
/* Nothing filtering, nothing to say. The element stays in the DOM so htmx has something to swap. */
.filter-count:empty {
    display: none;
}

.filter-count {
    position: absolute;
    top: -6px;
    /* Flush with the button's right edge rather than hanging past it. The button now sits on the
       page's right margin, and a badge sticking out beyond it was the only thing on the screen
       crossing the line the search field, the grid and the cards all stop at. It still lifts
       above the top edge, which is what makes it read as a badge. */
    inset-inline-end: 0;
    display: grid;
    place-items: center;
    min-width: 17px;
    height: 17px;
    padding: 0 4px;
    border-radius: var(--r-pill);
    background: var(--accent);
    box-shadow: 0 0 0 2px var(--bg);
    color: var(--accent-ink);
    font-size: 10px;
    font-weight: 700;
    line-height: 1;
}

/* Blijft staan met het paneel open. Hij verdween daar eerst, met als redenering dat de chips het
   dan zelf wel zeggen — maar dat klopt niet: de chips staan in twee rijen die ook nog ingeklapt
   kunnen zijn, en dan is er geen enkele plek meer die in één oogopslag zegt hoeveel filters er aan
   staan. Het getal hoort bij het filteren, niet bij de toestand van het paneel.
   Wel omgekeerd getekend, want de knop is in open toestand zelf accentkleurig: een accentbadge
   daarop zou erin verdwijnen. Wit met een accentrand leest tegen allebei. */
.filter-toggle:checked ~ .filters .filter-count {
    background: var(--surface);
    box-shadow: 0 0 0 2px var(--accent);
    color: var(--accent);
}

/* The second switch: unclips the rows inside the panel. Both live outside the form, so neither
   fires the search. */
.chip-expand {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.chip-expand:checked ~ .filters .chip-group {
    max-height: none;
}

.chip-expand:checked ~ .filters .chip-more {
    display: none;
}

.chip.chip-less {
    display: none;
}

.chip-expand:checked ~ .filters .chip.chip-less {
    display: inline-flex;
}

/* Two classes, higher specificity than .chip alone, so these win regardless of source order. */
.chip.chip-more,
.chip.chip-less {
    color: var(--ink-2);
}

/* The no-script fallback position: its own right-aligned line below the rows, used until
   filter-more.js relocates it to sit right after the last chip that actually fits. */
.filters > .chip.chip-more {
    display: flex;
    width: fit-content;
    margin-top: 6px;
    margin-inline-start: auto;
}

/* filter-more.js hides the "…" when nothing is actually clipped. Last, and matching the fallback
   selector above class for class: three separate rules give a chip a display, and a plain
   .chip[hidden] loses to all of them on specificity or on source order. */
.filters > .chip.chip-more[hidden],
.chip.chip-more[hidden],
.chip[hidden] {
    display: none;
}

.filter-toggle:checked ~ .filters .filter-button {
    background: var(--accent);
    box-shadow: none;
    color: var(--accent-ink);
}

.filter-toggle:focus-visible ~ .filters .filter-button {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.chip-row {
    display: none;
    flex-direction: column;
    gap: 8px;
    /* Gap from the search field above, which otherwise touches the first row directly — margins
       collapse onto this since the field has none of its own. */
    margin-top: 10px;
}

/* One dimension's chips, wrapping independently of the other's.
   The clamp is per group, not on the pair. It used to be one 66px box around both, which meant a
   long age row could wrap onto a second line and push the subject row out of sight entirely — at
   360px, adding the "LEEFTIJD" label was enough to do exactly that. A line each: whichever group
   is too long loses its own tail to the "…", never the other group's. */
.chip-group {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    max-height: 30px;
    overflow: hidden;
    /* 5px rather than 6 for the same reason as the chip padding above — it buys the last few
       pixels of headroom so the age row survives a font that renders a touch wider. */
    gap: 5px;
}

/* Says what the row is, in the row. Fixed width so the two labels line up under each other and
   the chips start at the same place on both lines — ragged starts read as two unrelated rows. */
.chip-group-label {
    flex: 0 0 auto;
    /* Wide enough for "LEEFTIJD", which is the longer of the two — at 44px it came out as
       "LEEFTIJC" on a 360px screen. */
    width: 52px;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.02em;
    line-height: 1;
    text-transform: uppercase;
    color: var(--ink-3);
}

/* A plain checkbox-and-label toggle rather than JS: "reveal the rest of this list" is exactly
   what that pattern is for, and it keeps working if a script fails to load. */
/* Stands where the chip rows would be, so a group that has not made any filters yet is asked for
   them in the place it would use them. Full width: it is the only thing to do on this line. */
.filter-invite {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    width: 100%;
    margin: 4px 0 2px;
    background: var(--surface);
    box-shadow: inset 0 0 0 1.5px var(--accent);
    color: var(--accent);
    font-size: 14px;
}

.filter-invite:hover {
    background: var(--surface-sunk);
}

.filter-invite svg {
    width: 16px;
    height: 16px;
    flex: 0 0 auto;
}









.chip {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    max-width: 100%;
    min-height: 28px;
    /* 9px, not 10: measured, not guessed. Adding the "!" badge to the "Geen" chip pushed the six
       age chips 10px over the line at 360px — the width the phones this is built for actually
       report — and 1px off each side of six chips buys back 12px. */
    padding: 0 9px;
    border: none;
    border-radius: var(--r-pill);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink);
    font-size: 12px;
    font-weight: 500;
    line-height: 1;
    letter-spacing: -0.01em;
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
    transition: transform 140ms var(--ease), background-color 140ms var(--ease),
        box-shadow 140ms var(--ease), color 140ms var(--ease);
}

/* The checkbox stays in the accessibility tree; only its rendering is replaced. */
.chip input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.chip:active {
    transform: scale(0.95);
}

.chip:has(input:checked),
.chip.on {
    background: var(--accent);
    box-shadow: none;
    color: var(--accent-ink);
    font-weight: 600;
}

/* Age chips carry no colour of their own - plain at rest, solid accent when checked, exactly
   like every other untinted chip. Subject is the one row that spends colour; see below. */

/* The "Geen" chip wears a miniature of the corner badge from .tile-warn, so the mark on an
   untagged card and the filter that collects those cards are recognisably the same thing. */
.chip-untagged::before {
    content: "!";
    display: inline-grid;
    place-items: center;
    flex: 0 0 auto;
    /* Kept deliberately tight: every pixel here pushes the age row towards wrapping onto a second
       line at 360px, and that row fitting on one line is worth more than a larger badge. */
    width: 12px;
    height: 12px;
    margin-inline-end: 4px;
    margin-inline-start: -1px;
    border-radius: 50%;
    background: var(--danger);
    color: var(--danger-ink);
    font-size: 8px;
    font-weight: 700;
    line-height: 1;
}

/* Selected, the chip fills with the accent — and red on dark blue is barely legible, so the badge
   inverts rather than keeping a colour that stops reading as a warning. */
.chip-untagged:has(input:checked)::before {
    background: #fff;
    color: var(--accent);
}

/* Subject chips, keyed by position in the group's own vocabulary rather than by value — see the
   --subject-* tokens. The matching mark on a file's own card (.tile-mark below) reads this same
   index, so a subject is always the same colour in both places without the two ever needing to
   agree on a name.
   Unselected is an outline: the neutral chip surface, the subject's accent on the border and on
   the dot ahead of the label, its deep colour on the text. Selected fills with that same accent
   and drops the ring, because a solid fill needs no border to read as "on". The ✓ (further down)
   says the same thing a second time for anyone who cannot rely on the colour. */
.chip[data-subject-color="0"] { background: var(--surface); color: var(--subject-0-ink); box-shadow: inset 0 0 0 1.5px var(--subject-0); }
.chip[data-subject-color="1"] { background: var(--surface); color: var(--subject-1-ink); box-shadow: inset 0 0 0 1.5px var(--subject-1); }
.chip[data-subject-color="2"] { background: var(--surface); color: var(--subject-2-ink); box-shadow: inset 0 0 0 1.5px var(--subject-2); }
.chip[data-subject-color="3"] { background: var(--surface); color: var(--subject-3-ink); box-shadow: inset 0 0 0 1.5px var(--subject-3); }
.chip[data-subject-color="4"] { background: var(--surface); color: var(--subject-4-ink); box-shadow: inset 0 0 0 1.5px var(--subject-4); }
.chip[data-subject-color="5"] { background: var(--surface); color: var(--subject-5-ink); box-shadow: inset 0 0 0 1.5px var(--subject-5); }
.chip[data-subject-color="6"] { background: var(--surface); color: var(--subject-6-ink); box-shadow: inset 0 0 0 1.5px var(--subject-6); }
.chip[data-subject-color="7"] { background: var(--surface); color: var(--subject-7-ink); box-shadow: inset 0 0 0 1.5px var(--subject-7); }
.chip[data-subject-color="8"] { background: var(--surface); color: var(--subject-8-ink); box-shadow: inset 0 0 0 1.5px var(--subject-8); }
.chip[data-subject-color="9"] { background: var(--surface); color: var(--subject-9-ink); box-shadow: inset 0 0 0 1.5px var(--subject-9); }

.chip[data-subject-color="0"]:has(input:checked) { background: var(--subject-0); color: var(--subject-0-on); box-shadow: none; }
.chip[data-subject-color="1"]:has(input:checked) { background: var(--subject-1); color: var(--subject-1-on); box-shadow: none; }
.chip[data-subject-color="2"]:has(input:checked) { background: var(--subject-2); color: var(--subject-2-on); box-shadow: none; }
.chip[data-subject-color="3"]:has(input:checked) { background: var(--subject-3); color: var(--subject-3-on); box-shadow: none; }
.chip[data-subject-color="4"]:has(input:checked) { background: var(--subject-4); color: var(--subject-4-on); box-shadow: none; }
.chip[data-subject-color="5"]:has(input:checked) { background: var(--subject-5); color: var(--subject-5-on); box-shadow: none; }
.chip[data-subject-color="6"]:has(input:checked) { background: var(--subject-6); color: var(--subject-6-on); box-shadow: none; }
.chip[data-subject-color="7"]:has(input:checked) { background: var(--subject-7); color: var(--subject-7-on); box-shadow: none; }
.chip[data-subject-color="8"]:has(input:checked) { background: var(--subject-8); color: var(--subject-8-on); box-shadow: none; }
.chip[data-subject-color="9"]:has(input:checked) { background: var(--subject-9); color: var(--subject-9-on); box-shadow: none; }

/* A filled dot ahead of the label on every unselected subject chip — the same glyph a file's own
   card shows next to that subject, in the same inherited colour, so the two are a direct,
   side-by-side comparison rather than an outline colour the reader has to separately map to a
   dot elsewhere. Replaced by the ✓ below the instant the chip is checked, since a solid-fill chip
   already reads as "this colour" without needing its own swatch repeated on top of it. */
.chip[data-subject-color]::before {
    content: attr(data-mark);
    margin-inline-end: 6px;
    min-width: 13px;
    padding: 1px 3px;
    border-radius: 4px;
    font-size: 9px;
    font-weight: 700;
    line-height: 1.2;
    text-align: center;
}

/* The mark wears the same fill here as it does on a card, so the two are a direct comparison
   rather than a letter the reader has to separately map to a colour somewhere else. */
.chip[data-subject-color="0"]::before { background: var(--subject-0); color: var(--subject-0-on); }
.chip[data-subject-color="1"]::before { background: var(--subject-1); color: var(--subject-1-on); }
.chip[data-subject-color="2"]::before { background: var(--subject-2); color: var(--subject-2-on); }
.chip[data-subject-color="3"]::before { background: var(--subject-3); color: var(--subject-3-on); }
.chip[data-subject-color="4"]::before { background: var(--subject-4); color: var(--subject-4-on); }
.chip[data-subject-color="5"]::before { background: var(--subject-5); color: var(--subject-5-on); }
.chip[data-subject-color="6"]::before { background: var(--subject-6); color: var(--subject-6-on); }
.chip[data-subject-color="7"]::before { background: var(--subject-7); color: var(--subject-7-on); }
.chip[data-subject-color="8"]::before { background: var(--subject-8); color: var(--subject-8-on); }
.chip[data-subject-color="9"]::before { background: var(--subject-9); color: var(--subject-9-on); }

/* The same ✓ convention the tag editor's chips already use for "this is on". Its colour is
   whatever .chip's own `color` resolves to at that moment (inherited, not set here), which the
   rule above already switches to white the instant a subject chip is checked. */
.chip[data-subject-color]:has(input:checked)::before {
    content: "✓";
    background: none;
    color: inherit;
    margin-inline-end: 6px;
    min-width: 0;
    padding: 0;
    font-size: 12px;
    font-weight: 700;
}

.chip:has(input:focus-visible) {
    outline: 2.5px solid var(--accent);
    outline-offset: 2px;
}

/* Nothing may be hidden on the tag editor, whose entire job is showing every option — that one
   wraps instead of scrolling. */
.chips {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin: 0;
    padding: 0;
    border: 0;
}

/* --------------------------------------------------------- bulk tagging -- */

/* Selection is a mode. Entering it is a checkbox-and-label toggle, exactly like the filter row's
   "meer" reveal, so the whole feature works with no script: the mode checkbox drives the per-tile
   overlays and the bar at the foot purely through sibling selectors. */
.select-mode {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.grid-head {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 0 0 8px;
}

/* The line under the filters: how many files they leave. Set in from the edge by the same amount
   the cards are, so it lines up with the first column. */
.grid-count {
    margin: 0 0 8px;
    padding: 0 2px;
}

.select-toggle {
    display: inline-flex;
    align-items: center;
    min-height: 30px;
    padding: 0 12px;
    border-radius: var(--r-pill);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink-2);
    font-size: 13px;
    font-weight: 500;
    cursor: pointer;
    user-select: none;
}

.select-toggle:hover {
    background: var(--surface-sunk);
}

/* The way in with no script. Once bulk-select.js runs it sets .has-longpress on <html> and this
   folds down to nothing visible — but stays in the tab order and comes back the moment it takes
   keyboard focus, because press-and-hold is a gesture a keyboard cannot make. */
.select-mode:checked ~ .grid-head .select-enter {
    display: none;
}

.has-longpress .select-enter {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.has-longpress .select-enter:focus-visible {
    position: static;
    width: auto;
    height: auto;
    padding: 0 12px;
    overflow: visible;
    clip-path: none;
}

.has-longpress .grid-head {
    margin: 0;
}

/* The mode's own controls, in the bar at the foot rather than above the grid. */
.bulk-head {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 8px;
}

.select-leave {
    margin-inline-start: auto;
    background: var(--accent);
    box-shadow: none;
    color: var(--accent-ink);
}

.select-leave:hover {
    background: var(--accent-press);
}

.select-count {
    font-size: 13px;
    font-weight: 500;
    color: var(--ink-2);
}

.tile-check {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

/* Inert while browsing; in selection mode it covers the whole card - above the tile's own link and
   above the corner badges - so a tap can only ever mean "select this". */
/* A footer row across the whole foot of the card. It is a sibling of .tile rather than a child —
   neither a <button> nor an <a> may sit inside an <a> — so it is pinned to the bottom of their
   shared wrapper, and .tile carries a matching padding-bottom to keep it off the uploader line.
   Full width, so the one icon in it gets a whole card's worth of tap target. */
.tile-actions {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 34px;
    /* Below .tile-select's 3, so a tap in selection mode always means "select this". */
    z-index: 2;
    display: flex;
    /* The card clips its own corners, but this is outside it — so it has to round and clip its
       own, or a hover fill would square off the bottom two. */
    border-end-start-radius: var(--r-tile);
    border-end-end-radius: var(--r-tile);
    overflow: hidden;
    box-shadow: inset 0 1px 0 var(--hairline);
}

/* Hidden outright in selection mode. The overlay above already swallows the taps, but leaving two
   controls visible that cannot be used is the kind of thing that makes an interface feel broken. */
.select-mode:checked ~ .grid .tile-actions {
    display: none;
}

.tile-action {
    display: grid;
    place-items: center;
    /* Takes half the row. Kept as a flex child rather than a block so that a third control here
       would divide the row evenly rather than needing this rewritten. */
    flex: 1 1 0;
    /* The base button rule sets a 44px min-height and 20px of horizontal padding for a normal
       tap-sized button. Both have to be reset here or they win over the row's own height —
       padding especially, since border-box cannot shrink a box below its own padding. */
    min-height: 0;
    min-width: 0;
    padding: 0;
    border: none;
    border-radius: 0;
    /* No disc any more: these sit on the card's own surface now rather than over a photograph,
       so the glyph alone is enough and a filled circle would only add noise to a busy grid. */
    background: transparent;
    color: var(--ink-3);
    cursor: pointer;
    transition: background-color 140ms var(--ease), color 140ms var(--ease);
}

.tile-action:hover,
.tile-action:focus-visible {
    background: var(--surface-sunk);
    color: var(--ink);
}

.tile-action:active {
    background: var(--surface-press);
}

.tile-action svg {
    width: 15px;
    height: 15px;
}

/* --------------------------------------------------------------- comments -- */

/* The chat button needs a corner of its own for the count to sit in. */
.tile-comments {
    position: relative;
}

/* The number, at the top-right of the chat glyph. Logical properties rather than left/right, so
   in Arabic it mirrors to the other side of the icon with the rest of the page.
   :empty is what hides it at zero — the element is always rendered, because the out-of-band swap
   after a new comment needs something with that id to land on. */
.tile-count {
    position: absolute;
    top: 3px;
    inset-inline-start: 50%;
    margin-inline-start: 3px;
    min-width: 15px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 10px;
    font-weight: 700;
    line-height: 15px;
    text-align: center;
    font-variant-numeric: tabular-nums;
}

.tile-count:empty {
    display: none;
}

/* The whole screen, at the owner's request: a conversation wants room, and on a phone a card
   with sentences in it was a card with three sentences in it. The dialog is a column — title,
   the file's name, the list, the box — and the LIST is what scrolls, so the box to write in is
   always at the bottom where a thumb expects it. On a wide screen it is a tall panel instead.
   Two classes in the selector on purpose: .notice, further down this file, sets the width and
   height of every small dialog, and a single class written earlier would lose to it. */
.notice.comments-dialog {
    display: flex;
    flex-direction: column;
    inset: 0;
    width: 100%;
    max-width: none;
    height: 100dvh;
    max-block-size: none;
    margin: 0;
    padding: max(14px, env(safe-area-inset-top)) 16px max(12px, env(safe-area-inset-bottom));
    border-radius: 0;
    overflow: hidden;
}

.comments-dialog:not([open]) {
    display: none;
}

@media (min-width: 720px) {
    .notice.comments-dialog {
        max-width: 640px;
        height: calc(100dvh - 40px);
        margin: auto;
        border-radius: var(--r-card);
    }
}

.comments-dialog .notice-title {
    flex: 0 0 auto;
    margin-bottom: 2px;
}

#comments-body {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    min-height: 0;
}

.comments-about {
    flex: 0 0 auto;
    margin: 0 0 8px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.comment-list {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    gap: 14px;
    min-height: 0;
    margin: 4px 0 12px;
    padding: 6px 2px 6px 0;
    overflow-y: auto;
    list-style: none;
    overscroll-behavior: contain;
}

.comment {
    display: flex;
    align-items: flex-start;
    gap: 9px;
}

/* The same round mark as the account menu and the school list, at the size a line of text wants. */
.comment-mark {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 11px;
    font-weight: 700;
}

.comment-body {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
}

.comment-head {
    display: flex;
    align-items: baseline;
    gap: 8px;
    flex-wrap: wrap;
}

/* The bin, at the end of the head line and only as loud as the timestamp beside it. */
.comment-delete {
    display: inline-flex;
    margin-inline-start: auto;
    align-self: center;
}

.comment-delete-button {
    display: grid;
    place-items: center;
    width: 26px;
    height: 26px;
    min-height: 0;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: var(--ink-2);
    cursor: pointer;
}

.comment-delete-button svg {
    width: 16px;
    height: 16px;
}

.comment-delete-button:hover,
.comment-delete-button:focus-visible {
    background: var(--surface-sunk);
    color: var(--danger, #b3261e);
    outline: none;
}

.comment-author {
    font-size: 13px;
    font-weight: 600;
}

.comment-when {
    font-size: 11px;
    font-variant-numeric: tabular-nums;
}

.comment-text {
    font-size: 14px;
    line-height: 1.45;
    /* A comment is one person typing: their own line breaks are part of what they said, and a
       pasted address should wrap rather than push the dialog sideways. */
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* Your own, marked by the mark alone — no alignment flip. This is a list of notes under a file,
   not a chat, and left-and-right bubbles would say otherwise. */
.comment-mine .comment-mark {
    background: var(--accent);
    color: var(--surface);
}

.comments-empty {
    flex: 1 1 auto;
    margin: 10px 0 14px;
}

.comment-form {
    display: flex;
    flex: 0 0 auto;
    flex-direction: column;
    gap: 8px;
}

.comment-form textarea {
    width: 100%;
    padding: 9px 11px;
    border: none;
    border-radius: var(--r-inner);
    background: var(--surface-sunk);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink);
    font: inherit;
    font-size: 14px;
    line-height: 1.4;
    resize: vertical;
}

.comment-form textarea:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
}

.comment-form button {
    align-self: flex-end;
}

/* A tagged name inside a comment. A weight and a colour, not a pill: it sits mid-sentence and a
   box around it would break the line it is part of. */
.mention {
    color: var(--accent);
    font-weight: 600;
}

/* The names you can tag, under the box. Scrolls sideways rather than wrapping into a wall — a
   school of thirty would otherwise push the button off the dialog. */
.mention-chips {
    display: flex;
    align-items: center;
    gap: 6px;
    margin: 0;
    padding-bottom: 2px;
    overflow-x: auto;
    scrollbar-width: none;
}

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

.mention-hint {
    flex: 0 0 auto;
    font-size: 11px;
}

.mention-chip {
    flex: 0 0 auto;
    min-height: 0;
    padding: 3px 9px;
    font-size: 12px;
    white-space: nowrap;
}

/* ------------------------------------------------- comments on a passage -- */

/* The passages people have commented on, drawn over the words in the PDF — see pdf-viewer.js.
   Quiet on purpose: a wash of the accent and a hairline, so a page with three comments on it
   still reads as a page. The layer sits above the text layer; the boxes are buttons. */
.pdf-anchors {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
}

.pdf-anchor {
    position: absolute;
    min-width: 0;
    min-height: 0;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 3px;
    background: color-mix(in srgb, var(--accent) 16%, transparent);
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
    cursor: pointer;
    pointer-events: auto;
    transition: background-color 140ms var(--ease);
}

.pdf-anchor:hover,
.pdf-anchor:focus-visible {
    background: color-mix(in srgb, var(--accent) 30%, transparent);
    outline: none;
}

/* Two slow blinks when a comment's chip asks for its passage, then back to quiet. */
.pdf-anchor.is-flashing {
    animation: pdf-anchor-flash 900ms var(--ease) 2;
}

@keyframes pdf-anchor-flash {
    50% {
        background: color-mix(in srgb, var(--accent) 45%, transparent);
        box-shadow: 0 0 0 3px var(--accent);
    }
}

/* The words being picked — see the selection section of pdf-viewer.js. A wash over each run of
   words and a round handle hanging off either end, the way a phone draws its own selection. The
   handles are the only part that takes touches; touch-action: none there, so dragging one is
   never mistaken for a scroll. */
.pdf-selection {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
}

.pdf-selection-box {
    position: absolute;
    border-radius: 2px;
    background: color-mix(in srgb, var(--accent) 30%, transparent);
}

.pdf-selection-handle {
    position: absolute;
    width: 18px;
    height: 18px;
    margin-left: -9px;
    border-radius: 50%;
    background: var(--accent);
    box-shadow: 0 0 0 2px #ffffff, var(--lift);
    pointer-events: auto;
    touch-action: none;
    cursor: grab;
}

/* A finger is wider than the knob: the part that takes the touch is. */
.pdf-selection-handle::before {
    content: "";
    position: absolute;
    inset: -12px;
}

/* The offer that follows a pick: one small button centred under the picked words, below the
   handles. */
/* The row of tools under a picked passage: a comment to the school, the four marker colours,
   and a note — see the reader section of pdf-viewer.js. */
.pdf-selection-tools {
    position: absolute;
    z-index: 3;
    display: flex;
    align-items: center;
    gap: 6px;
    margin-top: 24px;
    padding: 4px 6px;
    border-radius: var(--r-pill);
    background: var(--surface);
    box-shadow: var(--lift), 0 0 0 1px var(--hairline);
    transform: translateX(-50%);
    white-space: nowrap;
}

.pdf-selection-tools[hidden] {
    display: none;
}

.pdf-tool {
    min-width: 0;
    min-height: 0;
    margin: 0;
    padding: 6px 12px;
    border: 0;
    border-radius: var(--r-pill);
    background: var(--accent);
    color: #ffffff;
    font: inherit;
    font-size: 13px;
    font-weight: 600;
    line-height: 1.2;
    cursor: pointer;
}

.pdf-tool-note {
    background: var(--surface-sunk);
    color: var(--ink);
}

/* A marker colour, as a round swatch. The same four the strokes are drawn in. */
.pdf-colour {
    width: 26px;
    height: 26px;
    padding: 0;
    border-radius: 50%;
    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%);
}

.pdf-colour.is-current {
    box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent);
}

.pdf-colour.is-yellow,
.pdf-mark.is-yellow {
    background: #f9e26b;
}

.pdf-colour.is-green,
.pdf-mark.is-green {
    background: #b6e6a0;
}

.pdf-colour.is-blue,
.pdf-mark.is-blue {
    background: #b3d7f7;
}

.pdf-colour.is-pink,
.pdf-mark.is-pink {
    background: #f6bdd6;
}

/* The reader's highlights: one stroke per line, multiplied over the words so the print stays
   readable through the colour. No z-index here on purpose: a z-index makes this layer its own
   stacking context, and a multiplied stroke then blends with the empty layer instead of with the
   page underneath — the words vanished under the colour. Later in the page than the canvas and
   the text layer, which is what puts it above them. */
.pdf-marks {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.pdf-mark {
    position: absolute;
    min-width: 0;
    min-height: 0;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 2px;
    mix-blend-mode: multiply;
    cursor: pointer;
    pointer-events: auto;
}

.pdf-mark:hover,
.pdf-mark:focus-visible {
    filter: brightness(0.9);
}

/* The dot at the end of a highlight that carries a note. */
.pdf-mark-note {
    position: absolute;
    width: 12px;
    height: 12px;
    /* On the stroke's own top-right corner rather than past it, so it never sits on the next word. */
    margin: -7px 0 0 -7px;
    border-radius: 50%;
    background: var(--accent);
    box-shadow: 0 0 0 2px var(--surface);
}

/* The bookmark ribbon in the top corner of every page: faint until it is on, or hovered. */
.pdf-bookmark {
    position: absolute;
    top: -1px;
    right: 14px;
    z-index: 2;
    width: 22px;
    height: 32px;
    min-width: 0;
    min-height: 0;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: color-mix(in srgb, var(--ink-3) 45%, transparent);
    clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 72%, 0 100%);
    cursor: pointer;
    opacity: 0.55;
    transition: opacity 140ms var(--ease), background-color 140ms var(--ease);
}

[dir="rtl"] .pdf-bookmark {
    right: auto;
    left: 14px;
}

.pdf-page:hover .pdf-bookmark,
.pdf-bookmark:focus-visible,
.pdf-bookmark.is-on {
    opacity: 1;
}

.pdf-bookmark.is-on {
    background: var(--accent);
}

/* The row over the top of the viewer: search and the list on the left, the brand mark in the
   middle, download, share and the way out on the right. Stuck to the top of the scrolling viewer
   and laid OVER the pages — a sticky box of no height, its contents hung below it — so that fading
   it out leaves no empty band behind. The pages start a row's height down, once. */
.pdf-toolbar {
    position: sticky;
    top: 0;
    z-index: 5;
    height: 0;
}

.pdf-toolbar-inner {
    position: absolute;
    top: 0;
    inset-inline: 0;
    padding: 6px 10px;
    background: color-mix(in srgb, var(--surface-sunk) 94%, transparent);
    transition: opacity 220ms var(--ease), transform 220ms var(--ease);
}

.pdf-toolbar.is-hidden .pdf-toolbar-inner {
    opacity: 0;
    transform: translateY(-8px);
    pointer-events: none;
}

.pdf-viewer.has-toolbar .pdf-pages {
    padding-top: 54px;
}

.pdf-toolbar-row {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    gap: 6px;
}

.pdf-toolbar-left,
.pdf-toolbar-right {
    display: flex;
    align-items: center;
    gap: 6px;
}

.pdf-toolbar-left {
    justify-self: start;
}

.pdf-toolbar-right {
    justify-self: end;
}

.pdf-toolbar-brand {
    display: block;
    width: 30px;
    height: 30px;
    justify-self: center;
    color: var(--accent);
}

.pdf-toolbar-brand svg {
    display: block;
    width: 100%;
    height: 100%;
}

/* In the quick look the viewer runs to the very top of the screen, so the row gives way to a
   phone's notch, and the dialog's own three buttons step aside for the row that now carries them. */
.lightbox .pdf-toolbar-inner {
    padding-top: max(6px, env(safe-area-inset-top));
}

.lightbox:has(.lightbox-pdf:not([hidden])) .lightbox-tools {
    display: none;
}

.pdf-tool-icon {
    display: grid;
    place-items: center;
    box-sizing: border-box;
    text-decoration: none;
    width: 34px;
    height: 34px;
    min-width: 0;
    min-height: 0;
    flex: 0 0 auto;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--surface);
    box-shadow: 0 0 0 1px var(--hairline);
    color: var(--accent);
    cursor: pointer;
}

.pdf-tool-icon svg {
    width: 20px;
    height: 20px;
}

.pdf-tool-icon.is-on {
    background: var(--accent);
    box-shadow: none;
    color: #ffffff;
}

/* Search in the document: a field, the count, and a step either way. */
.pdf-search {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 8px 0 2px;
}

.pdf-search[hidden] {
    display: none;
}

.pdf-search-input {
    flex: 1 1 auto;
    min-width: 0;
    padding: 7px 12px;
    border: 1px solid var(--hairline-strong);
    border-radius: var(--r-pill);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    font-size: 14px;
}

.pdf-search-count {
    flex: 0 0 auto;
    color: var(--ink-2);
    font-size: 12px;
    white-space: nowrap;
}

.pdf-search-nav {
    flex: 0 0 auto;
    width: 30px;
    height: 30px;
    min-width: 0;
    min-height: 0;
    margin: 0;
    padding: 0 0 2px;
    border: 0;
    border-radius: 50%;
    background: var(--accent-wash);
    color: var(--accent);
    font: inherit;
    font-size: 18px;
    line-height: 1;
    cursor: pointer;
}

.pdf-search-count[hidden],
.pdf-search-nav[hidden] {
    display: none;
}

/* The list behind the list button: the bookmarked pages as chips, then every highlight with its
   colour, page, words and note. */
.pdf-marks-panel {
    padding: 4px 0 6px;
}

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

.pdf-panel-heading {
    margin: 8px 0 4px;
    color: var(--ink-2);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

.pdf-panel-empty {
    margin: 0;
    color: var(--ink-3);
    font-size: 13px;
}

.pdf-panel-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

.pdf-panel-mark {
    display: flex;
    align-items: flex-start;
    /* Against the start edge: the base button rule centres its content, which put a short entry
       in the middle of its card and a long one against the edge. */
    justify-content: flex-start;
    gap: 10px;
    width: 100%;
    max-width: 100%;
    min-height: 0;
    margin: 0 0 4px;
    padding: 8px 10px;
    border: 0;
    border-radius: var(--r-inner);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    text-align: start;
    white-space: normal;
    cursor: pointer;
}

.pdf-panel-dot {
    flex: 0 0 auto;
    width: 12px;
    height: 12px;
    margin-top: 4px;
    border-radius: 50%;
    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%);
}

.pdf-panel-dot.is-yellow { background: #f9e26b; }
.pdf-panel-dot.is-green { background: #b6e6a0; }
.pdf-panel-dot.is-blue { background: #b3d7f7; }
.pdf-panel-dot.is-pink { background: #f6bdd6; }

.pdf-panel-body {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

.pdf-panel-page {
    color: var(--ink-2);
    font-size: 12px;
}

.pdf-panel-quote {
    overflow: hidden;
    font-size: 13px;
    font-style: italic;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
}

.pdf-panel-note {
    color: var(--ink-2);
    font-size: 13px;
}

/* The matches, in orange, the current one darker. No z-index on the layer, for the same reason
   as .pdf-marks: multiply has to see the page. */
.pdf-finds {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.pdf-find {
    position: absolute;
    border-radius: 2px;
    background: #ffd166;
    mix-blend-mode: multiply;
}

.pdf-find.is-current {
    background: #ffa030;
    box-shadow: 0 0 0 2px rgb(255 140 0 / 60%);
}

/* A bookmarked page, as a chip in the list. */
.pdf-bookmark-chip {
    min-width: 0;
    min-height: 0;
    margin: 0;
    padding: 4px 10px;
    border: 0;
    border-radius: var(--r-pill);
    background: var(--accent-wash);
    color: var(--accent);
    font: inherit;
    font-size: 13px;
    font-weight: 600;
    line-height: 1.2;
    cursor: pointer;
}

/* The card under a highlight: colour, the words, the note, and the way out. */
.pdf-mark-popover {
    position: absolute;
    left: 50%;
    z-index: 4;
    width: min(320px, 92%);
    margin-top: 8px;
    padding: 10px 12px 12px;
    border-radius: var(--r-inner);
    background: var(--surface);
    box-shadow: var(--lift), 0 0 0 1px var(--hairline);
    text-align: start;
    transform: translateX(-50%);
}

.pdf-popover-colours {
    display: flex;
    gap: 8px;
    margin-bottom: 8px;
}

.pdf-popover-quote {
    max-height: 3.6em;
    margin: 0 0 8px;
    overflow: hidden;
    font-style: italic;
}

.pdf-mark-popover textarea {
    box-sizing: border-box;
    width: 100%;
    padding: 8px;
    border: 1px solid var(--hairline-strong);
    border-radius: 8px;
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    font-size: 14px;
    resize: vertical;
}

.pdf-popover-private {
    margin: 6px 0 8px;
}

.pdf-popover-actions {
    display: flex;
    align-items: center;
    gap: 12px;
}

/* On a comment: the passage it is about, set the way a quotation is set — a bar down the side,
   the page in small capitals above, the words in italics, a few lines at most. A button, so that
   tapping it closes this dialog and scrolls the viewer to the passage. */
.comment-anchor {
    /* A block, not the flex every button is: a flex item cannot be clamped to three lines, and
       the base button rule's nowrap is undone here so the words can wrap at all. */
    display: block;
    width: 100%;
    max-width: 100%;
    margin: 4px 0 6px;
    padding: 6px 12px 7px;
    border: 0;
    border-inline-start: 3px solid var(--accent);
    border-radius: 0 var(--r-inner) var(--r-inner) 0;
    background: var(--accent-wash);
    color: var(--ink);
    font: inherit;
    line-height: 1.4;
    white-space: normal;
    cursor: pointer;
    text-align: start;
}

.comment-anchor:hover,
.comment-anchor:focus-visible {
    background: color-mix(in srgb, var(--accent) 18%, transparent);
    outline: none;
}

.comment-anchor-page {
    display: block;
    margin-bottom: 2px;
    color: var(--accent);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

.comment-anchor-quote {
    display: -webkit-box;
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    font-size: 13.5px;
    font-style: italic;
    color: var(--ink-2);
    overflow-wrap: anywhere;
}

/* The same quotation marks on every one, in the reader's own language's shape. */
.comment-anchor-quote::before {
    content: open-quote;
}

.comment-anchor-quote::after {
    content: close-quote;
}

/* Above the box, while a comment on a passage is being written: the same quotation, with a way
   to let go of it. */
.comment-anchor-pending {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    margin: 0;
    padding: 6px 6px 7px 12px;
    border-inline-start: 3px solid var(--accent);
    border-radius: 0 var(--r-inner) var(--r-inner) 0;
    background: var(--accent-wash);
    color: var(--ink);
    line-height: 1.4;
}

.comment-anchor-pending[hidden] {
    display: none;
}

.comment-anchor-pending-text {
    display: block;
    flex: 1 1 auto;
    min-width: 0;
}

.comment-anchor-clear {
    flex: 0 0 auto;
    width: 24px;
    height: 24px;
    min-height: 0;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: inherit;
    font-size: 18px;
    line-height: 1;
    cursor: pointer;
}

/* The comment a passage was tapped for, once the dialog has scrolled to it. */
.comment.is-flashing {
    animation: comment-flash 900ms var(--ease) 2;
    border-radius: var(--r-inner);
}

@keyframes comment-flash {
    50% {
        background: var(--accent-wash);
    }
}

/* ---------------------------------------------------------- notifications -- */

/* The unread count on the bell: the points badge's shape, in the accent rather than gold, so the
   two numbers on the bar cannot be mistaken for each other. Empty at zero and hidden by :empty. */
.nav-unread {
    position: absolute;
    top: -4px;
    left: 50%;
    transform: translateX(2px);
    min-width: 15px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--accent);
    color: #ffffff;
    font-size: 10px;
    font-weight: 700;
    line-height: 15px;
    text-align: center;
    box-shadow: 0 0 0 2px var(--surface);
}

.nav-unread:empty {
    display: none;
}

.notifications-tools {
    display: flex;
    justify-content: flex-end;
    margin: 0 0 6px;
}

/* A line is a form with one button that is the whole row — the button reset here is what lets
   it borrow .group-row's layout and read as a row rather than as a button. */
.notification form {
    margin: 0;
}

.notification-row {
    width: 100%;
    min-height: 0;
    border: 0;
    border-radius: 0;
    background: transparent;
    font: inherit;
    text-align: start;
    cursor: pointer;
}

.notification-row .group-body {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
}

.notification-text {
    font-size: 14px;
    line-height: 1.35;
}

.notification-excerpt {
    font-size: 12.5px;
    line-height: 1.35;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.notification-when {
    font-size: 11px;
    font-variant-numeric: tabular-nums;
}

/* Unread: the sentence in weight, and a dot on the mark. Read lines fade a little rather than
   vanish, so the page stays a record and not just an inbox. */
.notification.is-unread .notification-text {
    font-weight: 600;
}

.notification.is-unread .group-avatar {
    box-shadow: 0 0 0 2px var(--accent);
}

.notification:not(.is-unread) .notification-row {
    opacity: 0.72;
}

/* The subscribe switch on a school's page: one sentence of state and one button. */
.setting-state {
    flex: 1 1 auto;
    min-width: 0;
    font-size: 13px;
    color: var(--ink-2);
}

/* The chat button on the file page: the same count badge a tile wears, on a round action. */
.icon-action.tile-comments {
    position: relative;
}

.icon-action.tile-comments .tile-count {
    top: -4px;
    inset-inline-start: auto;
    inset-inline-end: -6px;
    margin-inline-start: 0;
}

.comments-file {
    margin: 12px 0 0;
    padding-top: 10px;
    border-top: 1px solid var(--hairline);
    text-align: center;
}

/* The heart. Outlined while the file is in somebody else's school and nothing more; filled and in
   the heart colour once it is in yours. Colour is not the only channel — the shape fills too —
   because a muted outline and a muted fill are the same thing to a lot of eyes. */
.tile-favourite.on {
    color: var(--heart);
}

.tile-favourite.on:hover,
.tile-favourite.on:focus-visible {
    color: var(--heart-press);
}

.tile-select {
    display: none;
    position: absolute;
    inset: 0;
    z-index: 3;
    border-radius: var(--r-tile);
    cursor: pointer;
}

.select-mode:checked ~ .grid .tile-select {
    display: block;
}

/* A selected card gets a ring and a tick. Ring on .tile rather than the wrapper so it follows the
   card's own rounded corners. */
.tile-wrap:has(.tile-check:checked) .tile {
    box-shadow: inset 0 0 0 2px var(--accent);
}

.tile-wrap:has(.tile-check:checked) .tile-select::after {
    content: "✓";
    position: absolute;
    inset-inline-start: 6px;
    bottom: 6px;
    display: grid;
    place-items: center;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-ink);
    font-size: 11px;
    font-weight: 700;
}

/* Only present in selection mode, and fixed to the foot where the thumb already is. */
.bulk-bar {
    display: none;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    /* Above the tab bar rather than below it. Both are fixed to the foot of the window, and the two
       rules that hide the tab bar during this mode are the belt; this is the braces. If either ever
       fails to apply, the worst case is a tab bar covered by the bar the reader is using, instead
       of a row of tags and a delete button buried under something they cannot move. */
    z-index: 7;
    padding: 10px 16px max(12px, calc(env(safe-area-inset-bottom) + 10px));
    background: var(--surface);
    box-shadow: 0 -2px 4px rgb(0 0 0 / 6%), 0 -8px 24px rgb(0 0 0 / 10%);
}

.select-mode:checked ~ .bulk-bar {
    display: block;
}

.bulk-hint {
    margin: 0 0 8px;
    font-size: 12px;
    color: var(--ink-2);
}

.bulk-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
    /* Long vocabularies stay reachable without the bar swallowing the screen. */
    max-height: 96px;
    overflow-y: auto;
}

/* The add button would sit on top of the bar, and adding a file is not what this mode is for. */
body:has(.select-mode:checked) .fab,
.is-selecting .fab {
    display: none;
}

/* And so does the navigation bar, for the same reason and one more: both are fixed to the bottom
   of the window, and the tab bar is drawn on top, so leaving it there would bury the row of tags
   and the delete button under it. Selecting files is a mode you finish or leave — the way out is
   the button in the grid header, not a tab. */
body:has(.select-mode:checked) .bottom-nav,
.is-selecting .bottom-nav {
    display: none;
}

/* ------------------------------------------------------------------ grid -- */

.grid {
    display: grid;
    /* Exactly three, always — not "as many as fit". minmax(0, 1fr) rather than plain 1fr keeps
       an unbreakable long word from forcing a column wider than its share and pushing the row
       into horizontal scroll. */
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 8px;
    margin: 0;
    padding: 0;
    list-style: none;
}

/* Anchors the "!" badge (a sibling of .tile, not a child — see the markup comment) to the same
   box the tile itself occupies, since the badge can't rely on .tile's own position: relative. */
.tile-wrap {
    position: relative;
}

/* A file with no trusted age tag yet gets its own small marker, in the one corner a thumbnail is
   least likely to have anything important — replaces the single grid-wide "N bestanden zonder
   leeftijd" line this used to be, which cost a line of height on every visit regardless of
   whether it applied to anything still in view. */
.tile-warn {
    position: absolute;
    top: 5px;
    inset-inline-end: 5px;
    z-index: 2;
    width: 16px;
    height: 16px;
    /* The base button rule elsewhere sets a 44px min-height and 20px horizontal padding for a
       normal tap-sized button — both need resetting here, or they win over the width/height
       above (padding especially: border-box can't shrink below its own padding, so an unset
       padding this large would force the box wider than the 16px just set). */
    min-height: 0;
    min-width: 0;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--danger);
    color: var(--danger-ink);
    font: inherit;
    font-size: 10px;
    font-weight: 700;
    line-height: 1;
    display: grid;
    place-items: center;
    cursor: pointer;
    box-shadow: 0 1px 3px rgb(0 0 0 / 25%);
}

/* Reserves room in the header strip on exactly the cards that also carry a corner badge, so a
   long run of subject dots can't grow underneath it — :has() reaches from the shared <li> into
   the tile's own header, since the badge itself sits outside .tile. */
.tile-wrap:has(.tile-warn) .tile-header,
.tile-wrap:has(.tile-ages) .tile-header {
    padding-inline-end: 22px;
}

/* The third thing that can occupy a card's top-right corner, after the age pill and the "!".
   Shown when a file carries more than one age band: naming the first and hiding the others made
   the card state something false, and two pills do not fit in a 100px-wide corner. Tapping it
   opens the full list — see tile-badges.js. It stands in for age pills, so it wears age's green:
   green rather than danger red because more than one age is a fact about the file, not a problem
   with it. */
.tile-ages {
    position: absolute;
    top: 5px;
    inset-inline-end: 5px;
    z-index: 2;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    /* Same reset as .tile-warn: the base button rule's 44px min-height and 20px padding would
       otherwise win over the size set here. */
    min-height: 0;
    min-width: 0;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--surface-sunk);
    color: var(--ink-2);
    cursor: pointer;
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
}

.tile-ages svg {
    width: 10px;
    height: 10px;
}

/* The chips inside the dialog are read-only facts, like the file page's own label row. */
/* The bands on offer inside the "geen leeftijd" dialog. The same chips as everywhere else, wrapped
   rather than clipped: this row is the whole point of the dialog, so nothing in it may be one
   scroll away. */
.age-pick {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin: 12px 0 0;
}

.age-pick .chip {
    flex: 0 0 auto;
}

.age-pick-note {
    margin: 12px 0 0;
}

.age-list-values {
    margin-top: 10px;
}

.age-list-values .chip {
    cursor: default;
    background: var(--surface-sunk);
    box-shadow: none;
    color: var(--ink-2);
    font-weight: 600;
}

.tile {
    /* iOS answers a long press on a link with its own preview-and-share bubble, and both platforms
       offer to select the text under the finger. Either would land on top of the press-and-hold
       gesture that starts selection mode (see bulk-select.js). */
    -webkit-touch-callout: none;
    user-select: none;

    /* A real bug this used to have without min-width: a grid item's automatic minimum width is
       its content's min-content size, not zero — an unbroken filename like
       "groepen_groep5_thewerkboekjes_week12" has no space in it to wrap on, so without this the
       tile itself was forced wider than its grid column to fit that one word, and spilled into
       the next column. Real filenames off WhatsApp are exactly this shape far more often than
       the short, spaced-out titles this looked fine with in testing. */
    position: relative;
    display: flex;
    flex-direction: column;
    min-width: 0;
    height: 100%;
    /* No padding on the image itself: the cover runs edge to edge and is clipped by the card's
       own radius. Age and subject sit in their own header strip above it instead of floating on
       top of the picture, so they never sit over unpredictable photo content. */
    overflow: hidden;
    border-radius: var(--r-tile);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: inherit;
    /* Room at the foot for .tile-actions, which is a sibling and cannot take up flow space of its
       own. This is what makes the card a little taller than it used to be. */
    padding-bottom: 34px;
    transition: transform 200ms var(--ease);
}

.tile:hover {
    text-decoration: none;
}

.tile:active {
    transform: scale(0.975);
}

.thumb {
    /* Used to run edge to edge, on the theory that the tile's own rounded corners would clip it
       for free. They do clip it, but a child paints above its parent's box-shadow regardless —
       so the image sat on top of the tile's hairline border instead of being framed by it. An
       explicit inset margin and its own smaller radius means the border is never covered. */
    display: block;
    width: calc(100% - 12px);
    margin: 6px 6px 0;
    aspect-ratio: 1;
    border-radius: 8px;
    background: var(--surface-sunk);
    object-fit: cover;
    /* Crop from the top, not the middle. Almost everything here is a portrait page, and the top
       of a worksheet is where its title is — a centred crop throws away the one part that tells
       you which worksheet you are looking at. */
    object-position: top center;
}

/* A file with no cover still needs to be recognisable at a glance. */
span.thumb {
    display: grid;
    place-items: center;
    background: linear-gradient(160deg, var(--surface-sunk), var(--surface-press));
}

span.thumb::after {
    content: "BESTAND";
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.1em;
    color: var(--ink-3);
}

span.thumb[data-kind="pdf"]::after { content: "PDF"; }
span.thumb[data-kind="video"]::after { content: "VIDEO"; }
span.thumb[data-kind="audio"]::after { content: "AUDIO"; }
span.thumb[data-kind="image"]::after { content: "FOTO"; }

/* Age and subject are the whole reason to filter at all, and were invisible until a file was
   opened. One small badge on the corner of the cover fixes that without a second line of chrome
   under every card. Its colours are fixed, not tokens: it sits on top of unpredictable photo
   content rather than app chrome, so it needs to stay legible against whatever is underneath —
   worksheet, photo, drawing — regardless of the site's own light or dark theme. */
/* A real strip above the picture, not a badge floating on top of it — a floating badge has no
   reliable background to sit on (the photo underneath could be anything, light or dark), and
   sitting over the top of a worksheet is exactly the "obstructing the bitmap" this replaced. On
   its own card background, each tag can use real theme tokens instead of a fixed dark scrim. */
/* A fixed min-height rather than collapsing when empty (was display:none) — a tagged card
   sitting next to an untagged one in the same row must not push its own thumbnail down further
   than its neighbour's, or the row stops lining up. */
/* One line, always. Wrapping made this card's header taller than its neighbours' and knocked its
   thumbnail out of alignment across the row; tile-marks.js collapses the overflow into a "+3"
   counter instead. overflow: hidden is the no-script floor — the row clips rather than growing. */
.tile-header {
    display: flex;
    flex-wrap: nowrap;
    /* Centred rather than flex-start: dots are much shorter than the age pill and looked stranded
       against its top edge instead of sitting level with it. */
    align-items: center;
    gap: 5px;
    min-height: 24px;
    padding: 6px 6px 0;
    overflow: hidden;
}

/* Pushed to the right end of the row, so it occupies the same top-right corner as the "!" badge —
   the two never appear together, since a file either has a trusted age or needs one. It also holds
   its width, so the dots are what give way: an ellipsised "12–1…" is worse than a "+2". */
.tile-header .tile-tag-age {
    flex: 0 0 auto;
    margin-inline-start: auto;
}

.tile-mark.is-hidden {
    display: none;
}

/* The same letter a card shows, on the screen where the vocabulary is edited. Neutral here on
   purpose: this list is about names and order, and ten tinted badges would turn a settings screen
   into a colour chart. */
.tag-mark {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 20px;
    height: 20px;
    flex: 0 0 auto;
    padding: 0 5px;
    border-radius: 6px;
    background: var(--surface-sunk);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink-2);
    font-size: 11px;
    font-weight: 700;
    line-height: 1;
}

.tile-mark-more {
    flex: 0 0 auto;
    font-size: 9px;
    font-weight: 700;
    line-height: 1;
    letter-spacing: -0.02em;
    color: var(--ink-3);
    white-space: nowrap;
}

.tile-tag {
    max-width: 100%;
    padding: 2px 7px;
    border-radius: var(--r-pill);
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 0.02em;
    line-height: 1.5;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Subject, unlike age, has no fixed small domain to spell out in full on every card — a group's
   vocabulary can run long, and most files carry more than one. So each confirmed subject shows a
   one- or two-letter mark instead: "A" for Aardrijkskunde, near enough to free in width, and
   readable without depending on ten muted hues being told apart at this size. Its colour is now a
   second channel rather than the only one. The full name lives in the filter chip that carries
   the same mark, and in this element's own title/aria-label. */
.tile-mark {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 15px;
    height: 15px;
    padding: 0 3px;
    flex: 0 0 auto;
    border-radius: 5px;
    font-size: 9px;
    font-weight: 700;
    line-height: 1;
    letter-spacing: 0.01em;
}

.tile-mark[data-subject-color="0"] { background: var(--subject-0); color: var(--subject-0-on); }
.tile-mark[data-subject-color="1"] { background: var(--subject-1); color: var(--subject-1-on); }
.tile-mark[data-subject-color="2"] { background: var(--subject-2); color: var(--subject-2-on); }
.tile-mark[data-subject-color="3"] { background: var(--subject-3); color: var(--subject-3-on); }
.tile-mark[data-subject-color="4"] { background: var(--subject-4); color: var(--subject-4-on); }
.tile-mark[data-subject-color="5"] { background: var(--subject-5); color: var(--subject-5-on); }
.tile-mark[data-subject-color="6"] { background: var(--subject-6); color: var(--subject-6-on); }
.tile-mark[data-subject-color="7"] { background: var(--subject-7); color: var(--subject-7-on); }
.tile-mark[data-subject-color="8"] { background: var(--subject-8); color: var(--subject-8-on); }
.tile-mark[data-subject-color="9"] { background: var(--subject-9); color: var(--subject-9-on); }

/* A plain neutral pill, like any other fact-badge on a card. */
.tile-tag-age {
    background: var(--surface-sunk);
    color: var(--ink-2);
}

.tile-title {
    /* One line, not two — a raw filename is rarely worth a second line of vertical space, and
       the thumbnail is doing more of the identifying work now (see the top-third crop below).
       Real names, not a real title, is the actual content here until something upstream of this
       page can generate better ones; asking for less room to show them is the honest fix. */
    padding: 6px 8px 0;
    min-width: 0;
    font-size: 11px;
    font-weight: 400;
    line-height: 1.25;
    letter-spacing: -0.004em;
    display: -webkit-box;
    -webkit-line-clamp: 1;
    -webkit-box-orient: vertical;
    overflow: hidden;
    overflow-wrap: anywhere;
}

.tile-by {
    padding: 1px 8px 7px;
    font-size: 10px;
    color: var(--ink-3);
}

.empty {
    padding: 44px 20px;
    border-radius: var(--r-card);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    text-align: center;
    color: var(--ink-2);
}

/* ----------------------------------------------------------------- sheet -- */

.sheet {
    display: flex;
    flex-direction: column;
    gap: 18px;
}

.sheet-head {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 14px 0 4px;
}

.sheet-head h1 {
    flex: 1;
    min-width: 0;
}

/* Leaves the sheet whatever has happened. Quiet next to "Klaar" — one of them is the finish and
   the other is the escape, and they should not compete. */
/* Red, and half again the size of an ordinary icon button. It is the only control in this header
   and a 20px grey disc beside a heading was being read as decoration rather than as the way out.
   Not a destructive button - nothing already uploaded is lost by pressing it - but it does have to
   be findable at a glance. */
/* The cross now lives in a form, which is a block: without this it would push the heading onto
   its own line. */
.sheet-cancel {
    order: -1;
    display: contents;
}

.sheet-close {
    order: -1;
    width: 30px;
    height: 30px;
    /* Both resets matter now that this is sometimes a <button>: the shared button rule gives every
       one of them 44px of height and 20px of side padding, and 40px of padding inside a 30px
       border-box left no room at all for the cross - the circle came out empty. */
    min-height: 0;
    padding: 0;
    background: var(--danger);
    color: var(--danger-ink);
}

.sheet-close:hover {
    filter: brightness(1.08);
}

.sheet-close svg {
    display: block;
    width: 15px;
    height: 15px;
}

/* Not a real control yet. Kept in the layout rather than hidden, so the header does not resize the
   moment the first file lands. */
.button.is-disabled {
    background: var(--surface-sunk);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink-3);
    cursor: default;
    pointer-events: none;
}

#upload {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
}

.picker {
    display: grid;
    place-items: center;
    min-height: 116px;
    padding: 16px;
    border-radius: var(--r-card);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    text-align: center;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    transition: transform 140ms var(--ease), background-color 140ms var(--ease);
}

.picker:hover {
    background: var(--surface-sunk);
}

.picker:active {
    transform: scale(0.975);
}

.picker input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
}

#uploading {
    visibility: hidden;
    color: var(--ink-2);
    font-size: 15px;
}

/* htmx carries this class on the indicator for the life of the request. */
#uploading.htmx-request {
    visibility: visible;
}

.added-list {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: 0;
    list-style: none;
    border-radius: var(--r-card);
    overflow: hidden;
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
}

.added-list:empty {
    display: none;
}

/* Hairline between rows, never below the last one — the grouped-list detail. */
/* A column now, not a row: the file itself is one line and its own labels sit under it. */
.added {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    padding: 10px 13px;
    box-shadow: inset 0 -1px 0 var(--hairline);
}

.added:last-child {
    box-shadow: none;
}

.added img,
.added .thumb.small {
    width: 42px;
    height: 42px;
    flex: 0 0 auto;
    border-radius: 9px;
    aspect-ratio: auto;
}

.added a {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 15px;
    font-weight: 600;
    color: var(--ink);
}

.added .muted {
    flex: 0 0 auto;
    font-size: 13px;
    color: var(--ink-3);
}

/* What the file actually carries now, in place of the "no label yet" note. */
.added-labels {
    flex: 0 0 auto;
    max-width: 45%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 12px;
    color: var(--ink-2);
}

.same-labels-toggle {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

/* A checkbox drawn by hand, because the native one cannot be sized or coloured and this sits in a
   row of text at 13px. */
.same-labels.is-empty {
    display: none;
}

.same-labels {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 2px 0 8px;
    font-size: 13px;
    color: var(--ink-2);
    cursor: pointer;
    user-select: none;
}

.same-labels-box {
    display: inline-grid;
    place-items: center;
    width: 18px;
    height: 18px;
    flex: 0 0 auto;
    border-radius: 5px;
    background: var(--surface);
    box-shadow: inset 0 0 0 1.5px var(--hairline-strong);
}

.same-labels-toggle:checked ~ .same-labels .same-labels-box {
    background: var(--accent);
    box-shadow: none;
}

.same-labels-toggle:checked ~ .same-labels .same-labels-box::after {
    content: "✓";
    color: var(--accent-ink);
    font-size: 11px;
    font-weight: 700;
}

.same-labels-toggle:focus-visible ~ .same-labels .same-labels-box {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* The two states of the sheet. Same labels for everything: the row under the list does the work
   and the per-file rows are out of the way. Unchecked: the opposite. */
.same-labels-toggle:checked ~ #added .row-tags {
    display: none;
}

.same-labels-toggle:not(:checked) ~ #add-tagger {
    display: none;
}

.row-tags {
    margin: 6px 0 0 52px;
}

.row-tags summary {
    font-size: 12px;
    color: var(--accent);
    cursor: pointer;
}

.row-tag-form {
    margin-top: 6px;
}

.added-line {
    display: flex;
    align-items: center;
    gap: 12px;
    width: 100%;
    min-width: 0;
}

/* Sits under the list, so it reads as "and now label these" rather than as another upload
   control. Never blocking: Klaar stays available whether anything here is tapped or not. */
.add-tagger {
    margin-top: 4px;
}

.add-tagger p {
    margin: 0 0 8px;
}

.add-tag-form .bulk-tags {
    max-height: none;
}

/* ------------------------------------------------------------ file page -- */

.back {
    display: inline-block;
    margin: 2px 0 4px;
    font-size: 15px;
    font-weight: 500;
}

/* A heading that is also the way back. The arrow is set in its own hanging position so the words
   still line up with everything under them rather than being pushed across by it. */
.title-back {
    margin-bottom: 2px;
}

.title-back a {
    color: inherit;
    text-decoration: none;
}

.title-back a span {
    margin-inline-end: 6px;
    color: var(--accent);
    font-weight: 600;
}

.title-back a:hover span {
    color: var(--accent-press);
}

.file h1 {
    margin-bottom: 6px;
}

/* Attribution, not a headline — small and close under the title, taking as little of a phone
   screen as it can while staying readable. */
.file .byline {
    margin-top: 2px;
    font-size: 11px;
    line-height: 1.3;
    color: var(--ink-3);
    font-variant-numeric: tabular-nums;
}

/* Square corners and a real border. What sits in here is a sheet of paper — a worksheet, a scan, a
   PDF page — and paper has corners; rounding them made the page read as a UI card rather than as
   the document itself. The border is --hairline-strong rather than the usual --hairline because
   the content is very often white on a near-white surface, where the lighter line disappears and
   the page has no edge at all. */
.file .preview {
    margin: 12px 0 8px;
    border: 1px solid var(--hairline-strong);
    overflow: hidden;
    background: var(--surface);
}

/* The grid's thumbnail styling insets itself and rounds its own corners, which is right on a card
   and wrong inside this frame — here the content should meet the border. */
.file .preview .thumb {
    width: 100%;
    margin: 0;
    border-radius: 0;
}

.file .preview img {
    display: block;
    width: 100%;
    height: auto;
    max-height: 62vh;
    object-fit: contain;
}

/* A PDF gets the document itself rather than a picture of its first page. Tall on purpose: this
   is the main thing on the page, and the viewer supplies its own scrolling inside. */

.file .preview .thumb.large {
    aspect-ratio: 4 / 3;
}

.actions {
    display: flex;
    gap: 10px;
}

.actions .button,
.actions button {
    flex: 1 1 auto;
}

/* The button that is not the primary one should not compete with it. */
.actions button[data-share] {
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
    color: var(--accent);
}

.actions button[data-share]:hover {
    background: var(--surface-sunk);
}

.chips-row {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin: 8px 0 0;
    padding: 0;
    list-style: none;
}

.chips-row .chip {
    cursor: default;
    background: var(--accent-wash);
    box-shadow: none;
    color: var(--accent);
    font-weight: 600;
}

/* A suggestion nobody has tapped. Visibly provisional, because it is not being filtered on. */
.chips-row .chip.suggested {
    background: transparent;
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
    color: var(--ink-3);
    font-weight: 500;
}

/* Icon-only, matching the height of the label chips beside it so the row stays level. The words
   live on in aria-label/title — see the markup — so the control is still named for a screen reader
   and on hover. */
.chips-row .chip.edit {
    width: 28px;
    padding: 0;
    justify-content: center;
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
    color: var(--accent);
    cursor: pointer;
}

.chips-row .chip.edit svg {
    width: 14px;
    height: 14px;
}

.chips-row .chip.edit:hover {
    background: var(--surface-sunk);
    text-decoration: none;
}

/* The strip that turns a shared link into a new member. */
.from-group {
    margin-top: 28px;
    padding: 20px;
    border-radius: var(--r-card);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    text-align: center;
    font-size: 15px;
    color: var(--ink-2);
}

.related {
    margin-top: 36px;
}

.related .grid {
    grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
}

/* ----------------------------------------------------------- group page -- */

.section-label {
    margin: 30px 0 8px;
    padding-inline-start: 4px;
}

.card {
    padding: 18px;
    border-radius: var(--r-card);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
}

.invite .muted {
    margin-bottom: 14px;
    font-size: 15px;
}

/* The two cards on the group screen each end in one action, and they read as a pair — so they
   get the same full-width button rather than one full and one shrunk to its text. */
.invite button,
.vocab .button {
    width: 100%;
}

.vocab .muted {
    margin-bottom: 14px;
    font-size: 15px;
}

/* Four digits, read out loud over WhatsApp — so they are set wide apart and in a face where a 1
   cannot be mistaken for a 7. */
.pin-input {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
    letter-spacing: 0.35em;
    text-align: center;
}

/* The wide tracking is for four digits, not for a sentence — a spaced-out placeholder reads as
   broken text rather than as a hint. */
.pin-input::placeholder {
    letter-spacing: normal;
}

.pin-display {
    margin: 0 0 12px;
    padding: 12px;
    border-radius: var(--r-inner);
    background: var(--surface-sunk);
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
    font-size: 30px;
    font-weight: 700;
    letter-spacing: 0.35em;
    text-align: center;
    /* The letter-spacing adds a gap after the last digit too, which would sit the number visibly
       off-centre without this. */
    text-indent: 0.35em;
    color: var(--ink);
}

.pin-card .muted {
    margin-bottom: 14px;
    font-size: 15px;
}

.pin-change {
    display: flex;
    gap: 8px;
}

.pin-change .pin-input {
    flex: 1;
    min-width: 0;
}

.invite-url {
    margin-bottom: 12px;
    background: var(--surface-sunk) !important;
    box-shadow: none !important;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
    font-size: 13px;
    color: var(--ink-2);
}

.members {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: 0;
    list-style: none;
    border-radius: var(--r-card);
    overflow: hidden;
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
}

.members li {
    display: flex;
    align-items: center;
    gap: 8px;
    min-height: 52px;
    padding: 8px 16px;
    box-shadow: inset 0 -1px 0 var(--hairline);
    font-size: 16px;
}

.members li:last-child {
    box-shadow: none;
}

.members li > span:first-child {
    font-weight: 500;
}

.members .role {
    font-size: 14px;
    color: var(--ink-3);
}

.members form {
    margin-inline-start: auto;
}

/* The name with what that person has contributed to this school under it. Two lines rather than
   one more thing in the row: an admin's row already carries a role, a "maak beheerder" and a
   "verwijderen", and a fourth item beside them wraps off the edge of a phone. */
/* Deliberately without min-width: 0 and without an ellipsis. An admin's row already carries two
   buttons, which on a 390px phone leaves about eleven characters for the name — so something has to
   give, and a name that wraps onto a second line is readable where "Joris Pee…" is not. This is the
   wrapping the row did before the points line was added; only the second line is new. */
/* The delete row in the bulk bar. Two states behind one checkbox: the way in, and the confirmation
   that replaces it. Nothing here is a script — see the note in _ResourceGrid.cshtml. */
.bulk-confirm-toggle {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.bulk-danger {
    margin-top: 10px;
    padding-top: 10px;
    box-shadow: inset 0 1px 0 var(--hairline);
}

/* Quiet until it is needed: a red button sitting permanently under a row of labels is a red button
   somebody eventually taps by accident. */
.bulk-delete-start {
    display: inline-flex;
    align-items: center;
    min-height: 34px;
    padding: 0 14px;
    border-radius: var(--r-pill);
    color: var(--danger);
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
}

.bulk-delete-start:hover {
    background: var(--surface-sunk);
}

.bulk-confirm {
    display: none;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
}

.bulk-confirm-toggle:checked ~ .bulk-danger .bulk-delete-start {
    display: none;
}

.bulk-confirm-toggle:checked ~ .bulk-danger .bulk-confirm {
    display: flex;
}

.bulk-confirm-toggle:focus-visible ~ .bulk-danger .bulk-delete-start {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.bulk-confirm-ask {
    font-size: 14px;
    color: var(--ink-2);
}

.bulk-delete-go {
    min-height: 34px;
    padding: 0 14px;
    border: none;
    border-radius: var(--r-pill);
    background: var(--danger);
    color: var(--danger-ink);
    font-size: 14px;
    font-weight: 600;
}

.bulk-delete-cancel {
    min-height: 34px;
    display: inline-flex;
    align-items: center;
    padding: 0 10px;
    color: var(--ink-2);
    font-size: 14px;
    cursor: pointer;
}

.member-lines {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    gap: 1px;
}

/* Absent, not zero, for somebody who has not started yet — a "0 punten" beside a name is a
   scolding, and this list is not the place for one. */
.member-points {
    font-size: 12px;
    font-weight: 400;
    color: var(--ink-3);
    font-variant-numeric: tabular-nums;
}

.member-points:empty {
    display: none;
}

/* One setting, its explanation, and the way to save it. The checkbox reuses the tick-box the Add
   sheet already draws, so a switch looks the same wherever it appears. */
.setting-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 12px;
}

.setting-row .same-labels {
    flex: 1 1 auto;
    min-width: 0;
}

.setting-card p {
    margin: 0;
}

.members-note {
    margin: -4px 4px 8px;
}

/* A file the upload pipeline would not take, with the reason. Not a toast: this is something to
   read and act on, and a toast is gone before a sentence this long has been read. */
.upload-refused {
    margin-bottom: 12px;
    border-inline-start: 3px solid var(--danger);
}

.upload-refused p {
    margin: 0;
    font-size: 14px;
    line-height: 1.45;
    color: var(--ink);
}

/* The role dropdown and the ban button, on their own line under the name. A member row on a phone
   already carries a name, a points line and a role badge; putting a select and two buttons beside
   all that leaves about eleven characters for the name, which is how the old two-button row used
   to break. Full width and wrapped is the only arrangement that stays readable. */
.member-controls {
    display: flex;
    flex: 1 1 100%;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: 8px;
    margin-top: 2px;
}

.member-role {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-inline-start: 0;
}

.member-role select {
    padding: 5px 8px;
    border: 1px solid var(--hairline-strong);
    border-radius: var(--r-inner);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    font-size: 13px;
}

/* The row has to be allowed to become two rows for the controls to sit under the name. */
.members li {
    flex-wrap: wrap;
}

/* Said once, next to whoever made the school, so that "why can I not change this one" has an
   answer on the page rather than only in an error message. */
.role.founder {
    color: var(--accent);
}

/* The banned list: a card's calm, without pretending the rows inside it are a grouped list. */
.card-note {
    padding: 2px 0 0;
}

.card-note .muted {
    margin: 0 4px 8px;
}

/* ------------------------------------------------- file page: ownership -- */

/* The h1 replaced by its own field for members — but a quiet one. It reads as a title with an edit
   affordance, not as a form standing open: the pencil sits inside the field's right edge rather
   than beside it as a second control. */
.inline-edit {
    position: relative;
    display: block;
    margin-bottom: 0;
}

.inline-edit input {
    width: 100%;
    min-width: 0;
    min-height: 36px;
    /* Room for the save button parked on the right edge. */
    padding-inline-end: 34px;
    font-size: 15px;
    font-weight: 600;
    letter-spacing: -0.01em;
}

/* Same control, more than one line. The buttons drop to the bottom-right corner instead of the
   middle of the right edge, because the middle of a paragraph is nowhere in particular. */
.inline-edit textarea {
    display: block;
    width: 100%;
    min-width: 0;
    padding-inline-end: 34px;
    font-size: 15px;
    line-height: 1.45;
}

/* Disabled is the resting state, so it must not look broken — only the caret and the box go away.
   The browser's default greying-out reads as "this control is unavailable", which is the opposite
   of what a title sitting here means. */
.inline-edit input:disabled,
.inline-edit textarea:disabled {
    background: transparent;
    box-shadow: none;
    color: var(--ink);
    opacity: 1;
    padding-inline-start: 0;
    -webkit-text-fill-color: var(--ink);
}

/* Nothing written yet: the hint has to stay readable when the box is the resting text, which the
   browser's disabled placeholder is not. */
.inline-edit textarea:disabled::placeholder {
    color: var(--ink-3);
    -webkit-text-fill-color: var(--ink-3);
}

.inline-edit-block .inline-edit-save,
.inline-edit-block .inline-edit-cancel {
    top: auto;
    bottom: 4px;
    transform: none;
}

.inline-edit-block .inline-edit-save:active {
    transform: scale(0.94);
}

.inline-edit-save {
    position: absolute;
    top: 50%;
    inset-inline-end: 3px;
    transform: translateY(-50%);
    display: grid;
    place-items: center;
    width: 26px;
    height: 26px;
    min-height: 0;
    min-width: 0;
    padding: 0;
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-ink);
}

.inline-edit-save:hover {
    background: var(--accent-press);
}

/* Pressing shifts a centred element off its own centre, so the transform has to carry it. */
.inline-edit-save:active {
    transform: translateY(-50%) scale(0.94);
}

.inline-edit-save svg {
    width: 14px;
    height: 14px;
}

/* Sits just left of save, and only exists while editing — there is nothing to abandon otherwise.
   Outlined rather than filled red: it is an escape hatch beside the primary action, not a second
   thing competing for the tap. */
.inline-edit-cancel {
    position: absolute;
    top: 50%;
    inset-inline-end: 33px;
    transform: translateY(-50%);
    display: none;
    place-items: center;
    width: 26px;
    height: 26px;
    min-height: 0;
    min-width: 0;
    padding: 0;
    border-radius: 50%;
    background: transparent;
    box-shadow: inset 0 0 0 1.5px var(--danger);
    color: var(--danger);
}

.inline-edit.editing .inline-edit-cancel {
    display: grid;
}

.inline-edit-cancel:hover {
    background: var(--danger);
    color: var(--danger-ink);
}

.inline-edit-cancel:active {
    transform: translateY(-50%) scale(0.94);
}

.inline-edit-cancel svg {
    width: 12px;
    height: 12px;
}

/* While editing, the field has to clear both buttons rather than just one. */
.inline-edit.editing input {
    padding-inline-end: 64px;
}

/* Pencil until editing, then a check — driven by classes rather than the hidden attribute, which
   an author `display` would silently override. */
.inline-edit .icon-check {
    display: none;
}

.inline-edit.editing .icon-pencil {
    display: none;
}

.inline-edit.editing .icon-check {
    display: block;
}

/* Editing but nothing typed yet: there is nothing to save, and the control says so. Opacity back
   to 1 against the global button:disabled rule — at 0.4 this went ghostly rather than reading as a
   solid grey button, and "greyed out" is the actual message. */
.inline-edit.editing .inline-edit-save {
    background: var(--surface-press);
    color: var(--ink-3);
    opacity: 1;
}

/* The name actually differs — green, and now it submits. */
.inline-edit.editing.changed .inline-edit-save {
    background: var(--ok);
    color: var(--ok-ink);
    opacity: 1;
}

.inline-edit.editing.changed .inline-edit-save:hover {
    background: var(--ok);
}

/* Two icons, centred, with room to be tapped — not two full-width buttons. */
/* Under the PDF frame, for the browsers that draw nothing in it. */
.preview-escape {
    margin: 10px 0 0;
    text-align: center;
}

.file-actions {
    display: flex;
    justify-content: center;
    gap: 14px;
    margin: 0 0 4px;
}

.icon-action {
    display: grid;
    place-items: center;
    /* Below the usual 44px tap target on purpose: these sit alone in the middle of the page with
       nothing to mis-tap next to them, and the file itself is what the page is for. */
    width: 38px;
    height: 38px;
    min-height: 0;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--accent);
    cursor: pointer;
}

.icon-action:hover {
    background: var(--surface-sunk);
    text-decoration: none;
}

.icon-action:active {
    transform: scale(0.94);
}

.icon-action svg {
    width: 18px;
    height: 18px;
}

.file-title {
    font-size: 17px;
    font-weight: 600;
}

/* Three stacked buttons, not a dropdown: the current choice stays visible without opening
   anything, and picking the public one is a single deliberate tap. */
.visibility {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.visibility-option {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    width: 100%;
    min-height: 0;
    padding: 12px 14px;
    border-radius: var(--r-inner);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink);
    text-align: start;
}

.visibility-option:hover {
    background: var(--surface-sunk);
}

.visibility-option.on {
    background: var(--accent);
    box-shadow: none;
    color: var(--accent-ink);
}

.visibility-option.on:hover {
    background: var(--accent-press);
}

.visibility-label {
    font-size: 14px;
    font-weight: 600;
}

.visibility-hint {
    font-size: 12px;
    font-weight: 400;
    line-height: 1.35;
    opacity: 0.75;
    white-space: normal;
}

/* The destructive button is not on the page until it is asked for — no script, and nothing to
   mis-tap on the way past. */
.danger-zone {
    margin-top: 28px;
}

.danger-zone summary {
    padding: 6px 0;
    color: var(--ink-3);
    font-size: 14px;
    cursor: pointer;
}

.danger-zone summary:hover {
    color: var(--danger);
}

.danger-zone p {
    margin: 4px 0 10px;
}

/* --------------------------------------------------------- tag manager -- */

/* --------------------------------------------------------- htmx indicator -- */

/* Verbatim what htmx would otherwise inject as an inline <style> on load. It is here so that the
   Content-Security-Policy can keep style-src at 'self' — see the htmx-config meta tag in
   _Layout.cshtml. Nothing in this app wears .htmx-indicator today; the rules are kept so that the
   first thing that does behaves the way htmx's own documentation says it will. */
.htmx-indicator {
    opacity: 0;
    visibility: hidden;
}

.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
    opacity: 1;
    visibility: visible;
    transition: opacity 200ms ease-in;
}

/* ---------------------------------------------------------------- toast -- */

/* The no-script fallback only: toast.js lifts this out of the page on load and shows it as a
   toast instead, so in a scripted browser it is never painted. Left styled anyway because with no
   script it is the only confirmation the reader gets. */
.notice-line {
    margin: 12px 0 0;
    padding: 10px 14px;
    border-radius: var(--r-inner);
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 14px;
}

/* Bottom-centred and clear of the FAB's corner. pointer-events: none so a toast can never
   swallow a tap meant for the page underneath it. */
.toast-host {
    position: fixed;
    left: 50%;
    bottom: max(84px, calc(env(safe-area-inset-bottom) + 84px));
    z-index: 60;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    width: max-content;
    max-width: calc(100vw - 32px);
    pointer-events: none;
}

.toast {
    margin: 0;
    padding: 10px 16px;
    border-radius: var(--r-pill);
    /* Inverted against the page rather than tinted: a confirmation should read at a glance and
       then stop asking for attention, which a coloured banner never quite does. */
    background: var(--ink);
    color: var(--bg);
    font-size: 13px;
    line-height: 1.35;
    text-align: center;
    box-shadow: var(--lift);
    opacity: 0;
    transform: translateY(10px);
    transition: opacity 200ms var(--ease), transform 200ms var(--ease);
}

.toast.is-visible {
    opacity: 1;
    transform: none;
}

.tag-hint-line {
    margin: 0 0 8px;
    padding-inline-start: 4px;
}

.tag-rows {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: 0;
    list-style: none;
}

/* Hairline between rows, never below the last — the same grouped-list detail as .members. */
.tag-row {
    padding: 12px 0;
    box-shadow: inset 0 -1px 0 var(--hairline);
}

.tag-row:first-child {
    padding-top: 0;
}

.tag-row:last-child {
    padding-bottom: 0;
    box-shadow: none;
}

/* The label is the input — there is no separate edit mode to enter and leave. */
.tag-row-main {
    display: flex;
    align-items: center;
    gap: 8px;
}

.tag-row-main input {
    flex: 1 1 auto;
    min-width: 0;
    min-height: 38px;
}

.tag-row-foot {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 6px;
    padding-inline-start: 4px;
}

.tag-move {
    display: inline-flex;
    gap: 2px;
}

.tag-move button {
    min-height: 30px;
    padding: 0 9px;
    font-size: 14px;
    line-height: 1;
}

/* Pushed to the far edge, well away from the two arrows next to it — the destructive control on
   this row should never sit a thumb's width from one someone taps repeatedly. */
.tag-remove {
    margin-inline-start: auto;
}

/* Two numbers and a dash where the other dimensions have one text field, sized to the two digits
   they will ever hold rather than stretching across the row. */
.age-range {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    flex: 1;
    min-width: 0;
}

.age-range input {
    width: 100%;
    min-width: 0;
    text-align: center;
    /* The spinner arrows are unusable at this size and take room the digits need. */
    -moz-appearance: textfield;
    appearance: textfield;
}

.age-range input::-webkit-outer-spin-button,
.age-range input::-webkit-inner-spin-button {
    margin: 0;
    -webkit-appearance: none;
    appearance: none;
}

.age-dash {
    flex: 0 0 auto;
    color: var(--ink-3);
}

.tag-add {
    display: flex;
    gap: 8px;
    margin-top: 10px;
}

.tag-add input {
    flex: 1 1 auto;
    min-width: 0;
}

/* ------------------------------------------------------------ tag editor -- */

.tag-section {
    margin-bottom: 28px;
}

.tag-section h2 {
    margin-bottom: 3px;
}

.tag-section .small {
    margin-bottom: 12px;
    color: var(--ink-2);
}

/* Nothing may be hidden on the one screen whose entire job is showing every option. */
.chips.wrap {
    flex-wrap: wrap;
    padding-top: 0;
}

.chip.on::before {
    content: "✓";
    margin-inline-end: 7px;
    font-size: 12px;
    font-weight: 700;
}

button.chip.suggested {
    background: transparent;
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
    color: var(--ink-3);
}

/* A brief dim while the tap is in flight, so a slow connection still feels answered. */
.chip.htmx-request {
    opacity: 0.5;
}

/* --------------------------------------------------------- wider screens -- */

@media (min-width: 600px) {
    main {
        padding: 16px 24px 72px;
    }

    #upload {
        grid-template-columns: repeat(3, 1fr);
    }

    h1 {
        font-size: 28px;
    }
}

/* --------------------------------------------------------------- brand -- */

/* Small on purpose, on every screen. Not navigation — nothing in it is a link or a control, so
   it does not reintroduce the chrome the six screens were built to avoid; it is a mark, the way
   a letterhead is a mark rather than a menu. Centred within the same column main uses, so it
   lines up with the page content underneath it rather than spanning the full viewport. */
/* One line, not split around the icon — a split lockup reads nicely once, but it costs width the
   split treatment doesn't earn back, and this sits above a screen that is already fighting for
   every pixel on the phones it actually runs on. */
/* The bar itself runs the full width of the window rather than stopping at the 680px the page
   content uses — a rule that stops short of the edges reads as a line drawn on the page instead
   of as the top of the window. The brand inside it is centred, so it still sits over the middle
   of the content on any screen. */
/* Solid rather than a hairline under white: the bar is the top of the window, and a filled band
   says that where a rule under a pale strip only suggested it. Full width, brand centred, so it
   still sits over the middle of the 680px content column on any screen. */
.site-header {
    position: relative;
    display: flex;
    justify-content: center;
    padding: 7px 16px 6px;
    background: var(--accent);
    /* The mark hangs below the band, so nothing here may clip it. */
    overflow: visible;
}

/* Parked against the right edge rather than placed in the flex row, so the brand stays centred on
   the window and not on whatever is left over beside this. */
.account-menu {
    position: absolute;
    top: 50%;
    inset-inline-end: 10px;
    transform: translateY(-50%);
    /* The z-index belongs here, not only on the panel inside. translateY makes this element a
       stacking context, which seals the panel's own z-index in: however high it goes, it can only
       win against its siblings in here. From the page's point of view this menu was an untiered
       positioned element in the header, so anything positioned later in the document — the
       settings gear, the filter switch, the search field — painted straight over the open panel.
       Raised above the page's own layers (.fab is 4, .bulk-bar 5) but under the toasts at 60, so
       a confirmation is never hidden behind a menu. */
    z-index: 50;
}

.account-menu > summary {
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    color: var(--accent-ink);
    cursor: pointer;
    list-style: none;
}

/* Safari and Firefox each draw their own marker on a summary; both have to be told not to. */
.account-menu > summary::-webkit-details-marker,
.account-menu > summary::marker {
    display: none;
    content: "";
}

.account-menu > summary:hover,
.account-menu[open] > summary {
    background: rgb(255 255 255 / 18%);
}

.account-menu > summary svg {
    width: 19px;
    height: 19px;
}

/* A menu, not a box with two things in it. The padding is on the rows rather than the panel, so
   the sign-out row can highlight edge to edge the way a menu item is expected to. */
.account-panel {
    position: absolute;
    top: calc(100% + 9px);
    inset-inline-end: 0;
    z-index: 20;
    min-width: 236px;
    max-width: min(304px, calc(100vw - 24px));
    padding: 6px;
    border-radius: 14px;
    background: var(--surface);
    box-shadow: var(--lift);
}

/* Points back at the mark it opened from, so the panel belongs to that button instead of floating
   loose in the corner. A rotated square rather than a border triangle: the panel has no border to
   match, only a fill. */
.account-panel::before {
    content: "";
    position: absolute;
    top: -4px;
    inset-inline-end: 11px;
    width: 10px;
    height: 10px;
    border-radius: 2px;
    background: var(--surface);
    transform: rotate(45deg);
}

/* Who you are signed in as. Left-aligned: the old panel set everything flush right, which read as
   a stray label rather than a name, and left a wedge of empty white beside it. */
.account-who {
    position: relative;
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 7px 9px 11px;
    margin-bottom: 5px;
}

/* The divider is inset from the panel's edges rather than run wall to wall — it separates the two
   halves without cutting the card in two. */
.account-who::after {
    content: "";
    position: absolute;
    inset-inline-end: 9px;
    bottom: 0;
    inset-inline-start: 9px;
    height: 1px;
    background: var(--hairline);
}

/* The same round mark as a school in the list on the home page, one size down. */
.account-mark {
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.01em;
}

.account-name {
    margin: 0;
    min-width: 0;
    font-size: 14px;
    font-weight: 600;
    color: var(--ink);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* A menu row, not a button: full width, left-aligned, no fill until the pointer is on it. As a
   filled danger-coloured button it was the loudest thing on the screen, which is the wrong weight
   for something you use once a month. */
.account-action {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 9px;
    width: 100%;
    min-height: 0;
    padding: 9px;
    border-radius: 9px;
    background: none;
    color: var(--danger);
    font-size: 14px;
    font-weight: 500;
    letter-spacing: normal;
    text-align: start;
}

.account-action:hover,
.account-action:focus-visible {
    background: var(--surface-sunk);
    color: var(--danger);
}

.account-action:active {
    background: var(--surface-press);
}

.account-action svg {
    width: 17px;
    height: 17px;
    flex: 0 0 auto;
}

/* Who else is in this school and what they have put into it. Fetched when the menu is first
   opened, so this is empty on a page nobody has opened it on — and empty means gone, not a gap. */
.account-standings-slot:empty {
    display: none;
}

.account-standings-slot {
    padding-bottom: 5px;
    margin-bottom: 5px;
    box-shadow: inset 0 -1px 0 var(--hairline);
}

.account-standings-head {
    margin: 0 0 3px;
    padding: 0 9px;
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--ink-3);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.account-standings {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: 0;
    list-style: none;
}

.account-standings li {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 3px 9px;
    border-radius: 9px;
    font-size: 13px;
    color: var(--ink-2);
}

/* Your own row, picked out so the list answers "where am I in this" without being read through. */
.account-standings li.is-you {
    background: var(--accent-wash);
    color: var(--accent);
    font-weight: 600;
}

.account-standings-mark {
    display: grid;
    place-items: center;
    width: 20px;
    height: 20px;
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--surface-sunk);
    color: var(--ink-2);
    font-size: 9px;
    font-weight: 700;
}

.account-standings li.is-you .account-standings-mark {
    background: var(--accent);
    color: var(--accent-ink);
}

.account-standings-name {
    min-width: 0;
    flex: 1 1 auto;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.account-standings-points {
    flex: 0 0 auto;
    font-size: 12px;
    /* Tabular figures, so the column of numbers lines up instead of wobbling per digit. */
    font-variant-numeric: tabular-nums;
    color: var(--ink-3);
}

.account-standings li.is-you .account-standings-points {
    color: var(--accent);
}

/* Said out loud rather than left implied: a menu that silently shows eight of twenty-five reads
   as a complete list to anybody who does not count. */
.account-standings-more {
    margin: 3px 0 0;
    padding: 0 9px;
    font-size: 11px;
    color: var(--ink-3);
}

/* ------------------------------------------------------------ bonus points -- */

/* What you have earned by adding to a library. On the left of the band, mirroring the account
   button on the right, so the wordmark keeps the middle to itself - a badge tucked in beside that
   button collides with the wordmark on a 320px phone, which is most of them.

   It is not a control. Nothing opens, nothing is pressable, and it never takes focus: it is a
   number that goes up, and the panel behind the account button is where it is spelled out. */
.points-badge {
    position: absolute;
    top: 50%;
    inset-inline-start: 10px;
    transform: translateY(-50%);
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: 22px;
    padding: 0 8px 0 6px;
    border-radius: var(--r-pill);
    color: var(--accent-ink);
    /* A wash of the band's own ink rather than a fixed white one. In light mode that is white on
       blue; in dark mode the band lightens and the ink flips, and a hard-coded white wash would
       come out as a pale smudge on a pale band. The flat colour is the fallback for anything
       without color-mix. */
    background: rgb(255 255 255 / 16%);
    background: color-mix(in srgb, currentColor 14%, transparent);
    /* The "+N" hangs out of the bottom of the band, so nothing here may clip it. */
    overflow: visible;
    z-index: 49;
}

.points-spark {
    width: 12px;
    height: 12px;
    flex: 0 0 auto;
    opacity: 0.85;
}

.points-value {
    font-size: 12px;
    font-weight: 700;
    /* Tabular figures: without them the badge jumps sideways on every digit while it counts up,
       which is far more distracting than the number moving. */
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.01em;
}

/* The "+N". Rises into the badge and is gone in about a second. Never more than one on screen at a
   time - points.js adds later awards to whichever one is already up rather than stacking a second
   beside it, which is what keeps bulk-tagging fifteen files from turning into fifteen of these.

   pointer-events: none, because it hangs over the page below the band and a reward that swallows
   the tap underneath it is a punishment. */
.points-float {
    position: absolute;
    top: calc(100% + 5px);
    inset-inline-start: 0;
    padding: 2px 9px;
    border-radius: var(--r-pill);
    /* Lit from above, like a struck coin: the pale gold at the top and the deeper one at the
       bottom are the same hue, so it reads as one metal catching the light rather than as two
       colours. Dark ink on it rather than white — white on gold comes out at about 2:1. */
    background: linear-gradient(180deg, var(--gold-light) 0%, var(--gold) 55%, var(--gold-deep) 100%);
    color: var(--gold-ink);
    font-size: 12px;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    box-shadow:
        /* A warm shadow rather than the app's usual neutral one, so the glow belongs to the pill
           instead of looking like it was pasted onto it. */
        0 2px 8px rgb(160 112 12 / 38%),
        0 6px 16px rgb(160 112 12 / 20%),
        /* The lit edge along the top and a hairline all round, which is what stops it dissolving
           into a white page. */
        inset 0 1px 0 rgb(255 255 255 / 70%),
        inset 0 0 0 1px rgb(94 66 8 / 22%);
    pointer-events: none;
    opacity: 0;
    animation: points-float-rise 1150ms var(--ease) forwards;
}

/* Written for a badge in a band at the top, where "just below it" was into the page. Anchored in
   the bar at the foot, just below it is off the bottom of the window, so it hangs above the icon
   instead. The rise animation is unchanged and still reads correctly: it already moves upward.
   Centred with left/right/auto-margins rather than a translate, because the keyframes own the
   transform property and a translateX here would be overwritten the moment they start. */
.nav-icon .points-float {
    top: auto;
    bottom: calc(100% + 7px);
    left: 0;
    right: 0;
    width: fit-content;
    margin: 0 auto;
}

@keyframes points-float-rise {
    0% {
        opacity: 0;
        transform: translateY(8px) scale(0.86);
    }

    18% {
        opacity: 1;
        transform: translateY(0) scale(1);
    }

    68% {
        opacity: 1;
        transform: translateY(-3px) scale(1);
    }

    100% {
        opacity: 0;
        transform: translateY(-13px) scale(0.98);
    }
}

/* A short squeeze, so the eye lands on the number rather than only on the thing that flew past it.
   The keyframes carry the badge's own translateY: a transform in an animation replaces the one on
   the rule, and leaving it out would drop the badge half its height mid-pop. */
.points-badge.is-popping {
    animation: points-badge-pop 420ms var(--ease);
}

.points-badge.is-ranked-up {
    animation: points-badge-pop 560ms var(--ease);
}

@keyframes points-badge-pop {
    0% {
        transform: translateY(-50%) scale(1);
    }

    35% {
        transform: translateY(-50%) scale(1.16);
    }

    100% {
        transform: translateY(-50%) scale(1);
    }
}

/* The same two beats for the badge in the navigation bar. Its own keyframes because the header
   version bakes in the translateY(-50%) that centres it against the band, and this element is not
   positioned that way — reusing them would jump the icon half its height before squeezing it. */
.nav-icon.is-popping {
    animation: nav-points-pop 420ms var(--ease);
}

.nav-icon.is-ranked-up {
    animation: nav-points-pop 560ms var(--ease);
}

@keyframes nav-points-pop {
    0% {
        transform: scale(1);
    }

    35% {
        transform: scale(1.16);
    }

    100% {
        transform: scale(1);
    }
}

/* Crossing a threshold, which happens a handful of times ever. One ring, and a toast that says the
   new name - see RankLabels. Always present so it can be animated on demand; invisible until then. */
.points-badge::after {
    content: "";
    position: absolute;
    inset: -3px;
    border-radius: inherit;
    border: 1.5px solid currentColor;
    opacity: 0;
    pointer-events: none;
}

.points-badge.is-ranked-up::after {
    animation: points-badge-ring 1000ms var(--ease) forwards;
}

@keyframes points-badge-ring {
    0% {
        opacity: 0.9;
        transform: scale(0.92);
    }

    100% {
        opacity: 0;
        transform: scale(1.6);
    }
}

/* Motion is the whole of this effect, so with it turned down the "+N" simply appears and fades and
   the badge does not move at all. The number still counts - points.js sets it outright there. */
/* The blanket rule at the bottom of this file cuts every animation on the page to 0.01ms, so an
   animated fade here would be over before it was ever painted — which is how the "+N" managed to
   not appear at all the first time this was tried. No animation then: the bubble is simply drawn
   where it lands, and points.js takes it away again on its own timer. The reward still arrives;
   nothing moves. */
@media (prefers-reduced-motion: reduce) {
    .points-float {
        animation: none;
        opacity: 1;
    }

    .points-badge.is-popping,
    .points-badge.is-ranked-up,
    .points-badge.is-ranked-up::after,
    .nav-icon.is-popping,
    .nav-icon.is-ranked-up {
        animation: none;
    }
}

/* The name, the standing and how far the next rung is, stacked beside the round mark in the
   account panel. */
.account-lines {
    display: flex;
    flex-direction: column;
    min-width: 0;
    gap: 1px;
}

.account-standing {
    font-size: 12px;
    color: var(--ink-2);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Empty at the top of the ladder, where there is nothing left to reach and a blank line would just
   be a gap under the name. */
.account-next {
    font-size: 11px;
    color: var(--ink-3);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.account-next:empty {
    display: none;
}

.site-brand {
    display: inline-flex;
    align-items: center;
    gap: 6px;
}

/* A disc that hangs off the bottom edge of the band. The ring is the band's own colour, so the
   circle reads as punched through it rather than pasted on top. Its own margin is negative by less
   than it is pushed down, which keeps the band the height it was and lets the disc overlap the
   page under it. */
.site-brand-mark {
    position: relative;
    display: inline-grid;
    place-items: center;
    width: 38px;
    height: 38px;
    flex: 0 0 auto;
    /* Sits with the words rather than under them, and only its last few pixels cross the band's
       edge. The negative margins keep the band the height the wordmark needs; the small nudge down
       is what puts those pixels below it instead of above. */
    margin: -13px 4px -13px;
    border-radius: 50%;

    /* Frosted glass, not clear glass. The disc sits almost entirely on the accent band, so a
       genuinely see-through canvas would drag dark blue up underneath a near-black house glyph and
       cost it its contrast. At roughly three-quarters white the blurred band still reads through
       as a tint — which is what makes it look like a thing with depth rather than a printed
       circle — while the glyph keeps around 9:1 against it in both colour schemes.
       Two layers: a specular sweep off the top-left, and the body under it. */
    background:
        radial-gradient(112% 86% at 28% 4%,
            rgb(255 255 255 / 98%) 0%,
            rgb(255 255 255 / 52%) 38%,
            rgb(255 255 255 / 0%) 72%),
        linear-gradient(168deg,
            rgb(255 255 255 / 90%) 0%,
            rgb(255 255 255 / 72%) 54%,
            rgb(255 255 255 / 84%) 100%);
    backdrop-filter: blur(9px) saturate(150%);
    -webkit-backdrop-filter: blur(9px) saturate(150%);

    box-shadow:
        /* The band's own colour, so the circle reads as punched through it rather than pasted on. */
        0 0 0 3px var(--accent),
        /* Three shadows rather than one. A single blur reads as a sticker with a smudge under it;
           a contact shadow, a key and a wide ambient one is what the eye actually accepts as an
           object standing off a surface. */
        0 1px 1px rgb(0 0 0 / 14%),
        0 4px 8px rgb(0 0 0 / 26%),
        0 9px 18px rgb(0 0 0 / 14%),
        /* The glass itself: a lit top rim, a shaded bottom to give the disc thickness, and a hair
           of a bright edge all the way round so it reads as a cut piece rather than a hole. */
        inset 0 1.5px 1px rgb(255 255 255 / 95%),
        inset 0 -2px 3px rgb(54 69 79 / 14%),
        inset 0 0 0 1px rgb(255 255 255 / 45%);

    transform: translateY(5px);
    transition:
        transform 130ms var(--ease),
        box-shadow 130ms var(--ease);
}

/* Rises on hover and sinks under a press, so it behaves like the button it now looks like. Both
   nudges are on top of the 5px that puts it through the band's edge, never instead of it — lose
   that and the disc jumps back up into the band. */
a.site-brand-mark:hover {
    box-shadow:
        0 0 0 3px var(--accent),
        0 1px 1px rgb(0 0 0 / 14%),
        0 6px 12px rgb(0 0 0 / 30%),
        0 13px 24px rgb(0 0 0 / 16%),
        inset 0 1.5px 1px rgb(255 255 255 / 100%),
        inset 0 -2px 3px rgb(54 69 79 / 12%),
        inset 0 0 0 1px rgb(255 255 255 / 55%);
    transform: translateY(4px);
}

/* Pressed: the gap under it closes, the shadows collapse to a contact shadow, and the inner shade
   moves to the top — light now comes from behind the rim rather than over it, which is what a
   surface pushed in actually looks like. */
a.site-brand-mark:active {
    box-shadow:
        0 0 0 3px var(--accent),
        0 1px 2px rgb(0 0 0 / 22%),
        inset 0 2px 4px rgb(54 69 79 / 22%),
        inset 0 -1px 1px rgb(255 255 255 / 60%),
        inset 0 0 0 1px rgb(255 255 255 / 35%);
    transform: translateY(6px) scale(0.96);
}

/* The disc is the only thing in the band that moves, and moving is the whole of the press
   feedback — so with motion turned down it stays put and the shadow does the talking. */
@media (prefers-reduced-motion: reduce) {
    a.site-brand-mark:hover,
    a.site-brand-mark:active {
        transform: translateY(5px);
    }
}

.site-brand-mark svg {
    width: 26px;
    height: 26px;
}

a.site-brand-mark:hover {
    text-decoration: none;
}

.site-brand-word {
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--accent-ink);
    white-space: nowrap;
}

/* The two halves of the glyph are coloured here rather than in the file, so the one copy of the
   drawing can be used on the band and in the middle of the loader without knowing about either. */
.site-brand-mark .mark-house {
    stroke: var(--mark-house);
}

.site-brand-mark .mark-book {
    stroke: var(--mark-book);
    stroke-opacity: 1;
}

/* The attention mark in a chip row. Same red and same glyph as .tile-warn on a card, so the two
   read as one thing said in two places. */
/* Scoped the same way .chips-row .chip is, or that rule's two classes would outrank this one and
   the mark would come out looking like any other tag. */
.chips-row .chip.chip-warn {
    min-width: 28px;
    justify-content: center;
    padding: 0 8px;
    background: var(--danger);
    box-shadow: none;
    color: var(--danger-ink);
    font-weight: 700;
    cursor: pointer;
}

.chips-row .chip.chip-warn:hover {
    text-decoration: none;
    filter: brightness(1.1);
}

/* Title on the left, the one action on the right — the same arrangement as .library-head, so the
   two screens open the same way. */
/* Only a title now — adding a school moved to the floating button in the corner, the same one the
   school page uses. */
.home-head {
    margin-bottom: 12px;
}

/* The first thing a stranger reads. A touch larger than body text and given room to breathe: it
   is the only place that says what this app is for. */
.welcome {
    margin: 6px 0 22px;
    max-width: 42ch;
    font-size: 16px;
    line-height: 1.5;
    color: var(--ink-2);
}

.home-intro {
    margin: -4px 0 14px;
    max-width: 46ch;
}

/* Only ever on screen in Development. Deliberately looks like a warning rather than a hint: it
   marks a step this build is NOT doing. */
.dev-note {
    margin: 0 0 4px;
    padding: 8px 12px;
    border-radius: var(--r-inner);
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 13px;
    font-weight: 600;
}

/* The list of schools on the root, laid out the way a chat list is: full width, rows flush
   against each other, a round mark where a photo would be, and a hairline that starts after that
   mark rather than running edge to edge. Only the shape is borrowed — every colour here is this
   app's own.
   The negative margins take the list out of main's 16px gutter so the rows reach both edges; the
   padding inside each row puts the content back where it was. */
.group-list {
    margin: 4px -16px 0;
    padding: 0;
    list-style: none;
}

.group-row {
    display: flex;
    align-items: center;
    gap: 11px;
    padding: 6px 16px;
    color: var(--ink);
    text-decoration: none;
}

.group-row:hover {
    background: var(--surface-sunk);
    text-decoration: none;
}

.group-row:active {
    background: var(--surface-press);
}

/* Small enough that it does not set the height of the row on its own. It used to be 48px, which
   made every row taller than its two lines of text needed. */
.group-avatar {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 0.01em;
}

/* Everything right of the mark. The divider lives here, which is what insets it. */
.group-body {
    display: flex;
    flex-direction: column;
    gap: 1px;
    flex: 1;
    min-width: 0;
    padding: 8px 0;
    box-shadow: inset 0 -1px 0 var(--hairline);
}

li:last-child .group-body {
    box-shadow: none;
}

/* Two rows of "something on the left, something small on the right". */
.group-line {
    display: flex;
    align-items: baseline;
    gap: 8px;
    min-width: 0;
}

.group-name {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 14px;
}

.group-desc {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 12px;
}

.group-members {
    flex: 0 0 auto;
    font-size: 12px;
}

/* The storage a school takes up, after the member count and separated from it by a dot. Only the
   superadmin's rows carry it — see GroupRow.ShowsStorage. */
.group-storage {
    flex: 0 0 auto;
    font-size: 12px;
}

.group-storage::before {
    content: "\00B7\00A0";
}

/* A school the superadmin is not in — see IndexModel. Sunk and outlined rather than tinted with
   the accent: it is the row that is NOT theirs, and it should read as "outside", not as special.
   Nobody but that one account ever gets such a row. */
.group-item-outside .group-row {
    background: var(--surface-sunk);
}

.group-item-outside .group-avatar {
    background: var(--surface);
    color: var(--ink-2);
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
}

.group-outside-label {
    flex: 0 0 auto;
    padding: 1px 7px;
    border-radius: 999px;
    box-shadow: inset 0 0 0 1px var(--hairline-strong);
    color: var(--ink-2);
    font-size: 10.5px;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    white-space: nowrap;
}

/* Under the list, for the one account that has an accounts page to go to. */
.admin-link {
    margin: 14px 0 0;
    text-align: center;
}

/* The accounts page: the same rows as the schools, minus the tap. */
.account-list .group-row {
    cursor: default;
}

.account-email {
    font-size: 12px;
}

.admin-count {
    margin: 4px 0 10px;
}

/* On top of a school the superadmin is only looking at. Says why nothing here can be pressed. */
.viewer-note {
    margin: 0 0 12px;
    padding: 10px 14px;
    border-radius: var(--r-inner);
    background: var(--surface-sunk);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink-2);
    font-size: 13px;
}

/* Your own favourites school. It sits first in the list and is set apart from the schools other
   people are in, because it is not one of those: nobody else is in it and nothing leaves it. The
   separation is a gap and a tint rather than a heading — a row that looks slightly different needs
   no label to explain itself. */
.group-item-personal {
    margin-bottom: 12px;
}

/* The hairline marks where one row ends and the next begins. This row is followed by a gap
   instead, and two separators in a row would read as an empty row between them. */
.group-item-personal .group-body {
    box-shadow: none;
}

.group-item-personal .group-row {
    background: var(--heart-wash);
}

.group-item-personal .group-row:hover {
    background: var(--heart-wash);
    filter: brightness(0.98);
}

.group-avatar-heart {
    background: var(--surface);
    color: var(--heart);
}

.group-avatar-heart svg {
    width: 19px;
    height: 19px;
}

.new-group-line {
    margin-top: 4px;
}

/* --------------------------------------------------------- bottom nav -- */

/* Three destinations along the foot of the window, the shape a phone user already knows. Fixed
   rather than sticky: it is the frame around the page, not part of it, and it must not scroll away
   on a long grid.
   The bar is opaque on purpose. A translucent one over a thumbnail grid turns every icon into a
   guess, and this is the one control that has to be readable on every screen without thinking. */
.bottom-nav {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    /* Above the floating button (4) and the bulk bar (5), below the toasts (60): a confirmation
       still has to be able to sit over this, but nothing else may cover it. */
    z-index: 6;
    display: flex;
    height: var(--nav-total);
    padding-bottom: env(safe-area-inset-bottom);
    background: var(--surface);
    box-shadow: inset 0 1px 0 var(--hairline);
}

.nav-item {
    display: flex;
    flex: 1 1 0;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 3px;
    min-width: 0;
    padding: 6px 4px 7px;
    color: var(--ink-2);
    font-size: 11px;
    font-weight: 500;
    letter-spacing: -0.005em;
    text-decoration: none;
}

.nav-item:hover {
    color: var(--ink);
    text-decoration: none;
}

.nav-item:active {
    background: var(--surface-press);
}

/* Colour alone marks the tab you are on, with aria-current saying the same thing out loud. There
   is no pill or underline behind it: three items with one tinted is unambiguous, and a filled
   shape at this size crowds the word underneath. */
.nav-item.is-current {
    color: var(--accent);
    font-weight: 600;
}

.nav-item svg {
    width: 23px;
    height: 23px;
    flex: 0 0 auto;
}

/* The word under the icon, and only that. This used to be written as `.nav-item span`, which also
   caught the wrapper around the profile icon — and its overflow: hidden then sliced the points
   count in half, because that pill deliberately hangs outside its parent. */
.nav-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 100%;
}

/* The logo sits in the middle tab. Its own wrapper because the mark is drawn on a 64 grid while
   the other two icons are on a 22 one, and because it is given a little more room: it is the app's
   own mark, not one of three matching outlines, and at the same size as its neighbours the house
   and the book inside it closed up into a smudge. */
.nav-mark {
    display: grid;
    place-items: center;
    /* The same box the other two icons occupy, while the mark inside it is drawn larger and simply
       overhangs. Without this the middle tab's word sat a few pixels below its neighbours', which
       on a three-item bar reads as a mistake rather than as emphasis. */
    height: 23px;
}

.nav-mark svg {
    width: 27px;
    height: 27px;
}

/* Wraps the profile icon so the points count can hang off its corner, and so points.js has one
   element to anchor the "+N" bubble to. Positioned, which is what the bubble is measured against. */
.nav-icon {
    position: relative;
    display: grid;
    place-items: center;
}

/* The number that used to sit in the top band. Small, in the corner of the silhouette, the way an
   unread count sits on a chat app's tab — it is the same idea: something of yours, counted. */
.nav-points {
    position: absolute;
    top: -4px;
    left: 50%;
    transform: translateX(2px);
    min-width: 15px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--gold);
    color: var(--gold-ink);
    font-size: 10px;
    font-weight: 700;
    line-height: 15px;
    text-align: center;
    /* Against the bar behind it, so the pill reads as sitting on top of the icon rather than
       merging into it. */
    box-shadow: 0 0 0 2px var(--surface);
}

/* ------------------------------------------------------------- profile -- */

.profile-card {
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.profile-who {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
}

.profile-mark {
    display: grid;
    place-items: center;
    width: 46px;
    height: 46px;
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 16px;
    font-weight: 700;
}

.profile-lines {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
}

.profile-name {
    font-size: 16px;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.profile-mail {
    font-size: 13px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.profile-note {
    margin: 0;
}

/* The total, large, with its rank beside it. The one number on this screen worth reading from
   across the room. */
.profile-score {
    display: flex;
    align-items: baseline;
    gap: 9px;
}

.profile-total {
    font-size: 30px;
    font-weight: 700;
    line-height: 1;
    letter-spacing: -0.02em;
    color: var(--accent);
}

.profile-rank {
    font-size: 14px;
    font-weight: 600;
    color: var(--ink-2);
}

/* ------------------------------------------------------------- lightbox -- */

/* A native <dialog> rather than a hand-rolled overlay: the browser supplies centring, an
   ::backdrop, focus trapping, Esc-to-close and focus restoration on close, all for free. One
   dialog is shared by every tile on the page — lightbox.js just points it at whichever tile was
   tapped, rather than one dialog existing per file.
   Near full-screen with a small margin on every side, rather than a small framed card — the point
   is to see the whole file (all pages of a PDF, a full-resolution image), which a card sized for
   a thumbnail preview can't do. */
/* Edge to edge. No margin, no radius, no shadow and nothing in it but the file: this is a viewer,
   not a card floating on a page, and every pixel spent on a frame is a pixel not spent on the
   worksheet somebody is trying to read on a phone.
   dvh rather than vh: on mobile Safari and Chrome, vh is the height with the browser chrome
   retracted, so a 100vh dialog is taller than the screen and its bottom edge sits under the URL
   bar. */
.lightbox {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100dvh;
    max-width: 100vw;
    max-height: 100dvh;
    margin: 0;
    padding: 0;
    border: none;
    border-radius: 0;
    /* A fixed near-black in both themes. A viewer is a viewer: letterboxing around a portrait
       worksheet should read as "nothing here", which a light ground does not do, and following
       the theme would make the same file sit on two different grounds. */
    background: #0e1417;
    color: #ffffff;
}

/* The column layout belongs on [open], not on the base rule. A closed <dialog> is hidden by
   `dialog:not([open]) { display: none }` in the BROWSER's stylesheet, and an author `display`
   declaration beats a UA one whatever the specificity — so putting `display: flex` on .lightbox
   itself un-hid the closed dialog and dropped it into normal flow at the foot of the page.
   (.notice never had this bug precisely because it sets no display of its own.) */
.lightbox[open] {
    display: flex;
    flex-direction: column;
}

.lightbox::backdrop {
    background: rgb(0 0 0 / 45%);
}

/* The three controls, floated over the file rather than given a bar of their own, and clear of a
   notch: with the dialog running to the very top of the screen there is nothing between these and
   the status bar. */
.lightbox-tools {
    position: absolute;
    top: max(10px, env(safe-area-inset-top));
    inset-inline-end: 10px;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 8px;
}

/* Both dialogs use this disc: the preview, where three of them sit in a row, and the small notice
   cards, where the close button stands alone. Keeping the two selectors together is the point —
   when the preview grew a row, .lightbox-close briefly lost every one of these declarations and
   the notice dialogs came back with a full-size blue button in the corner. */
.lightbox-tool,
.lightbox-close {
    display: grid;
    place-items: center;
    width: 34px;
    height: 34px;
    min-height: 0;
    min-width: 0;
    flex: 0 0 auto;
    padding: 0;
    border: none;
    border-radius: 50%;
    /* Solid, not translucent. These used to be 62% black with a blur behind them, which works over
       a photograph of a worksheet and disappears entirely over a photograph taken at night — the
       disc, the blur and the picture were all the same near-black and the middle button read as
       nothing at all.
       Opaque dark plus a light ring covers both grounds: over a dark picture the ring is what
       separates the button from the background, and over a white one the disc does it. */
    background: #0e1417;
    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 38%);
    color: #ffffff;
    text-decoration: none;
}

.lightbox-tool:hover,
.lightbox-tool:focus-visible,
.lightbox-close:hover,
.lightbox-close:focus-visible {
    background: #223038;
    color: #ffffff;
    text-decoration: none;
}

.lightbox-tool:active,
.lightbox-close:active {
    transform: scale(0.94);
}

.lightbox-tool svg {
    width: 19px;
    height: 19px;
}

/* The close button is the one glyph here that is a character rather than a drawing. */
.lightbox-close {
    font-size: 20px;
    line-height: 1;
}

/* Alone in the corner of a notice card, so it places itself. No safe-area inset in this one: the
   card is centred in the window rather than run up against the top of the screen, so the notch is
   somebody else's problem here. */
.notice .lightbox-close {
    position: absolute;
    top: 10px;
    inset-inline-end: 10px;
    z-index: 1;
}

/* Fills the whole dialog — the file is the point, not the chrome around it. Its own
   background shows while the image/frame loads, and behind the letterboxing object-fit: contain
   leaves around an image that isn't exactly the dialog's own aspect ratio. */
.lightbox-body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: transparent;
}

.lightbox-image,
.lightbox-frame {
    display: block;
    width: 100%;
    height: 100%;
    border: none;
    /* Meaningless on the <iframe> (a PDF supplies its own internal layout and scrollbar) but
       exactly what an <img> that isn't the body's own aspect ratio needs. */
    object-fit: contain;
}

/* The `hidden` attribute hides through `[hidden] { display: none }` in the BROWSER's stylesheet,
   and an author `display` declaration beats a UA one whatever the specificity — the same trap that
   left the closed dialog visible. Every element here sets its own display AND is toggled with
   `hidden`, so without restating it the attribute does nothing: a hidden <img> kept filling the
   body at height:100%, showing a stale alt string and pushing the PDF's <iframe> out of the
   overflow:hidden container entirely. */
.lightbox-image[hidden],
.lightbox-frame[hidden] {
    display: none;
}

/* Same small floating-card treatment as .lightbox, kept as its own class rather than sharing that
   one: this dialog holds a sentence of plain text, not an image, and giving it its own name keeps
   that difference honest even though the underlying card chrome happens to be identical today. */
/* Fixed, and centred against the window rather than the page.
   It was position: relative, which leaves a modal <dialog> anchored in the document instead of the
   viewport — so opening one scrolled the page to wherever the dialog happens to sit in the DOM.
   Tapping the red "!" on a card near the bottom of a long grid threw the reader back to the top of
   the school, every time. Fixed positioning gives showModal() nothing to scroll to.
   It still establishes a containing block, which is what the close button in the corner positions
   itself against. */
.notice {
    position: fixed;
    inset: 0;
    margin: auto;
    max-block-size: calc(100dvh - 32px);
    overflow-y: auto;
    max-width: min(300px, calc(100vw - 32px));
    width: 100%;
    padding: 18px 16px 16px;
    border: none;
    border-radius: var(--r-card);
    background: var(--surface);
    box-shadow: var(--lift);
    color: var(--ink);
}

.notice::backdrop {
    background: rgb(0 0 0 / 45%);
}

.notice-title {
    margin: 0 0 6px;
    padding-inline-end: 24px;
    font-size: 15px;
    font-weight: 700;
}

/* ----------------------------------------------------------------- FAB -- */

/* Bottom-right rather than beside the title: on a one-handed phone grip that corner is the one
   the thumb actually reaches, and the top corners are the two hardest points on the screen.
   A plain "+" rather than "+ Toevoegen": the single Add button on a library screen doesn't need
   a label to be understood, and the circle is a smaller target for a photo/thumbnail underneath
   to be lost behind. aria-label carries the name that used to be on-screen text. */
/* The corner is written down once, in three custom properties, because the options that fly out
   of this button have to line up with it exactly and a second copy of the safe-area maths would
   drift the first time one of them changed. */
.fab {
    position: fixed;
    inset-inline-end: var(--fab-right);
    bottom: var(--fab-bottom);
    z-index: 4;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--fab-size);
    height: var(--fab-size);
    border-radius: 50%;
    background: var(--accent);
    color: var(--accent-ink);
    text-decoration: none;
    /* A floating circle over arbitrary content underneath needs its own lift — this is the one
       other element in the app, besides the lightbox, allowed to keep a real shadow. */
    box-shadow: 0 2px 4px rgb(0 0 0 / 12%), 0 8px 20px rgb(0 0 0 / 18%);
}

.fab:hover {
    background: var(--accent-press);
    text-decoration: none;
}

.fab:active {
    transform: scale(0.96);
}

.fab svg {
    width: 22px;
    height: 22px;
    flex: 0 0 auto;
}

/* Room at the foot of the page so the last row of the grid never sits behind the floating
   button — which now sits above the navigation bar, so this has to clear both. */
body:has(.bottom-nav) main:has(.fab) {
    padding-bottom: calc(var(--nav-total) + var(--fab-size) + 32px);
}

/* ------------------------------------------------------- add sheet steps -- */

/* Numbered rather than dotted. Three dots showed that there were three of something and left the
   reader to work out which one they were on and what the other two were. */
.step-count {
    margin: 0 0 4px;
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ink-3);
}

.step-form {
    margin-top: 20px;
}

.step-ask {
    margin: 0 0 12px;
    font-size: 17px;
    font-weight: 600;
    color: var(--ink);
}

.step-choices {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}

/* Roomier than the same chip in the filter row: there it is one of thirty things on a dense
   screen, here it is the only thing being asked. */
.chip.step-choice {
    min-height: 38px;
    padding: 0 16px;
    font-size: 15px;
}

.step-actions {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-top: 22px;
}

/* An even half each. They are two ways forward from the same question, and a wide one next to a
   word was reading as one button with a note beside it. */
.step-actions .button {
    flex: 1 1 0;
    min-width: 0;
    justify-content: center;
}

/* Quieter than the one beside it, without becoming furniture: skipping is a real answer here, just
   not the one being suggested. */
.button.step-skip {
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink-2);
}

.button.step-skip:hover {
    background: var(--surface-sunk);
}

/* The preview step. One file gets the room; several stack, because scrolling past four of them is
   still better than four thumbnails nobody can check anything on. */
.previews {
    display: flex;
    flex-direction: column;
    gap: 14px;
    margin-bottom: 4px;
}

.preview-one {
    margin: 0;
    padding: 10px;
    border-radius: var(--r-inner);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
}

.preview-one img {
    display: block;
    width: 100%;
    /* With the width and height attributes the sheet now writes, auto lets the browser reserve the
       picture's own shape before a byte of it has arrived — the sunk box below is on screen at once
       instead of the page jumping when the thumbnail lands. */
    height: auto;
    max-height: 46vh;
    border-radius: 8px;
    object-fit: contain;
    background: var(--surface-sunk);
}

/* ------------------------------------------------------------- pdf viewer -- */

/* A PDF drawn by pdf.js — see pdf-viewer.js. The host holds the cover fallback until the first
   page is drawn, then the pages, one under the other, scrolling inside the host on the file page
   and the Add sheet and inside the dialog in the lightbox. */
.pdf-viewer {
    position: relative;
    display: block;
    width: 100%;
    border-radius: 8px;
    background: var(--surface-sunk);
}

.pdf-viewer.is-live {
    overflow-y: auto;
    overscroll-behavior: contain;
}

.file .preview .pdf-viewer.is-live {
    max-height: 62vh;
}

.preview-one .pdf-viewer.is-live {
    max-height: 46vh;
}

/* The cover stays in the markup for the fallback and goes away the moment pages exist. */
.pdf-viewer.is-live .pdf-cover,
.pdf-viewer.is-loading .pdf-cover-label,
.pdf-cover[hidden] {
    display: none;
}

.pdf-viewer-status {
    position: absolute;
    inset-inline: 0;
    bottom: 12px;
    margin: 0;
    padding: 6px 12px;
    text-align: center;
    color: var(--ink-2);
    font-size: 13px;
    pointer-events: none;
}

.pdf-viewer.is-failed .pdf-viewer-status {
    position: static;
    padding: 10px 12px 4px;
}

.pdf-pages {
    display: flex;
    flex-direction: column;
    gap: 10px;
    padding: 10px;
}

/* One page. The variables are pdf.js's own: its text layer sizes itself and every glyph from
   --total-scale-factor, and pdf-viewer.js writes --scale-factor per page when it draws it. */
/* No callout on a held finger: holding is how words are picked (pdf-viewer.js), and iOS would
   otherwise offer to save the page picture instead. */
.pdf-page {
    -webkit-touch-callout: none;
    position: relative;
    width: 100%;
    --scale-factor: 1;
    --user-unit: 1;
    --total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
    --scale-round-x: 1px;
    --scale-round-y: 1px;
    background: #ffffff;
    box-shadow: 0 1px 3px rgb(0 0 0 / 0.18);
}

.pdf-page canvas {
    display: block;
    width: 100%;
    height: auto;
}

.pdf-page-failed::after {
    content: "!";
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--ink-3);
    font-size: 28px;
}

/* pdf.js's text layer, as it ships it (web/viewer.css), trimmed to what this app draws. Every
   span is a transparent copy of a run of text, placed and scaled to sit exactly over the glyphs
   on the canvas — so selecting, copying and, later, commenting on a passage work on the real
   words. The custom properties are set by the library on each span; the rule that turns them
   into a font size has to be here or every span is drawn at zero size. */
.textLayer {
    position: absolute;
    inset: 0;
    overflow: clip;
    opacity: 1;
    line-height: 1;
    letter-spacing: normal;
    word-spacing: normal;
    text-align: initial;
    text-size-adjust: none;
    forced-color-adjust: none;
    transform-origin: 0 0;
    caret-color: CanvasText;
    z-index: 0;
    color-scheme: only light;
    --min-font-size: 1;
    --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
    --min-font-size-inv: calc(1 / var(--min-font-size));
}

/* user-select: none, on purpose. Picking words in a PDF is done by pdf-viewer.js itself, with
   its own highlight and handles, because the browser's selection snapped from line to line on a
   phone and ate the tap on the comment button. With no browser selection there is nothing for a
   tap to dismiss. */
.textLayer :is(span, br) {
    color: transparent;
    position: absolute;
    white-space: pre;
    cursor: text;
    transform-origin: 0% 0%;
    user-select: none;
    -webkit-user-select: none;
}

.textLayer > :not(.markedContent),
.textLayer .markedContent span:not(.markedContent) {
    z-index: 1;
    --font-height: 0;
    font-size: calc(var(--text-scale-factor) * var(--font-height));
    --scale-x: 1;
    --rotate: 0deg;
    transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}

.textLayer .markedContent {
    display: contents;
}

.textLayer span[role="img"] {
    user-select: none;
    cursor: default;
}

.textLayer .endOfContent {
    display: block;
    position: absolute;
    inset: 100% 0 0;
    z-index: 0;
    cursor: default;
    user-select: none;
}

/* Inside the quick-look dialog: the whole body, dark around the pages like the image view, and
   the pages themselves no wider than a sheet of paper on a big screen. */
.lightbox-pdf {
    width: 100%;
    height: 100%;
    border-radius: 0;
    background: #0e1417;
}

.lightbox-pdf .pdf-pages {
    max-width: 900px;
    margin: 0 auto;
    padding-top: max(64px, calc(env(safe-area-inset-top) + 56px));
}

.lightbox-pdf .pdf-viewer-status {
    color: #ffffff;
}

.lightbox-pdf[hidden] {
    display: none;
}

.pdf-cover {
    position: relative;
    display: block;
    border-radius: 8px;
    overflow: hidden;
    background: var(--surface-sunk);
    color: inherit;
    text-decoration: none;
}

.pdf-cover img {
    display: block;
    width: 100%;
    height: auto;
    max-height: 62vh;
    object-fit: contain;
}

.preview-one .pdf-cover img {
    max-height: 46vh;
}

.pdf-cover .thumb.large {
    aspect-ratio: 4 / 3;
}

/* Over the bottom of the cover, so the picture reads first and the way in reads second. */
.pdf-cover-label {
    position: absolute;
    left: 50%;
    bottom: 12px;
    transform: translateX(-50%);
    white-space: nowrap;
}

.preview-one figcaption {
    margin-top: 8px;
    font-size: 13px;
    color: var(--ink-2);
    overflow-wrap: anywhere;
}

/* A field with something living inside its right-hand end. The padding keeps the text off it, so a
   long name runs under the cross rather than through it. */
.name-field {
    position: relative;
    margin-bottom: 10px;
}

.name-field input {
    padding-inline-end: 44px;
}

.field-clear {
    position: absolute;
    top: 50%;
    inset-inline-end: 6px;
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    min-height: 0;
    padding: 0;
    border: none;
    border-radius: 50%;
    background: none;
    color: var(--ink-3);
    cursor: pointer;
    transform: translateY(-50%);
}

.field-clear:hover {
    background: var(--surface-sunk);
    color: var(--ink-2);
}

.field-clear svg {
    display: block;
    width: 14px;
    height: 14px;
}

/* ------------------------------------------------------------- add dial -- */

/* The form itself is only a container for things that are fixed or hidden, so it takes no room in
   the flow. */
.add-dial {
    display: contents;
}

/* Off screen but still reachable by keyboard, the same arrangement the filter switch uses: this is
   the real control, the round button is its label. */
.add-dial-toggle,
.add-dial-input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.add-dial-button {
    cursor: pointer;
}

/* The plus turns into a cross while the two options are out — the same button now closes them. */
.add-dial-button svg {
    transition: transform 200ms var(--ease);
}

.add-dial-toggle:checked ~ .add-dial-button svg {
    transform: rotate(45deg);
}

.add-dial-toggle:focus-visible ~ .add-dial-button {
    outline: 2px solid var(--accent);
    outline-offset: 3px;
}

/* Catches the tap that means "not this after all". Below the button and the options, above the
   page. No fill: a dimmed screen would be a heavier promise than a two-item menu deserves. */
.add-dial-scrim {
    position: fixed;
    inset: 0;
    z-index: 3;
    display: none;
}

.add-dial-toggle:checked ~ .add-dial-scrim {
    display: block;
}

/* Stacked straight above the button and hard against the same right edge, so they read as having
   come out of it. */
/* Stacked straight above the button, centred on it rather than aligned to the same edge: they are
   round and it is round, so a shared centre line is what makes them one control. */
.add-dial-options {
    position: fixed;
    inset-inline-end: calc(var(--fab-right) + (var(--fab-size) - var(--dial-size)) / 2);
    bottom: calc(var(--fab-bottom) + var(--fab-size) + 12px);
    z-index: 4;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
}

/* Icons only. Two words on two pills was a menu; two discs read as what the round button opened,
   and there is nothing to size differently. What each one does is on the input it labels, so a
   screen reader and a long press both still say it. */
.add-option {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--dial-size);
    height: var(--dial-size);
    flex: 0 0 auto;
    border-radius: 50%;
    background: var(--surface);
    box-shadow: var(--lift);
    color: var(--ink);
    cursor: pointer;
    /* Closed: down by a few pixels, slightly small, and gone. Scaled from the bottom right corner
       so they shrink back towards the button rather than towards their own middle. */
    opacity: 0;
    transform: translateY(8px) scale(0.92);
    transform-origin: bottom right;
    transition: opacity 160ms var(--ease), transform 160ms var(--ease);
    pointer-events: none;
}

.add-option svg {
    width: 20px;
    height: 20px;
    flex: 0 0 auto;
}

.add-dial-toggle:checked ~ .add-dial-options .add-option {
    opacity: 1;
    transform: none;
    pointer-events: auto;
}

/* Out one after the other, the nearest one first, so they read as leaving the button rather than
   arriving together. Only on the way out — closing them all at once is what a dismissal should
   feel like. */
.add-dial-toggle:checked ~ .add-dial-options .add-option:last-of-type {
    transition-delay: 0ms;
}

.add-dial-toggle:checked ~ .add-dial-options .add-option:first-of-type {
    transition-delay: 55ms;
}

.add-option:hover {
    background: var(--surface-sunk);
}

.add-option:active {
    transform: scale(0.97);
}

/* The no-script fallback keeps its word: it is the only thing on the screen that has to be
   understood before it is pressed, and a third disc would say nothing. */
.add-option-go {
    width: auto;
    height: auto;
    min-height: var(--dial-size);
    padding: 0 18px;
    border-radius: var(--r-pill);
    color: var(--ink);
    font-size: 14px;
    font-weight: 600;
    letter-spacing: normal;
}

/* An animation that only ever plays with a transform in it is motion whichever way you cut it: the
   options simply appear. */
@media (prefers-reduced-motion: reduce) {
    .add-option {
        transform: none;
    }
}

/* --------------------------------------------------------- page loader -- */

/* Covers everything, toasts included: for the moment it is up, the page underneath is on its way
   out and nothing on it can be acted on. */
.page-loader {
    position: fixed;
    inset: 0;
    z-index: 70;
    display: grid;
    place-items: center;
    gap: 14px;
    /* Barely tinted. The blur is what does the work — it turns the page underneath into a texture
       instead of something you can still half-read, while leaving enough of it showing that it is
       obviously the same page waiting rather than a different screen. */
    background: color-mix(in srgb, var(--bg) 55%, transparent);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    animation: page-loader-in 140ms var(--ease) both;
}

/* Without the blur a 45% wash leaves the page perfectly readable, which defeats the whole thing.
   Where it is not supported, the veil goes nearly solid instead. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .page-loader {
        background: color-mix(in srgb, var(--bg) 92%, transparent);
    }
}

/* [hidden] is only a UA rule of display:none, and it loses to any display declared in this
   stylesheet. Without this line the veil would be on screen from the moment the page loads. */
.page-loader[hidden] {
    display: none;
}

/* Fades in rather than appearing, so a navigation that finishes in 50ms leaves a hint that
   something happened instead of a flash. */
@keyframes page-loader-in {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

/* The same lockup as the band at the top, standing on its own: bigger, dark words instead of white
   ones, and none of the negative margins that tuck the disc into a coloured bar. */
/* Wraps everything the loader shows, so the halo below is sized to all of it. A column rather than
   two grid rows: grid gave each child a row of its own and centred it in that row, which under
   reduced motion — the one case where there are two — put the words a third of a screen below the
   mark. */
.page-loader-body {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
    padding: 26px 30px;
    /* A soft halo, not a card. The veil is deliberately see-through, so without this the wordmark
       lands on top of whatever file names happen to be underneath it and the two fight. Fading out
       to nothing at the edges keeps the page visible around it, which is the point of a blurred
       backdrop rather than a solid one.
       farthest-side, so the oval spans the whole lockup: closest-side sizes both radii to the
       shorter half — a circle the width of the disc — which cleared the middle and left both words
       sitting on the page. */
    background: radial-gradient(
        ellipse farthest-side,
        color-mix(in srgb, var(--bg) 96%, transparent) 55%,
        transparent
    );
}

.page-loader-brand {
    margin: 0;
    gap: 9px;
}

.page-loader-brand .site-brand-word {
    font-size: 13px;
    color: var(--ink);
}

.page-loader-brand .site-brand-mark {
    width: 56px;
    height: 56px;
    margin: 0 6px;
    background: var(--mark-canvas);
    box-shadow: none;
    /* Flat here on purpose. This one is a span standing on a flat overlay, not a button on the
       band, and blurring what is behind it would only smear the sheet it is already sitting on. */
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    transform: none;
    /* A slow breath rather than a spinner. It is the only thing on screen saying the app is still
       working, and it is the brand doing it. */
    animation: page-loader-breathe 1400ms var(--ease) infinite;
}

.page-loader-brand .site-brand-mark svg {
    width: 38px;
    height: 38px;
}

@keyframes page-loader-breathe {
    0%,
    100% {
        transform: scale(1);
    }

    50% {
        transform: scale(0.88);
    }
}

/* Read out by a screen reader, off screen for everybody else — the mark says the same thing to
   anyone who can see it. */
.page-loader-text {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* The upload variant. A file going up can take twenty seconds on a phone, and for that long the
   breathing mark on its own reads as a page that has frozen. So while data-mode="upload" is on the
   veil there is also a turning ring and a sentence saying what is happening. Both are absent for a
   plain link, where the wait is a second or two and the mark is enough. */
.page-loader-ring,
.page-loader-upload {
    display: none;
}

.page-loader[data-mode="upload"] .page-loader-ring {
    display: block;
    width: 34px;
    height: 34px;
    border-radius: 50%;
    border: 3px solid var(--hairline);
    border-top-color: var(--accent);
    animation: page-loader-spin 900ms linear infinite;
}

.page-loader[data-mode="upload"] .page-loader-upload {
    display: block;
    color: var(--ink-2);
    font-size: 15px;
}

/* The progress of an upload the script is sending itself — see page-loader.js. Hidden until the
   script has a number; while the bytes are going up the number and the bar stand in for the
   ring, and the ring comes back for the wait on the server afterwards. */
.page-loader-percent,
.page-loader-bar,
.page-loader-detail {
    display: none;
}

.page-loader[data-mode="upload"] .page-loader-percent:not([hidden]) {
    display: block;
    font-size: 30px;
    font-weight: 700;
    line-height: 1;
    color: var(--ink);
    font-variant-numeric: tabular-nums;
}

.page-loader[data-mode="upload"] .page-loader-bar:not([hidden]) {
    display: block;
    width: 220px;
    max-width: 70vw;
    height: 6px;
    border-radius: 999px;
    background: var(--hairline);
    overflow: hidden;
}

.page-loader-bar-fill {
    display: block;
    width: 0;
    height: 100%;
    border-radius: 999px;
    background: var(--accent);
    transition: width 160ms linear;
}

.page-loader[data-mode="upload"] .page-loader-detail:not([hidden]) {
    display: block;
    max-width: 280px;
    color: var(--ink-2);
    font-size: 13px;
    line-height: 1.4;
    text-align: center;
    overflow-wrap: anywhere;
}

.page-loader[data-phase="sending"] .page-loader-ring {
    display: none;
}

/* The mark's breathing is the loader's normal signal; under the ring it is one motion too many. */
.page-loader[data-mode="upload"] .page-loader-brand .site-brand-mark {
    animation: none;
}

@keyframes page-loader-spin {
    to {
        transform: rotate(360deg);
    }
}

/* The blanket rule below cuts every animation to 0.01ms. On something that loops for ever that is
   not "no motion", it is a strobe — so the breathing is switched off by name here, and the words
   it stood in for are shown instead of implied. */
@media (prefers-reduced-motion: reduce) {
    .page-loader-brand .site-brand-mark,
    .page-loader[data-mode="upload"] .page-loader-ring {
        animation: none;
    }

    .page-loader-text {
        position: static;
        width: auto;
        height: auto;
        margin: 0;
        overflow: visible;
        clip-path: none;
        color: var(--ink-2);
        font-size: 14px;
    }
}

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        transition: none !important;
    }

    :active {
        transform: none !important;
    }
}

/* --------------------------------------------------------------- talen -- */

/* Four chips, wrapping. Each is written in its own language and carries its own lang attribute, so
   a screen reader pronounces "Русский" in Russian rather than sounding it out in Dutch. */
.language-picker {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin: 12px 0 0;
}

.language-picker .chip {
    flex: 0 0 auto;
}

/* ------------------------------------------------------- rechts naar links -- */

/* Almost nothing is needed here, and that is the point: every margin, padding, offset and corner in
   this stylesheet is written as a logical property, so the whole layout mirrors itself when the
   html element says dir="rtl". What is left over is the handful of things that point somewhere.

   An arrow is a picture of a direction. Mirroring the layout moves it to the other side of the
   screen but leaves it aimed the wrong way, so these two are flipped by hand. */
[dir="rtl"] .head-back svg,
[dir="rtl"] .title-back a span {
    transform: scaleX(-1);
}

/* The points pill sits just off the centre of the profile icon. The nudge is a transform, which no
   direction property reaches, so it is mirrored here. */
[dir="rtl"] .nav-points {
    transform: translateX(-2px);
}

/* The push card on the profile page. The rows reuse the settings shape from the school page, and
   only one of the five states is ever shown: push.js sets hidden on the rest. Spelled out here
   because .setting-row's display:flex would otherwise beat the browser's own [hidden] rule and
   show every state at once. */
.push-card [hidden] {
    display: none;
}

.push-card .setting-row {
    margin-top: 0.75rem;
}

.push-card .profile-note + .setting-row {
    margin-top: 0.5rem;
}

/* The labels on the school's settings page: a list of inline-edit rows, grouped under a small
   heading per kind. Nothing here says "table"; it is the same field the description uses, once
   per label. */
.labels-kind {
    margin: 12px 0 4px;
    color: var(--ink-2);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

.labels-card .labels-kind:first-of-type {
    margin-top: 4px;
}

.labels-list {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin: 0;
    padding: 0;
    list-style: none;
}

.inline-edit-label input {
    font-size: 15px;
}

/* ------------------------------------------------- the two halves of a school -- */

/* Archief | Berichten, in the bar under the title: the two things a school is. The current one is
   underlined in the accent, the way a tab strip reads on every phone. */
.library-tabs {
    box-shadow: inset 0 1px 0 var(--hairline);
}

.library-tabs-inner {
    display: flex;
    max-width: 680px;
    height: var(--tabs-height);
    margin: 0 auto;
    padding: 0 14px;
}

.library-tab {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 1 1 0;
    color: var(--ink-2);
    font-size: 14px;
    font-weight: 600;
    text-decoration: none;
}

.library-tab.is-current {
    color: var(--accent);
}

.library-tab.is-current::after {
    content: "";
    position: absolute;
    right: 12px;
    bottom: 0;
    left: 12px;
    height: 3px;
    border-radius: 3px 3px 0 0;
    background: var(--accent);
}

/* The board. The box first, then the posts: somebody who came to say something should not have
   to scroll past everything that was said before. */
.post-form {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin: 0 0 18px;
}

.post-form textarea {
    width: 100%;
    padding: 10px 12px;
    border: none;
    border-radius: var(--r-inner);
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--hairline);
    color: var(--ink);
    font: inherit;
    font-size: 15px;
    line-height: 1.45;
    resize: vertical;
}

.post-form textarea:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
}

.post-form button {
    align-self: flex-end;
}

.posts-note,
.posts-empty {
    margin: 6px 0 16px;
}

.post-list {
    display: flex;
    flex-direction: column;
    gap: 14px;
    margin: 0;
    padding: 0;
    list-style: none;
}

.post {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 12px 14px;
    border-radius: var(--r-card);
    background: var(--surface);
    box-shadow: 0 0 0 1px var(--hairline);
}

.post .comment-mark {
    width: 32px;
    height: 32px;
    font-size: 12px;
}

.post-mine .comment-mark {
    background: var(--accent);
    color: var(--surface);
}

.post-body {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    gap: 3px;
    min-width: 0;
}

.post-head {
    display: flex;
    align-items: baseline;
    gap: 8px;
    flex-wrap: wrap;
}

.post-author {
    font-size: 14px;
    font-weight: 600;
}

.post-when {
    font-size: 11.5px;
    font-variant-numeric: tabular-nums;
}

.post-delete {
    display: inline-flex;
    margin-inline-start: auto;
    align-self: center;
}

.post-text {
    margin: 0;
    font-size: 15px;
    line-height: 1.5;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* A comment on the board, as against a post: the same card, marked. A small pill after the name
   says what it is, a line under the head says which file, and the card sits on a faint wash so a
   run of comments reads as a run. */
.post-comment {
    background: color-mix(in srgb, var(--accent-wash) 45%, var(--surface));
}

.post-kind {
    display: inline-block;
    padding: 1px 8px;
    border-radius: 999px;
    background: var(--accent-wash);
    color: var(--accent);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    line-height: 1.6;
}

.post-about {
    font-size: 12.5px;
    line-height: 1.4;
}

a.post-about {
    color: var(--accent);
    text-decoration: none;
}

a.post-about:hover,
a.post-about:focus-visible {
    text-decoration: underline;
}
