/* The Mini App's baseline. Mobile-first, Telegram-themed, safe-area aware.
 *
 * Colours come from Telegram's theme variables with a fallback, so the app is
 * readable in a plain browser and correct inside the client. Applying the
 * theme parameters from the SDK is a sibling Story; using the variables here
 * costs nothing and means it has somewhere to write them.
 */

:root {
  /* **How large the app draws its text**, as a multiplier of the browser's own
     size. §21 and the settings screen: a learner chooses `small`, `normal`,
     `big` or `huge` and settings.js writes the number here.
   *
   * Set on the root because every size in this file is in `rem`, and `rem` is
   * relative to the root and to nothing else — so one value moves all of them
   * at once and nothing has to remember to scale.
   *
   * **Only text scales.** Padding, tap targets and the safe-area insets are in
   * px on purpose: a 44px target is 44px because a finger is, not because of
   * how large somebody likes their words, and an inset is the device's
   * measurement rather than a preference. */
  font-size: calc(100% * var(--app-text-scale, 1));

  --bg: var(--tg-theme-bg-color, #ffffff);
  --fg: var(--tg-theme-text-color, #10161f);
  --muted: var(--tg-theme-hint-color, #646e7a);
  --section-bg: var(--tg-theme-secondary-bg-color, #f2f4f7);
  --accent: var(--tg-theme-link-color, #1f6ac4);
  --button-fg: var(--tg-theme-button-text-color, #ffffff);
  /* The modal's backdrop. Named here rather than written where it is used,
     because **every colour in this file lives in this block or in the two
     scheme blocks below** — `TestNoScreenHardcodesAColour` fails on one
     anywhere else, and a scrim is exactly the kind of value that gets typed
     inline during a rushed fix and then stops following the theme. */
  --scrim: rgba(0, 0, 0, 0.4);
  --gap: 12px;

  /* The device's edges, read once and named once.
   *
   * Every rule that has to keep clear of a notch, a home indicator or one of
   * Telegram's own bars uses these — nothing else calls env() (TDE-189 AC-6).
   * A screen that remembered its own inset would be a screen that forgets one.
   *
   * **A fixed control must add --app-inset-bottom to its own bottom offset.**
   * There is no fixed control yet; the reading screen's «Пройдено» button is
   * the first and this is the rule it inherits. AGENTS.md: "Do not let fixed
   * controls overlap Telegram system areas."
   */
  --app-inset-top: env(safe-area-inset-top, 0px);
  --app-inset-right: env(safe-area-inset-right, 0px);
  --app-inset-bottom: env(safe-area-inset-bottom, 0px);
  --app-inset-left: env(safe-area-inset-left, 0px);
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  /* `1rem`, not `16px`: the root above is what a learner's chosen size moves,
     and a fixed base here would hold the body's own text at 16px while every
     heading around it grew. */
  font: 1rem/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  /* §21: readable under zoom — nothing here fixes a font size in px that the
     user cannot enlarge, and no viewport meta disables scaling. */
  -webkit-text-size-adjust: 100%;
}

body {
  /* The height Telegram says we have, not the browser's idea of it.
   *
   * Inside a WebView the two disagree and 100vh is the wrong one: it counts
   * space the client's chrome is occupying, which is what puts a bottom control
   * under the home indicator. viewport.js sets --app-height from the client and
   * updates it on every resize; the fallback is for a plain browser, where
   * there is no client to ask and 100dvh is right.
   */
  min-height: var(--app-height, 100dvh);

  /* **The column the height is now divided in**, rather than given whole to
     `#app`.
   *
   * `backcontrol.js` puts a Back control above the app, outside the container
   * so that no screen can replace it. Something has to give it room: with
   * `#app` claiming the full height itself, the control would push the app's
   * last 44 pixels below the fold. So the height is stated once here and the
   * two share it — the control takes what it needs and `#app` takes the rest,
   * which is also what makes it disappear cleanly on Home.
   *
   * `height` as well as `min-height`: a flex child cannot take "the rest" of a
   * container that has no definite height, and `#app` giving `.rows` a definite
   * height is what keeps a list scrolling inside the screen instead of growing
   * the page — see the note on `.rows`. */
  display: flex;
  flex-direction: column;
  height: var(--app-height, 100dvh);
}

/* The shell's own chrome: one Back control, above the screen and outside it.
 *
 * See backcontrol.js for why it is not rendered by the screens, and why it is
 * hidden rather than disabled on Home. */
.app-nav {
  display: flex;
  padding:
    calc(var(--app-inset-top) + 12px)
    calc(var(--app-inset-right) + 16px)
    0
    calc(var(--app-inset-left) + 16px);
  max-width: 640px;
  width: 100%;
  margin: 0 auto;
  /* Never shrinks. It is the way out, so it is the last thing that should give
     up space when a screen is tall. */
  flex: 0 0 auto;
}

/* `display: flex` above beats the `[hidden]` the user agent applies, so the
   rule has to be restated. A control that is meant to be gone and is merely
   invisible is still in the tab order. */
.app-nav[hidden] {
  display: none;
}

#app {
  /* §21: respect the safe areas. The Telegram header and the home indicator
     both live inside these insets, which are named once on :root. */
  padding:
    calc(var(--app-inset-top) + 16px)
    calc(var(--app-inset-right) + 16px)
    calc(var(--app-inset-bottom) + 16px)
    calc(var(--app-inset-left) + 16px);
  max-width: 640px;
  /* **`width` as well as `max-width`, now that the body is a flex column.** An
     auto margin on the cross axis stops a flex item stretching to its
     container, so `margin: 0 auto` alone centres the app at its *content*
     width — which made a full-width list row as narrow as its title.
     `TestTheTextRow` caught it at 135px. */
  width: 100%;
  margin: 0 auto;
  /* A column, so a list can take the height that is left instead of a number
     somebody guessed.
     *
     * `height` and not `min-height`: a container that grows with its content
     * never makes its flex children shrink, so the list would extend the page
     * instead of scrolling inside it — which silently turns off restoration,
     * because there is nothing to restore a scroll within. */
  display: flex;
  flex-direction: column;
  /* Takes the height the column has left, rather than claiming all of it: the
     Back control above is the other share. `min-height: 0` is what lets it
     actually shrink, and without it `.rows` grows the page instead of
     scrolling. */
  flex: 1 1 auto;
  min-height: 0;
}

/* A list scrolls inside the screen, rather than the screen scrolling.
 *
 * **Placeholder layout**, and the Learning Flow Screens Epic may well move the
 * scroll back to the page. `keepScroll` takes whatever scrolls and does not
 * care which it is; that is the point of it taking an element.
 */
.rows {
  flex: 1 1 auto;
  /* Without this a flex child refuses to shrink below its content, and the
     list grows the page instead of scrolling. */
  min-height: 0;
  overflow-y: auto;
  /* A scroll that reaches the end of the list must not then scroll the client
     behind it. */
  overscroll-behavior: contain;
}

h1 {
  font-size: 1.35rem;
  margin: 0 0 4px;
}

.subtitle {
  color: var(--muted);
  margin: 0 0 20px;
}

.sections {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  list-style: none;
  margin: 0;
  padding: 0;
}

/* **The tap-target minimum, in one place.**
 *
 * §21 asks for at least 24×24 CSS px and AGENTS.md asks for more on primary
 * actions. Every interactive primitive in controls.js carries `.control`, so
 * the minimum is a property of the primitive rather than something each screen
 * remembers.
 *
 * `control-word` is the one exemption, and it is decided and explained in
 * controls.js: an inline word in running prose meets the height and cannot
 * meet the width without breaking the typography it lives in. */
.control {
  min-height: 24px;
  min-width: 24px;
  color: inherit;
  cursor: pointer;
  border: none;
  background: none;
  font: inherit;
  text-align: left;
}

/* A list row, and the app's main navigation target. Comfortably above the
   minimum, because a row is what a thumb aims at while walking. */
.control-row {
  display: block;
  min-height: 56px;
  padding: 14px 16px;
  border-radius: 12px;
  background: var(--section-bg);
  text-decoration: none;
  width: 100%;
}

/* The Back control. A row would span the screen and read as a destination; this
   is chrome, so it is sized to its label and sits at the leading edge where a
   back control has sat since before any of this. Comfortably past the 24px
   minimum because it is reached in a hurry. */
.control-back {
  min-height: 44px;
  padding: 10px 14px 10px 0;
  font-weight: 600;
  color: var(--tg-theme-link-color, var(--accent));
}

/* «Предыдущий» and «Следующий», at the foot of a text.
 *
 * A row of its own rather than two more buttons in `.reading-actions`, so
 * «Пройдено» — the action that changes something — is not sitting between two
 * that merely move. Space-between puts them at the edges a thumb reaches. */
.reading-neighbours {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-top: 24px;
}

.control-neighbour {
  min-height: 44px;
  padding: 10px 14px;
  border-radius: 12px;
  background: var(--section-bg);
  font-weight: 600;
  color: var(--tg-theme-link-color, var(--accent));
}

/* A single control sits at the leading edge, not stretched across the row: at
   either end of a level there is one neighbour, and a lone button spanning the
   width reads as a primary action rather than as a way onwards. */
.reading-neighbours .control-neighbour:only-child {
  margin-right: auto;
}

/* The reading screen's header: the title, and the star at the far end.
 *
 * `align-items: start` so a title running to two lines keeps the star level
 * with its first line rather than centred against the block — the star marks
 * the text, and the top of the text is where a learner looks for it. */
.reading-header {
  display: flex;
  align-items: start;
  justify-content: space-between;
  gap: 12px;
}

.reading-header h1 {
  /* Takes the space the star does not, so a long title wraps instead of
     pushing the star off the edge. */
  flex: 1 1 auto;
  min-width: 0;
}

/* The star itself. Larger than the 24px minimum because it is a single glyph a
 * thumb aims at while holding the phone to read, and `line-height: 1` so the
 * box is the glyph rather than a text line's leading. */
/* The settings screen.
 *
 * **The choices are drawn at the sizes they name**, which settings.js sets on
 * each control: choosing how large the text should be by reading four labels
 * that are all the same size is choosing blind. This is the one place in the
 * app where the type size is the information rather than the presentation of
 * it.
 *
 * A column rather than a row, because «Огромный» drawn at «Огромный» does not
 * fit beside three others on a phone. */
.setting-title {
  margin: 20px 0 8px;
  font-size: 1.1rem;
}

.setting-choices {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* A chosen option, marked by more than colour: §21 and AGENTS.md — colour is
   never the only carrier of a state. `aria-checked` is what a screen reader
   reads; the rule down the side is what everybody else sees. */
.control-choice {
  padding: 12px 16px;
  border: 1px solid var(--section-bg);
  border-radius: 8px;
  text-align: left;
  background: var(--bg);
  color: var(--fg);
}

.control-choice[data-chosen] {
  border-color: var(--accent);
  border-left-width: 4px;
  font-weight: 600;
}

/* The complaint screens.
 *
 * Plain labelled lines: an administrator deciding whether to take a text down
 * is reading, not navigating, and everything they need to read is on one
 * screen. The German text keeps the reading screen's own measure so it looks
 * like what a learner sees. */
.complaint-reason {
  margin: 0 0 16px;
  font-size: 1.1rem;
}

.complaint-field {
  display: flex;
  gap: 8px;
  margin: 0 0 4px;
}

.complaint-label {
  flex: 0 0 40%;
  color: var(--muted);
}

.complaint-value {
  flex: 1 1 auto;
  min-width: 0;
}

.complaint-heading {
  margin: 20px 0 8px;
  font-size: 1.1rem;
}

.complaint-text {
  margin: 8px 0 16px;
  padding: 12px;
  border-radius: 8px;
  background: var(--section-bg);
  white-space: pre-wrap;
}

.complaint-actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 16px 0;
}

/* The administrative control, beside the star.
 *
 * **Quieter than the star, and the same size.** The star is a learner's own
 * control and belongs to the content; this one is a tool, drawn for the handful
 * of accounts that have it, and it should not compete with the title. Same
 * 44px target, because a small icon button is exactly where a tap target gets
 * quietly lost. */
.control-admin {
  flex: 0 0 auto;
  min-width: 44px;
  min-height: 44px;
  font-size: 1.25rem;
  line-height: 1;
  text-align: center;
  color: var(--muted);
}

/* Inside the administrative modal.
 *
 * The confirmation reads as prose because it is the sentence somebody has to
 * agree with; the reason is a field because it is the instruction the model is
 * given, and a one-line input invites one word. */
.admin-confirm {
  margin: 0 0 12px;
}

.admin-label {
  display: block;
  margin: 0 0 4px;
  font-size: 0.9rem;
  color: var(--muted);
}

.admin-reason {
  width: 100%;
  padding: 8px;
  /* `--section-bg` as a border colour, which is what the modal's own frame
     uses: the palette has no border token and inventing one here would put a
     colour outside the two blocks that own them. */
  border: 1px solid var(--section-bg);
  border-radius: 8px;
  font: inherit;
  color: var(--fg);
  background: var(--bg);
  resize: vertical;
}

/* The prompt an administrator takes to a chat.
 *
 * Monospace and selectable: it is two thousand characters of instructions, and
 * what matters is that it can be copied exactly — a proportional font invites
 * reading it, which is not what it is for. Read-only rather than disabled, so
 * the text can still be selected by hand where the clipboard is not available. */
.admin-prompt {
  width: 100%;
  padding: 8px;
  border: 1px solid var(--section-bg);
  border-radius: 8px;
  font: 0.8rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  color: var(--fg);
  background: var(--section-bg);
  resize: vertical;
}

.admin-unavailable,
.admin-previous {
  margin: 0 0 8px;
  color: var(--muted);
}

/* Not a failure colour: the palette has none, and a message telling somebody
   to type a reason is guidance rather than a fault. It reads as body text with
   the accent rule the failed state uses, so it is visibly a thing to act on. */
.admin-invalid {
  margin: 8px 0 0;
  padding-left: 8px;
  border-left: 3px solid var(--accent);
  color: var(--fg);
}

.admin-done {
  margin: 0;
}

.control-star {
  flex: 0 0 auto;
  min-width: 44px;
  min-height: 44px;
  font-size: 1.5rem;
  line-height: 1;
  text-align: center;
  color: var(--tg-theme-link-color, var(--accent));
}

/* The level, in the place §8 reserved for a row's actions. A label rather than
 * a control: «Избранное» spans levels and a row has to say which it came from,
 * but nothing here is tappable except the row. */
.row-level {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* A main action — AGENTS.md: "prefer larger targets for primary actions". */
.control-primary {
  min-height: 48px;
  padding: 12px 20px;
  border-radius: 12px;
  background: var(--tg-theme-button-color, var(--accent));
  color: var(--button-fg);
  font-weight: 600;
}

/* A tappable word inside running text. See controls.js for the exemption and
   why the height is met this way: the vertical padding overlaps the leading the
   line already had, so the line box reaches 24px without spreading the text.
 *
 * **A dotted underline, and nothing else** (TDE-219).
 *
 * Almost every word in a text is tappable — measured, 636 of 636 committed
 * tokens resolve to a dictionary word — so whatever marks one marks nearly the
 * whole paragraph. The filled background this rule used to carry turned a
 * reading screen into a block of grey with white slots where the spaces were:
 * a treatment that is fine on one word and unreadable on sixty.
 *
 * A dotted underline is a **shape**, so it survives being seen without colour,
 * and it reads as a hint rather than as a highlight. Nothing else changes: same
 * font, same size, same weight, same background as the prose around it — so the
 * text is still text.
 *
 * A colour change alone would not have been acceptable here; §21 and AGENTS.md
 * both refuse colour as the only carrier of meaning. */
.reading {
  line-height: 24px;
}

.control-word {
  display: inline;
  padding: 2px 1px;
  text-decoration: underline dotted;
  text-decoration-color: var(--muted);
  text-underline-offset: 3px;
}

/* A word the learner already keeps.
 *
 * **A different underline, not only a different colour.** §21 and AGENTS.md
 * both refuse colour as the sole carrier of meaning, and a dotted-to-solid
 * change is a shape: it survives being seen without colour, and it survives
 * being printed. The colour follows the client's link colour, so it belongs to
 * the same family as the tappable hint rather than introducing a new one.
 *
 * Deliberately quiet. Nearly every word in a text is tappable and a learner may
 * have saved dozens of them, so anything with a background would turn a
 * paragraph into a highlighter test — the failure TDE-219 already found once
 * and fixed by making tappability a dotted underline rather than a fill. */
.control-word[data-in-dictionary] {
  text-decoration: underline solid;
  text-decoration-color: var(--tg-theme-link-color, var(--accent));
  text-decoration-thickness: 2px;
}

/* §11's Random 5: the draw's actions, under the words. */
/* §11's cards.
 *
 * A card shows the German word and turns over to the Russian. It is the one
 * screen in the app that is a drill rather than a reference, and the shape is
 * what says so: a learner answers before they look.
 *
 * **The card moves with the finger and the buttons do not.** A drag is
 * translated live so a learner can see which way they are going and change
 * their mind; letting go inside the threshold springs it back. */
.draw-cards {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 16px 0;
  padding: 0;
  list-style: none;
}

.draw-card {
  position: relative;
  border: 1px solid var(--section-bg);
  border-radius: 12px;
  background: var(--bg);
  /* The placeholder underneath is the size of the card, so the card's own edge
     is what crops it as it is revealed. */
  overflow: hidden;
  /* **A long press must not select the word or raise the platform's callout.**
     Both are what a phone does with a press-and-hold on text by default, and
     both would land on top of the sheet this one opens. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

/* Past the threshold, so letting go now would act. Not the only signal — the
   card has also visibly moved most of a thumb's width, and the placeholder
   underneath names the action — but it is what makes the difference readable at
   a glance. */
.draw-card-going {
  border-color: var(--accent);
}

/* **What the drag reveals**, the size of the card and behind it.
 *
 * `pointer-events: none` because it is a picture rather than a control: every
 * press belongs to the card above it, and a placeholder that could be clicked
 * would swallow the gesture that reveals it. */
.draw-hint {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-radius: 12px;
  background: var(--section-bg);
  pointer-events: none;
}

/* Each label sits against the edge the card uncovers as it travels — a drag to
   the left exposes the right-hand side — so it is legible at the sixty pixels
   that commit the gesture rather than only once the card is half gone. */
.draw-hint-label {
  display: none;
  width: 100%;
  font-weight: 600;
  color: var(--accent);
}

[data-hint="left"] {
  text-align: right;
}

[data-hint="right"] {
  text-align: left;
}

[data-going="left"] [data-hint="left"],
[data-going="right"] [data-hint="right"] {
  display: block;
}

/* The face: the whole card is the control that turns it over.
 *
 * **This is what travels under a drag**, not the card — the placeholder above
 * has to stay still — and the `perspective` here is what makes the turn a turn
 * rather than a horizontal squash. */
.control-flip {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 112px;
  padding: 24px 16px;
  border: none;
  border-radius: 12px;
  text-align: center;
  background: var(--bg);
  color: var(--fg);
  perspective: 800px;
  /* The spring back wants an easing curve; the drag does not, and gets none —
     the transform is set on every pointermove, and a transition would make the
     card lag behind the thumb. This is short enough not to. */
  transition: transform 150ms ease-out;
  /* **Vertical scrolling stays the browser's.** The list scrolls, so only the
     horizontal axis is ours; without this the first drag on a phone is a fight
     between the two. */
  touch-action: pan-y;
}

/* **The focus ring turned inward, because the card clips.**
 *
 * The global rule draws it two pixels *outside* the border box, and this button
 * fills its card — which sets `overflow: hidden` so the placeholder underneath
 * is cropped to the card's own edge. Outside means clipped, and a keyboard
 * learner would have had no visible focus at all.
 *
 * The sweep in a11y.js could not have caught it: it compares the computed
 * `outline` focused against unfocused, and `outline-offset` is not part of
 * `outline` — so a ring that exists and is invisible reads as present. */
.control-flip:focus-visible {
  outline-offset: -3px;
}

/* **Both faces in one grid cell.** The card is then as tall as the longer of
   the two and a compound noun at the `huge` text size still fits; stacking them
   with absolute positioning would fix the height and clip it. */
.draw-faces {
  display: grid;
  width: 100%;
  transform-style: preserve-3d;
  transition: transform 320ms ease;
}

.draw-face {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Each face is only visible from its own side, which is what makes the two
     of them one card rather than two overlapping labels. */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}

[data-face="back"] .draw-faces {
  transform: rotateY(180deg);
}

.draw-german {
  font-size: 1.35rem;
  font-weight: 600;
}

/* Pre-turned to face away, so rotating the pair brings it round the front. */
.draw-russian {
  font-size: 1.2rem;
  color: var(--accent);
  transform: rotateY(180deg);
}

.draw-note {
  margin: 0 0 12px;
  color: var(--muted);
}

.draw-actions {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  margin-top: 20px;
}

/* §10's saved-words list: the row, and the control that removes it.
 *
 * The two sit side by side — tapping the word opens it, tapping «Убрать» takes
 * it out of the list — so the row must not swallow the whole width. */
.saved-item {
  display: flex;
  gap: 8px;
  align-items: stretch;
}

/* A row showing a numbered word: the ordinal, then the lemma.
 *
 * Shared by both word lists — «Мой словарь» → «Список» and «Словарь» — because
 * they are the same row. `.saved-row` is kept beside it and carries only what
 * belongs to the saved list: growing to fill the space its «Убрать» does not
 * take. The catalogue's rows have no such sibling, so they have no such rule. */
.word-row {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 10px;
}

.saved-row {
  flex: 1 1 auto;
}

.row-remove {
  flex: 0 0 auto;
  width: auto;
  color: var(--muted);
}

/* §9's lexical card. The frame is modal.js's; this is what is inside it.
 *
 * The lemma leads, because it is the word a learner just tapped and the reason
 * the card is open. Everything else is quieter than it. */
.card-lemma {
  margin: 0 0 4px;
  font-size: 1.25rem;
  font-weight: 600;
}

.card-translation {
  margin: 0 0 16px;
}

.card-label {
  margin: 0 0 2px;
  color: var(--muted);
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.card-example {
  margin: 0 0 2px;
}

.card-example-ru {
  margin: 0 0 16px;
  color: var(--muted);
}

.card-saved {
  margin: 0;
  color: var(--muted);
}

/* «Пройдено», under the text rather than fixed over it.
 *
 * **Not a fixed control**, which is what AGENTS.md's rule about Telegram's
 * system areas is mostly about: a button pinned to the bottom of a reading
 * screen covers the last line of the text, and on a device with a home
 * indicator it needs --app-inset-bottom to avoid sitting under it. A text is
 * about 350 characters, so the end of it is a short scroll away and the button
 * is simply there when a learner arrives. Nothing to overlap, nothing to inset.
 */
/* **The action sits at the right-hand edge**, asked for and worth stating: it
 * is the end of a thing a learner has just read top-to-bottom, and the right is
 * where a "done, next" action belongs in a left-to-right script. The neighbour
 * row below it keeps «Следующий →» on the same edge, so the two reads as one
 * column of forward motion rather than two controls that happen to be near
 * each other. */
.reading-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}

/* The same control once the text is finished — «Отменить».
 *
 * **Quieter than the primary it replaces**, deliberately. Finishing a text is
 * the action the screen is built around and undoing one is a correction, so it
 * keeps the size and the position and gives up the filled background. A second
 * button as loud as the first would read as two things to do rather than one
 * thing and its undo. */
.control-undo {
  min-height: 48px;
  padding: 12px 20px;
  border-radius: 12px;
  background: var(--section-bg);
  color: var(--tg-theme-link-color, var(--accent));
  font-weight: 600;
}

.reading-done {
  margin: 0;
  color: var(--muted);
}

/* §21: a visible label, never a placeholder standing in for one. */
.field {
  display: block;
  margin: 0 0 var(--gap);
}

.field-label {
  display: block;
  margin-bottom: 4px;
  color: var(--muted);
  font-size: 0.9rem;
}

.field-input {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: 10px 12px;
  border-radius: 12px;
  border: 1px solid var(--section-bg);
  background: var(--section-bg);
  color: var(--fg);
  cursor: text;
}

/* The modal. Focus handling is modal.js's; this is only what it looks like. */
/* **The card is a sheet at the bottom of the screen, not a box in the middle.**
 *
 * Asked for, and it is also where the platform puts things: a Mini App is itself
 * a sheet, and a card rising from the same edge reads as part of it rather than
 * as something floating over it. It is nearer the thumb than a centred box on a
 * tall phone, which matters for the control the learner came to press.
 *
 * `align-items: flex-end` with no padding is what glues it down. The safe-area
 * inset moves onto the card itself — see `.modal` — because a gap between the
 * card and the bottom of the screen is exactly what "glued" excludes, while
 * text under the home indicator is what the inset exists to prevent. */
.modal-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  background: var(--scrim);
  z-index: 10;
}

/* **Half a screen of text at most, and that text is the only thing that scrolls.**
 *
 * The height cap was asked for and it is the right shape for a sheet: the text
 * the word came from stays visible above it, which is the context a learner
 * needs while reading the card.
 *
 * A column rather than one scrolling box, so the title and «Закрыть» stay put
 * and only the content moves. Scrolling the whole card would put the control
 * that closes it below the fold of a long entry — reachable only by scrolling
 * past the thing you wanted to stop looking at.
 *
 * Rounded at the top only: the bottom corners have no screen edge to soften. */
.modal {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 480px;
  /* **The cap is on the body, not here.** Capping the sheet clips whatever does
     not fit, and what does not fit is the last thing in it — «Закрыть», pushed
     under the home indicator at 200% zoom, which is what
     TestEveryScreenSurvivesZoom found. Capping the *scrolling* part instead
     means the sheet is only ever as tall as its own controls plus at most half
     a screen of text, and the controls are never the part that gives way.
     The notch's share comes off the top: the sheet is glued to the bottom of
     the screen and may reach it, but it has no business under the status bar at
     the other end. */
  max-height: calc(100% - var(--app-inset-top));
  padding: 20px;
  /* The home indicator's share, on the card rather than on the overlay — see
     `.modal-overlay`. */
  padding-bottom: calc(var(--app-inset-bottom) + 20px);
  border-radius: 16px 16px 0 0;
  background: var(--bg);
  color: var(--fg);
}


/* The one part that scrolls. `min-height: 0` is what lets a flex child shrink
 * below its content — without it the body keeps its full height and the card
 * grows past its cap instead of scrolling. `overscroll-behavior` stops a scroll
 * that reaches the end from carrying on into the text behind the card. */
.modal-body {
  /* Shrinks, never grows: a card with four short fields is four short fields
     tall, and only a long one reaches the cap. `min-height: 0` is what lets a
     flex child shrink below its content — without it the body keeps its full
     height and pushes the controls off the bottom instead of scrolling.
     Half the screen, which is the limit that was asked for; measured against
     the viewport because the sheet's own height is what this decides. */
  flex: 0 1 auto;
  min-height: 0;
  max-height: 50vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  margin-bottom: 16px;
}

.modal-title {
  flex: 0 0 auto;
  margin: 0 0 8px;
  font-size: 1.2rem;
}

/* **Every control, pinned to the bottom of the sheet.** Asked for, and the
 * reason is the same one that made the body the only scrolling part: a control
 * that scrolls out of a half-height sheet is a control the learner has to go
 * looking for. «Добавить в словарь» used to be the last thing in the card's
 * body and did exactly that on a long entry.
 *
 * `flex: 0 0 auto` keeps the footer at its own height while the body absorbs
 * whatever is left — the footer never shrinks to make room for text. */
.modal-actions {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* §8's text list.
 *
 * **The status indicator carries three signals and colour is the least of
 * them** — see textlist.js for why the eye became a circle. These two values
 * are the only semantic colours in the product: they mean something rather
 * than decorating something, so they cannot be inherited from the client's
 * theme the way everything else is (docs/decisions/0010). They are named per
 * scheme here, and measured against both palettes by TDE-210.
 *
 * Grey comes from --muted, which is the client's own hint colour and is
 * already legible on whatever background it sent. */
:root {
  --mark-started: #8a6100;
  --mark-done: #1f7a3d;
}

:root[data-colour-scheme="dark"] {
  --mark-started: #f0c419;
  --mark-done: #4cc46e;
}

.text-row {
  /* The indicator and the number stay at their natural width, the title takes
     the rest, and the actions area is whatever it needs. A long title wraps
     inside its column instead of pushing the indicator off the row. */
  display: grid;
  grid-template-columns: auto auto 1fr auto;
  align-items: center;
  gap: 10px;
  text-align: left;
}

.row-mark {
  font-size: 1.1rem;
  line-height: 1;
  color: var(--muted);
}

.row-mark[data-status="in_progress"] {
  color: var(--mark-started);
}

.row-mark[data-status="completed"] {
  color: var(--mark-done);
}

.row-number {
  color: var(--muted);
  /* So the numbers line up down the list rather than shuffling with their
     digits. */
  font-variant-numeric: tabular-nums;
}

.row-title {
  /* A German compound with no spaces in it is one word and would otherwise
     widen the row until the layout gives up. */
  overflow-wrap: anywhere;
}

/* Reserved by §8, empty in v1, and taking no space while it is empty. A
   fixed-width gap would be a hole a learner can see and cannot explain. */
.row-actions:empty {
  display: none;
}

.section-title {
  display: block;
  font-weight: 600;
}

.section-hint {
  display: block;
  color: var(--muted);
  font-size: 0.9rem;
}

/* **§21's focus rule, applied to everything focusable rather than to a class.**
 *
 * A rule scoped to one component is a rule the next component does not have.
 * This is deliberately `:focus-visible` on every element: the browser decides
 * when a focus ring is warranted — keyboard yes, a tap no — and we decide only
 * that it is visible when it is.
 *
 * **Nothing anywhere sets `outline: none`.** TestTheAccessibilityBaseline reads
 * the computed style of every focusable element and fails if a focus ring is
 * missing, so removing this for looks is a failing build rather than a quiet
 * loss for everyone navigating by keyboard. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.control[aria-disabled="true"],
.control:disabled {
  opacity: 0.6;
}

/* The two screens a launch can end on instead of Home: opened outside Telegram,
   and a sign-in that did not happen. Both are text and, at most, one button. */
.notice {
  margin: 0 0 12px;
  color: var(--muted);
}


/* The three shared states.
 *
 * **They must be visually distinct, and the empty one must not read as a
 * failure.** A learner meets an empty list on their first day — no saved words
 * yet, a filter matching nothing — and rendering that as an error teaches them
 * the app is broken when it is working exactly as intended.
 *
 * So: loading and empty are quiet, in the hint colour. Only the failed state
 * carries emphasis, and it earns it.
 */
.state {
  padding: 24px 0;
  text-align: center;
}

.state-loading,
.state-empty {
  color: var(--muted);
}

/* The loading mark: a ring with one quarter in the foreground colour, turning.
 *
 * **Drawn in CSS rather than shipped as an image.** An asset would be a request
 * on the path of a screen that is already waiting, and the Content Security
 * Policy allows no external one anyway — so a file would have to be a data URI
 * inside the stylesheet, which is the same bytes with none of the theming. This
 * follows the client's colours because it is built from them. */
.state-loading {
  display: flex;
  align-items: center;
  gap: 8px;
}

.spinner {
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  border: 2px solid var(--section-bg);
  border-top-color: var(--tg-theme-link-color, var(--accent));
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

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

/* §21 and AGENTS.md: motion is not the only carrier, and somebody who has asked
   for less of it gets a still ring rather than nothing. The word beside it says
   what is happening either way. */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: none;
  }

  /* **The turn and the spring back, not the state.** Both faces still swap and
     the card still returns to where it was; what goes is the motion between,
     which is the part somebody who asked for less of it did not want. */
  .draw-faces,
  .control-flip {
    transition: none;
  }
}

.state-failed {
  /* The one state with weight. A left rule rather than a background: it reads
     as emphasis at any zoom and does not depend on colour alone, which
     §21 asks for. */
  text-align: left;
  border-left: 3px solid var(--accent);
  padding: 12px 0 12px 12px;
}

.state-note {
  color: var(--muted);
  font-size: 0.9rem;
  /* A request id is read out loud or copied; both are easier when it is not
     wrapped mid-token. */
  user-select: all;
  margin: 4px 0 12px;
}
