/* AIMPHAN Media Library — design system (tokens from design.pdf). */

/*
 * Design tokens — AIMPHAN Foundation (#33).
 *
 * Values come from the Figma `00 · Foundation` frame, which every screen in the
 * file is drawn against. They previously read #1f2a8c, a dark indigo that is not
 * the AIMPHAN brand; three independent sources say #034ea0 — the Foundation
 * swatch, the main-website child theme's --ap-primary, and the live staging CSS.
 *
 * The Foundation name for each token is in the comment, so the next person can
 * find the swatch rather than guessing.
 */

/*
 * Cascade layers (#63).
 *
 * The order below IS this theme's precedence, stated once instead of falling
 * out of source order and selector weight: components beats layout even when
 * layout is an ID, and utilities beats both. It exists because the file lost
 * two specificity fights — `.site-nav + .site-nav` cancelling a `margin-left:
 * auto`, and `.exam__q` needing `width: 100%` to beat an auto margin.
 *
 *   wp          WordPress's output, wrapped into this layer by inc/assets.php.
 *               theme.json prints UNLAYERED `a` and `h1`…`h6`, and unlayered
 *               beats every layer whatever its specificity — without the wrap,
 *               layering this file turns every link brand-blue.
 *   tokens      :root custom properties, and nothing else
 *   base        element selectors and document-wide preferences
 *   layout      the page shells and the columns they create
 *   components  everything with a name: cards, pills, fields, exam, tables
 *   utilities   classes meant to win: .muted, .screen-reader-text, .is-busy
 *
 * Layers are RE-OPENED rather than gathered, so every rule stayed on its line
 * under the comment explaining it and source order still decides within a
 * layer. Two things are deliberately NOT layered; see the note above each.
 */
@layer wp, tokens, base, layout, components, utilities;

@layer tokens {
  :root {
    --brand: #034ea0;                     /* accent      — buttons, focus, pills  */
    /*
     * DERIVED from --brand, not written down (#63).
     *
     * These were hand-picked hexes that had to be re-picked every time --brand
     * moved — and it moved once already, in #33. OKLCH rather than sRGB because
     * sRGB steps are not perceptually even: keeping the hue angle and scaling
     * lightness is what "the same colour, deeper" means.
     *
     * The multipliers were fitted to the Foundation, so this derives the design
     * system rather than changing it:
     *
     *   --brand-light  #d5ecff   Foundation panel  #d5ecff  (exact)
     *
     * --brand-dark is the exception, and is PINNED to the Foundation value
     * rather than derived. The fitted formula lands on #043c7c against Figma's
     * #023c7c — one step in the red channel, invisible, and 10.81:1 vs 10.82:1
     * on white. It is still a deviation, and the Foundation is the contract:
     * this project has already had to fix a palette that disagreed with Figma
     * (#32, #33), and a designer checking the build against the file should
     * find the value they specified, not one a formula came close to.
     *
     * The formula is kept here so the ramp can be regenerated if --brand ever
     * moves, at which point Figma moves too and this pin is re-derived from it:
     *   oklch(from var(--brand) calc(l * 0.835) calc(c * 0.82) h)
     */
    --brand-dark: #023c7c;
    --brand-light: oklch(from var(--brand) 0.936 calc(c * 0.33) h);
    /*
     * brand-ink is heading text, not a blue. The Foundation has ONE ink for
     * headings and body, so this and --ink are now the same value. Left as two
     * names deliberately: collapsing them touches 19 declarations and is a
     * refactor, not this fix.
     */
    --brand-ink: #1a1f2b;                 /* ink                                  */
    --surface: #f2f5f9;                   /* surface2                             */
    --card: #ffffff;
    --sunken: #eef0f6;
    --ink: #1a1f2b;                       /* ink         — body copy              */
    /*
     * Muted was validated on --card and used on three grounds (#79).
     *
     * #6b7280 measures 4.83 on white, which is where it was checked, and 4.42 on
     * --surface and 4.24 on the --sunken pill, which is where most of it actually
     * renders. #656c7a clears 4.5 on all three (5.28 / 4.83 / 4.63) and still
     * sits 3.12 against --ink, so it stays visibly subordinate to body copy.
     */
    --ink-muted: #656c7a;                 /* muted       — AA on card, surface and sunken */
    /*
     * NOT a text colour (#79).
     *
     * At 2.38 on --surface it was the worst contrast in the theme, and it cannot
     * be repaired by darkening: to clear 4.5 there it would have to pass
     * --ink-muted, collapsing the one step it exists to provide. The surface only
     * affords two readable greys, so text uses moved to --ink-muted and this
     * stays for non-text marks — chevrons, separators, placeholder glyphs — where
     * the threshold is 3:1.
     */
    --ink-faint: #9aa1ad;                 /* no Foundation equivalent             */
    --line: #d8dee8;                      /* line        — see #32, chip value    */

    /* Named in the Foundation, previously unavailable to this theme. */
    --gold: #ffbd58;
    --red: #ff3131;
    --exam: #2e7d52;
    --admin: #b07d2e;

    /*
     * The Foundation's red is a SIGNAL, not an ink (#63).
     *
     * #ff3131 is 3.66:1 on white — enough for a border or a wash, short of the
     * 4.5:1 AA wants of anything under 18.66px bold, and every red word here is
     * a 13px or 15px label. So the swatch stays as drawn and the ink is derived
     * from it: same hue and chroma, lightness scaled until it clears. #c00000 —
     *
     *   6.48:1 on --card    5.92:1 on --surface    5.97:1 on the confirm wash
     *   5.46:1 on the error notice's wash, its tightest pairing
     *   6.48:1 for white text ON it (the destructive button)
     *
     * It also absorbs #c0143c and #ef5a86, two more hand-picked reds doing the
     * same job. #ef5a86 was 3.24:1 and it was the field error message.
     */
    --red-ink: oklch(from var(--red) calc(l * 0.74) c h);

    /*
     * Same story for the exam green (#79).
     *
     * #2e7d52 on the pale --status--ok wash is 4.47:1 — under AA for a 13px bold
     * label by a margin too small to see and too real to keep. Derived rather
     * than redrawn, for the reason above: the swatch is the brand's, the ink is
     * ours. 0.96 lands at 4.87, chosen over a tighter multiplier because a value
     * that clears the bar by 0.01 is not a fix.
     */
    --exam-ink: oklch(from var(--exam) calc(l * 0.96) c h);

    /*
     * The six education levels, as CSS (#69).
     *
     * These already existed in theme.json, which makes them available to the
     * block editor and to nothing else — the theme's own stylesheet could not
     * reach them. They are the most distinctive thing the brand owns: every
     * AIMPHAN series is colour-coded by level, so on a shelf they are the
     * spines. Named for the level rather than the hue, because a future
     * repaint changes the value and not the meaning.
     *
     * Ordered deliberately: this is the sequence a Thai pupil moves through,
     * early years to vocational, with the two non-graded ranges last. Anywhere
     * the set is drawn as a run, that order is the one to draw.
     */
    --level-preschool: #ef5a86;           /* ปฐมวัย                */
    --level-primary: #a855f7;             /* ประถมศึกษา            */
    --level-secondary: #22b573;           /* มัธยมศึกษา            */
    --level-vocational: #f97316;          /* อาชีวศึกษา            */
    --level-nonformal: #2f80ed;           /* การศึกษานอกโรงเรียน    */
    --level-supplementary: #f5b60a;       /* หนังสือเพิ่มเติม        */

    /*
     * Type scale — Figma `00 · Foundation` (#34).
     *
     * Six steps, 32 / 24 / 19 / 15 / 13 / 11, named for the role the text plays
     * rather than for its pixel value. A screen then picks a step by asking "what
     * is this text for?", and the mobile step-down below can change the number
     * without a single call site changing with it.
     *
     *   display  the page title — one per screen
     *   title    a card or section title that owns a block
     *   heading  a sub-heading inside a block
     *   body     running text; the default, set on <body>
     *   small    captions, meta, hints, form labels, table cells
     *   tiny     badges, pill tags, uppercase overlines
     *
     * Body is 15px, not the browser's 16px. 15 is the Foundation's body step, and
     * a scale that excludes the most common size on the page is not a scale.
     */
    --fs-display: 32px;
    --fs-title: 24px;
    --fs-heading: 19px;
    --fs-body: 15px;
    --fs-small: 13px;
    --fs-tiny: 11px;

    /*
     * Leading. Thai stacks tone marks above the ascender and sara below the
     * descender, so it needs more room than Latin at the same size. 1.4 is the
     * floor for anything that can wrap — headings included, which wrap readily in
     * Thai because the script writes without spaces between words — and running
     * text keeps the 1.55 the body already used.
     */
    --lh-heading: 1.4;
    --lh-body: 1.55;

    --radius-card: 14px;
    --shadow-card: 0 1px 2px rgba(16, 21, 51, 0.06), 0 6px 20px rgba(16, 21, 51, 0.06);
    --shadow-pop: 0 12px 40px rgba(16, 21, 51, 0.16);
    /*
     * 1360, not 1200. The Foundation's desktop row reads `40 / 1360 @ 1440` and
     * every desktop frame in the file is 1440 wide, so 1200 cannot match any
     * screen that was designed. This is the one change here with visible layout
     * impact — check it against the frames.
     */
    --shell: 1360px;
  }

  /*
   * Mobile step-down (#34).
   *
   * The Foundation shrinks the three heading steps below 768 — 32 -> 24, 24 -> 20,
   * 19 -> 17 — and holds body, small and tiny, because shrinking the text people
   * actually read is the opposite of what the smallest screen needs.
   *
   * Done once, on the token values, rather than per selector: every rule that
   * reaches for --fs-display gets the smaller number for free, and adding a
   * heading later does not mean remembering to add a media query with it.
   */
  @media (max-width: 767px) {
    :root {
      --fs-display: 24px;
      --fs-title: 20px;
      --fs-heading: 17px;
    }
  }
}

