/* ============================================================================
   COMPONENTS
   All values come from tokens.css. If you want to change a colour or size,
   change it there, not here.
   ========================================================================= */

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

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;

  /* iOS housekeeping. Without these the prototype feels like a web page:
     grey flashes on tap, text selection on double-tap, rubber-band bounce
     dragging the whole document. */
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
  overscroll-behavior: none;
}

/* The browser's own `[hidden] { display: none }` is a bare attribute selector,
   so any class that sets `display` outranks it and the element keeps rendering
   after you set `.hidden = true`. Anything here given an explicit display needs
   its own hidden rule.

   Not a blanket `!important` — .topbar deliberately keeps its box and fades via
   opacity while hidden, and that has to stay possible. */
.say[hidden], .circle-btn[hidden], .icon-btn[hidden] { display: none; }


/* Every Arabic run gets this class: RTL direction and the face.

   It does NOT protect the diacritics. The marks are kept intact by the
   measured --leading-arabic-* values in tokens.css, because the ancestors here
   genuinely have to clip — .feed scrolls, .device is the bezel. overflow is
   stated anyway so nobody "tidies up" by adding hidden here. */
.arabic {
  direction: rtl;
  font-family: var(--font-arabic);
  overflow: visible;
}


/* ==========================================================================
   THE PHONE
   Mobile: fills the screen. Desktop (≥480px): becomes a 390×844 iPhone box.
   --inset-top absorbs the difference between a real notch and our fake
   status bar, so no component needs to know which context it is in.
   ========================================================================= */

.device {
  position: relative;   /* positioning context for all the chrome */
  overflow: hidden;     /* the bezel. Safe: nothing Arabic reaches the edges */
  width: 100%;
  height: 100dvh;       /* dvh, not vh — accounts for mobile browser chrome */
  background: var(--bg);
  --inset-top: env(safe-area-inset-top, 0px);
  --inset-bottom: env(safe-area-inset-bottom, 0px);
}

.statusbar { display: none; }

@media (min-width: 480px) {
  body {
    display: grid;
    place-items: center;
    /* A shade darker than the app, so the phone reads as an object on a
       surface rather than the page bleeding into the device. */
    background: #DEDBD3;
  }
  .device {
    width: 390px;
    height: 844px;
    border-radius: 44px;
    box-shadow: 0 24px 70px rgba(0, 0, 0, .22), 0 0 0 10px #1a1a1c;
    --inset-top: 44px;      /* the fake status bar's height */
    --inset-bottom: 22px;   /* room for the home indicator */
  }
  .statusbar {
    position: absolute;
    top: 0; left: 0; right: 0;
    height: 44px;
    /* Above the session and end screens. On a real phone the system status bar
       stays visible over full-screen views, and the reference app's quiz screen
       shows it too — so the fake one has to sit on top of everything. */
    z-index: 80;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 var(--space-8);
    font-size: 15px;
    font-weight: 600;
    pointer-events: none;
  }
  .statusbar-icons { display: inline-flex; align-items: center; gap: 5px; }
  /* The home indicator pill */
  .device::after {
    content: '';
    position: absolute;
    bottom: 8px; left: 50%;
    transform: translateX(-50%);
    width: 134px; height: 5px;
    border-radius: 3px;
    background: var(--ink);
    opacity: .28;
    z-index: 80;
    pointer-events: none;
  }
}


/* ==========================================================================
   FEED
   scroll-snap gives the one-word-per-screen swipe for free. `mandatory` means
   the browser always lands on a card, never between two.
   ========================================================================= */

.feed {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-snap-type: y mandatory;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-y: contain;   /* don't chain scroll to the page */
  scrollbar-width: none;
}
.feed::-webkit-scrollbar { display: none; }

.word-card {
  height: 100%;
  scroll-snap-align: start;
  scroll-snap-stop: always;   /* one card per swipe, no skipping */
  display: grid;
  /* Three bands: reserved top, content, reserved bottom. The content band is
     where the word block centres itself.

     These two numbers are not arbitrary. The gap between them shifts the
     content band's midpoint, and the midpoint is what positions the word.
     Measured off the reference app, the adjective's optical centre sits at
     ~43% of screen height — noticeably below the top third but above true
     middle. 192 − 140 = 52px of downward bias puts it there.

     Change one and change the other by the same amount, or the word drifts.

     Back to 148/200 after a detour. While the action row sat in the flow of
     .word-main it added 84px to the block and pushed the word up by half of that,
     so these were retuned to 190/158 to hold the word still. The row is
     absolutely positioned now and contributes no height, so the original pair is
     correct again. */
  grid-template-rows: calc(var(--inset-top) + 148px) 1fr 200px;
  text-align: center;
  padding: 0 var(--space-6);

  /* Containing block for the action row, which is placed against the card rather
     than against the page so it travels with the word. */
  position: relative;
}

.word-main { align-self: center; }


/* --- The word block, three tiers ---------------------------------------- */

.word-ar {
  font-weight: var(--weight-arabic-display);
  font-size: var(--size-word-ar);
  line-height: var(--leading-arabic-display);
  margin: 0;
  /* Measured: keeps stacked marks (مْرَتَّب) inside the line box. See tokens.css */
}

/* A headword that wrapped to two lines.

   --leading-arabic-display puts 10px above the tallest mark and, being symmetric,
   spends the same again below. That is right for the one line it was designed
   for and far too much between two: at 2.233 a wrapped expression had a visible
   hole down its middle. Between stacked lines the only thing that has to clear is
   the upper line's descenders against the lower line's marks, which is what
   --leading-arabic-wrapped measures.

   The pill pull follows it. That value is the dead space under the last line
   minus the gap we want, so tightening the leading without swapping the pull
   would drag the pill up into the word. Overridden on the block rather than
   globally, so only wrapped cards get it. */
.word-ar.is-wrapped {
  line-height: var(--leading-arabic-wrapped, var(--leading-arabic-display));
}
.word-main:has(> .word-ar.is-wrapped) {
  --say-pull: var(--say-pull-wrapped, var(--say-pull));
}

.say {
  position: relative;            /* for the hit area below */
  display: inline-flex;
  align-items: center;
  /* 6px, up from 2px. The tight original was guarding against the speaker reading
     as a separate control rather than a property of the word beside it, and 8px of
     air did break that pairing. 6px sits under the threshold where it does: the
     pill is one common region and its padding is now 20px, more than three times
     this, so proximity still groups the two against the edges. */
  gap: 6px;
  min-height: 38px;

  /* 9px / 20px, up 4px from 5px / 16px, and symmetric now where it used to be
     16px left against 12px right.

     That split is the usual trick for a pill ending in an icon: an icon glyph
     normally carries air inside its own box, so it needs less padding than text
     to look balanced. Measured, this one does not — speaker-high's ink fills 94%
     of its viewBox, leaving 0.5px at 16px. So the 4px was not compensating for
     anything and just pushed the whole content 3.5px right of centre. */
  padding: 9px var(--space-5);

  /* Negative top margin, deliberately, and computed rather than typed. The
     word's leading exists to stop stacked diacritics clipping, but leading is
     symmetric — so it also drops ~35px of empty space *below* the word,
     shoving this pill away from the word it belongs to. --say-pull claws that
     back so the pairing reads as one unit (Gestalt proximity).

     js/arabic-metrics.js sets --say-pull from the live font metrics, because
     the dead space differs per Arabic face. The fallback below only applies
     before that runs. */
  /* 16px below, down from 20px. The pill and the meaning are the same fact about
     the word said two ways, so they belong in one group; the next real boundary is
     the card edge, and that gets the larger gap. Still on the scale, not a typed
     16px. */
  margin: var(--say-pull, -14px) 0 var(--space-4);
  border: 0;
  border-radius: var(--radius-pill);
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: var(--size-ui);
  font-weight: 500;
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios),
              background var(--dur-tap) var(--ease-ios);
}
/* The pill is 38px tall and the tap target is still comfortably past 44.

   iOS asks for 44pt and it is not a stylistic minimum — it is the size of a
   fingertip. Rather than choose between a chunky pill and a hittable one, the
   pill stays small and an invisible box extends past it. Nothing shifts in the
   layout because the pseudo-element is absolutely positioned. */
.say::after {
  content: '';
  position: absolute;
  inset: -5px -4px;              /* 38 + 10 = 48 tall */
}

.say:active { transform: scale(.96); }
.say.is-speaking { background: var(--accent); }
.say svg { width: 16px; height: 16px; flex: none; }

.word-meaning {
  font-size: var(--size-body);
  line-height: var(--leading-body);
  margin: 0;
}

/* Example sentences are not shown on the feed — the card stays word,
   pronunciation, meaning. The data is still in vocab.js and the small-Arabic
   tokens still exist, both for the review session in stage 4. */


/* ==========================================================================
   CHROME
   ========================================================================= */

/* The tab bar floats over its page. The action row used to as well, and does not
   any more — it sits inside each card so it scrolls with the word. */
.tabbar { position: absolute; z-index: 20; }


.circle-btn {
  flex: none;
  width: var(--size-circle);
  height: var(--size-circle);
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  color: var(--ink);
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios);
}
.circle-btn:active { transform: scale(.93); }
.circle-btn svg { width: 23px; height: 23px; }


/* --- Action row --------------------------------------------------------- */

/* Absolutely placed INSIDE each card, which is the only arrangement that gets
   both of the things asked for.

   In the card, so it scrolls away with the word it belongs to and a button can
   only ever act on its own card. Absolute, so its height is fixed rather than
   inherited from a block that changes size — laid out in the flow with a plain
   `margin-top: 32px` it sat at 21 different heights across the deck, because
   `.word-main` centres itself and its height depends on the fitted Arabic, on
   whether the meaning wraps, and on whether the word has a literal line. An
   end-to-end test caught that; by hand it reads as a slight drift.

   --actions-top is measured once per render (see placeActions in app.js) and
   inherited by every card, so they all agree. The four tallest cards override it
   inline to keep 20px of clearance.

   left/right rather than the card's padding: an absolutely positioned child is
   laid out against the padding box, so it would otherwise span the full 390 and
   sit 24px wider than the text above it. */
.actions {
  position: absolute;
  left: var(--space-6);
  right: var(--space-6);
  top: var(--actions-top, 60%);
  display: flex;
  align-items: center;
  justify-content: space-around;

  /* Visible by default so a card with no JS, or one the observer below hasn't
     reached yet, is never stuck with a dead row of icons.

     The 120ms delay is what makes this read as "the icons catch up to the word"
     rather than "everything arrived at once" — Von Restorff: the row draws the
     eye precisely because it moves a beat after the text that was already
     still. Fading in during the swipe itself would just be one more thing
     flickering past mid-gesture. */
  opacity: 1;
  transition: opacity var(--dur-tap, 260ms) var(--ease-ios) 120ms;
}

/* Set by the IntersectionObserver in app.js the instant a card leaves the
   viewport — no transition, so the row is simply gone before you're mid-swipe
   to the next word. It comes back via the rule above, on its own delayed
   fade, once the new card settles. */
.actions.is-offscreen {
  opacity: 0;
  transition: none;
}
.icon-btn {
  position: relative;        /* containing block for the favourite flourish */
  width: 52px; height: 52px;
  display: grid;
  place-items: center;
  border: 0;
  background: none;
  color: var(--ink);
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios);
}
.icon-btn:active { transform: scale(.88); }
.icon-btn svg {
  width: 26px;
  height: 26px;

  /* 1.5, down from the 1.8 written into each icon's markup. Set here rather than
     edited in three places so the row can only ever have one weight, and because
     CSS beats an SVG presentation attribute — the inline 1.8 stays as the value
     these were drawn at.

     1.5 is not a guess, but the arithmetic is about PROPORTION rather than
     absolute pixels — the two icons being matched are different sizes, so equal
     stroke widths would not look equal.

     Phosphor regular draws its stroke at 16 of 256 units, or 6.25% of the icon.
     That is the weight of the speaker in the pill, and at its 16px it comes out a
     hair over 1px. These are 24-unit viewBoxes drawn at 26px, so a stroke renders
     at width x 26/24: the old 1.8 gave 1.95px, which is 7.5% of the icon and
     visibly heavier. 1.5 gives 1.63px, or 6.27% — the same proportion Phosphor
     uses, so the row now carries the same optical weight as the one icon in the
     app that came from a designed set. */
  stroke-width: 1.5;
}
/* Toggled state fills the outline — recognisable from every social app, so
   it needs no explanation (Jakob's Law). The favourite is the one toggle that
   also changes hue, because it is the only one recording a choice rather than
   reporting a fact. */
.icon-btn[aria-pressed='true'] svg { fill: currentColor; }
.icon-btn[data-act='like'][aria-pressed='true'] svg {
  fill: var(--liked);
  stroke: var(--liked);
}

/* --- Favouriting -------------------------------------------------------
   Spotify's heart, in our olive.
   https://medium.com/spotify-design/bringing-the-spotify-heart-to-life-e31440625d7

   Their brief was max 500ms at 60fps, built from "pulses, flourishes, and the
   glow of light and colour", against three principles: move with purpose,
   provide feedback, add delight. This is 460ms and three overlapping parts:

     0-70ms     anticipation. The heart dips to .8 before it grows. A wind-up is
                what makes the pop read as a response to the press rather than a
                state that changed on its own.
     70-260ms   the pulse, overshooting to 1.35, and the colour arriving WITH the
                peak rather than at the tap. Colour on the way up is the "glow of
                light and colour"; colour at 0ms is just a class toggle.
     70-460ms   the flourish. A ring expanding out and fading, plus eight sparks.

   This replaces a 260ms pop that deliberately had no particles, on the reading
   that Apple asks for feedback proportional to the event and a burst rewards an
   act that only needs acknowledging. Spotify's answer to the same question is the
   opposite, and both are defensible: theirs is a save you make hundreds of times
   on content you love, and delight compounds. Ours is a vocabulary bookmark. The
   article won the call; the earlier reasoning is kept here because it is the
   thing to re-read if the flourish ever starts to grate.

   Everything animates transform and opacity only, so none of it can shift the
   layout or the two buttons beside it. The heart's transform sits on the SVG
   rather than the button: the button owns the 52px tap target and its own :active
   scale, and animating both would have the pop cancel the press. */
/* THE OVERSHOOT IS 1.22, DOWN FROM 1.35. This animation is now the only thing
   that changes the heart's size — the Lottie used to bring a heart of its own and
   no longer does — so its peak is what "the heart enlarges too much" refers to.
   1.35 on an 18px glyph reaches 24px, a 6px jump inside a 26px box, and at that
   scale a stroked outline visibly thickens as well as grows. 1.22 reaches 22px:
   still a clear pulse, no longer a lurch. */
@keyframes like-pop {
  0%   { transform: scale(1); }
  15%  { transform: scale(.86); }     /* anticipation */
  45%  { transform: scale(1.22); }    /* the pulse */
  70%  { transform: scale(.97); }
  100% { transform: scale(1); }
}

/* The fill is held off until the pulse is on its way up, then snapped rather
   than crossfaded — a slow fill looks like loading, a snap looks like a switch. */
@keyframes like-colour {
  0%, 24%   { fill: transparent;   stroke: var(--ink); }
  32%, 100% { fill: var(--liked);  stroke: var(--liked); }
}

/* The ring. Starts just outside the heart and grows away from it, thinning as it
   goes, so it reads as light leaving the icon rather than a circle drawn over it.

   The first attempt started at scale .35 with a 5px border, which at peak opacity
   was a solid olive blob sitting on top of the heart — it masked the thing it was
   celebrating. Starting at .8 keeps it clear of the glyph from the first frame,
   and 2px is thin enough that it never competes with the fill. */
@keyframes like-ring {
  0%   { opacity: 0;   transform: scale(.8);  border-width: 3px; }
  20%  { opacity: .45; }
  /* 1.35 of a 26px box is a 17.5px radius, well short of where the hearts finish
     at 21px. At 1.55 the two landed on top of each other and the frame read as a
     decorative wreath — hearts threaded onto a ring — instead of a burst. Ending
     inside them, and fading out first, makes the ring the shockwave they escape. */
  100% { opacity: 0;   transform: scale(1.35); border-width: 1px; }
}

/* One keyframe for all eight hearts. Each supplies its own direction, distance,
   size, tilt and tone, so the shared timing produces a scatter rather than a
   pattern. They peak early and spend most of their life fading, which is what
   stops the burst reading as fireworks.

   THE ROTATE SANDWICH. `rotate(--a) translateY() rotate(-1 * --a)` is how a heart
   gets a direction without being turned by it: rotate, translate along the new Y
   axis, then undo the rotation so the glyph stands up again. Without the third
   step the heart at 90deg flies out lying on its side, and upright is the
   difference between reading as a heart and reading as a blob at 5px.
   `rotate(--r)` after that is a deliberate small tilt, which is a different thing
   from the accidental one — a frame of the real animation shows the hearts leaning
   rather than standing to attention.

   PEAK OPACITY IS .9, NOT 1. In that frame the flying hearts are markedly dimmer
   than the heart they came from. At full opacity they compete with it, and the
   thing being celebrated stops being the thing you look at. */
@keyframes like-spark {
  0% {
    opacity: 0;
    transform: rotate(var(--a)) translateY(-6px) rotate(calc(var(--a) * -1))
               rotate(var(--r, 0deg)) scale(calc(var(--s, 1) * .3));
  }
  25% {
    opacity: .9;
    transform: rotate(var(--a)) translateY(calc(var(--d, 18px) * -.78))
               rotate(calc(var(--a) * -1)) rotate(var(--r, 0deg)) scale(var(--s, 1));
  }
  100% {
    opacity: 0;
    transform: rotate(var(--a)) translateY(calc(var(--d, 18px) * -1))
               rotate(calc(var(--a) * -1)) rotate(var(--r, 0deg))
               scale(calc(var(--s, 1) * .45));
  }
}

.icon-btn.is-liking svg {
  /* Two animations on one element: the scale and the hue run on independent
     timings, which a single keyframe track could not express. */
  animation: like-pop 320ms var(--ease-ios),
             like-colour 320ms linear;
}

/* Un-favouriting: the heart shakes left and right. A "no", not the celebration
   played backwards — reversing a celebration to mean "not that" reads as a second
   celebration. Sideways is the axis people already read as refusal, borrowed from
   outside software rather than invented here (Jakob's Law).

   THIS IS APPLE'S SHAKE, RESCALED. The canonical one is a CAKeyframeAnimation on
   `position.x`, values 0, 10, -10, 10, 0 at keyTimes 0, 1/6, 3/6, 5/6, 1 over
   0.4s — the wrong-password shake, documented in objc.io's Animations Explained.
   Two things about it are easy to get wrong:

   1. IT DOES NOT DECAY. Amplitude stays at 10 the whole way. A decaying shake
      reads as something settling; a constant one reads as a deliberate "no". An
      earlier version here decayed 11 -> 9 -> 6 -> 3 degrees and that was the
      wrong gesture as well as the wrong axis.

   2. THE UNEVEN keyTimes ARE THE POINT. The legs are 1/6, 2/6, 2/6, 1/6 while the
      distances are 10, 20, 20, 10 — so speed is CONSTANT across all four. Spacing
      the keyframes evenly instead, which is the obvious thing to type, makes the
      two long middle swings travel double the distance in the same time and the
      shake sags in the middle. Hence 16.67% / 50% / 83.33%, and `linear`: an
      ease curve on top would undo the constant velocity these times exist to give.

   AMPLITUDE IS 5px, NOT 10. Apple's 10px is applied to a login panel hundreds of
   pixels wide. This is a 26px glyph with 13px of clearance inside a 52px button,
   so 10px would nearly hit the edge and crowd the share icon beside it. 5px is
   clearly visible on a shape this size and keeps 8px in hand.

   DURATION 320ms, between Apple's 400 and the ~200 Material suggests for a
   component this small. Frequency is what settles it rather than either figure:
   Apple's timing puts the oscillation near 7.5Hz, and holding roughly that band
   is what keeps this reading as a head-shake. Compressing to 200ms while keeping
   three direction changes pushes it past 15Hz, where it stops being a gesture and
   becomes a buzz.

   Nothing here touches fill. The colour is already back to the ink outline on the
   frame the state changed, and animating it would restore the exact bug that made
   this worth fixing: an icon still looking liked after you unliked it. */
@keyframes like-shake {
  0%      { transform: translateX(0); }
  16.67%  { transform: translateX(5px); }
  50%     { transform: translateX(-5px); }
  83.33%  { transform: translateX(5px); }
  100%    { transform: translateX(0); }
}
.icon-btn.is-unliking svg {
  animation: like-shake 320ms linear;
}

/* The Lottie stage.

   Bigger than the button on purpose: the animation's particles fly well past the
   heart, and at 52px they would land outside the element. Nothing in this stack
   clips — the actions row, the card and the feed all leave overflow visible — so
   the stage can overhang, which is why it is centred with a negative margin
   rather than inset into the button. */
/* 80px. Originally derived so the animation's own heart landed on the icon's 18px
   of ink (132 x 18/30 = 79, rounded up), back when there were two hearts. That
   heart is gone now, so what 80px sets is where the eight particles fly to: the
   stage is the radius of the burst.

   Still the right number for that. The outermost particles reach the stage edge,
   which puts them ~40px from the centre — clear of the 18px heart, inside the
   52px tap target's neighbours. Growing the stage would fling them under the ⓘ
   and share icons; shrinking it packs them onto the heart. */
.heart-lottie {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 80px;
  height: 80px;
  margin: -40px 0 0 -40px;
  pointer-events: none;
  transition: opacity 120ms linear;
}
/* Faded rather than yanked. Even at matching sizes the two hearts are different
   paths, so a hard cut is a visible flicker on the last frame. */
.heart-lottie.is-leaving { opacity: 0; }

/* THERE IS NO LONGER A HAND-OFF, AND THAT IS THE FIX.

   The button's icon used to stand down for the ~1s the animation played, because
   the animation brought its own heart. Two hearts changing places, and they were
   not the same heart: measured, the Lottie's was 6.1% wider in proportion (1.149
   against the icon's 1.083) and a different curve. So the heart visibly changed
   shape mid-tap. Sizing them to match and cross-fading over 120ms softened it
   and never removed it — you cannot blend one silhouette into another.

   So the animation's heart is now stripped out at build time (DROP_HEART in
   tools/prepare-heart-lottie.py) and the icon never hides. One heart, start to
   finish, and the Lottie contributes only its eight particles.

   Kept as history, because it cost hours: as a DESCENDANT selector the old
   hiding rule also hit the Lottie's own <svg>, which lottie-web mounts inside
   this same button. The animation rendered correctly the whole time — real
   geometry, right green — and painted nothing, because the rule meant to hide
   one icon hid both. Any future rule reaching into this button wants `>`. */

/* The flourish layer. Absolute and centred on the button, sized to the icon
   rather than the 52px target so the ring hugs the heart. */
.like-burst {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  pointer-events: none;      /* never intercepts the tap it decorates */
}
.like-burst::before {        /* the ring */
  content: '';
  position: absolute;
  inset: 0;
  border: 3px solid rgba(var(--liked-rgb), .9);
  border-radius: 50%;
  opacity: 0;
}
.like-burst i {
  position: absolute;
  top: 50%;
  left: 50%;
  /* 6px, down from 8px. With --s spanning .8 to 1.1 the hearts render between 4.8
     and 6.6px. Not going below this: a heart silhouette needs its two lobes and
     its point to survive antialiasing, and under about 4px it collapses into a
     round blob — at which point these may as well be the dots they replaced. */
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  opacity: 0;

  /* A masked heart rather than an <svg> each, or a background-image of one.
     The mask carries the shape and `background` carries the colour, so these stay
     on --liked like everything else in the flourish — a background-image would
     bake the fill into a data URI and quietly stop following the theme.

     Same path as the button's own heart, filled instead of stroked, so the things
     flying out are the icon rather than a generic heart that nearly matches.

     --c is the per-heart tone, defaulting to the main green. Three of the eight
     override it to --liked-alt: Spotify throws a mixture rather than one colour,
     and the second tone is what stops eight identical shapes reading as a stamped
     pattern instead of a handful thrown. */
  background: var(--c, var(--liked));
  --heart: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 20.4S3.6 15.2 3.6 9.4A4.6 4.6 0 0 1 12 6.9a4.6 4.6 0 0 1 8.4 2.5c0 5.8-8.4 11-8.4 11z'/%3E%3C/svg%3E");
  -webkit-mask: var(--heart) center / contain no-repeat;
  mask: var(--heart) center / contain no-repeat;

  /* Matches the keyframe's 0% so a heart cannot jump into position on frame one. */
  transform: rotate(var(--a)) translateY(-6px) rotate(calc(var(--a) * -1))
             rotate(var(--r, 0deg)) scale(calc(var(--s, 1) * .3));
}
.icon-btn.is-liking .like-burst::before {
  /* Done at 370ms, before the hearts finish at 460ms. The ring leaving first is
     what sequences the two: shockwave, then hearts carrying on outward. The
     flourish as a whole stays inside the article's 500ms ceiling. */
  animation: like-ring 300ms var(--ease-ios) 70ms;
}
.icon-btn.is-liking .like-burst i {
  /* The 70ms delay here is only a default. Each heart overrides it inline with a
     value between 60 and 92ms, and an inline declaration beats a stylesheet one,
     so what actually runs is the staggered set — this figure covers a ninth heart
     added later without one. Longest total is 92 + 390 = 482ms, still inside the
     article's 500ms ceiling.

     Distances are per-heart (--d, 19 to 22px) rather than shared, which is part of
     what makes the scatter read as thrown. */
  animation: like-spark 390ms var(--ease-ios) 70ms;
}