@layer base {
  * { box-sizing: border-box; }
  html { -webkit-text-size-adjust: 100%; }
  /*
   * The page is a column — chrome, content, chrome — and the browser measures it
   * (#56).
   *
   * Height used to be spelled `calc(100vh - 64px - 60px)`, restated in three
   * places: a header constant and a footer constant, both written down by hand.
   * Neither kept up. The footer had already grown to ~131px, so .site-main
   * reserved some 70px more than the viewport holds and every page short enough
   * to fit exactly scrolled by that much instead. The 64px had to be re-checked
   * once when the gate stopped drawing a header and again when the bar became
   * min-height, and nothing would have caught either going stale in between.
   *
   * A flex column asks for no numbers. The header and the footer take whatever
   * height their content gives them, .site-main is handed the rest, and the next
   * time either is redesigned there is nothing in this file to update.
   */
  body {
    margin: 0;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    font-family: "Noto Sans Thai", system-ui, -apple-system, sans-serif;
    background: var(--surface);
    color: var(--ink);
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    /*
     * Thai line breaking, part one (#63). Thai writes without spaces, so the
     * browser can break almost anywhere and left alone leaves a one-syllable
     * last line; `pretty` asks it to look further ahead. Inherited, so every
     * paragraph, hint, cell and error message gets it without naming it.
     *
     * Worth recording so nobody re-measures it: Chrome 152 computes this but
     * does not move a line, Thai or Latin, on any of this theme's text.
     * `balance` below does. Kept because it is right for prose, costs nothing,
     * and improves as the engine does. `white-space: nowrap` still wins where
     * a rule sets it — nowrap is text-wrap-MODE, pretty is text-wrap-STYLE.
     */
    text-wrap: pretty;
  }
  a { color: inherit; text-decoration: none; }
  img { max-width: 100%; display: block; }
}

/*
 * Thai line breaking, part two: headings balance (#63).
 *
 * A heading is short, set large, and in Thai wraps far more readily than the
 * same words in Latin. Measured on a 166px book card: "สื่อการเรียน ปฐมวัย
 * เล่ม 1" went from 122px + a 36px stub to 73px + 85px. One list rather than
 * fifteen declarations, because it is one policy — everything here is a title.
 *
 * `.pill__label`, `.adminnav__label` and `.tile__title` are deliberately
 * absent: each sits inside something that sets `white-space: nowrap`, and
 * balance sets text-wrap-mode: wrap, so naming them would hand back exactly the
 * wrapping those rules prevent — the three-line-Thai-label-in-a-pill defect.
 */
@layer components {
  .brand__name,
  .gate__brandname,
  .gate__title,
  .adminui__title,
  .adminui__panel-title,
  .sidebar__title,
  .empty__title,
  .book__title,
  .book__section-title,
  .book__subsection,
  .book-card__title,
  .entry__title,
  .entry__content :is(h1, h2, h3, h4, h5, h6),
  .admin-hub__item-title,
  .exam-sets__title {
    text-wrap: balance;
  }
}

@layer layout {
  .shell { max-width: var(--shell); margin: 0 auto; padding: 0 20px; }
}

@layer components {
  .card { background: var(--card); border-radius: var(--radius-card); box-shadow: var(--shadow-card); }
}

@layer utilities {
  .muted { color: var(--ink-muted); }
}