/* Motion is the point here, so honouring the preference means removing it rather
   than shortening it. The colour still changes, which is the state; the sparks
   and ring never appear at all. */
@media (prefers-reduced-motion: reduce) {
  .icon-btn.is-liking svg { animation: none; }
  .icon-btn.is-liking .like-burst::before,
  .icon-btn.is-liking .like-burst i { animation: none; }
  /* The shake carries no information the fill does not already carry, so under
     this preference it simply goes. */
  .icon-btn.is-unliking svg { animation: none; }
}


/* --- Scroll fades --------------------------------------------------------
   Soft ramps from the page colour to nothing, one at each end of the screen.

   Bottom: without it a scrolling card meets the floating bar as a hard edge —
   half a topic tile, sliced by the bezel, sitting under a translucent capsule.
   The ramp makes content dissolve into the page instead, so the bar reads as
   floating above a surface rather than cutting into one, and whatever passes
   under the glass stops competing with the labels.

   Top: the same problem in reverse. Scroll the topics and "Explore topics"
   rides up into the status bar and collides with the clock. The status bar is
   part of the phone, not the page, so content has to stop being visible before
   it gets there.

   Both are pointer-events: none, so neither ever eats a tap meant for the card
   underneath, and both sit above the pages but below the bar, the deck chip and
   the per-word action row.                                                   */

.scroll-fade,
.scroll-fade-top {
  position: absolute;
  left: 0;
  right: 0;
  z-index: 15;
  pointer-events: none;
}

.scroll-fade {
  bottom: 0;
  height: calc(var(--inset-bottom) + var(--space-3) + var(--tabbar-h) + 56px);
  background: linear-gradient(to top,
    var(--bg) 0,
    var(--bg) calc(var(--inset-bottom) + var(--space-3) + var(--tabbar-h)),
    var(--bg-0) 100%);
}

/* Solid across the status bar, then a short fade that ends exactly where the
   page's content padding begins — any longer and it would veil the top of the
   heading while the page is sitting still. */
.scroll-fade-top {
  top: 0;
  height: calc(var(--inset-top) + var(--space-4));
  background: linear-gradient(to bottom,
    var(--bg) 0,
    var(--bg) var(--inset-top),
    var(--bg-0) 100%);
}


/* --- Tab bar -------------------------------------------------------------
   One floating pill holding five destinations, instead of three loose circles.

   Labels as well as icons. A graduation cap and a bar chart are not
   self-evident, and reading "Practice" beats decoding a mortarboard
   (recognition over recall). It also makes every target a full column wide
   rather than a 52px circle, which is easier to hit (Fitts's Law).           */

.tabbar {
  /* Spans the screen with a 16px margin each side, the way iOS floats a tab bar.
     Four tabs sharing that width gives each one a ~86px column, so the target is
     the whole column rather than the icon (Fitts's Law). */
  left: var(--space-4);
  right: var(--space-4);
  /* 12px of clearance below the bar. Measured from the safe-area inset, not the
     screen edge, so on a notched phone the 12px sits above the home indicator
     rather than underneath it. */
  bottom: calc(var(--inset-bottom) + var(--space-3));
  display: flex;
  align-items: stretch;
  gap: 2px;
  padding: 4px;
  border-radius: var(--radius-pill);

  /* Liquid Glass. Translucent so the words scroll faintly beneath, blurred so
     they never compete with the labels, and edged so it reads as a lens rather
     than a panel — bright along the top where light would catch, dark
     underneath where it falls away. */
  background: var(--glass-tint);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow:
    inset 0 1px 0 var(--glass-edge-top),
    inset 0 -1px 0 var(--glass-edge-bottom),
    0 0 0 0.5px var(--glass-ring),
    0 6px 20px rgba(0, 0, 0, .10);

  /* Arrives a beat after the word, rising into place.

     The delay is the point rather than a garnish. The word is what the screen is
     for and the bar is how you leave it, so letting the word land alone first
     states that order in the only way motion can — whatever moves last reads as
     secondary. Arriving together would make them equals.

     320ms is long enough to register as a sequence rather than a stagger, and
     short enough that nobody reaching for Topics finds the bar missing. Below
     ~200ms the two read as one event; past ~500ms it starts to feel broken.

     --ease-sheet-lg, not --ease-ios, and the difference is measurable. Over the
     first tenth of its length this curve covers 16% of the distance where
     --ease-ios covers 27%; by a third of the way it is at 70% against 86%. So it
     leaves more gently and has more of the move left to spend on the finish, which
     is what stops a 0.58s rise reading as "shot up, then crawled". Neither curve
     literally starts from rest, whatever the token comment says — both decelerate
     throughout, this one just less abruptly at the top.

     `both` holds the from-state through the delay. Without it the bar paints in
     its final position for 320ms, then jumps back down to animate up from there. */
  animation: tabbar-in var(--dur-sheet-lg) var(--ease-sheet-lg) 320ms both;
}

@keyframes tabbar-in {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* The sequence was saying "the word first". Without motion there is nothing to
   say it with, so the bar is simply there — which is the correct resting state,
   not a degraded one. */
@media (prefers-reduced-motion: reduce) {
  .tabbar { animation: none; }
}

/* No backdrop-filter (older Safari), or the reader has asked for less
   transparency: fall back to a solid surface rather than a see-through bar
   with unreadable labels. */
@supports not (backdrop-filter: blur(1px)) {
  .tabbar { background: var(--surface); }
}
@media (prefers-reduced-transparency: reduce) {
  .tabbar {
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

.tab {
  flex: 1;
  display: grid;
  justify-items: center;
  align-content: center;
  /* 4px, not 2. At 2 the label crowded the icon and the pair read as one
     smudge; the gap is what lets you take in the glyph and the word separately
     (Gestalt proximity works both ways — too tight and grouping becomes
     merging). */
  gap: var(--space-1);
  /* 54, where 44 is the iOS minimum. The minimum is a floor for reachability,
     not a target: this is the control you hit most often in the app, and a
     taller column is a faster one to land on (Fitts's Law). Plus 4px of bar
     padding top and bottom makes --tabbar-h. */
  min-height: 54px;
  padding: var(--space-1) 2px;
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  /* Unselected destinations recede. The gap between this and full ink is what
     marks the current page, so the inactive state has to actually be quieter —
     near-black everywhere would flatten the signal. See --ink-nav for why this
     is not the usual --ink-muted. */
  color: var(--ink-nav);
  font-family: var(--font-ui);
  font-size: var(--size-label);
  font-weight: 500;
  cursor: pointer;
  transition: background var(--dur-tap) var(--ease-ios),
              color var(--dur-tap) var(--ease-ios);
}
.tab svg {
  width: 22px;
  height: 22px;
  transition: stroke-width var(--dur-tap) var(--ease-ios);
}

/* The selected destination.

   No colour in the bar at all — a neutral pill, full-strength ink and a heavier
   stroke do the work. Three cues stacked on one target: common region (the pill
   groups icon and label into a single shape), weight, and contrast. The pill is
   a black tint rather than a fixed grey, so it darkens whatever shows through
   the glass instead of sitting on it as an opaque patch.

   The pill alone only reaches 1.2:1 against the bar, so it is not carrying the
   state on its own — no light pill on a light bar can. The ink does: 15.3:1 on
   the selected tab against 5.2:1 on the others, a threefold step. */
.tab.is-active {
  background: rgba(var(--ink-rgb), .10);
  color: var(--ink);
  font-weight: 600;
}
.tab.is-active svg { stroke-width: 2.1; }

.tab:active { background: rgba(var(--ink-rgb), .10); }
.tab.is-active:active { background: rgba(var(--ink-rgb), .14); }

/* --- Toast -------------------------------------------------------------- */

.toast {
  position: absolute;
  z-index: 60;
  left: var(--space-6);
  right: var(--space-6);
  bottom: calc(var(--inset-bottom) + var(--tabbar-h) + 127px);
  padding: var(--space-3) var(--space-4);
  border-radius: 14px;
  background: rgba(var(--ink-rgb), .88);
  color: #fff;
  font-size: var(--size-small);
  line-height: var(--leading-body);
  text-align: center;
  opacity: 0;
  transform: translateY(6px);
  pointer-events: none;
  transition: opacity var(--dur-tap) var(--ease-ios),
              transform var(--dur-tap) var(--ease-ios);
}
.toast.is-shown { opacity: 1; transform: none; }


/* ==========================================================================
   BUTTONS
   Three weights. Primary carries the accent and marks the expected path;
   outline is the equal-but-secondary choice; ghost is for the prototype
   controls, which should look like tooling rather than product.
   ========================================================================= */

.btn-primary, .btn-outline, .btn-ghost {
  min-height: 52px;
  padding: 0 var(--space-6);
  border: 0;
  border-radius: var(--radius-pill);
  font-family: var(--font-ui);
  font-size: 17px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios),
              background var(--dur-tap) var(--ease-ios);
}
.btn-primary { background: var(--accent); }
.btn-primary:active { background: var(--accent-deep); transform: scale(.97); }

.btn-outline { background: transparent; box-shadow: inset 0 0 0 1.5px var(--ink); }
.btn-outline:active { background: rgba(var(--ink-rgb), .06); transform: scale(.97); }

.btn-ghost {
  min-height: 44px;
  padding: 0 var(--space-4);
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  font-size: var(--size-ui);
  font-weight: 500;
  color: var(--ink-muted);
}
.btn-ghost:active { transform: scale(.97); }
.btn-ghost.is-active { background: var(--accent); color: var(--ink); }

/* The dialect flag on a voice button.

   6px of space and nothing else — no size bump. Emoji flags already render a good
   deal larger than the text beside them on iOS, and scaling them up made the row
   read as a list of flags with names attached rather than a list of voices. The
   name is the label; the flag qualifies it. */
.voice-flag {
  margin-right: 6px;
  /* Colour emoji ignore `color`, so this survives the is-active state without
     needing an override — worth stating, because it looks like an oversight. */
  font-style: normal;
}

.btn-wide { width: 100%; }


/* ==========================================================================
   PRACTICE SHEET
   ========================================================================= */

/* ==========================================================================
   PAGES
   One per tab. Each fills the device and scrolls on its own; the tab bar floats
   above them and never moves. Switching tabs swaps which page is in the flow —
   no sheet slides over anything, so nothing is ever "on top of" the feed.
   ========================================================================= */

.page {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-y: contain;
  background: var(--bg);
  /* Clear the status bar above and the floating tab bar below, so no page has
     to know the chrome exists. */
  /* Derived, not typed: the bar's own height, its 12px clearance, and 24px of
     air so the last card does not sit flush against the glass. */
  padding: calc(var(--inset-top) + var(--space-4)) var(--space-5)
           calc(var(--inset-bottom) + var(--tabbar-h) + var(--space-3) + var(--space-6));
  scrollbar-width: none;
}
.page::-webkit-scrollbar { display: none; }
.page[hidden] { display: none; }

/* The feed is the exception: full-bleed, its own scroller, no page padding —
   the word cards manage their own vertical rhythm. */
#pageWords { padding: 0; overflow: hidden; }

.page-title {
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  font-size: 30px;
  font-weight: 700;
  line-height: var(--leading-tight);
  margin: var(--space-2) 0 var(--space-5);
}


/* --- Practice hero -------------------------------------------------------
   Sits between the title and the ready card. Height-driven rather than
   width-driven: the illustration is near-square, so pinning the height is what
   controls how far Start review gets pushed down, and the width follows.

   260px. Three figures and two book stacks need the size — at 200 the faces
   were 8px and the scene read as texture rather than people. Measured at this
   height, Start review still clears the tab bar by 202px.

   The source arrived as a 1024px PNG on a cream field four values lighter than
   --bg, which would have drawn a pale square on the page. That field is keyed
   out, so the page colour shows through and the artwork carries no background
   of its own.                                                               */

.practice-hero {
  display: block;
  height: 260px;
  width: auto;
  margin: 0 auto var(--space-5);
  /* Matches the file's own dimensions. Redundant with the width/height on the
     <img>, and kept anyway: the ratio survives if someone later drops those
     attributes, which is the usual way a hero starts jumping on load. */
  aspect-ratio: 600 / 635;
}

/* Short screens: the review card matters more than the picture, so the
   illustration gives up height first rather than pushing the button off. */
@media (max-height: 700px) {
  .practice-hero { height: 170px; }
}


/* --- Scrim, now only for the word-detail overlay ------------------------ */

.scrim {
  position: absolute;
  inset: 0;
  z-index: 30;
  background: rgba(var(--ink-rgb), .32);
  opacity: 0;
  /* Matched to the sheet it sits behind, so the room darkens at exactly the
     rate the card rises. Mismatched, the dimming arrives first and the sheet
     looks like it is chasing it. */
  transition: opacity var(--dur-sheet-lg) var(--ease-sheet-lg);
}
.scrim.is-open { opacity: 1; }
.scrim[hidden] { display: none; }

/* The info overlay is still a sheet — it's a detail view opened from a word,
   not a destination, so sliding up over the page is the right presentation. */
.sheet {
  position: absolute;
  z-index: 31;
  left: 0; right: 0; bottom: 0;
  max-height: calc(100% - var(--inset-top) - var(--space-3));
  overflow-y: auto;
  /* 12px at the sides, not 20. The cards carry their own 16px of inner
     padding, so the sheet's own gutter was stacking on top of it and the text
     sat 36px from the screen edge. */
  padding: var(--space-4) var(--space-3) calc(var(--inset-bottom) + var(--space-6));
  border-radius: var(--radius-sheet) var(--radius-sheet) 0 0;
  background: var(--sheet);
  box-shadow: var(--shadow-sheet);

  /* FULL TRAVEL, restored deliberately after the short lift failed.

     This used to start at a fixed 40px with an opacity fade, on the reasoning
     that a short move reads as "this surface was already sitting there" rather
     than a panel driving in from outside, and that a fixed distance is
     consistent where the sheet's own height is not.

     Measured, that argument did not survive contact: the info sheet renders 785px
     tall on an 844px screen, so a 40px lift moved it five percent of its own
     height. What you actually saw was a fade with a twitch, which is what "the
     sheet animation is not really showing" meant. The consistency point was also
     solving a problem nobody had — nobody compares the travel of two sheets.

     So it comes from off-screen, which is what a card sheet does and what makes
     the backdrop card behind it legible: during the rise you see the scaled page
     the whole way, instead of a 47px band at the end.

     THE FADE IS GONE WITH IT. It existed to make a short move look intentional.
     Over a full-height slide it just makes the sheet look translucent on the way
     up, and iOS does not fade a sheet it is sliding. translateY(100%) already
     hides the panel completely, so nothing needs opacity to stay out of sight.

     Duration steps up to match the distance: 0.3s over 785px is a lunge. This is
     the same 0.58s and curve the tab bar already uses for a large move, so the
     two pieces of chrome that travel a long way now agree. */
  transform: translateY(100%);
  transition: transform var(--dur-sheet-lg) var(--ease-sheet-lg);

  /* Promote the sheet to its own compositor layer for the slide.

     Without it the browser re-rasterises the whole panel every frame, and this
     panel is the most expensive thing to rasterise in the app: four bordered
     cards, four pastel circles, and Arabic at 18px with harakaat, which is slow
     to shape. With it, the move is a GPU transform of an already-painted layer
     and the text is never re-shaped at all.

     Safe to leave declared. The sheet is display:none when closed, so no layer
     exists until it opens. */
  will-change: transform, opacity;
}
.sheet.is-open { transform: none; }

/* ---------------------------------------------------------------------------
   THE CARD-SHEET BACKDROP

   iOS 13 changed what a modal sheet means: the page you came from does not just
   dim, it scales back, gains corners and becomes a card sitting behind the one
   you are now reading. That is the cue this adds. The page is still there and
   still yours — you have put something on top of it, not navigated away — which
   is exactly what the ⓘ sheet is (a detail view of the word behind it, not a
   destination).

   DRIVEN OFF body.has-overlay, WHICH WAS ALREADY THERE AND DEAD. openOverlay()
   has been adding that class since the sheet was written, with a comment saying
   "floating navigation reads this in CSS and steps back" — and no rule anywhere
   read it. The hook existed, the behaviour never got built. So this needs no JS.

   .page ONLY, not the tab bar. Every .page is `position:absolute; inset:0`, the
   same box as .device, so they all scale about the same point and move as one
   surface. The tab bar is a separate floating element that would scale about its
   own centre and slide out of register — and it does not need to, because the
   sheet is bottom-anchored at z-index 31 against the bar's 20, so it is already
   underneath by the time any of this is visible.

   THE SHEET'S OWN 40px RISE IS DELIBERATELY UNCHANGED. A real iOS card sheet
   drives in from off-screen; this one lifts a short way and fades, and the
   comment above .sheet explains why at length — a fixed short travel reads as a
   surface that was already there rather than a panel arriving from outside. That
   was a considered decision and this is not the moment to overturn it. What was
   missing was the backdrop half.

   No overflow change. .page is the scroll container and already clips, so the
   radius takes effect without touching overflow — which matters because setting
   it would freeze the mandatory-snap feed mid-scroll. */
/* Same duration and curve as the sheet itself. They are one gesture — the card
   goes back as the sheet comes up — and at different timings the page finishes
   early and sits there waiting, which reads as two unrelated things happening. */
.page {
  transform-origin: 50% 0;
  transition: transform var(--dur-sheet-lg) var(--ease-sheet-lg),
              border-radius var(--dur-sheet-lg) var(--ease-sheet-lg);
}

/* 0.92 and a 12px dip. Apple's is near 0.92 at phone sizes; below about 0.88 the
   page reads as thrown away rather than set down, and above 0.95 the move is not
   worth making. The dip is what puts daylight under the status bar so the corners
   are visible — without it the card scales into its own top edge and the whole
   effect is two rounded corners nobody notices. */
body.has-overlay .page {
  transform: translateY(calc(var(--inset-top) + 12px)) scale(.92);
  border-radius: var(--radius-sheet);
}

/* What shows in the gap the scaled page leaves. Cream on cream would make the
   scale invisible everywhere except the corners, so the frame darkens to near-ink
   for as long as a sheet is open — the same near-black iOS puts back there. */
.device {
  transition: background var(--dur-sheet-lg) var(--ease-sheet-lg);
}
body.has-overlay .device { background: #14140F; }

/* The page is the card now, so the scrim would be dimming a surface that has
   already stepped back — two treatments doing one job, and together they made the
   backdrop muddy rather than recessive. The scale carries the depth; the scrim
   drops to a fraction of its old weight just to keep the sheet's edge readable
   against a light page. */
body.has-overlay .scrim.is-open { opacity: .18; }

/* Somebody who has asked not to see motion gets the state without the move: the
   page still steps back so the layering is legible, it simply arrives there. */
@media (prefers-reduced-motion: reduce) {
  .page, .device { transition: none; }
}

/* ---------------------------------------------------------------------------
   Share
   ------------------------------------------------------------------------ */

/* Taller than the info sheet and anchored to the top, because it holds one
   object you are meant to look at rather than a column you scroll. */
.sheet--full {
  top: 0;
  max-height: none;
  /* Clear the status bar. The info sheet never needed this because it is
     anchored to the bottom and stops short of the top; this one reaches the top
     of the screen, so the sheet's own 16px put the close button under the clock
     and the notch. --inset-top is the real safe area on a device and the fake
     status bar's 44px in the prototype frame. */
  padding-top: calc(var(--inset-top) + var(--space-3));
  border-radius: 0;   /* it covers the whole screen; a rounded top would float */
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: var(--space-5);
  background: var(--surface);
}

/* The thing being shared. Deliberately not the feed card: this one has a
   visible edge, because it is an object leaving the app rather than a screen
   inside it, and an object needs a boundary to read as one. */
.share-card {
  align-self: center;
  display: grid;
  justify-items: center;
  gap: var(--space-2);
  padding: var(--space-8) var(--space-5) var(--space-6);
  border: 1px solid #E4E1DA;
  border-radius: 20px;
  background: var(--bg);
  text-align: center;
}
.share-word {
  font-family: var(--font-arabic);
  font-weight: var(--weight-arabic-display);
  font-size: 46px;
  line-height: var(--leading-arabic-display);
  margin: 0;
}
.share-translit { font-size: var(--size-body); color: var(--ink-muted); margin: 0; }
.share-meaning  { font-size: var(--size-body); margin: 0; }
.share-example {
  font-family: var(--font-arabic);
  font-size: var(--size-example-ar-sheet);
  line-height: var(--leading-arabic-text);
  margin: var(--space-3) 0 0;
}
.share-example-en {
  font-size: var(--size-card-body);
  color: var(--ink-muted);
  margin: 0;
}
/* The equivalent of the reference app's watermark, minus the upsell. A shared
   card should say where it came from; it does not need to advertise. */
.share-mark {
  margin-top: var(--space-4);
  font-size: var(--size-card-label);
  color: var(--ink-muted);
}

/* Three actions, not seven. The reference offers collection, copy, watermark,
   dislike, report, Messages and share-via, which is a product with an account
   system behind it; here there are three things you can actually do, and fewer
   choices is a faster decision (Hick's Law). */
.share-actions {
  display: flex;
  justify-content: center;
  gap: var(--space-6);
  padding-bottom: calc(var(--inset-bottom) + var(--space-6));
}
.share-act {
  display: grid;
  justify-items: center;
  gap: var(--space-2);
  width: 84px;
  background: none;
  border: 0;
  padding: 0;
  color: var(--ink);
  font-family: var(--font-ui);
  cursor: pointer;
}
.share-act-icon {
  display: grid;
  place-items: center;
  width: 56px;              /* comfortably past the 44px minimum */
  height: 56px;
  border-radius: 50%;
  background: #F4F2EC;
  transition: transform var(--dur-tap) var(--ease-ios),
              background var(--dur-tap) var(--ease-ios);
}
.share-act-icon svg { width: 24px; height: 24px; }
.share-act:active .share-act-icon { transform: scale(.93); background: #E9E6DE; }
.share-act-label {
  font-size: var(--size-card-label);
  line-height: 1.3;
  color: var(--ink-muted);
}
/* Share via… is the one you came here for, so it is the one that is filled
   (Von Restorff — the odd one out is the one you reach for). */
.share-act--primary .share-act-icon { background: var(--ink); color: var(--surface); }
.share-act--primary:active .share-act-icon { background: #333; }
.share-act--primary .share-act-label { color: var(--ink); font-weight: 500; }
/* Saved is a state, not an action, so it changes rather than animates. */
.share-act[aria-pressed='true'] .share-act-icon { background: var(--accent); }
.share-act[aria-pressed='true'] svg { fill: currentColor; }

/* ---------------------------------------------------------------------------
   Pushing between words inside the sheet

   Tapping a related word is navigation, so it moves like navigation: the word
   you were on leaves to the left and the new one arrives from the right, back
   the other way round. Without it the content simply blinked and you could not
   tell whether you had gone somewhere or the same card had changed under you.

   The outgoing word is a clone. There is only one set of markup and rendering
   the new word overwrites it, so the old one is snapshotted, laid over the top
   and animated out while the real page comes in behind it. It is inert — no
   ids, aria-hidden, no pointer events — because for a third of a second there
   are two copies of the same Arabic in the tree.

   The clone is pinned to the top of the sheet rather than left in flow, so two
   words of different heights do not stack and shove the sheet around mid-move.
   ------------------------------------------------------------------------ */
.info-page { position: relative; }

/* Clipped only while something is moving. Permanent overflow:hidden here would
   trap the sheet's own scrolling. */
.sheet.is-pushing { overflow-x: hidden; }

.info-ghost {
  position: absolute;
  top: 0; left: 0; right: 0;
  pointer-events: none;
}

@keyframes info-in-from-right {
  from { opacity: 0; transform: translateX(38%); }
  to   { opacity: 1; transform: none; }
}
@keyframes info-in-from-left {
  from { opacity: 0; transform: translateX(-38%); }
  to   { opacity: 1; transform: none; }
}
/* The departing word travels less far than the arriving one and fades sooner.
   Matching them made the pair read as one strip sliding past a window; the
   offset is what makes the new word feel like it is on top. */
@keyframes info-out-to-left {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateX(-22%); }
}
@keyframes info-out-to-right {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateX(22%); }
}

.info-page.is-push  { animation: info-in-from-right var(--dur-push) var(--ease-push); }
.info-page.is-pop   { animation: info-in-from-left  var(--dur-push) var(--ease-push); }
.info-ghost.is-push { animation: info-out-to-left   var(--dur-push) var(--ease-push) forwards; }
.info-ghost.is-pop  { animation: info-out-to-right  var(--dur-push) var(--ease-push) forwards; }

/* Motion is the whole point here, so honouring the preference means removing it
   rather than shortening it — a 1ms slide is still a slide. The content still
   changes and the back button still appears, which is the information. */
@media (prefers-reduced-motion: reduce) {
  .info-page.is-push, .info-page.is-pop,
  .info-ghost.is-push, .info-ghost.is-pop { animation: none; }
  .info-ghost { display: none; }
}
.sheet[hidden] { display: none; }
.sheet--plain { background: var(--surface); }

.sheet-head { display: flex; }


/* ==========================================================================
   WORD INFO
   ========================================================================= */

.info-head {
  text-align: center;
  padding-bottom: var(--space-6);
  margin-bottom: var(--space-6);
  border-bottom: 1px solid rgba(var(--ink-rgb), .1);
}
.info-word {
  font-family: var(--font-arabic);
  font-weight: var(--weight-arabic-display);
  font-size: 58px;
  line-height: var(--leading-arabic-display);
  margin: 0;
}
/* 4px tighter than the metric pull alone — and block-level so that the pull
   lands at all.

   --say-pull is measured at runtime by js/arabic-metrics.js: the Arabic display
   line box is tall enough to clear the deepest descender and the tallest mark
   in the whole wordlist, so on most words it leaves dead space under the ink
   and the pill drifts away from the word it names. That correction is font data
   and stays; subtracting from it keeps the measurement doing its job if the
   face or the size changes.

   The display change is the part that matters. As an inline-flex the pill is
   baseline-aligned on its own line, and the parent's line-box strut puts a
   floor under how far a negative top margin can lift it. Measured: -7px moved
   it 7px, and -11px, -30px and -60px all moved it the same 7px. Every pull past
   the saturation point was silently doing nothing. Block-level takes the line
   box out of it, so the margin means what it says; auto keeps it centred now
   that text-align no longer applies. */
/* Scaled by how big the headword actually got.

   --say-pull is a pixel value measured against the full 58px display size, and
   the headword is no longer always 58px — long expressions render as small as
   33px. The dead space under the ink shrinks with the font, the fixed pull does
   not, so the same number that sits right under a 58px word over-corrects under
   a 45px one. Measured at a flat -15px: بَصَلْتِي مَحْرُوقَة came within 1.2px of
   the pill, which reads as touching.

   js/practice.js writes --headword-scale (rendered size ÷ 58) alongside the
   font size, so both halves of the pull shrink with the word they belong to. */
.info-head .say {
  display: flex;
  width: fit-content;
  margin: calc((var(--say-pull, -14px) - 8px) * var(--headword-scale, 1))
          auto var(--space-3);
}
.info-meaning { font-size: var(--size-body); margin: 0; }

/* Even padding either side of every rule, so the dividers sit centred in their
   gaps rather than hugging whichever block has more margin. */
/* --- Cards --------------------------------------------------------------
   Each kind of information about a word gets a bordered card instead of a
   divider-separated run.

   The sheet can hold six blocks — pronunciation note, memory hook, root,
   example, synonyms, provenance — and as flat sections they read as one long
   column where nothing announced where a topic started. A border draws the
   boundary the eye was looking for anyway (Gestalt common region), which a
   horizontal rule only half does: a rule says "something changed here", a box
   says "this belongs together".                                             */

.info-card {
  padding: var(--space-4);
  /* 20px between cards against 16px of padding inside them. The gap has to beat
     the padding or the border stops reading as an edge and starts reading as a
     divider in one long list (Gestalt proximity — things closer together group,
     and at 12px the space between two cards was tighter than the space inside
     one). Lands on --space-5, so it stays on the scale. */
  margin-bottom: var(--space-5);
  border: 1px solid #DEDEDE;
  border-radius: 12px;
  /* Flat values, not tints of the ink. The sheet is white, so a translucent
     card had to be dark enough to show against it and then read as grey rather
     than as paper. #FCFCFC is barely off-white and lets the border do the work. */
  background: #FCFCFC;
}
.info-card:last-child { margin-bottom: 0; }
.info-card[hidden] { display: none; }

/* Badge in the first column, EVERYTHING else in the second.

   Not just the heading. The alternative was a header row — badge beside the
   heading, content returning to the card's left padding underneath — which
   buys the chips 50px of width and costs the card its left edge: the body text
   started under the badge instead of under the words it belongs to, so each
   card had two competing left margins and read as two blocks rather than one.

   One text column, starting after the icon. The chips wrap sooner and that is
   the right trade. */
.info-card--icon {
  display: grid;
  grid-template-columns: 38px 1fr;
  align-items: start;
  gap: var(--space-3);
}
/* The second column. Everything the badge labels lines up against its left
   edge — heading, body, prose, rule and chips alike. */
.info-card-main { min-width: 0; }
.info-card-badge {
  width: 38px;
  height: 38px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  /* Flat colour, not a translucent tint. The card behind it is already a tint
     of the same black, so a see-through badge on a see-through card compounded
     into something muddier than either. Overridden per card below. */
  background: var(--badge-memory-bg);
  color: var(--badge-memory-ink);
}
/* One hue per section — see the badge pastels in tokens.css. Keyed off the
   card, not a modifier class on the badge, so a new card cannot end up with an
   icon and no colour. */
#infoExampleWrap .info-card-badge {
  background: var(--badge-say-bg);
  color: var(--badge-say-ink);
}
#infoRootWrap .info-card-badge {
  background: var(--badge-root-bg);
  color: var(--badge-root-ink);
}
#infoSynonymsWrap .info-card-badge {
  background: var(--badge-same-bg);
  color: var(--badge-same-ink);
}
.info-card-badge svg { width: 22px; height: 22px; }