@layer layout {
  /* Header */
  .site-header { background: #fff; border-bottom: 1px solid rgba(154, 161, 173, 0.18); }

  /*
   * The bar states its own structure (#53).
   *
   * Two zones, named: WHERE YOU ARE at the start edge — the brand, then the
   * library's own menu — and WHO YOU ARE at the end edge — the role entry point
   * and sign out. Three explicit grid areas, so what header.php prints decides
   * which areas are FILLED and never which end of the bar anything sits at.
   *
   * What this replaces was a flex row in which the brand, both navs and the
   * sign-out form were four peers and position was negotiated by `margin-left:
   * auto` on the navs. That works only while there is exactly one nav: the moment
   * #36 added a second, it inherited a push it should never have been given and
   * needed `.site-nav + .site-nav { margin-left: 0 }` to take it back. Both the
   * auto margins and that override are gone.
   *
   * min-height, not height, and the same 64px at every width. Nothing subtracts
   * that number any more (#56), but .site-header is still the element the view
   * transition pairs across documents — a bar measuring 64px on the catalog and
   * something else on the book page would animate that difference on every
   * navigation, which is the opposite of what naming it was for.
   */
  .site-header__inner {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "brand library account";
    align-items: center;
    column-gap: 16px;
    min-height: 64px;
  }
  .site-header__inner > .brand { grid-area: brand; }
  .site-nav--library { grid-area: library; }
  .site-header__account { grid-area: account; justify-self: end; display: flex; align-items: center; gap: 8px; }
  /*
   * The boundary between the two zones, drawn once by the group that owns the
   * right-hand one — not by a rule that reacts to whichever sibling happens to
   * precede it. header.php omits the whole group when there is no account, so the
   * line can never end up floating against the edge of an empty zone.
   */
  .site-header__account::before { content: ""; flex: 0 0 1px; height: 24px; background: var(--line); }

  /*
   * The brand is the 'back to the library' control on every screen, and on a
   * phone it is the only one in the header — so it needs the 44px floor like
   * any other target. It was 34px, sized by the logo alone (#34).
   */
  .brand {
    min-height: 44px; display: inline-flex; align-items: center; gap: 10px;
  }
  .brand__logo { display: block; height: clamp(34px, 5vw, 44px); width: auto; max-width: 160px; object-fit: contain; }
  .brand__name { font-weight: 800; letter-spacing: -0.01em; color: var(--brand-ink); font-size: var(--fs-heading); }
  .brand__name strong { color: var(--brand); }
  /*
   * min-height 44px (#34).
   *
   * Height came entirely from padding, so putting body text on the Foundation's
   * 15px shrank this to ~39px — a control that was already under the 44x44 floor
   * quietly got smaller. The floor has to be declared, not derived from whatever
   * the font size happens to be.
   */
  .signout__btn {
    display: inline-flex; align-items: center; justify-content: center;
    min-height: 44px;
    border: 0; background: transparent; color: var(--ink-muted); font: inherit; font-weight: 600;
    padding: 8px 16px; border-radius: 999px; cursor: pointer;
  }
  .signout__btn:hover { background: var(--sunken); }

  /*
   * Header navigation.
   *
   * Both navs are the same component; only the zone they sit in differs, and the
   * zone is decided by .site-header__inner above. Neither carries a margin of its
   * own any more — a nav that pushes itself around is a nav whose position depends
   * on what else was printed beside it.
   *
   * gap 8px and a 44px minimum height (#34).
   *
   * padding 8px + a 16px/1.55 line box came to 40.8px, under the Foundation's
   * 44x44 rule, and 4px between items is under its 8px minimum spacing. Using
   * min-height with flex centring rather than more padding keeps the pill the
   * same visual weight while making it a legitimate tap target.
   */
  .site-nav__list { list-style: none; display: flex; gap: 8px; margin: 0; padding: 0; }
  /*
   * A pill keeps its own width and its label keeps its own line — the two halves
   * of the same rule, and the pair .sidebar__list already needs at ≤767.
   *
   * Without them a menu wider than its column does not scroll, it collapses: flex
   * items shrink by default, so each pill is squeezed narrower than its label and
   * the Thai label breaks over two and three lines inside it. At 390 that turned
   * a 44px row into an 89px one and the header into 166px. Thai wraps far more
   * readily than Latin here — it writes without spaces, so the browser breaks
   * wherever it likes rather than only at a space.
   */
  .site-nav__list li { flex: 0 0 auto; }
  .site-nav__list a {
    display: inline-flex; align-items: center; min-height: 44px;
    padding: 0 14px; border-radius: 999px;
    font-weight: 600; color: var(--ink-muted); white-space: nowrap;
  }
  /*
   * The library menu is admin-authored, so its length is not something this theme
   * gets to assume. Its grid column is minmax(0, 1fr) — it cannot push the account
   * group off the end of the bar — so the overflow has to go somewhere, and a
   * scroller is where. At widths where the menu fits, no scrollbar appears and
   * this does nothing.
   */
  .site-nav--library .site-nav__list { overflow-x: auto; }
  .site-nav__list a:hover { background: var(--sunken); color: var(--brand-ink); }
  .site-nav__list .current-menu-item a { color: var(--brand); }

  /*
   * The role link is tinted rather than being one more muted pill. It is the only
   * item in the bar that changes with WHO is signed in, and at ≤767 it shares a
   * row with sign out — two identical grey pills there would read as a pair of
   * equals rather than "into your app" beside "leave it".
   */
  .site-nav--role .site-nav__list a { background: var(--brand-light); color: var(--brand-dark); font-weight: 700; }
  .site-nav--role .site-nav__list a:hover { background: var(--brand); color: var(--card); }

  /*
   * One row per zone at ≤767, and no hamburger (#53).
   *
   *   row 1   brand  +  library menu   — where you are
   *   row 2   role link  +  sign out   — who you are
   *
   * A drawer would be the wrong answer for a menu and one role link, and the
   * catalog already shows the right one: .sidebar__list turns its category column
   * into a horizontal scroller at this width, so the library menu does the same
   * here and any number of items fits a 360px phone without hiding behind a tap.
   *
   * Row 2 comes from the grid template rather than from `order`, so the sequence
   * the tab key and a screen reader follow stays the document sequence. It also
   * costs nothing when it is empty: the space above it is a margin on the account
   * group itself, not a row-gap, which a signed-out page printing no account at
   * all would still have been charged for.
   *
   * 767, the Foundation's mobile ceiling — 640 is not one of its breakpoints (#34).
   */
  @media (max-width: 767px) {
    .site-header__inner {
      grid-template-columns: auto minmax(0, 1fr);
      grid-template-areas:
        "brand   library"
        "account account";
      column-gap: 12px;
      padding-top: 10px;
      padding-bottom: 10px;
    }
    .site-header__account { justify-self: stretch; justify-content: space-between; margin-top: 8px; }
    /* Side by side the zones needed a rule between them; stacked, the row is one. */
    .site-header__account::before { display: none; }
    .site-nav__list { overflow-x: auto; padding-bottom: 4px; }
  }

  /* Takes the leftover height, so the footer lands on the fold or below the
     content and never anywhere in between. */
  .site-main { flex: 1; }
}

@layer layout {
  /*
   * A gate is a whole screen, not a block on one.
   *
   * .gate centres its card inside ITSELF, so it has to be as tall as the space
   * .site-main was handed rather than as tall as the card — otherwise the card
   * rides up under the header with dead surface beneath it, which is the #11
   * symptom. A grid stretches its single row to fill, which is all that is
   * wanted here; keying on the child rather than on a body class keeps the code
   * gate, the admin sign-in and the admin refusal — all three of them .gate — on
   * one rule instead of on a route list that has to be kept in sync.
   *
   * The column is stated rather than left implicit because an auto track is
   * sized to its contents, and .gate would then be as wide as whatever it
   * happens to hold rather than as wide as the page. A minmax(0, 1fr) column is
   * exactly as wide as .site-main and no wider — which is also what gives
   * .gate's own column something definite to be measured against (#59).
   *
   * Without :has() support the panel simply sizes to its content: the card sits
   * high, nothing overlaps or overflows.
   */
  .site-main:has(> .gate) { display: grid; grid-template-columns: minmax(0, 1fr); }
  .site-footer { border-top: 1px solid rgba(154, 161, 173, 0.18); background: #fff; margin-top: 48px; }

  /*
   * Two columns that become one, without a media query (#73).
   *
   * auto-fit collapses to a single column when the columns can no longer hold
   * 260px, which is a fact about the CONTENT rather than about a phone: the
   * same footer inside a narrow container gets the same answer. The Thai
   * department names are what set that floor.
   */
  .site-footer__inner {
    padding: 28px 20px 20px;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 24px 40px;
    align-items: start;
    color: var(--ink-muted);
    font-size: var(--fs-small);
  }

  .site-footer__brandblock { display: grid; justify-items: start; gap: 10px; }

  .site-footer__brand {
    display: inline-flex; align-items: center;
    min-height: 44px; min-width: 44px;
    border-radius: 12px;
    transition: background 0.15s ease;
  }
  .site-footer__brand:hover { background: var(--sunken); }
  .site-footer__logo { display: block; height: clamp(40px, 9vw, 52px); width: auto; max-width: 200px; object-fit: contain; }

  /* Thai name leads, Latin sits under it — the audience reads Thai first. */
  .site-footer__org { margin: 0; display: grid; gap: 2px; }
  .site-footer__org-th { font-weight: 700; color: var(--ink); }
  .site-footer__org-en { font-size: var(--fs-tiny); color: var(--ink-muted); }

  .site-footer__contact { display: grid; gap: 8px; justify-items: start; }
  .site-footer__heading { margin: 0 0 2px; font-size: var(--fs-small); font-weight: 700; color: var(--ink); }

  /*
   * Department and number on one row, wrapping together rather than the number
   * stranding itself on a line of its own.
   */
  .site-footer__line { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
  .site-footer__dept { color: var(--ink-muted); }

  /*
   * The tap target is the padding, not the text: a phone number is short, and
   * without this it is a ~90px wide, 18px tall target on the device where it is
   * most likely to be tapped.
   */
  .site-footer__tel,
  .site-footer__link {
    display: inline-flex; align-items: center;
    min-height: 44px; padding: 0 4px;
    color: var(--brand); font-weight: 700;
    text-decoration-thickness: 1px; text-underline-offset: 3px;
  }
  .site-footer__tel:hover,
  .site-footer__link:hover { color: var(--brand-dark); }

  .site-footer__copy {
    margin: 0;
    padding: 14px 20px 20px;
    border-top: 1px solid color-mix(in oklab, var(--line) 55%, transparent);
    text-align: center;
    color: var(--ink-muted);
    font-size: var(--fs-tiny);
  }
}

@layer components {
  /* Buttons + fields */
  .btn-brand {
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
    width: 100%; border: 0; cursor: pointer; font: inherit; font-weight: 700;
    color: #fff; background: var(--brand); border-radius: 999px; padding: 13px 24px;
    transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease;
    box-shadow: 0 1px 2px rgba(2, 60, 124, 0.24), 0 6px 14px -6px rgba(2, 60, 124, 0.45);
  }
  .btn-brand:hover { background: var(--brand-dark); }

  /*
   * A press that answers the tap and nothing more.
   *
   * 1px down with the shadow pulled in — the control appears to take the
   * weight of the finger. It fires on :active only, so it never plays at
   * someone who is merely reading, and 80ms is under the threshold where a
   * transition starts to feel like an animation.
   */
  .btn-brand:active {
    transform: translateY(1px);
    box-shadow: 0 1px 1px rgba(2, 60, 124, 0.28);
  }
  .btn-brand:focus-visible { outline: none; box-shadow: 0 0 0 4px var(--brand-light); }
  .field {
    width: 100%; border: 1px solid rgba(154, 161, 173, 0.4); border-radius: 12px;
    background: #fff; padding: 12px 16px; font: inherit; color: var(--ink);
  }
  /*
   * Motion that answers an action, rather than motion that plays at someone.
   *
   * The ring grows in ~140ms so the field visibly ACCEPTS focus — on a page
   * whose entire job is one input, confirming "you are typing here" is worth a
   * frame or two. Transitioning the border and shadow only; never the size, so
   * nothing below the field moves while someone is aiming at it.
   */
  .field { transition: border-color 140ms ease, box-shadow 140ms ease; }
  .field:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 4px var(--brand-light); }
  .field-label { display: block; margin-bottom: 6px; font-size: var(--fs-small); font-weight: 600; color: var(--ink-muted); }
  .field-label .req { color: var(--red-ink); }
  /*
   * The error arrives, it does not shake.
   *
   * A wrong code is a mistake, not a failure to scold: the message fades and
   * rises a few pixels so the eye catches that something appeared, and stops.
   * A shake reads as rejection and is the reflex choice here — this page is
   * used by teachers reading a code off a printed sheet, and getting it wrong
   * once is ordinary.
   *
   * It animates on every render because the server round-trips to show it, so
   * the element is new each time — there is no state to reset.
   */
  .field-error {
    margin: 4px 0 0; color: var(--red-ink); font-size: var(--fs-small); font-weight: 500;
    animation: aimphan-error-in 220ms ease-out both;
  }

  @keyframes aimphan-error-in {
    from { opacity: 0; transform: translateY(-4px); }
    to   { opacity: 1; transform: none; }
  }

  /*
   * The code field, which is the whole job of the gate (#69).
   *
   * Bigger and tracked, because a member code is not prose: it is read off a
   * printed sheet or a message one character at a time, and typed the same way.
   * Letter-spacing is what lets someone check what they have typed against what
   * they are reading without losing their place — it is doing work here, not
   * styling.
   *
   * Uppercase is a DISPLAY transform only. The value posted is whatever was
   * typed, and the server already normalises case before it looks a code up, so
   * this cannot change which code is submitted — it just stops "abc" and "ABC"
   * looking like different answers to the person entering them.
   *
   * 56px tall clears the 44px target floor with room, which matters on the one
   * control the entire page exists to serve.
   */
  .field--code {
    min-height: 56px;
    padding: 14px 18px;
    font-size: var(--fs-title);
    font-weight: 700;
    line-height: 1.2;
    letter-spacing: 0.12em;
    text-transform: uppercase;
  }

  /* The placeholder is a format hint, so it must not shout like a value. */
  .field--code::placeholder {
    font-weight: 400;
    letter-spacing: 0.06em;
    color: var(--ink-muted);
    text-transform: none;
  }
}

@layer layout {
  /* Code gate */
  /*
   * Height comes from .site-main above — this only says where the card sits in
   * whatever height that turns out to be.
   *
   * The column is stated, not left implicit (#59).
   *
   * `place-content: center` alone leaves the column implicit, and an implicit
   * track is sized `auto` — to its contents. A percentage width on a child of an
   * auto track has nothing definite to resolve against (the track is what it
   * would be measured against, and the track is still being measured), so it is
   * treated as auto and the child collapses to its own max-content. That is the
   * reason the card was sized in vw at all: a viewport unit is the one measure
   * that resolves without asking the parent.
   *
   * A minmax(0, 1fr) column is exactly .gate's content box, so `100%` on a child
   * now means "the width the padding left me". .gate__staff has carried
   * `width: 100%; max-width: 420px` all along and only starts reaching that
   * max-width now, for the same reason.
   *
   * align-content, the half of place-content that does the vertical centring
   * this rule was written for, is untouched; justify-content simply has no free
   * space left to distribute once the 1fr column has taken it.
   */
  .gate {
    display: grid; grid-template-columns: minmax(0, 1fr); place-content: center;
    gap: 24px; padding: 40px 16px;
    /*
     * Depth from the brand, not from decoration (#69).
     *
     * Two soft radial washes in the brand hue, one warm counterweight from the
     * supplementary level, over the flat surface. They sit far outside the card
     * so the card reads as lifted off a ground rather than pasted onto a
     * gradient — a mesh behind the content would compete with the one thing on
     * this page that matters.
     *
     * color-mix against the surface rather than fixed rgba: if --brand moves,
     * the ground follows it instead of drifting into a colour nobody chose.
     */
    background:
      radial-gradient(
        68% 52% at 12% 0%,
        color-mix(in oklab, var(--brand) 9%, transparent) 0%,
        transparent 62%
      ),
      radial-gradient(
        58% 46% at 92% 8%,
        color-mix(in oklab, var(--level-supplementary) 10%, transparent) 0%,
        transparent 58%
      ),
      linear-gradient(180deg, var(--surface), rgba(232, 234, 247, 0.5));
  }
}

@layer components {
  .gate__brand { text-align: center; display: grid; justify-items: center; gap: 12px; }
  .gate__logo { display: block; height: clamp(64px, 16vw, 88px); width: auto; max-width: min(240px, 70vw); object-fit: contain; }
  .gate__brandname { margin: 0; font-weight: 800; color: var(--brand-ink); }
  /*
   * min(440px, 100%), not min(440px, 92vw) (#59).
   *
   * 92vw and .gate's own `padding: 40px 16px` were two units competing for the
   * same space, and below 400px they overdrew it: 0.92 x 390 + 32 = 390.8px on a
   * 390px screen. The card did not overflow — .gate centres it — so what gave
   * instead was the gutter, squeezed to 15.6px at 390 and 14.4px at 360. It
   * shrank fastest on the narrowest screens, which is backwards for a gutter.
   * With a 15px scrollbar on a 360px window it fell to 6.9px, because vw is
   * measured against the viewport and knows nothing about the scrollbar.
   *
   * On the exam sitting the same card is not inside .gate at all: it is inside
   * #aimphan-exam-take, in .exam--take's 760px column, where 92vw bears no
   * relation to the container. It measured 331px in a 320px track at 360, 345 in
   * 335 at 375, 358.8 in 350 at 390 — overhanging its own column by 8-11px on
   * every phone width.
   *
   * 100% asks the parent, so the padding does the gutter work alone and there is
   * no second measure left to disagree with it. It needs .gate's column to be
   * definite — see the note on .gate above.
   *
   * margin-inline auto so it still centres now that the exam container stretches
   * its items rather than shrinking them to content (#43).
   */
  /*
   * The card carries the only ornament on the page (#69).
   *
   * A 4px run of the six education levels across the top edge, in the order a
   * pupil moves through them. Not decoration: every AIMPHAN series is
   * colour-coded by level, so this is the row of spines behind the door, and it
   * is the one thing on the gate that says what the code opens.
   *
   * Drawn on a ::before rather than as the card's own background-image, and
   * that is not a style preference — `.card` sets the `background` SHORTHAND in
   * @layer components, which resets background-image to none. Layers beat
   * specificity, so no selector written here in @layer layout can win that
   * fight. The pseudo-element sidesteps it entirely: the shorthand applies to
   * the card, not to its ::before.
   *
   * Hard stops at exact sixths. A gradient without them blends the levels into
   * each other, which is the opposite of what colour-coding is for.
   *
   * All three doors get it — the member gate, the staff sign-in and the admin
   * refusal are the same building's front.
   */
  .gate__card {
    position: relative;
    overflow: hidden;
    width: min(440px, 100%);
    margin-inline: auto;
    padding: 36px 32px 32px;

    /*
     * Lifted a step above --shadow-card, which is tuned for cards in a grid.
     * This one is alone on the page and needs to read as the single object in
     * focus. Three stops rather than two: a hairline contact shadow, a short
     * ambient, and a long soft cast — layering is what stops a large shadow
     * looking like a grey smudge.
     *
     * The hairline border does the work at the top edge, where the spine rule
     * would otherwise meet the ground with nothing between them.
     */
    border-radius: 18px;
    border: 1px solid color-mix(in oklab, var(--line) 60%, transparent);
    box-shadow:
      0 1px 2px rgba(16, 21, 51, 0.05),
      0 8px 16px -8px rgba(16, 21, 51, 0.10),
      0 28px 56px -24px rgba(16, 21, 51, 0.18);
  }

  .gate__card::before {
    content: "";
    position: absolute;
    inset: 0 0 auto;
    height: 4px;
    background: linear-gradient(
      to right,
      var(--level-preschool) 0 16.666%,
      var(--level-primary) 16.666% 33.333%,
      var(--level-secondary) 33.333% 50%,
      var(--level-vocational) 50% 66.666%,
      var(--level-nonformal) 66.666% 83.333%,
      var(--level-supplementary) 83.333% 100%
    );

    /*
     * The one orchestrated moment on the page: the spines draw in from the
     * left, once, on load. It reads as the shelf arriving rather than as a
     * decoration animating, and it points at the field below it.
     *
     * Deliberately the ONLY unprompted motion here. Fading and sliding every
     * element in turn is the generic default and it would spend attention that
     * the code field needs.
     */
    transform: scaleX(0);
    transform-origin: left center;
    animation: aimphan-spines 620ms cubic-bezier(0.22, 0.61, 0.36, 1) 120ms forwards;
  }

  @keyframes aimphan-spines {
    to { transform: scaleX(1); }
  }
  .gate__title { margin: 0 0 8px; text-align: center; font-size: var(--fs-title); line-height: var(--lh-heading); font-weight: 800; color: var(--brand-ink); }
  .gate__welcome { margin: 0 0 20px; text-align: center; color: var(--ink-muted); }
  .gate__form { display: grid; gap: 16px; }
  .gate__hint { margin: 0; text-align: center; font-size: var(--fs-small); color: var(--ink-muted); }
  /*
   * Admin sign-in, deliberately OUTSIDE the code card (#42).
   *
   * Two different logins with two different credentials were sharing one card,
   * which invites a teacher to try their member code on the WordPress form. The
   * divider and the separate block say plainly: this is not the same door.
   *
   * Still understated — nearly everyone arriving here is a teacher or a student
   * who needs the code field, and only a handful of publisher staff need this.
   */
  .gate__staff {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    width: 100%;
    max-width: 420px;
    margin: 18px auto 0;
    padding-top: 18px;
    border-top: 1px solid var(--line);
  }
  .gate__staff-label {
    margin: 0;
    color: var(--ink-muted);
    /* An overline above the admin door, not a sentence — the tiny step. */
    font-size: var(--fs-tiny);
  }
  /*
   * The admin route on the gate (#39).
   *
   * Understated on purpose. Nearly everyone arriving here is a teacher or student
   * who needs the code field; this is for the handful of publisher staff who need
   * the admin screens. Muted until hovered or focused, and given its own row so
   * it never competes with the submit button.
   *
   * min-height 44px keeps it a legitimate tap target on a phone, per the
   * Foundation's 44x44 rule.
   */
  .gate__admin {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    padding: 0 18px;
    color: var(--ink-muted);
    font-size: var(--fs-small);
    font-weight: 700;
    text-decoration: none;
    border: 1px solid var(--line);
    border-radius: 999px;
    transition: color 0.15s ease, border-color 0.15s ease;
  }
  .gate__admin:hover,
  .gate__admin:focus-visible {
    color: var(--brand);
    border-color: var(--brand);
  }
}

@layer components {
  /*
   * The staff console is a list of jobs, not a menu of screens (#46), so each row
   * leads with what it is for and explains itself underneath.
   */
  .admin-hub__grid { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 8px; }
  /* 768, not 767: this is the complement of the max-width: 767px queries, and the
     two must not overlap. 768 is where the Foundation's tablet band begins. */
  @media (min-width: 768px) {
    .admin-hub__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  .admin-hub__item {
    display: flex; flex-direction: column; gap: 2px;
    min-height: 44px; padding: 12px 14px;
    border: 1px solid var(--line); border-radius: 12px;
    text-decoration: none; background: var(--card);
    transition: border-color 0.15s ease, background 0.15s ease;
  }
  .admin-hub__item:hover,
  .admin-hub__item:focus-visible { border-color: var(--brand); background: var(--brand-light); }
  .admin-hub__item-title { font-size: var(--fs-body); font-weight: 700; color: var(--brand-ink); }
  .admin-hub__item-desc { font-size: var(--fs-small); color: var(--ink-muted); }
  /* WordPress is the way out to the super-admin view, not a peer of the jobs. */
  .admin-hub__item-wp { margin-top: 2px; font-size: var(--fs-tiny); color: var(--ink-muted); }

  /*
   * Staff hub actions (#44). A column so the primary route and the way back to
   * the library never sit side by side competing; the divider separates the
   * destinations from signing out, which is not a destination.
   */
  .admin-hub__actions { display: flex; flex-direction: column; gap: 10px; margin-top: 14px; }
  .admin-hub__actions .btn-brand,
  .admin-hub__actions .button { width: 100%; }
  .admin-hub__out { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); text-align: center; }
}

@layer layout {
  /*
   * The admin web UI (#47). Full-width working screens, not gate-shaped cards —
   * staff are reading tables here, not filling in one field.
   */
  .adminui { padding: 24px 20px 40px; }
}

@layer components {
  .adminui__title { margin: 8px 0 16px; font-size: var(--fs-display); font-weight: 800; color: var(--brand-ink); line-height: var(--lh-heading); }
  .adminui__notice { margin: 0 0 16px; padding: 12px 16px; border-radius: 10px; font-weight: 600; }
  .adminui__notice--ok { background: var(--brand-light); color: var(--brand-dark); }
  .adminui__notice--err { background: color-mix(in srgb, var(--red) 12%, var(--card)); color: var(--red-ink); }
  /*
   * The panel is a container (#63).
   *
   * Every filter bar, save bar, fact list and wrapping cell on the admin sits
   * in one of these, and what decides whether they fit is the panel — the
   * viewport minus a 256px rail on desktop, a 208px one on tablet, and nothing
   * on a phone where the rail becomes a scroller. The viewport queries had that
   * arithmetic baked in and got it wrong in both directions: see the notes on
   * the filter bar in admin.css.
   */
  .adminui__panel { padding: 20px; margin-bottom: 16px; container: adminui-panel / inline-size; }
  .adminui__panel-title { margin: 0 0 14px; font-size: var(--fs-heading); font-weight: 800; color: var(--brand-ink); }
  .adminui__field { display: grid; gap: 6px; }
  /* The table is the one thing that can exceed a phone: give it its own scroller
     so the page body never scrolls sideways. */
  .adminui__scroll { overflow-x: auto; }
  .adminui__table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
  .adminui__table th, .adminui__table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
  .adminui__table th { color: var(--ink-muted); font-weight: 700; }
  .adminui__code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; }
  .adminui__status { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 999px; font-weight: 700; }
  .adminui__status--ok { background: #e6f5ec; color: var(--exam-ink); }
  .adminui__status--off { background: var(--sunken); color: var(--ink-muted); }
  .adminui__row-actions form { margin: 0; }
}

@layer utilities {
  /* Screen-reader-only text (standard WP-core utility class). */
  .screen-reader-text {
    position: absolute !important;
    width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
    clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
  }
}

@layer layout {
  /* Catalog */
  .catalog { padding: 32px 20px; }
}

@layer components {
  .crumbs { color: var(--ink-muted); font-size: var(--fs-small); margin-bottom: 24px; }
  .crumbs a:hover { color: var(--brand); }
  .crumbs__current { color: var(--ink); }
}

@layer layout {
  .catalog__body { display: flex; gap: 32px; align-items: flex-start; }
  /*
   * A container: the grid's constraint is this column, not the window (#63).
   * The books sit beside a 256px rail, so the space the grid has is the
   * viewport minus the rail minus two gutters — and below 768 the rail stops
   * being a rail. Named, so #62's partials can drop the grid into a narrower
   * column without inventing a breakpoint for the occasion.
   */
  .catalog__main { flex: 1; min-width: 0; container: catalog-main / inline-size; }

  .sidebar { width: 256px; flex-shrink: 0; }
}

@layer components {
  .sidebar__title { margin: 0 0 12px; padding: 0 4px; font-size: var(--fs-small); font-weight: 700; color: var(--ink-muted); }
  .sidebar__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
  /*
   * min-height 44px (#34). Height came from padding alone, so the Foundation's
   * 15px body (line-height 23.25) left these at 43.25px — the third control found
   * with this exact shape, and the one tapped most: every category filter.
   */
  /*
   * Colour identifies; fill means selected (#72).
   *
   * Every pill used to be a saturated fill in its level colour, and the active
   * one was marked by translateX(2px) and a slightly larger shadow. Against
   * seven competing fills that is invisible — the one state that has to be
   * readable had no visual language left, because filling everything spends the
   * strongest signal on decoration.
   *
   * So the fill is reserved for the selected pill, and the level colour appears
   * on the others as a solid marker. The category is still identifiable at a
   * glance, and "filled" now carries exactly one meaning.
   *
   * This also removes a contrast failure rather than papering over it: white on
   * --level-supplementary (#f5b60a) is about 1.9:1, and it was rendering that
   * way for every unselected pill. One filled pill at a time makes contrast a
   * single solvable case instead of a systemic one — see the selected rule.
   */
  .pill {
    min-height: 44px;
    display: flex; align-items: center; gap: 10px;
    padding: 10px 16px; border-radius: 999px;
    font-weight: 700; color: var(--ink);
    background: var(--card);
    border: 1px solid color-mix(in oklab, var(--line) 70%, transparent);
    transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
  }

  /*
   * The marker carries the category's colour when the pill is not filled. A
   * flex item rather than a ::before so it keeps its size when the label wraps,
   * which Thai category names do at narrow widths.
   */
  .pill::before {
    content: "";
    flex: 0 0 auto;
    width: 10px; height: 10px;
    border-radius: 999px;
    background: var(--pill, var(--brand));
  }

  .pill:hover { background: var(--sunken); border-color: var(--line); }

  /*
   * Selected: the pill takes its own colour, darkened until white is readable.
   *
   * Measured every level against both text colours before choosing. At full
   * strength, WHITE FAILS AA ON ALL SIX — 1.81:1 on supplementary through
   * 3.96:1 on primary, against the 4.5:1 that 15px bold needs. Ink is better
   * but still fails two: primary 4.16:1 and nonformal 4.26:1.
   *
   * So there is no text colour that works against these fills, and the earlier
   * attempt here — white normally, ink on the two palest — was wrong about the
   * problem. It is the FILL that has to move.
   *
   * Darkening in OKLCH is the pattern this file already uses for --brand-dark
   * and --red-ink: keep the hue angle, scale lightness, and the result is
   * recognisably the same colour rather than a different one. The level stays
   * identifiable while the label becomes readable, and it needs no per-level
   * exception, which is what makes it correct rather than lucky.
   *
   * The multiplier was fitted by measuring every level through a canvas, not
   * guessed. White against the derived fill, worst level in brackets:
   *
   *   x0.62  5.94:1   x0.66  5.16:1   x0.70  4.51:1   x0.72  4.21:1
   *          (all six pass to x0.70; supplementary #f5b60a is worst throughout)
   *
   * 0.66, not 0.70. 0.70 clears 4.5:1 by 0.01 — a rounding difference between
   * engines, or a half-step repaint of one token, tips it into failing, and a
   * contrast rule that holds only at the current values is not a rule. 0.66
   * keeps real headroom while staying lighter than 0.62, so more of the level's
   * own colour survives.
   */
  .pill.is-active {
    color: #fff;
    background: oklch(from var(--pill, var(--brand)) calc(l * 0.66) c h);
    border-color: transparent;
    box-shadow: 0 1px 2px rgba(16, 21, 51, 0.14);
  }

  .pill.is-active::before { visibility: hidden; }

  .pill__label { flex: 1; }
}

@layer components {
  /*
   * Catalog search (#36). Reuses .field / .btn-brand rather than inventing new
   * input/button styles. Both already clear the Foundation's 44x44 tap-target
   * minimum (.field: 12px+12px padding + a 24.8px line box; .btn-brand: 13px+13px
   * padding + the same line box), so no extra sizing rules are needed here.
   */
  .search { display: flex; gap: 8px; margin-bottom: 20px; }
  .search__input { flex: 1; }
  .search__submit { width: auto; flex-shrink: 0; padding-left: 28px; padding-right: 28px; }
}

@layer components {
  /* Book grid */
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; }
  /*
   * The tighter track, keyed to the column. 480px is where a 160px minimum
   * stops fitting three covers — not 767, which was the number that happened to
   * be nearby. The two agree where it matters: a 390px phone gives
   * .catalog__main 358px, and the old `repeat(2, 1fr)` and this both draw two
   * 173px cards.
   */
  @container catalog-main (max-width: 480px) {
    .grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
  }
  /* A card is a container too, so it can answer for its own inside — see the
     rule under .book-card__title below. */
  .book-card { overflow: hidden; container: book-card / inline-size; transition: transform 0.15s ease, box-shadow 0.15s ease; }
  .book-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-pop); }
  .book-card__cover { position: relative; aspect-ratio: 3 / 4; background: var(--sunken); overflow: hidden; }
  .book-card__cover img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
  .book-card:hover .book-card__cover img { transform: scale(1.03); }
  .book-card__placeholder { display: grid; place-items: center; height: 100%; color: var(--ink-faint); font-weight: 800; letter-spacing: 0.05em; }
  .book-card__tag { position: absolute; top: 8px; left: 8px; padding: 3px 10px; border-radius: 999px; color: #fff; font-size: var(--fs-tiny); font-weight: 700; }
  .book-card__title { margin: 0; padding: 12px; font-size: var(--fs-body); font-weight: 700; color: var(--brand-ink); }
  /*
   * Three columns of a 480px catalog column are 141px each, and a Thai title at
   * the 15px body step wraps to four or five lines in that. On the CARD, not
   * the viewport: it is a fact about how much room the title has, and the same
   * card in a 140px rail elsewhere is entitled to the same answer.
   */
  @container book-card (max-width: 150px) {
    .book-card__title { padding: 10px; font-size: var(--fs-small); }
  }
}