.info-card-title {
  font-size: var(--size-card-head);
  font-weight: 600;
  line-height: var(--leading-body);
  margin: 0 0 var(--space-1);
}
.info-card-body {
  font-size: var(--size-card-body);
  line-height: var(--leading-body);
  color: var(--ink-muted);
  margin: 0;
}
.info-card-body strong { color: var(--ink); font-weight: 600; }
.info-card-body .ar { font-family: var(--font-arabic); }

/* Card headings, in full ink at 14px.

   They were 12px in --ink-muted, the same grey as the body underneath, which
   left every card opening with two lines of identical colour and weight and no
   way to tell the label from the content at a glance. Black against muted grey
   is a bigger step than any size change would have been (19:1 against 3.5:1 on
   this background), so the size can match the body without the two blurring.

   "Related words:" shares the class and so shares the treatment. That is right:
   the rule above it makes it a section of its own, not a caption on the chips. */
.info-card-sublabel {
  font-size: var(--size-card-head);
  /* 500. At 400 these were black but no heavier than the sentence below them,
     so a heading and its content differed by colour alone. 600 fixed that and
     overshot — at 14px in SF Pro it reads as a semibold UI label shouting over
     a 14px body, and there are four of them stacked down a 360px column.
     500 is the smallest step that still registers before you read the words. */
  font-weight: 500;
  color: var(--ink);
  /* 4px, down from 12 by way of 8. A heading and the thing it heads are one
     unit, and at 12px the heading sat as far from its own content as the cards
     sit from each other, so the eye had to be told they belonged together
     instead of just seeing it (Gestalt proximity).

     4px also makes this identical to .info-card-title above, which means every
     heading on the sheet now sits the same distance from what it heads —
     "Easy way to remember the word" was already at 4 and the other four were
     not. The chip rows read looser than the number suggests, because each pill
     carries its own padding on top of this. */
  margin: 0 0 var(--space-1);
}

/* Root explanation as a sentence, not a label-and-value table.

   "The word X comes from the root Y, which means Z" is one idea and reads as
   one; the same content split across three labelled rows made the reader
   assemble it themselves. */
.info-prose {
  font-size: var(--size-card-body);
  line-height: var(--leading-body);
  color: var(--ink-muted);
  margin: 0;
}
.info-prose .ar {
  font-family: var(--font-arabic);
  /* Roots are spaced letters — ف ر ص — and want a touch more room than the
     surrounding Latin at the same size. */
  font-size: 1.05em;
  color: var(--ink);
}
.info-prose--sub { margin-top: var(--space-2); font-size: var(--size-card-label); }
.info-prose:empty, .info-prose--sub:empty { display: none; }

/* Divider INSIDE a card, between the root and the words that share it. They
   answer the same question, so they stay in one card; the rule separates them
   without implying they are unrelated. */
.info-rule {
  border: 0;
  border-top: 1px solid rgba(var(--ink-rgb), .10);
  margin: var(--space-4) 0 var(--space-3);
}

.info-examples { list-style: none; margin: 0; padding: 0; }
.info-examples li { margin-bottom: var(--space-4); }
.info-examples li:last-child { margin-bottom: 0; }

/* `direction: rtl` with `text-align: left`.

   The direction has to stay RTL or the words come out in the wrong order — it
   governs reading order, not which edge the block sits against. The alignment
   then pulls the line to the left so it shares an edge with the English gloss
   under it and the label above it. Right-aligned, the Arabic was the only thing
   in the card starting from the other side, and every card became two columns
   that did not line up with anything.

   No bold anywhere. The headword used to be bolded inside its own sentence,
   which works in the Latin-script reference app where bold is a weight change.
   In Arabic it thickens the joins as well as the strokes, and the word stops
   looking like the one you just read at the top of the sheet. */
.info-example-ar {
  font-family: var(--font-arabic);
  direction: rtl;
  text-align: left;
  font-size: var(--size-example-ar-sheet);
  line-height: var(--leading-arabic-text);
  margin: 0;
}
/* 700 is a real file in the bundle, not a synthesised weight — Noto Sans Arabic
   ships 400/500/700 and all three are self-hosted. Synthesised bold smears the
   joins in Arabic in a way it does not in Latin. */
.info-example-ar b { font-weight: 700; }
.info-example-en {
  font-size: var(--size-small);
  line-height: var(--leading-body);
  color: var(--ink-muted);
  text-align: left;
  margin: var(--space-1) 0 0;
}

.info-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }

/* Chip rows sit 4px further from their heading than prose does.

   The 4px that reads as "these belong together" over a line of text reads as
   cramped over a row of pills: a pill's fill starts where its own padding does,
   so the visible gap between the heading's baseline and the first thing you see
   below it is smaller than the number says. 8px puts the two back at the same
   apparent distance. Adjacent-sibling rather than :has(), which keeps it to the
   one case and works everywhere.

   Padding, not margin. A margin-top of 4px here does nothing at all: it is
   adjacent to the heading's 4px margin-bottom, the two collapse, and collapsing
   takes the larger of the pair rather than the sum — so the gap stayed at 4 and
   the rule looked applied while changing nothing. Padding does not collapse. */
/* 8px of padding on top of the heading's 4px margin, so 12px in total. */
.info-card-sublabel + .info-chips { padding-top: var(--space-2); }

/* Same 8px above the example, and for a different reason worth writing down.

   The Arabic example carries harakaat, and they reach almost to the top of its
   line box — that is why the box is 34.6px tall for 18px text, measured by
   arabic-metrics to stop the marks clipping. So the box gap and the gap you
   actually see are not the same number. At a flat 4px above and below:

       heading -> Arabic ink   4.5px
       Arabic  -> English ink  13px

   Identical box spacing, three times the apparent gap on one side, because the
   descenders below the Arabic do not reach as far down as the marks reach up.
   The heading looked stuck to the sentence.

   4px of padding lands the ink gap at about 12.5px against the 13px below it,
   which is the point: even, not equal. 8px was the first attempt and overshot,
   pushing the heading further from its sentence than the sentence is from its
   translation. The leading is untouched either way — it is load-bearing. */
.info-card-sublabel + .info-examples { padding-top: var(--space-1); }
/* Chips sit inside a card, so they take the card's steps rather than the app's.
   At --size-ui they competed with the card title above them. */
.info-chip {
  padding: 8px var(--space-3);
  border-radius: var(--radius-pill);
  background: rgba(var(--ink-rgb), .06);
  font-size: var(--size-card-body);
}
.info-chip .ar { font-family: var(--font-arabic); }
.info-chip .tr { color: var(--ink-muted); font-size: var(--size-card-label); }

/* Root-family chips hold Arabic and nothing else, so the face goes on the chip.
   Synonym chips still pair Arabic with a gloss and keep the inner spans. */
/* No visible border. It used to be the affordance — the one thing saying this
   chip goes somewhere — and the chevron does that job now, better and more
   specifically. Two signals for one fact just made a tappable chip look like a
   different component from the one beside it.

   The 1px stays, transparent, so a chip with a chevron is exactly the same size
   as a chip without one and the rows still line up. */
#infoRootFamily button.info-chip,
#infoSynonyms button.info-chip {
  border: 1px solid transparent;
  cursor: pointer;
  transition: background var(--dur-tap) var(--ease-ios),
              transform var(--dur-tap) var(--ease-ios);
}
#infoRootFamily button.info-chip:active,
#infoSynonyms button.info-chip:active {
  background: rgba(var(--ink-rgb), .12);
  transform: scale(.96);
}

/* The chevron marks a chip that goes somewhere.

   Laid out with flex so it rides beside the word instead of joining the text
   run — the words are Arabic and the chevron is not, and left in the flow the
   bidi algorithm puts it on whichever side it decides the run ends, which is
   the left for these. Explicit order beats guessing.

   Muted and 12px against 14px of near-black word: present when you look for it,
   quiet when you are reading the Arabic, which is what "subtle" has to mean on
   an element repeated six times in a row. */
/* Pinned to the pill's right edge, and taken out of the text flow to get there.

   As a flex item it trailed the word, which on a chip with a 76px floor left it
   stranded mid-pill with the slack behind it. Absolute puts it at the edge
   whatever the word is doing.

   It also lets the text alone. The chips centre their word on a measured
   baseline offset (--chip-shift below), and making the chip a flex container
   had quietly turned that centring off for the tappable ones — so a chip with a
   chevron sat its word left while the chip beside it centred. Out of flow, the
   chevron costs the text nothing.

   6px from the edge, inside the 20px side padding the text already reserves, so
   nothing has to widen and nothing shifts. */
.chip-chevron {
  position: absolute;
  right: 6px;
  top: 50%;
  transform: translateY(-50%);
  width: 12px;
  height: 12px;
  opacity: .38;
}
button.info-chip { position: relative; }

/* Room for the chevron on the synonym chips.

   The root chips carry 20px of side padding for their own reasons, which the
   chevron happens to fit inside. Synonym chips carry 12px, and an out-of-flow
   chevron reserves nothing — so it landed 6px on top of the Arabic on every one
   of the ten that link. Measured, not guessed: the chevron occupies 6px to 18px
   in from the edge, so the text has to stop at 18px and 26px leaves a gap. */
#infoSynonyms button.info-chip { padding-right: 26px; }

#infoRootFamily .info-chip {
  font-family: var(--font-arabic);
  border: 1px solid transparent;   /* so tappable and static chips align */
  color: inherit;

  /* Wider than tall. These are short words — 16 to 30px of ink — and at 12px
     of side padding the pill hugged them into little lozenges. 20px each side
     plus a floor gives every chip room and stops كِبِر from reading as half the
     size of كِبْرِيَاء when they sit next to each other. */
  /* 4px, down from 8. Measured across all 171 words that can appear in a
     chip: the tallest ink is أُمَّة at 17.2px above the baseline and the
     deepest إِخْوة at 6.2px below, so the marks needed 23.4px and the line box
     gives 23.8px. The line-height therefore cannot come down — it is the marks
     holding it up — but the padding was leaving the worst word 6.2px of
     clearance above and 10.2px below, which is where the height was going.
     At 4px the tightest word still clears the pill edge by 2.2px. */
  padding: calc(4px - var(--chip-shift, 0px)) 20px
           calc(4px + var(--chip-shift, 0px));
  min-width: 76px;
  text-align: center;

  /* Asymmetric on purpose, and the number is measured rather than nudged.

     A line box centres the font box, and this face's is lopsided — 19px above
     the baseline against 10px below at this size — so symmetric padding puts
     the glyphs about 2px low. js/arabic-metrics.js measures the loaded face and
     writes --chip-shift; switching the Arabic face on Profile re-derives it,
     because Kufi and Naskh are lopsided by different amounts. */
  line-height: 1.7;
}

.sheet-head { display: flex; }
.sheet-title {
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  font-size: var(--size-display);
  font-weight: 700;
  line-height: var(--leading-tight);
  margin: var(--space-4) 0 var(--space-5);
}

.section-label {
  font-size: var(--size-label);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: 0 0 var(--space-3);
}

/* THE STREAK CARD.

   Same family as the review and mode cards — ink border, offset shadow, the count
   in a circle — so the Practice screen reads as one set of objects rather than
   three designs.

   DELIBERATELY NOT GREEN AND DELIBERATELY NOT PRESSABLE. Green is the app's "do
   this" colour and it is spent on the review card directly below; a second green
   card would split the primary action in two (Von Restorff only works while one
   thing stands out). It is white with no :active state and no cursor, because
   there is nothing to tap — a card that looks pressable and does nothing is worse
   than one that plainly reports.

   The offset shadow is shorter than the review card's 3px 4px. It sits at the top
   of the page above the thing you are meant to press, so it should read as
   attached to the page rather than floating over it. */
.streak {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-4) var(--space-3) var(--space-3);
  margin-bottom: var(--space-4);
  border: 2px solid var(--ink);
  border-radius: 18px;
  background: var(--surface);
  box-shadow: 2px 3px 0 var(--ink);
  color: var(--ink);
}

/* 56px, against the review card's 76. The streak is context, not the action, and
   sizing them the same would make the screen argue with itself about which number
   matters. */
.streak-flame {
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  border: 2px solid var(--ink);
  /* --warm has been in the palette since the beginning, commented "coral — the
     streak flame", and nothing ever used it. It does now. */
  background: var(--warm);
  font-size: 26px;
  line-height: 1;
}
/* No streak: the flame is out. A lit flame over "No streak yet" is a small lie,
   and the grey still shows the shape of what could be there. */
.streak.is-cold .streak-flame {
  background: var(--surface);
  filter: grayscale(1);
  opacity: .55;
}

.streak-foot { display: grid; gap: 2px; }
.streak-n {
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  font-size: var(--size-title);
  font-weight: 700;
  line-height: var(--leading-tight);
  font-variant-numeric: tabular-nums;
}
.streak-sub { font-size: var(--size-label); color: var(--ink-muted); line-height: 1.35; }
/* The one line worth reading twice: today is still open and the streak is what
   you stand to lose. Ink rather than muted, so it carries. */
.streak.is-risk .streak-sub { color: var(--ink); font-weight: 600; }

/* THE REVIEW CARD — the primary action, in the mode cards' language.

   It shares .mode's selector list rather than restating the border, radius and
   offset shadow, so the two cannot drift. That matters more than the saved
   lines: this card sits directly above the three mode cards, and the whole point
   is that a learner reads them as the same kind of object (Gestalt similarity).
   A copy would survive exactly one change to .mode.

   Green by default, where a mode card is only green when selected. This is the
   one on the screen you are meant to press — Von Restorff — and it is above the
   fold now that the illustration and the count tiles are gone. */
.review-card {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: var(--space-4);
  width: 100%;
  padding: var(--space-3) var(--space-4) var(--space-3) var(--space-3);
  margin-bottom: var(--space-6);
  border: 2px solid var(--ink);
  border-radius: 18px;
  background: var(--accent);
  box-shadow: 3px 4px 0 var(--ink);
  color: var(--ink);
  font-family: var(--font-ui);
  text-align: left;
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios),
              box-shadow var(--dur-tap) var(--ease-ios);
}
/* Same press as the mode cards: the card travels exactly its own shadow offset,
   so it looks pushed flat to the page rather than merely tinted. */
.review-card:active {
  transform: translate(3px, 4px);
  box-shadow: 0 0 0 var(--ink);
}

/* The count, in the slot a mode card gives its illustration.

   76px, not the 96x84 of .mode-art: a circle reads bigger than a picture of the
   same width because nothing about it is empty space, and at 96 it crowded the
   label. Surface white on the green so it lifts off the card the way the number
   needs to — this is the one fact on the card that changes.

   Tabular figures so 8 and 10 and 188 do not shuffle the label sideways as the
   deck drains through a session. */
.review-count {
  display: grid;
  place-items: center;
  width: 76px;
  height: 76px;
  border-radius: 50%;
  border: 2px solid var(--ink);
  background: var(--surface);
  font-family: var(--font-display);
  font-size: 30px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: var(--track-display);
  font-variant-numeric: tabular-nums;
}

.review-foot { display: grid; gap: 2px; }
.review-name { font-size: var(--size-ui); font-weight: 600; }
/* Ink at .7 rather than --ink-muted: the muted grey was chosen against the cream
   page and goes muddy on the green. Same treatment .mode uses when it is the
   selected, green card. */
.review-desc {
  font-size: var(--size-label);
  color: var(--ink);
  opacity: .7;
  line-height: 1.35;
}

.hero {
  padding: var(--space-6);
  border-radius: var(--radius-card);
  background: var(--surface);
  box-shadow: var(--shadow-card);
  margin-bottom: var(--space-6);
  text-align: center;
}
.hero-count {
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  font-size: 30px;
  font-weight: 700;
  line-height: var(--leading-tight);
  margin: 0;
}
.hero-sub {
  font-size: var(--size-ui);
  color: var(--ink-muted);
  margin: var(--space-1) 0 var(--space-5);
}
.hero.is-empty .hero-sub { margin-bottom: var(--space-5); }
.hero .btn-primary { width: 100%; }

/* Anki's own vocabulary — new, learning, due. Naming them the way the tool
   the user already knows names them (Jakob's Law). */
.counts { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); }
.count {
  flex: 1;
  padding: var(--space-4) var(--space-2);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  text-align: center;
}
.count-n { font-size: 22px; font-weight: 700; line-height: 1.1; }
.count-k {
  display: block;
  font-size: var(--size-label);
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-top: 3px;
}

/* Mode picker. A 2×2-ish grid mirroring the reference app's game cards, but
   selecting rather than launching — the mode applies to whatever you start
   next, so mode and topic stay independent instead of multiplying into one
   choice per combination. */
.modes {
  display: grid;
  grid-template-columns: 1fr 1fr;
  /* 12px, matching the topic grid. At 8px the hard offset shadows on adjacent
     cards nearly touched, and two stickers sharing an edge read as one panel. */
  gap: var(--space-3);
  margin-bottom: var(--space-6);
}
/* Built as a topic card, because that is what it is: a picture, a name, and a
   line saying what you get. They were soft white pills with a drop shadow while
   the topic grid on the next tab was outlined stickers, so two screens of the
   same app offered two different card languages for the same kind of choice.

   Everything structural here is lifted from .topic deliberately — the 2px ink
   border, the 18px radius, the hard 3px/4px shadow, and pressing into that shadow
   rather than scaling. Matching by copying the VALUES rather than by extending
   the class keeps the two free to diverge, which they need to: a topic is a
   filter you can leave selected, a mode starts a session immediately. */
.mode {
  display: grid;
  align-content: start;
  gap: var(--space-2);
  padding: var(--space-3);
  border: 2px solid var(--ink);
  border-radius: 18px;
  background: var(--surface);
  box-shadow: 3px 4px 0 var(--ink);
  color: var(--ink);
  font-family: var(--font-ui);
  text-align: left;
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios),
              box-shadow var(--dur-tap) var(--ease-ios),
              background var(--dur-tap) var(--ease-ios);
}
/* Pushed into its own shadow, the same physical read as a topic card. */
.mode:active {
  transform: translate(3px, 4px);
  box-shadow: 0 0 0 var(--ink);
}
.mode[aria-pressed='true'] { background: var(--accent); }

/* Fixed box with contain, for the reason the topic art needed it: these three
   illustrations are not the same ratio, so sizing on width alone would leave one
   sitting shorter than its neighbours and the row would look untidy. */