@layer components {
  .empty { display: grid; place-items: center; padding: 48px; text-align: center; color: var(--ink-muted); }

  /*
   * No-results state for a search (#36, design.pdf 02.2a / 02.2b) — deliberately
   * a different shape from the plain one-line .empty message above (title +
   * hint + a way back), so a "no matches for this search" state reads as
   * distinct from "this category is empty".
   */
  .empty--search { display: grid; gap: 12px; justify-items: center; }
  .empty__title { margin: 0; font-size: var(--fs-heading); line-height: var(--lh-heading); font-weight: 700; color: var(--brand-ink); }
  .empty__hint { margin: 0; color: var(--ink-muted); }
  .empty__back { width: auto; padding: 13px 28px; }

  /*
   * Lazy-load sentinel (#48).
   *
   * Was a 28px spinner in a 56px band: it said "something is happening" but not
   * what, and when the cards landed the page jumped by a full row. A skeleton in
   * the shape of the thing being fetched answers both — it reserves the height
   * the incoming row will occupy, and it tells the eye where to wait.
   *
   * Same grid template and gap as .grid above, so the placeholders line up with
   * the columns of real cards rather than sitting near them. Two of them, because
   * ::before/::after is all a single empty div can offer and catalog.js is out of
   * scope for this card — a full row would need the markup to change.
   */
  .catalog__sentinel { height: 1px; }
  .catalog__sentinel.is-loading {
    height: auto;
    margin-top: 16px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    gap: 16px;
  }
  /* The skeleton has to keep matching the grid it is standing in for, so it
     takes the same container query rather than a media query of its own. */
  @container catalog-main (max-width: 480px) {
    .catalog__sentinel.is-loading {
      grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
      gap: 12px;
    }
  }
  .catalog__sentinel.is-loading::before,
  .catalog__sentinel.is-loading::after {
    content: "";
    /* Cover (3/4) plus the ~45px title strip, so the reserved height matches a
       real .book-card and the grid does not shift when the cards arrive. */
    aspect-ratio: 3 / 4;
    margin-bottom: 45px;
    border-radius: var(--radius-card);
    background: var(--sunken);
    /* Opacity only — a background-position shimmer would repaint the whole box
       every frame for no extra meaning. */
    animation: aimphan-skeleton 1.4s ease-in-out infinite;
  }
  .catalog__sentinel.is-loading::after { animation-delay: 0.2s; }
  @keyframes aimphan-skeleton {
    50% { opacity: 0.45; }
  }
}