.mode-art {
  width: 96px;
  height: 84px;
  object-fit: contain;
  object-position: left center;
  pointer-events: none;   /* the card is the tap target, not the picture */
}

.mode-foot { display: grid; gap: 2px; }
.mode-name { font-size: var(--size-ui); font-weight: 600; }
.mode-desc { font-size: var(--size-label); color: var(--ink-muted); line-height: 1.35; }
.mode[aria-pressed='true'] .mode-desc { color: var(--ink); opacity: .7; }

/* Recall spans the full width — it is the default and the one the spaced
   repetition is really built around — so it gets the horizontal treatment of
   .topic--wide: art and label share a line. The other two are half-width, where
   there is no room for that, so they stack. */
.mode.is-wide {
  grid-column: 1 / -1;
  grid-template-columns: auto 1fr;
  align-content: center;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4) var(--space-3) var(--space-3);
}


/* Topic cards, two across.

   The look is the reference app's "Explore topics": a black outline plus a hard
   offset shadow rather than a soft blur, which reads like a sticker or a
   printed card. Note this is a deliberate exception to --shadow-card — every
   other surface here uses the soft diffuse shadow, and the outline is what
   makes these read as objects you pick up rather than panels you read. */
/* One column, so every topic card is the same width.

   It was two columns with three full-width cards stacked above, which sized the
   first three topics differently from the other six and read as a ranking. There
   isn't one — they are nine equal sets of words. A single column also gives the
   labels room, so "Love & endearment" stops wrapping. */
.topics {
  display: grid;
  grid-template-columns: 1fr;
  /* 16px of grid to get 12px of air.
     The sticker shadow is offset 4px downward and is not part of the border box,
     so it eats into the gap below every card: at gap 12 the measured space
     between two borders was 12px and the space you could actually see was 8. */
  gap: var(--space-4);
}

.topic {
  display: grid;
  align-content: space-between;
  gap: var(--space-3);
  min-height: 156px;
  padding: var(--space-4);
  border: 2px solid var(--ink);
  border-radius: 18px;
  background: var(--surface);
  box-shadow: 3px 4px 0 var(--ink);
  color: var(--ink);
  font-family: var(--font-ui);
  text-align: left;
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios),
              box-shadow var(--dur-tap) var(--ease-ios);
}
/* Pressing pushes the card into its own shadow — the physical read of the
   sticker style, and it makes the tap feel answered without any colour change. */
.topic:active {
  transform: translate(3px, 4px);
  box-shadow: 0 0 0 var(--ink);
}

/* The topic the feed is filtered to right now.

   Selecting one no longer jumps you to Words, so this is the only sign the tap
   landed. It borrows the pressed position — pushed into its own shadow — and
   stays there, which reads as "held down" rather than as a new colour nobody
   has seen before. Filling it with the accent was the alternative and it put
   the loudest thing on the screen on a card you have already dealt with. */
.topic.is-selected {
  transform: translate(3px, 4px);
  box-shadow: 0 0 0 var(--ink);
  background: var(--sheet);
}

/* Stands in for the reference's illustrations, which are commissioned artwork I
   can't reproduce. Sized as the card's focal area so dropping real art in later
   is a swap, not a re-layout. */
.topic-icon {
  font-size: 40px;
  line-height: 1;
  /* Emoji ignore colour, so nothing to set — just give them room. */
}

/* --- Featured topics -----------------------------------------------------
   The first three, given a wide card and an illustration. Three, because that
   is how many illustrations exist — each one used once at full size rather than
   cycled across nine cards, where you would meet the same picture three times
   in a single screenful and read it as a bug.

   Horizontal, so the art and the label share a line and the card stays short
   enough that the ordinary grid is still on screen underneath.

   No emoji on these — the illustration is the card's picture and a second one
   beside the label was two images doing one job. The grid cards below keep
   theirs, because there the emoji IS the picture.                            */

.topics-featured {
  display: grid;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

.topic--wide {
  grid-template-columns: auto 1fr;
  align-content: center;
  align-items: center;
  gap: var(--space-3);
  min-height: 122px;
  padding: var(--space-3) var(--space-4) var(--space-3) var(--space-3);
}

/* A fixed box with contain, not a fixed width. The illustrations run from 1.05
   to 1.27 in ratio, so sizing on width alone would leave the widest one sitting
   a full 20px shorter than its neighbours. */
.topic-art {
  width: 112px;
  height: 96px;
  object-fit: contain;
  /* Left, not centred. With contain and three different ratios, centring parks
     each illustration at a different distance from the label — the box is fixed
     so the label never moves, but the art appeared to drift between cards. */
  object-position: left center;
  /* An illustration is not the tap target; the card is. */
  pointer-events: none;
}

/* The emoji stand-in for the four topics with no illustration yet.

   Occupies the same 112x96 box, so a card without art is the same height as one
   with it and every label starts at the same x.

   64px, and it still does not match. Measured: the illustrations draw about
   4,450 square pixels of ink each; these glyphs manage 771 (🚏) to 1,635 (💼) at
   46px, and reaching parity would need 76 to 100px — at which point 100px does
   not fit a 96px box and a shopping trolley is pretending to be a composed
   scene. 64px is the largest that sits in the slot with air around it, and gets
   them to roughly half the weight instead of a quarter.

   The glyphs also disagree with each other by 2x, which no single size fixes.
   These are a stopgap until the remaining four illustrations exist, and the
   honest read of the row is that four cards are lighter than five. */
.topic-art--icon {
  display: grid;
  place-items: center;
  justify-content: start;
  padding-left: var(--space-4);
  font-size: 64px;
  line-height: 1;
}

.topic-foot { display: grid; gap: 2px; }
.topic-label { font-size: var(--size-ui); font-weight: 700; line-height: 1.2; }
.topic-meta { font-size: var(--size-label); color: var(--ink-muted); }

/* Title and badge on one line, so the count sits with the thing it counts. */
.topic-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* A topic with cards waiting earns the accent; an untouched one stays quiet, so
   the eye lands on what there is to do (Von Restorff).

   A circle, not a pill. Fixed width and height with the radius at half, so a
   one-digit count is round rather than a squashed lozenge — the pill shape only
   read as intentional at two digits, and every count here is one.

   grid + place-items rather than line-height for the centring: line-height
   guesses at the font's metrics and a digit is not vertically symmetrical in the
   box, so it sat a pixel high. flex: none stops it being squeezed when a long
   topic label runs out of room; the label wraps, the badge does not shrink. */
.topic-due {
  display: grid;
  place-items: center;
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--ink);
  font-size: var(--size-label);
  font-weight: 700;
}
.topic.is-empty { opacity: .55; }

/* --- Topic detail ---------------------------------------------------------
   One topic's words, shown as the real feed rather than a second list that
   would have to be kept in sync with it by hand. #feed gets reparented in
   here (see openTopicDetail in practice.js): full-bleed, exactly as it sits
   in #pageWords, which is what makes the borrow invisible.

   Opaque, not transparent like most overlays here — during the slide (below)
   it is the layer moving over #topicsGrid, and a see-through panel would show
   the grid bleeding through instead of being cleanly covered and uncovered. */
/* ABOVE THE SCROLL FADES, and that is the fix for a real bug rather than a
   preference.

   .scroll-fade-top is global chrome at z-index 15 — a strip of OPAQUE page colour
   fading to transparent, sitting over every screen. This panel was at 10, which
   makes it a stacking context, which caps everything inside it: .topic-detail-head
   declares z-index 20 and could never exceed 10-point-something. So the fade
   painted cream over the top 16px of the back button and it came out looking
   half-dissolved. It reads as a blur or a rendering fault; it is neither, it is
   just paint order.

   16 clears the fade. Losing the top fade on this screen is the right trade
   anyway: the fade exists so cards can scroll under nothing on the bare Words
   feed, and here there IS something — the back button and the topic title. Fading
   content into a header it is meant to slide behind was doing two jobs at once. */
#topicDetail {
  position: absolute;
  inset: 0;
  z-index: 16;
  background: var(--bg);
}
#topicDetail[hidden] { display: none; }

/* #pageTopics never needs to scroll sideways, and without this the slide
   below can briefly hand the page a horizontal scrollbar mid-transition. */
#pageTopics { overflow-x: hidden; }

/* The real iOS navigation-push pair, not a single panel sliding over a static
   background: the incoming screen travels the full width while the one
   underneath it only gives up the last third of that distance and dims
   slightly, so it visibly recedes rather than just sitting there being
   covered. Popping runs the same two motions in reverse. Both layers move on
   the same clock (--dur-push/--ease-push) so they read as one gesture. */
@keyframes topic-detail-push-in {
  from { transform: translateX(100%); }
  to   { transform: translateX(0); }
}
@keyframes topic-detail-pop-out {
  from { transform: translateX(0); }
  to   { transform: translateX(100%); }
}
@keyframes topic-grid-push-out {
  from { transform: translateX(0);     filter: brightness(1); }
  to   { transform: translateX(-30%);  filter: brightness(.85); }
}
@keyframes topic-grid-pop-in {
  from { transform: translateX(-30%);  filter: brightness(.85); }
  to   { transform: translateX(0);     filter: brightness(1); }
}
.topic-detail-anim-push { animation: topic-detail-push-in var(--dur-push) var(--ease-push); }
.topic-detail-anim-pop  { animation: topic-detail-pop-out  var(--dur-push) var(--ease-push); }
.topic-grid-anim-push   { animation: topic-grid-push-out   var(--dur-push) var(--ease-push); }
.topic-grid-anim-pop    { animation: topic-grid-pop-in     var(--dur-push) var(--ease-push); }

/* Floats over the feed rather than pushing it down — the tab bar does the
   same over every page, and here it costs nothing extra: every word-card
   already reserves this exact band at its top for the status bar, so the
   header sits in space the card was leaving blank anyway. Same corner and
   icon as the info sheet's back button (Jakob's Law: a thumb that has
   already learned where "back" lives on this screen should not relearn it
   here). */
.topic-detail-head {
  position: absolute;
  z-index: 20;
  top: var(--inset-top);
  left: 0;
  right: 0;
  padding-top: var(--space-4);
}

/* Out of flow rather than a flex sibling of the title, so the title can be
   centred against the whole width instead of the leftover space beside the
   button — the iOS nav-bar convention: back stays put on the left, the title
   is centred independent of it. */
.topic-detail-head .circle-btn {
  position: absolute;
  left: var(--space-5);
  top: 0;
}

/* Small and centred like a nav-bar title, not the page-level heading the grid
   uses — this screen's real subject is the word on the card below it; the
   topic name is here for orientation, not to compete with it (visual
   hierarchy). */
.topic-detail-title {
  margin: 0;
  text-align: center;
  font-size: var(--size-ui);
  font-weight: 700;
}

.devtools {
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px dashed var(--ink-faint);
}
.devtools-row { display: flex; gap: var(--space-2); }
.devtools-row .btn-ghost { flex: 1; }

/* The voice row wraps; the others do not need to.
   It held two options, then three, and now five. At flex: 1 with no wrapping
   that is 60px a button in a 350px row, which is narrower than the word
   "Kareem" — the labels would clip rather than the row growing. A basis of 44%
   gives two per line and lets a short list still fill the width. */
#voiceRow { flex-wrap: wrap; }
#voiceRow .btn-ghost { flex: 1 1 44%; }
.devtools-note {
  font-size: var(--size-label);
  color: var(--ink-muted);
  margin: var(--space-3) 0 0;
}


/* ==========================================================================
   REVIEW SESSION
   ========================================================================= */

.session, .done {
  position: absolute;
  inset: 0;
  z-index: 50;
  display: grid;
  background: var(--bg);
  padding: calc(var(--inset-top) + var(--space-3)) var(--space-6)
           calc(var(--inset-bottom) + var(--space-6));
}
.session[hidden], .done[hidden] { display: none; }

/* Row counts differ, and they must match each view's child count. Give .done
   three rows and its two children stretch to fill — the footer takes the 1fr
   and the button inflates into a blob. */
.session { grid-template-rows: auto 1fr auto; }   /* head, body, foot */
.done    { grid-template-rows: 1fr auto; }        /* body, foot */

.session-head { display: flex; align-items: center; gap: var(--space-4); }

/* An outlined capsule rather than a hairline track — it holds its own weight
   next to the black icon strokes instead of disappearing into the background. */
.session-track {
  flex: 1;
  height: 22px;
  padding: 3px;
  border: 2px solid var(--ink);
  border-radius: var(--radius-pill);
  background: var(--surface);
  overflow: hidden;   /* safe — no Arabic in here */
}
.session-fill {
  display: block;
  height: 100%;
  width: 0%;
  border-radius: var(--radius-pill);
  background: var(--ink);
  transition: width var(--dur-sheet) var(--ease-ios);
}

/* A grid of label + card. The card takes the 1fr so it fills whatever height is
   left after the label, which is what keeps it a constant size front and back. */
.session-body {
  align-self: stretch;
  display: grid;
  /* topic (may be hidden), mode label, card */
  grid-template-rows: auto auto 1fr;
  min-height: 0;            /* let the 1fr row actually shrink inside the grid */
  text-align: center;
  padding: 0 0 var(--space-6);
}

/* Which set you're in, when a session is scoped to one. Quiet — it's context,
   not the instruction. */
.session-topic {
  justify-self: center;
  margin: var(--space-4) 0 0;
  font-size: var(--size-label);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.session-topic[hidden] { display: none; }
/* With a topic showing, the mode label sits closer beneath it. */
.session-topic:not([hidden]) + .session-label { margin-top: var(--space-2); }

/* Mode label. Tells you what to retrieve before you look at the card, which is
   the difference between a prompt and a puzzle. */
.session-label {
  justify-self: center;
  padding: 9px var(--space-5);
  margin: var(--space-4) 0 var(--space-4);
  border-radius: var(--radius-pill);
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  font-size: 17px;
  font-weight: 700;
}

/* The flashcard itself.

   Fixed to the full height of its row on purpose. If the card sized to its
   contents it would grow the moment the answer appears, and the word would jump
   under your thumb mid-review. A stable surface with content filling in beneath
   the word is calmer and reads as one object turning over.

   Common region: putting the word and its answer inside one boundary says they
   belong together more clearly than proximity alone. */
.flashcard {
  /* No fixed height. js/practice.js measures .flashcard-inner and sets an
     explicit pixel height, which is what makes the transition below possible —
     `height: auto` cannot be animated, two pixel values can.

     Sizing to content means the front face is compact instead of a mostly-empty
     tall card, and the reveal grows it smoothly rather than jumping. */
  align-self: center;
  height: auto;
  transition: height .34s var(--ease-ios);

  padding: var(--space-5);
  background: var(--surface);
  box-shadow: var(--shadow-card);
  /* Never clip — Arabic marks sit high and the leading is what protects them. */
  overflow: visible;

  /* Uneven corners, so the card reads as drawn rather than generated. Each
     corner gets its own horizontal and vertical radius via the slash syntax:
     the four values before the slash are horizontal, the four after are
     vertical. Equal values would give the usual machined rounding. */
  border-radius: 34px 26px 30px 24px / 26px 34px 24px 32px;
}

.flashcard-inner { display: grid; justify-items: center; }

/* Both the recall card and the quiz occupy this one row, so switching modes
   never shifts anything above or below. */
.session-stage { display: grid; align-content: center; }
.session-stage > [hidden] { display: none; }


/* ==========================================================================
   MULTIPLE CHOICE — Guess the word, Meaning match
   ========================================================================= */

.quiz { display: grid; gap: var(--space-5); }
.quiz[hidden] { display: none; }

/* The prompt sits in a soft recessed panel rather than a white card, so it
   reads as the question and the answer pills below read as the actionable
   things. */
.quiz-prompt {
  min-height: 108px;
  display: grid;
  place-items: center;
  padding: var(--space-5);
  border-radius: var(--radius-card);
  background: rgba(var(--ink-rgb), .05);
  font-size: var(--size-body);
  line-height: var(--leading-body);
  text-align: center;
}
/* When the prompt is the Arabic word, it needs the display treatment. */
.quiz-prompt.is-arabic {
  font-family: var(--font-arabic);
  direction: rtl;
  font-size: 54px;
  font-weight: var(--weight-arabic-display);
  line-height: var(--leading-arabic-display);
  overflow: visible;
}

.quiz .say-solo { justify-self: center; margin: 0; }

.quiz-options { display: grid; gap: var(--space-3); }

.quiz-option {
  width: 100%;
  min-height: 56px;
  padding: var(--space-3) var(--space-5);
  border: 0;
  border-radius: var(--radius-pill);
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: 17px;
  font-weight: 500;
  cursor: pointer;
  transition: transform var(--dur-tap) var(--ease-ios),
              opacity var(--dur-tap) var(--ease-ios),
              background var(--dur-tap) var(--ease-ios);
}
.quiz-option:active { transform: scale(.98); }

/* An Arabic option gets the face and a bump in size — 17px Arabic next to 17px
   Latin looks smaller than it is. */
.quiz-option.is-arabic {
  font-family: var(--font-arabic);
  direction: rtl;
  font-size: 26px;
  font-weight: var(--weight-arabic-display);
  line-height: var(--leading-arabic-text);
}

/* Answered states, matching the reference: the right answer fills olive with a
   black outline, and the others fade rather than disappear — so you still see
   what you passed over. */
.quiz-options.is-answered .quiz-option { pointer-events: none; }
.quiz-option.is-correct {
  background: var(--correct);
  box-shadow: inset 0 0 0 2px var(--ink);
}
.quiz-option.is-wrong {
  background: var(--surface);
  box-shadow: inset 0 0 0 2px var(--warm);
}
.quiz-option.is-dimmed { opacity: .35; }


/* ==========================================================================
   VERDICT SHEET
   ========================================================================= */

.verdict {
  position: absolute;
  z-index: 55;
  left: 0; right: 0; bottom: 0;
  padding: var(--space-6) var(--space-6) calc(var(--inset-bottom) + var(--space-6));
  border-radius: var(--radius-sheet) var(--radius-sheet) 0 0;
  background: var(--sheet);
  box-shadow: var(--shadow-sheet);
  transform: translateY(100%);
  transition: transform var(--dur-sheet) var(--ease-ios);
}
.verdict.is-open { transform: none; }
.verdict[hidden] { display: none; }

.verdict-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 var(--space-2);
}
.verdict-head::before {
  content: '';
  width: 24px; height: 24px;
  flex: none;
  border-radius: 50%;
  background: var(--correct);
}
.verdict.is-wrong .verdict-head::before { background: var(--warm); }

.verdict-answer { font-size: var(--size-body); margin: 0 0 var(--space-5); }
.verdict-answer b { font-family: var(--font-arabic); font-size: 22px; }

.verdict-example { margin-bottom: var(--space-6); }
.verdict-label {
  font-size: var(--size-label);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: 0 0 var(--space-2);
}
.verdict-example-ar {
  font-size: var(--size-example-ar);
  line-height: var(--leading-arabic-text);
  margin: 0;
}
.verdict-example-en {
  font-size: var(--size-small);
  color: var(--ink-muted);
  margin: var(--space-1) 0 0;
}

.session-word {
  font-weight: var(--weight-arabic-display);
  font-size: 64px;
  /* Same measured leading ratio as the feed — it scales with the size, so the
     stacked diacritics stay intact. */
  line-height: var(--leading-arabic-display);
  margin: 0;
}

/* Front of the card: audio with no transliteration beside it, since the
   transliteration would hand over the answer. */
.say-solo {
  margin: var(--say-pull, -14px) 0 0;
  width: 52px;
  padding: 0;
  /* Held at the old 34px. The pill it inherits from grew to 42px to give the
     transliteration room, and this variant has no transliteration in it — it is
     the review card's front, where a label would hand over the answer. Letting it
     follow would have restyled the review flow to fix the feed. */
  min-height: 34px;
  justify-content: center;
  /* Direct child of the .flashcard grid, so text-align on an ancestor can't
     centre it — a grid item with a fixed width needs justify-self. */
  justify-self: center;
}
.say-solo svg { width: 22px; height: 22px; }

.answer[hidden] { display: none; }
/* The pill takes the same negative pull as the feed, so it tucks under the
   word rather than floating away from it. */
.answer .say { margin: var(--say-pull, -14px) 0 var(--space-4); }
/* How to say it, for an English reader. Monospaced so the syllable split reads
   as notation rather than as another spelling of the word. */
.answer-breakdown {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--size-ui);
  letter-spacing: .02em;
  color: var(--ink-muted);
  margin: 0 0 var(--space-3);
}
.answer-meaning { font-size: var(--size-body); margin: 0; }

/* On the feed these pills are white on cream. Inside the flashcard that's
   white on white, so they invert: cream fill, no shadow. */
.flashcard .say {
  background: var(--bg);
  box-shadow: none;
}
.flashcard .say:active { background: var(--ink-faint); }
.flashcard .say.is-speaking { background: var(--accent); }
.answer-example {
  margin: var(--space-6) auto 0;
  max-width: 30ch;
  padding-top: var(--space-4);
  border-top: 1px solid var(--ink-faint);
}
.answer-example-ar {
  font-size: var(--size-example-ar);
  line-height: var(--leading-arabic-text);
  margin: 0;
}
.answer-example-en {
  font-size: var(--size-small);
  color: var(--ink-muted);
  margin: var(--space-1) 0 0;
}

.session-foot { display: grid; gap: var(--space-3); }
/* Class sets display, so it needs its own hidden rule to beat the UA's bare
   [hidden] selector. The quiz modes hide this row — an option tap is the answer. */
.session-foot[hidden] { display: none; }
.grade-row { display: flex; gap: var(--space-3); }
.grade-row[hidden] { display: none; }
/* Equal width, unequal weight. "Got it" carries the accent because it's the
   expected outcome; "Again" stays present but recedes (Von Restorff). */
.grade-row button { flex: 1; padding: 0 var(--space-4); }


/* ==========================================================================
   END SCREEN
   ========================================================================= */

.done-body { align-self: center; text-align: center; }
.done-title {
  font-family: var(--font-display);
  letter-spacing: var(--track-display);
  font-size: 32px;
  font-weight: 700;
  margin: 0 0 var(--space-6);
}

.ring { position: relative; width: 140px; margin: 0 auto var(--space-5); }
.ring svg { width: 100%; height: auto; transform: rotate(-90deg); display: block; }
.ring-track, .ring-fill {
  fill: none;
  stroke-width: 9;
  stroke-linecap: round;
}
.ring-track { stroke: var(--ink-faint); }
.ring-fill {
  stroke: var(--accent);
  /* 2πr for r=52. dashoffset is set from JS to show the fraction. */
  stroke-dasharray: 326.7;
  stroke-dashoffset: 326.7;
  transition: stroke-dashoffset .7s var(--ease-ios);
}
.ring-label {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 26px;
  font-weight: 700;
}

.done-line { font-size: var(--size-body); margin: 0; }
.done-next { color: var(--ink-muted); font-size: var(--size-ui); }
/* Placeholder for artwork. Holds the vertical rhythm so adding an
   illustration later is a drop-in, not a re-layout. */
.done-art { height: var(--space-8); }


/* --- Accessibility ------------------------------------------------------ */

:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
  .feed { scroll-behavior: auto; }
}


#infoFamilyWrap[hidden] { display: none; }

/* The literal reading of an idiom. Quiet and italic — it's the picture behind
   the meaning, not a competing definition. Kept on the card rather than buried
   in the detail sheet, because "his onion is burnt" is the thing you actually
   remember. */
.word-literal,
.answer-literal {
  font-size: var(--size-small);
  line-height: var(--leading-body);
  font-style: italic;
  color: var(--ink-muted);
  margin: var(--space-2) 0 0;
}
.answer-literal[hidden] { display: none; }


/* Segmented choice — level and difficulty. One row of equal options, the picked
   one filled. Three at a time keeps the decision instant (Hick's Law). */
.choice { display: flex; gap: var(--space-2); }
.choice button {
  flex: 1;
  display: grid;
  gap: 1px;
  min-height: 56px;
  padding: var(--space-2) var(--space-1);
  border: 0;
  border-radius: 14px;
  background: var(--surface);
  box-shadow: var(--shadow-pill);
  color: var(--ink);
  font-family: var(--font-ui);
  cursor: pointer;
  transition: background var(--dur-tap) var(--ease-ios),
              transform var(--dur-tap) var(--ease-ios);
}
.choice button:active { transform: scale(.97); }
.choice button[aria-pressed='true'] { background: var(--accent); }
.choice-name { font-size: var(--size-ui); font-weight: 700; }
.choice-sub { font-size: var(--size-label); color: var(--ink-muted); }
.choice button[aria-pressed='true'] .choice-sub { color: var(--ink); opacity: .7; }