@layer components {
  /* No-JS pagination fallback */
  /* gap 8px, not 6: the Foundation requires >= 8px between adjacent tap targets (#34). */
  .pagination { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 24px; }
  .pagination .page-numbers {
    /* 44x44 minimum (#34). Was 38px, which is not a legitimate tap target. */
    display: inline-grid; place-items: center; min-width: 44px; height: 44px; padding: 0 10px;
    border-radius: 10px; background: #fff; box-shadow: var(--shadow-card); color: var(--ink); font-weight: 600;
  }
  .pagination .page-numbers.current { background: var(--brand); color: #fff; }
  .pagination a.page-numbers:hover { background: var(--brand-light); }
}

@layer layout {
  /* Book detail */
  .book { padding: 32px 20px; }
  .book__body { display: flex; gap: 32px; align-items: flex-start; }
  .book__cover { width: 256px; flex-shrink: 0; overflow: hidden; }
  .book__cover img { aspect-ratio: 3 / 4; width: 100%; object-fit: cover; }
  .book__main { flex: 1; min-width: 0; }
}

@layer components {
  .book__back { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 12px; font-weight: 600; color: var(--ink-muted); }
  .book__back:hover { color: var(--brand); }
  .book__title { margin: 0; font-size: var(--fs-display); line-height: var(--lh-heading); font-weight: 800; color: var(--brand-ink); }
  .book__desc { margin-top: 8px; color: var(--ink-muted); }
  .book__section-title { margin: 32px 0 12px; font-size: var(--fs-heading); line-height: var(--lh-heading); font-weight: 800; color: var(--brand-ink); }
}

@layer components {
  /* Media tiles */
  .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
  .tile {
    display: flex; align-items: center; gap: 16px; padding: 16px; text-align: left;
    border: 0; cursor: pointer; font: inherit; width: 100%;
    transition: box-shadow 0.15s ease, transform 0.15s ease;
  }
  .tile:hover { box-shadow: var(--shadow-pop); transform: translateY(-1px); }
  /* Off-scale: an icon glyph sized to its 48px tile, not text. */
  .tile__icon { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 12px; color: #fff; font-size: 20px; flex-shrink: 0; }
  .tile__text { min-width: 0; flex: 1; }
  .tile__kind { display: block; font-size: var(--fs-tiny); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-muted); }
  .tile__title { display: block; font-weight: 700; color: var(--brand-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .tile__chevron { color: var(--ink-faint); }

  /* Notices */
  .notice { padding: 40px 20px; text-align: center; color: var(--ink-muted); }
  .notice--deny { color: var(--ink); }
}

@layer layout {
  /* Regular WordPress content (Pages/posts) */
  .content { padding: 32px 20px; }
}

@layer components {
  .entry { padding: 32px; max-width: 760px; margin: 0 auto; }
  .entry__title { margin: 0 0 16px; font-size: var(--fs-display); line-height: var(--lh-heading); font-weight: 800; color: var(--brand-ink); }
  .entry__content { color: var(--ink); }
  /*
   * Editor-authored headings. Without these the browser default sizes apply —
   * ems off the 15px body, so 30 / 22.5 / 17.5 — none of which is on the scale.
   */
  .entry__content h1 { font-size: var(--fs-display); line-height: var(--lh-heading); }
  .entry__content h2 { font-size: var(--fs-title); line-height: var(--lh-heading); }
  .entry__content h3 { font-size: var(--fs-heading); line-height: var(--lh-heading); }
  .entry__content h4,
  .entry__content h5,
  .entry__content h6 { font-size: var(--fs-body); line-height: var(--lh-heading); }
  .entry__content p { margin: 0 0 1em; }
  .entry__content a { color: var(--brand); text-decoration: underline; }
  .entry__content img { border-radius: 10px; margin: 1em 0; }
}

@layer utilities {
  /* Lock body scroll while the viewer modal is open. */
  body.aimphan-modal-open { overflow: hidden; }
}

@layer components {
  /* Media viewer modal (built by viewer.js) */
  .viewer { position: fixed; inset: 0; z-index: 9999; display: grid; place-items: center; padding: 16px; background: rgba(15, 21, 51, 0.72); }
  .viewer__dialog { display: flex; flex-direction: column; width: min(960px, 96vw); max-height: 90vh; overflow: hidden; background: #fff; border-radius: var(--radius-card); box-shadow: var(--shadow-pop); }
  .viewer__bar { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid rgba(154, 161, 173, 0.18); }
  .viewer__title { font-weight: 800; color: var(--brand-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /*
   * 44x44, was 32x32 (#34).
   *
   * This one matters more than its size suggests: it is the only way out of a
   * full-screen media overlay, so missing it on a phone traps the reader.
   */
  /* Off-scale: the close glyph, sized to sit inside the 44px hit area. */
  .viewer__close { width: 44px; height: 44px; border: 0; border-radius: 999px; background: transparent; color: var(--ink-muted); cursor: pointer; font-size: 16px; }
  .viewer__close:hover { background: var(--sunken); }
  .viewer__stage { flex: 1; display: grid; place-items: center; min-height: 60vh; background: #000; color: rgba(255, 255, 255, 0.85); }
  .viewer__stage iframe { width: 100%; height: 70vh; border: 0; }
  .viewer__stage iframe.is-video { aspect-ratio: 16 / 9; height: auto; }
}

/* Tablet / mobile */
/*
 * 767, not 768 (#34).
 *
 * The Foundation puts Tablet at 768-1023, so `max-width: 768px` reached one
 * pixel into the tablet band and applied phone rules at exactly 768 — the
 * width of the tablet artboard everything else is designed against.
 */
@layer layout {
  @media (max-width: 767px) {
    .catalog__body, .book__body { flex-direction: column; gap: 20px; }
    /*
     * stretch, not the flex-start the row needs — not optional now that
     * .catalog__main is an inline-size container. Such a container is sized by
     * its parent and never by its contents, which is what makes the query
     * answerable; on a flex-start column the parent offers nothing, so
     * .catalog__main measured 343px before and exactly 0 after. .adminshell
     * carries the same note for the same reason.
     */
    .catalog__body { align-items: stretch; }
    .sidebar, .book__cover { width: 100%; }
    .book__cover { max-width: 220px; }
    .catalog, .book { padding: 20px 16px; }
  }
}

@layer components {
  @media (max-width: 767px) {
    /*
     * Category pills WRAP; they do not scroll sideways (#74).
     *
     * This was a horizontal rail — flex-wrap: nowrap with overflow-x: auto —
     * and measured at a 485px viewport it put 875px of pills in a 453px box.
     * 422px of it, nearly half the filters, sat off-screen behind a swipe with
     * nothing on the page suggesting they existed.
     *
     * A rail earns its place when a list is long enough that wrapping would
     * swallow the screen: twenty tags, a filmstrip. There are SEVEN categories,
     * each a short Thai word, and wrapped they take two or three rows with
     * every option visible at once.
     *
     * Against that the rail cost a hidden-content problem, a gesture that
     * fights the back-swipe on iOS, and an interaction that is simply awkward in
     * a narrow desktop window where there is no touch at all. It also hid the
     * SELECTED pill whenever it fell beyond the fold — the state #72 had just
     * made visible.
     *
     * It was a reasonable call when pills were saturated fills and seven of them
     * wrapped would have been a wall of colour. #72 made them quiet rows, which
     * removed the objection.
     */
    .sidebar__list { display: flex; flex-wrap: wrap; gap: 8px; }
    .sidebar__list li { flex: 0 1 auto; }

    /*
     * No white-space: nowrap. A long category name is allowed to break rather
     * than force a row wider than the screen — which is how the overflow got
     * there in the first place.
     */
    .search { margin-bottom: 16px; }
    .search__submit { padding-left: 20px; padding-right: 20px; }
    /* `.grid` moved to a container query — see .catalog__main. `.tiles` needed
       no replacement: its `auto-fill, minmax(260px, 1fr)` already collapses to
       one column below ~530px, so the override only fired between 530 and 767,
       where it took away a second column the screen had room for. */
  }
}

@layer components {
  /* Small phones */
  @media (max-width: 420px) {
    .gate__card { padding: 24px; }
    /* Full-screen viewer on phones. */
    .viewer { padding: 0; }
    .viewer__dialog { width: 100vw; height: 100dvh; max-height: 100dvh; border-radius: 0; }
    .viewer__stage { min-height: 0; }
    .viewer__stage iframe { height: calc(100dvh - 57px); }
    .viewer__stage iframe.is-video { height: auto; }
  }
}

/*
 * In components, not base — the one placement here worth arguing about.
 *
 * `.btn-brand:focus-visible` sets `outline: none` earlier in the file, so this
 * rule wins on source order and a focused primary button gets both the outline
 * and the ring. base would hand that fight to the button and delete a focus
 * outline — an accessibility regression smuggled in under a refactor. utilities
 * would take the outline away from `.exam__review-item:focus-visible`, which
 * turns it off on purpose. Both are real decisions; neither is this card's.
 */
@layer components {
  /* Visible keyboard focus for interactive elements (a11y). */
  a:focus-visible, button:focus-visible, .tile:focus-visible, .pill:focus-visible,
  .book-card:focus-visible, input:focus-visible {
    outline: 3px solid var(--brand);
    outline-offset: 2px;
  }
}

@layer base {
  /* Respect reduced-motion preferences. */
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.001ms !important;
    }
  }
}

@layer layout {
  /* Exam system */
  .exam { padding: 32px 20px; max-width: 940px; margin-inline: auto; }
}

@layer components {
  .exam__loading { color: var(--ink-muted); padding: 24px; text-align: center; }
  .exam__card { padding: 28px; margin-bottom: 28px; }
  /* Secondary button, shared by exam actions + the book-page exam link. */
  /*
   * min-height 44px (#34).
   *
   * `padding: 9px 16px` on a 16px/1.5 line box lands at ~42.8px — just under the
   * Foundation's 44x44 floor, and invisibly so. min-height rather than more
   * padding, so the button keeps its proportions and only grows when the text
   * alone would not reach 44.
   */
  .button {
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    min-height: 44px;
    border: 1px solid rgba(154, 161, 173, 0.4); background: #fff; color: var(--brand-ink);
    font: inherit; font-weight: 600; padding: 9px 16px; border-radius: 10px; cursor: pointer;
    text-decoration: none; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
  }
  .button:hover { background: var(--sunken); border-color: var(--brand); color: var(--brand); }

  .exam__form { display: grid; gap: 16px 20px; }
  /*
   * Two columns from the tablet band up, so the create-room form fills the card
   * instead of running down a lone left column.
   *
   * 768, not 720 (#59). 720 is not a Foundation breakpoint and it fired 47px
   * INSIDE the mobile band: between 720 and 767 this split the form into two
   * columns while every other rule in this file — the header, the catalog, the
   * sidebar, the review grid — was still laying the page out as a phone. The
   * start/end pair is itself a two-up .exam__row, so at 720 that was four fields
   * across a screen the rest of the theme had already decided was narrow.
   *
   * Deliberately NOT 1024. 1024 is the desktop floor, and moving the query there
   * would take the two-column form away from the whole 768-1023 tablet band,
   * which is a change to a designed layout rather than a correction to a
   * breakpoint. 768 is the complement of the max-width: 767px queries — the same
   * reasoning already written down on .admin-hub__grid — so it fixes the overlap
   * and leaves every band that was two-column two-column.
   */
  @media (min-width: 768px) {
    .exam__card .exam__form { grid-template-columns: 1fr 1fr; }
    .exam__card .exam__form > .exam__row,
    .exam__card .exam__form > .exam__check,
    .exam__card .exam__form > .btn-brand { grid-column: 1 / -1; }
    .exam__card .exam__form > .btn-brand { justify-self: start; width: auto; min-width: 240px; }
  }
  .exam__row { display: flex; gap: 14px; }
  .exam__row .exam__field { flex: 1; }
  .exam__field { display: grid; gap: 6px; }
  .exam__check { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ink-muted); }
  .exam__room { padding: 20px; margin-bottom: 16px; }
  .exam__room-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; }
  .exam__code { font-size: var(--fs-title); font-weight: 800; letter-spacing: 0.08em; color: var(--brand); }
  .exam__link { color: var(--ink-muted); font-size: var(--fs-small); text-decoration: underline; word-break: break-all; }
  .exam__meta { display: flex; gap: 18px; flex-wrap: wrap; color: var(--ink-muted); font-size: var(--fs-small); margin-bottom: 12px; }
  .exam__count { font-weight: 700; color: var(--brand-ink); }
  .exam__actions { display: flex; gap: 10px; flex-wrap: wrap; }
  /* min-height 44px (#34) — same padding-derived shortfall as .signout__btn, and
     this one deletes an exam room, so it is the last control that should be a
     near-miss on a phone. */
  .exam__delete { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; border: 0; background: transparent; color: var(--red-ink); font: inherit; font-weight: 600; cursor: pointer; padding: 9px 12px; border-radius: 10px; }
  .exam__delete:hover { background: color-mix(in srgb, var(--red-ink) 8%, transparent); }
  .exam__results { margin-top: 16px; overflow-x: auto; }
  .exam__table { width: 100%; border-collapse: collapse; font-size: var(--fs-small); }
  .exam__table th, .exam__table td { border: 1px solid var(--sunken); padding: 6px 10px; text-align: center; }
  .exam__table td.is-correct { background: #c6efce; font-weight: 700; }
  .exam__q { width: 100%; padding: 28px; max-width: 640px; margin: 0 auto; }
  /* Flex, not text-align: the clock's own margin-left:auto was written for a row
     that never existed, so the counter and the clock rendered butted together. */
  .exam__progress { display: flex; align-items: center; gap: 12px; color: var(--ink-muted); font-weight: 700; margin-bottom: 8px; }

  /*
   * Exam countdown (#35).
   *
   * Sits at the right of the progress row, tabular so the digits do not jitter
   * every second as the glyph widths change. Turns red under a minute — colour is
   * not the only signal, the number itself is still counting.
   */
  .exam__clock {
    margin-left: auto;
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    color: var(--ink-muted);
  }
  .exam__clock.is-urgent {
    color: var(--red-ink);
  }
  /* Running Thai prose at a heading size, so it keeps the body leading, not the heading one. */
  .exam__q-body { font-size: var(--fs-heading); line-height: var(--lh-body); margin: 0 0 20px; color: var(--brand-ink); }
  /*
   * Answer choices (#41).
   *
   * The selected state is the whole point here and the design does not specify
   * one. Without it the only signal is the browser's ~13px native radio dot, so a
   * student on a phone cannot tell at a glance what they picked, and cannot scan
   * back over a paper to see where they are.
   *
   * Three signals, not one, because colour alone fails for the colour-blind and
   * in bright sunlight on a phone: the border thickens to the brand colour, the
   * row takes a tint, and the letter badge inverts to solid brand.
   *
   * The border is always 2px — transparent-to-brand rather than 1px-to-2px — so
   * selecting never changes the row's height and nudges the rest of the list.
   */
  .exam__choice {
    display: flex; align-items: center; gap: 12px;
    padding: 13px 15px;
    border: 2px solid var(--sunken); border-radius: 12px;
    margin-bottom: 10px; cursor: pointer;
    transition: border-color 0.15s ease, background 0.15s ease;
  }
  .exam__choice:hover { border-color: var(--brand); }

  /* :focus-within, so tabbing through the radios shows which row is live. */
  .exam__choice:focus-within {
    border-color: var(--brand);
    box-shadow: 0 0 0 4px var(--brand-light);
  }

  .exam__choice:has(input:checked) {
    border-color: var(--brand);
    background: var(--brand-light);
  }
  .exam__choice:has(input:checked) .exam__choice-key {
    background: var(--brand);
    color: #fff;
  }

  /*
   * Fallback for browsers without :has() — the row stays plain, but the badge
   * still inverts, so a selected answer is never completely unmarked.
   */
  .exam__choice input:checked ~ .exam__choice-key {
    background: var(--brand);
    color: #fff;
  }

  .exam__choice-key {
    display: grid; place-items: center;
    width: 28px; height: 28px; flex-shrink: 0;
    border-radius: 50%; background: var(--sunken); font-weight: 800;
    transition: background 0.15s ease, color 0.15s ease;
  }
  .exam__nav { display: flex; justify-content: space-between; gap: 12px; margin-top: 20px; }
  .exam__next { width: auto; margin-left: auto; }
  .exam__result { text-align: center; }
  /*
   * Deliberately off the type scale (#34).
   *
   * Four raw px font-sizes survive in this file and none of them is text: each is
   * a glyph sized to fill a box, or a numeral sized to be read as a figure. Two
   * are the pair below; the other two are marked "Off-scale" where they sit, on
   * .tile__icon and .viewer__close. (There were five — .brand__mark--lg went with
   * its dead rule in #59.)
   *
   * A type scale governs prose and labels; forcing a 48px result icon down to the
   * 32px display step would shrink an ornament to match a heading it is not
   * competing with. Left as literals, on purpose — do not tokenise them in the
   * next pass.
   */
  .exam__result-icon { font-size: 48px; color: #16a34a; }
  .exam__score { font-size: 44px; font-weight: 800; color: #16a34a; }
  .exam__pct { font-size: var(--fs-heading); color: var(--ink-muted); }
  .exam__error { padding: 24px; text-align: center; color: var(--red-ink); font-weight: 600; }

  /*
   * Review before submit (#36).
   *
   * A grid of numbered chips instead of a list of question text: the only thing a
   * student is scanning for here is the gaps, and a grid answers "which ones did I
   * skip?" in one glance even on a forty-item paper — a list would not fit on a
   * phone without scrolling past the very blanks it is meant to surface.
   */
  .exam__review-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 4px; font-weight: 700; color: var(--ink-muted); }
  .exam__review-summary .is-blank { color: var(--red-ink); }
  .exam__review-sep { color: var(--ink-faint); }
  .exam__review-hint { margin: 0 0 16px; font-size: var(--fs-small); color: var(--ink-muted); }
  .exam__review-error { margin: 0 0 12px; color: var(--red-ink); font-weight: 700; }

  .exam__review-grid {
    display: grid;
    /* auto-fill rather than a fixed column count: the same rule then holds from a
       360px phone up through 1279+ without a breakpoint per paper length. */
    grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
    gap: 10px;
    margin: 0 0 20px;
  }
  .exam__review-item {
    /* 56px, comfortably over the 44px tap-target floor — these chips sit shoulder
       to shoulder and a mis-tap under time pressure costs an answer. */
    display: grid;
    place-content: center;
    gap: 2px;
    min-width: 56px;
    min-height: 56px;
    padding: 6px 4px;
    border: 1px solid var(--line);
    border-radius: 12px;
    background: #fff;
    font: inherit;
    cursor: pointer;
    transition: border-color 0.15s ease, background 0.15s ease;
  }
  .exam__review-item:hover { border-color: var(--brand); }
  .exam__review-item:focus-visible { outline: none; box-shadow: 0 0 0 4px var(--brand-light); }
  .exam__review-no { font-size: var(--fs-body); font-weight: 800; line-height: 1.1; color: var(--brand-ink); }
  .exam__review-pick { font-size: var(--fs-tiny); font-weight: 700; line-height: 1.1; color: var(--ink-muted); }

  .exam__review-item.is-answered { border-color: var(--brand); background: var(--brand-light); }
  .exam__review-item.is-answered .exam__review-no,
  .exam__review-item.is-answered .exam__review-pick { color: var(--brand); }

  /* Three signals at once for an unanswered item — dashed edge, red, and an em
     dash where the chosen letter would be. Colour alone would fail exactly the
     students this screen exists for. */
  .exam__review-item.is-blank { border-style: dashed; border-color: var(--red-ink); }
  .exam__review-item.is-blank .exam__review-no,
  .exam__review-item.is-blank .exam__review-pick { color: var(--red-ink); }

  .exam__review-confirm {
    display: grid;
    gap: 12px;
    padding: 16px;
    margin-bottom: 16px;
    border: 1px solid var(--red-ink);
    border-radius: 12px;
    background: color-mix(in srgb, var(--red) 6%, transparent);
  }
  /* display:grid above outranks the hidden attribute's UA rule, so restate it. */
  .exam__review-confirm[hidden] { display: none; }
  /* :focus, not :focus-visible — the panel is tabindex="-1" and only ever focused
     programmatically, which browsers disagree about matching as "visible". */
  .exam__review-confirm:focus { outline: 3px solid var(--brand); outline-offset: 2px; }
  .exam__review-confirm-text { margin: 0; color: var(--ink); font-weight: 600; }
  .exam__review-confirm-actions { display: flex; flex-wrap: wrap; gap: 10px; }

  @media (max-width: 767px) {
    .exam__review-grid { grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 8px; }
    .exam__review-item { min-width: 48px; min-height: 48px; }
    /* Three buttons (back / review / next) will not sit on one phone row. */
    .exam__nav { flex-wrap: wrap; }
  }

  /* Student take-flow: keep every step centered (question card was, info/result weren't). */
  .exam--take { max-width: 760px; }
  .exam--take .book__title { text-align: center; margin-bottom: 8px; }
}

@layer layout {
  /*
   * stretch, not center (#43).
   *
   * The whole exam used to render in a ~280px column on a 1440px screen, wrapping
   * answer text far more than intended. Two separate things caused it, and fixing
   * either alone does nothing — both are needed:
   *
   *   1. `display: grid` with no grid-template-columns creates a single IMPLICIT
   *      track sized `auto`, which fits content rather than filling the container.
   *      minmax(0, 1fr) makes the column fill it; the 0 minimum stops long
   *      unbreakable content from pushing it wider.
   *
   *   2. `.exam__q` carries `margin: 0 auto`, and in grid an auto inline margin
   *      takes PRECEDENCE over stretch — the box is sized to fit-content and the
   *      margins centre it. So justify-items never had any effect here, whatever
   *      it was set to. `.exam__q` therefore needs an explicit `width: 100%` to
   *      fill the track, with max-width still capping it at 640.
   *
   * Measured: 282px with justify-items:stretch, 640px with width:100%.
   */
  #aimphan-exam-take { display: grid; grid-template-columns: minmax(0, 1fr); }
}

@layer components {
  /* Book: unit subsections + exam sets */
  .book__subsection { margin: 18px 0 10px; font-size: var(--fs-body); font-weight: 700; color: var(--ink-muted); }
  .exam-sets { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 8px; }
  .exam-sets__item { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; background: var(--card); border-radius: 10px; box-shadow: var(--shadow-card); }
  .exam-sets__title { font-weight: 600; color: var(--brand-ink); }
  .exam-sets__count { color: var(--ink-muted); font-size: var(--fs-small); white-space: nowrap; }
}

/*
 * Everything to the end of the reduced-motion block below is deliberately
 * OUTSIDE the layers. `@view-transition` is a document-level at-rule the spec
 * only allows inside conditional group rules — @media and @supports, not
 * @layer — and wrapping it risks a browser dropping it and taking the
 * transitions with it. The rules keeping it company have no cascade competitor
 * anywhere in the theme, so unlayered costs them nothing.
 */
/* ==========================================================================
 * Motion (#48)
 *
 * Every rule below exists to answer a question the student is actually asking
 * — "did my tap register?", "is this a new question?", "how much time is
 * left?" — never to decorate. Two hard rules hold throughout:
 *
 *   1. Only `transform` and `opacity` are animated. Animating width/height/
 *      top/margin would re-run layout on every frame, and this is used on
 *      mid-range Android phones where that is the difference between a swap
 *      that reads as instant and one that stutters.
 *
 *   2. No element is ever left at `opacity: 0` in a base rule. Where a fade is
 *      wanted the 0 lives inside the @keyframes only, so a browser that never
 *      runs the animation still paints the content at full opacity.
 * ========================================================================== */

/*
 * Cross-document view transitions.
 *
 * Every screen here is a full server-rendered page load, so catalog -> book ->
 * viewer is a white flash today. One rule replaces it; there is no JS and no
 * framework, and a browser without support simply navigates the way it does
 * now. It also improves the SLOW navigation for free: the browser keeps the
 * old page painted until the new document is ready to show, rather than
 * blanking the screen the moment the link is tapped.
 */
@view-transition {
  navigation: auto;
}

/*
 * The site header is named, so it is captured as its own element and paired
 * across the two documents — it holds still while the page beneath it changes,
 * instead of cross-fading along with everything else.
 *
 * That is the orientation argument for this app's navigations specifically:
 * catalog -> book -> viewer is always "same site, one level deeper", never
 * "somewhere else", and a shell that does not move says so. On the gate, which
 * prints no header at all, the name is simply unpaired and the header fades in
 * on its own — correct, because arriving from the gate genuinely is a change of
 * place.
 *
 * The FOOTER is deliberately left unnamed. It sits in normal flow, so on a long
 * catalog it is far below the fold and on a short book page it is on screen;
 * pairing it would animate it flying up the viewport between two pages where
 * nothing about it actually changed.
 */
.site-header {
  view-transition-name: aimphan-header;
}

/*
 * The content itself: the outgoing page fades out quickly, the incoming one
 * rises 8px as it fades in. The rise is the same gesture as the exam card's
 * arrival, so "new content is here" looks the same everywhere in the theme.
 *
 * Asymmetric on purpose — the old page should be gone before the new one is
 * legible, so a student is never reading two pages at once.
 *
 * 0.1s + 0.22s, inside the 350ms budget: this sits between a tap and the thing
 * that was asked for, and every millisecond of it is a millisecond of waiting.
 */
@keyframes aimphan-vt-out {
  to { opacity: 0; }
}
@keyframes aimphan-vt-in {
  from { opacity: 0; transform: translateY(8px); }
}
::view-transition-old(root) {
  animation: aimphan-vt-out 0.1s ease-in both;
}
::view-transition-new(root) {
  animation: aimphan-vt-in 0.22s ease-out both;
}

/*
 * Reduced motion, part two — the part the global rule cannot reach.
 *
 * The `*` rule earlier in this file zeroes transition/animation durations, but
 * `*` matches ELEMENTS: the ::view-transition-* pseudo-elements live in their
 * own tree and are not selected by it. Without the block below, a student who
 * asked their OS for less motion would still get a full page transition on
 * every navigation. Two independent belts here on purpose: `navigation: none`
 * turns the transition off at the source, and `animation: none` neutralises
 * the UA's cross-fade in any engine that does not honour @view-transition
 * nested inside @media.
 */
@media (prefers-reduced-motion: reduce) {
  @view-transition {
    navigation: none;
  }

  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }

  /* The global rule zeroes DURATION but not DELAY, so a staggered entrance
     would still hold each card back before snapping into place. */
  .grid .book-card {
    animation-delay: 0s !important;
  }

  /*
   * Same trap, and worse here (#69): the spine rule starts at scaleX(0), so a
   * surviving 120ms delay leaves the card's only ornament INVISIBLE and then
   * snaps it in — a flash of missing brand rather than a shortened animation.
   * Zero the delay so it is simply present from the first paint.
   */
  .gate__card::before {
    animation-delay: 0s !important;
  }
}

@layer components {
  /*
   * Exam screen entrances (#48, case 1 — the one that matters).
   *
   * question() re-renders the card in place and consecutive questions are laid
   * out identically: same progress row, same four choice rows. When only the
   * text changes, a student who taps "ถัดไป" cannot tell whether the tap
   * registered or the paper advanced. The card entering from the side they
   * travelled answers both at once, and going back reads as going back — the
   * direction is information, not ornament.
   *
   * 12px, not more: enough to register as an arrival, small enough that the
   * answer text is readable through the whole 180ms rather than smearing.
   */
  @keyframes aimphan-enter-next {
    from { opacity: 0; transform: translateX(12px); }
  }
  @keyframes aimphan-enter-back {
    from { opacity: 0; transform: translateX(-12px); }
  }
  /* The result is an arrival, not a step, so it settles upward instead of
     sliding sideways — a different shape for a different kind of change. */
  @keyframes aimphan-enter-up {
    from { opacity: 0; transform: translateY(10px); }
  }

  .is-enter-next { animation: aimphan-enter-next 0.18s ease-out; }
  .is-enter-back { animation: aimphan-enter-back 0.18s ease-out; }
  .is-enter-up { animation: aimphan-enter-up 0.22s ease-out; }

  /*
   * The countdown under a minute (#48, case 3).
   *
   * Red is a state a student has to look at the clock to learn. A slow scale
   * carries into peripheral vision, which is the entire point of a countdown:
   * you should not have to check it to know it is running out.
   *
   * 2s and 1.06 deliberately: this is an exam, and a fast or large pulse buys
   * awareness at the price of panic, which is a real cost to a real score.
   * Origin is the right edge because the clock is flush right in the progress
   * row — scaling from the centre would nudge it toward the question number.
   */
  @keyframes aimphan-clock-pulse {
    50% { transform: scale(1.06); }
  }
  .exam__clock.is-urgent {
    transform-origin: right center;
    animation: aimphan-clock-pulse 2s ease-in-out infinite;
  }

  /*
   * Answer selection (#48, case 4).
   *
   * Three colours already change the instant a choice is picked. What they do
   * not do is confirm THIS tap — they look identical whether the student just
   * tapped or is returning to a question they answered five minutes ago. The pop
   * is therefore driven by the change event (see exam-take.js), not by
   * :has(input:checked), so re-entering an answered question never replays it.
   */
  @keyframes aimphan-pick {
    50% { transform: scale(1.18); }
  }
  .exam__choice-key.is-picked {
    animation: aimphan-pick 0.18s ease-out;
  }

  /*
   * Catalog grid entrance (#48, case 5).
   *
   * Transform only — no fade — and that is a decision, not an oversight. The
   * covers are the largest thing on the catalog and therefore the LCP element;
   * starting them at opacity 0 would make them non-paintable for the length of
   * their stagger delay and push the measured first paint out by exactly that
   * much. A 6px rise gives the eye an order to read the grid in while every book
   * stays fully visible from the first frame, animation or no animation.
   *
   * The stagger stops after eight cards: past that it is no longer an order to
   * read in, it is a queue to wait through. Cards appended by the infinite
   * scroll (catalog.js) fall past the cap and simply rise on insertion.
   */
  @keyframes aimphan-card-in {
    from { transform: translateY(6px); }
  }
  .grid .book-card {
    animation: aimphan-card-in 0.2s ease-out backwards;
  }
  .grid .book-card:nth-child(2) { animation-delay: 0.025s; }
  .grid .book-card:nth-child(3) { animation-delay: 0.05s; }
  .grid .book-card:nth-child(4) { animation-delay: 0.075s; }
  .grid .book-card:nth-child(5) { animation-delay: 0.1s; }
  .grid .book-card:nth-child(6) { animation-delay: 0.125s; }
  .grid .book-card:nth-child(7) { animation-delay: 0.15s; }
  .grid .book-card:nth-child(8) { animation-delay: 0.175s; }

  /*
   * Waiting, in the exam (#48).
   *
   * Loading and submitting used to replace the card with one line of Thai. On a
   * school connection that is indistinguishable from a page that has died, and at
   * submit time it is worse: the student's answers disappear from the screen
   * while their paper is in flight.
   *
   * The spinner is the only indefinite motion in this theme and it earns that by
   * being the one case where the wait genuinely has no known end. It rotates —
   * transform, on a composited 28px box — and it borrows the shape the catalog
   * sentinel used to draw, so a wait looks the same wherever it happens.
   */
  .exam__pending {
    display: grid;
    justify-items: center;
    gap: 12px;
    padding: 32px 24px;
    text-align: center;
  }
  /* Lives here, with its only user. It used to sit beside the catalog sentinel's
     keyframes, which is where it was written — but that sentinel is a skeleton
     now and has not spun since #48, so the next reader would have found a
     keyframe under a comment explaining that the spinner was replaced. */
  @keyframes aimphan-spin { to { transform: rotate(360deg); } }
  .exam__pending-spinner {
    width: 28px;
    height: 28px;
    border-radius: 50%;
    border: 3px solid var(--sunken);
    border-top-color: var(--brand);
    animation: aimphan-spin 0.7s linear infinite;
  }
  .exam__pending-text { margin: 0; font-weight: 700; color: var(--brand-ink); }
  /* The answered count and "do not close this page" — quieter than the heading,
     but the part that actually reassures. */
  .exam__pending-detail { margin: 0; font-size: var(--fs-small); color: var(--ink-muted); }
}

@layer utilities {
  /*
   * A button that owns an in-flight request (#48).
   *
   * state.submitting has always guaranteed exactly one submission; what it never
   * did was say so. A student who taps ส่งคำตอบ and sees nothing change taps
   * again, and the only honest fix is for the button to look spent the instant it
   * is pressed. Dimmed and no longer reacting to touch — the disabled attribute
   * carries the behaviour, this carries the appearance.
   */
  .btn-brand.is-busy,
  .button.is-busy {
    opacity: 0.65;
    cursor: default;
    pointer-events: none;
  }
}
