:root {
  color-scheme: light dark;
  --bg: #f6f0fb;
  --fg: #1f2320;
  --accent: #2563eb;
  --accent-fg: #ffffff;
  --border: #c9c9c5;
  --black-cell: #1f2320;
  --white-cell: #ffffff;
  --error: #b3261e;
  --selected: #bfdbfe;
  --correct-bg: #bbf7d0;
  --correct-fg: #14532d;
  --incorrect-bg: #fecaca;
  --incorrect-fg: #7f1d1d;
  --locked: #ea580c;
  --low-candidates-bg: #fed7aa;
  --noise-bg: #e9d5ff;
  --best: #16a34a;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--fg);
}

/* Faint logo watermark, centered on the browser window at all times —
   position: fixed (relative to the viewport, not to any content box)
   rather than attached to #result or main: both of those have a height
   driven by dynamic content (#result is hidden until a grid exists, and
   even then can be shorter than the watermark's own height for a small
   grid), which previously made the image invisible at first load and
   clipped once a small grid appeared. A viewport-sized box is always
   tall enough, so neither problem can recur. z-index: -1 paints it above
   body's own background but below every real element on the page (body
   is the root stacking context here, so this simply needs body's own
   normal-flow children — header/main — to have no z-index of their own,
   which they don't). background-size: 50vw auto sizes the image to 50%
   of the viewport's width, height auto-scaled to the SVG's own aspect
   ratio; opacity: 0.1 (90% transparent) applies to the pseudo-element
   itself, not a filter on the image, so it never fades real content. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  background-image: url("logo.svg");
  background-repeat: no-repeat;
  background-position: center center;
  background-size: 50vw auto;
  opacity: 0.1;
  z-index: -1;
  pointer-events: none;
}

/* `min-height: 100vh` + une colonne flexible, à la demande explicite de
   l'utilisateur ("Afficher les actus en dessous des boutons de
   l'application, sur ce qui reste de fenêtre vide") — #rss-panel (seul
   enfant avec `flex: 1`, voir plus bas) grandit alors pour occuper tout
   l'espace vertical encore libre de la fenêtre, quelle que soit la
   hauteur réelle du formulaire au-dessus de lui ; les autres enfants
   (#generate-form, #library/#attempt-preview/#result une fois affichés)
   gardent leur propre hauteur naturelle, `display: flex` en colonne ne
   change rien à leur mise en page interne. */
main {
  width: 80%;
  margin: 0 auto;
  padding: 2rem 1rem 4rem;
  min-height: 100vh;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
}

/* Marge de défilement réservée pendant que le clavier virtuel est
   déplié, à la demande explicite de l'utilisateur — voir script.js pour
   quand cette classe est posée/retirée. `position: fixed` (voir
   #virtual-keyboard) fait que le clavier ne pousse jamais naturellement
   le bas de <main> ; sans ce padding, rien ne permet de faire défiler la
   page assez loin pour dégager le bas de la grille de dessous le
   clavier. 14rem (~224px) suffit largement à couvrir la hauteur réelle
   du clavier déplié (2 rangées de lettres + la marge du widget). */
main.keyboard-open-padding {
  padding-bottom: 14rem;
}

#page-header {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1.5rem;
}

#logo {
  position: static;
  width: 4rem;
  height: 4rem;
  flex-shrink: 0;
}

h1 {
  font-size: 1.4rem;
  margin: 0;
  margin-right: auto;
}

.badge {
  flex-shrink: 0;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  background: var(--white-cell);
  border: 1px solid var(--border);
  color: #555;
  font-size: 0.75rem;
  font-weight: 600;
  white-space: nowrap;
}

.info-badge {
  position: relative;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 999px;
  color: #555;
  cursor: default;
}

.info-badge:hover,
.info-badge:focus-visible {
  color: var(--accent);
  outline: none;
}

/* Lien vers le dépôt GitHub, en haut à droite du header (dernier élément
   de la ligne flex, après le badge d'info). Discret par défaut, comme
   le badge d'info, et mis en avant au survol / focus clavier. */
#github-link {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  color: #555;
}

#github-link:hover,
#github-link:focus-visible {
  color: var(--accent);
  outline: none;
}

#github-link svg {
  display: block;
}

/* Hidden by default; shown on hover/keyboard-focus of the badge itself —
   a pure-CSS tooltip, no JS needed to open/close it (script.js only fills
   in its text once, from /api/system_info, see the style-guide SKILL). */
.info-tooltip {
  display: none;
  position: absolute;
  top: 100%;
  right: 0;
  z-index: 10;
  margin-top: 0.5rem;
  padding: 0.6rem 0.8rem;
  border-radius: 0.5rem;
  background: var(--fg);
  color: var(--bg);
  font-size: 0.8rem;
  font-weight: 400;
  line-height: 1.5;
  white-space: nowrap;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}

.info-badge:hover .info-tooltip,
.info-badge:focus-visible .info-tooltip {
  display: block;
}

form {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: end;
  margin-bottom: 1rem;
}

/* Les boutons d'action (bleus) sur leur propre ligne, sous les combos
   d'options, à la demande explicite de l'utilisateur : `flex-basis: 100%`
   force ce conteneur à passer seul sur la ligne suivante du flex-wrap du
   #generate-form ; #library-btn garde son `margin-left: auto` (voir plus
   bas) et pousse "Bibliothèque" + "Dictionnaire" à droite de cette ligne. */
#form-actions {
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}

label {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: 0.9rem;
}

input, select, button {
  font-size: 1rem;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--white-cell);
  color: var(--fg);
}

input {
  width: 6rem;
}

/* Wraps a free-text percentage input ("Taux noir"/"Graines")
   with a visible "%" suffix, at the user's explicit request when these two
   fields moved from a fixed-choice <select> to a free-entry integer input —
   the unit needs to stay visible even though it's no longer baked into an
   <option>'s own text. */
.input-with-suffix {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.input-with-suffix input {
  width: 4rem;
}

.input-suffix {
  color: var(--fg);
  opacity: 0.7;
}

button {
  background: var(--accent);
  color: var(--accent-fg);
  border-color: var(--accent);
  cursor: pointer;
  font-weight: 600;
}

button:disabled {
  opacity: 0.6;
  cursor: wait;
}

/* Distinct red (--error, already used for #status.error/.cell.incorrect)
   rather than the shared blue every other button uses — at the user's
   explicit request for a "Stop" button, so it reads as an interrupting
   action rather than blending in with the neutral generate/toggle
   buttons next to it. */
#stop-btn {
  background: var(--error);
  border-color: var(--error);
}

/* "Bibliothèque" is the one button in #generate-form that's never hidden
   (see index.html) — permanent, at the user's explicit request. Pushed to
   the row's own far right (mirrors h1's own `margin-right: auto` trick in
   #page-header) regardless of how many of the other, conditionally-shown
   buttons happen to be visible alongside it at any given moment, rather
   than just sitting wherever it falls in source order. */
#library-btn {
  margin-left: auto;
}

#status {
  min-height: 1.2rem;
  font-size: 0.9rem;
}

#status.error {
  color: var(--error);
}

/* Short, LLM-generated grid title (backend/clues.py's LLMClueGenerator.
   generate_title), at the user's explicit request: "Affiche ce nom en
   haut de la grille à jouer." A proper heading (not styled like #stats'
   own small secondary metadata line right below it) since this is the
   one piece of text meant to read as the puzzle's own name, not a status
   readout. Hidden via the `hidden` attribute (see script.js's
   displayFinalGrid) rather than emptied out, for a grid with no title at
   all (title generation itself failed, or a grid saved before this
   feature existed) — no empty heading line taking up space. */
#grid-title {
  margin: 0 0 0.3rem;
  font-size: 1.3rem;
}

#stats {
  font-size: 0.9rem;
  color: #555;
}

/* Rapport des temps de génération, à la demande explicite de
   l'utilisateur : "en petits caractères sur une seule ligne", juste après
   le rapport "% mots placés"/"% cases noires" (#stats ci-dessus) — même
   traitement visuel que .attempt-preview-stats (petit, discret), pour
   rester cohérent avec l'autre endroit de l'interface qui affiche déjà ce
   genre de métadonnée secondaire. */
#generation-times {
  margin: 0;
  font-size: 0.7rem;
  color: var(--fg);
  opacity: 0.7;
  display: flex;
  align-items: center;
  gap: 0.35rem;
}

/* #generation-times-prev-btn/#generation-times-next-btn (the two .nav-btn
   at the end of this line, reusing the exact mechanism/history already
   built for #attempt-preview-prev-btn/#attempt-preview-next-btn — see
   script.js's showPreviousPreview()/showNextPreview()) let the player
   step back through the same search-history states once the final,
   playable grid is shown, at the user's explicit request — the attempt-
   preview panel above stays hidden by default once a grid is ready
   (hideAttemptPreviewPanel(), unlike the full hideAttemptPreview() reset
   at the start of a new generation), and reappears on demand the moment
   either button is first used. #generation-times-position mirrors
   #attempt-preview-position's own small/centered/fixed-width treatment,
   just smaller to match this line's own 0.7rem. */
#generation-times-position {
  font-size: 0.7rem;
  color: var(--fg);
  min-width: 3rem;
  text-align: center;
}

.toggle-btn.active {
  background: var(--accent-fg);
  color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
}

#board {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 2rem;
  margin-top: 1rem;
}

/* Wraps #grid and #hover-definition so the definition panel sits directly
   below the grid specifically (not the whole #board row, which also
   includes the #clues sidebar) — a plain flex column, no explicit width:
   #grid sizes itself from its own grid-template-columns, and
   #hover-definition's width: 100% below then stretches to match it via
   the column's default align-items: stretch. */
#grid-column {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  flex-shrink: 0;
}

#grid {
  display: inline-grid;
  flex-shrink: 0;
  align-self: flex-start;
  gap: 2px;
  background: var(--border);
  border: 2px solid var(--border);
}

/* Fixed-height, always-3-lines-tall panel showing the definition of
   whichever word is currently hovered (grid cell or clue line — both
   paths go through highlightWordAt()/clearHighlights() in script.js), at
   the user's explicit request: lets a player read the current word's
   definition without needing the full across/down clue lists in view at
   the same time. Height is fixed (not auto-growing with the text) so the
   layout never shifts as different definitions are shown; line-height is
   given in rem (not a unitless multiplier) so "3 lines" maps to an exact,
   predictable height. overflow-y: auto rather than clipping, so an
   unusually long definition can still be read by scrolling within the
   fixed window instead of being cut off outright.
   min-width: 0 is load-bearing, not decoration: #grid-column sizes itself
   from its children's natural width (see the comment above it), and a
   block element's natural/max-content width for that computation is how
   wide its text would be laid out on a *single unwrapped line* — without
   overriding the flex/grid default of min-width: auto, a long definition
   made the whole box (and #grid-column with it) stretch wide enough to
   fit the text on one line instead of wrapping within the fixed 3-line
   height, exactly the bug reported live. min-width: 0 lets this element
   shrink below that unwrapped width, so normal text wrapping actually
   takes effect at the width #grid already dictates. overflow-wrap:
   break-word is defensive, for the rare case of one single "word" (e.g.
   a run-on LLM artifact) wider than the box on its own. */
#hover-definition {
  margin: 0;
  width: 100%;
  min-width: 0;
  overflow-wrap: break-word;
  height: 4.5rem;
  padding: 0.5rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--white-cell);
  color: var(--fg);
  font-size: 0.9rem;
  line-height: 1.5rem;
  overflow-y: auto;
}

/* Idle state (nothing currently hovered) reads as a hint, not a real
   definition — lighter, italic text distinguishes it from the moment a
   real clue is shown, so a player never mistakes the placeholder for an
   actual (oddly generic) clue. */
#hover-definition.placeholder {
  color: #777;
  font-style: italic;
}

.cell {
  position: relative;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  font-size: 1rem;
}

/* Live preview of every failed generation attempt's most-filled-in state
   (no fixed cap — see crossword_gen.py, at the user's explicit later
   request "Afficher toutes les meilleures grilles dans l'aperçu, pas
   seulement les 6 meilleures" — a fixed cap of 6 lived here before that),
   laid out as many rows of 3 as needed, at the user's explicit request —
   before this, a single example; now one mini-grid per parallel attempt,
   for a more representative glimpse of what was tried at that step, not a
   real interactive grid.
   #attempt-preview-grids is the wrapper holding one .attempt-preview-grid
   per example — a fixed 3-column CSS grid (not flex-wrap) specifically so
   examples always lay out in full rows of 3 regardless of viewport width
   or how many there are, rather than however many happen to fit per row
   at a given window size. Also reused as-is for a single-grid preview
   (the "minimizing"/"clues" steps, see CLAUDE.md) — one grid then simply
   occupies the first of the 3 columns, no special-casing needed.
   `justify-items: start` overrides CSS Grid's own default (`stretch`) for
   grid items — without it, each `auto`-sized column stretches its
   `.attempt-preview-grid` item to fill the column's full width (sized to
   whichever row's mini-grid happens to be widest), and since that element's
   own background is the gray `--border` gap-line color (see below), the
   stretched, content-less remainder painted visibly as a wide gray block
   next to a narrower grid — reported directly by the user from a real
   screenshot. `start` keeps each mini-grid at its own intrinsic width
   instead. */
#attempt-preview-grids {
  display: grid;
  grid-template-columns: repeat(3, auto);
  justify-items: start;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

/* Wraps one .attempt-preview-stats line above its own .attempt-preview-grid
   — this wrapper, not the mini-grid itself, is now the actual grid item of
   #attempt-preview-grids, at the user's explicit request to show a fill-
   rate readout above each preview. A plain flex column keeps the stats
   line and the grid stacked tightly, and lets each item's own intrinsic
   width still drive #attempt-preview-grids' `justify-items: start` layout
   exactly as before this change. */
.attempt-preview-item {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.attempt-preview-stats {
  margin: 0;
  font-size: 0.7rem;
  color: var(--fg);
  opacity: 0.7;
}

/* Prefix identifying which real process produced this specific preview
   grid, at the user's explicit request — "permet de suivre une grille qui
   change de place d'un cycle à l'autre." Full opacity and the accent
   color (unlike .attempt-preview-stats' own dimmed --fg) so it reads as
   the identifying label it is, distinct from the plain percentage stats
   it's prefixed to. `<strong>` already carries its own bold weight; the
   explicit font-weight here just makes that intent unambiguous regardless
   of any future base-style change. */
.attempt-preview-process {
  color: var(--accent);
  font-weight: 700;
}

/* Each individual mini-grid: reuses the same .cell/.white/.black classes/
   colors as #grid for visual consistency, but scoped smaller (1.1rem vs.
   2rem) since this is a quick, secondary glance, not the main puzzle, and
   non-interactive (no cursor: pointer, no hover/selection states — those
   only make sense for a real playable grid). No row/column headers
   either, for the same "quick glance" reason.
   `align-self: flex-start` — at the user's explicit report, from a real
   screenshot: "Les grilles des extraits s'affichent sur un fond gris trop
   grand." Root cause: `.attempt-preview-item` (the flex parent wrapping
   this mini-grid together with .attempt-preview-stats just above it, see
   its own comment below) never sets `align-items`, so it defaults to
   flexbox's own `stretch` — every child, including this mini-grid, is
   stretched to match the WIDTH of whichever sibling is naturally widest
   along that column-flex container's cross axis. `.attempt-preview-stats`
   is a plain sentence ("23 % noir, 77 % rempli, 0 % injouable") that can
   easily be wider than a small mini-grid's own natural width (`width ×
   1.1rem` — a 9-column grid is under 10rem, well short of that sentence's
   own width at 0.7rem) — on a grid narrower than its own stats line,
   flexbox's default stretch pulled this element wider than its actual
   cell content, and since its own background is the gray `--border`
   grid-line color (needed for the cell-gap trick above), the stretched,
   cell-less remainder painted visibly as a wide gray block next to the
   real cells — never visible on this project's own larger 15×10 benchmark
   grid, whose mini-grid is already wider than the stats line, which is
   exactly why this went unnoticed through all of this project's own prior
   testing on that grid. `align-self: flex-start` overrides the inherited
   stretch for this one child, keeping it at its own natural width instead
   — the same fix, and the same reasoning, already applied once to #grid
   itself inside #board's own flex row (see #grid's own comment). */
.attempt-preview-grid {
  display: inline-grid;
  gap: 1px;
  background: var(--border);
  border: 2px solid var(--border);
  align-content: start;
  align-self: flex-start;
}

/* Green outline around the grid the backend reports as the real winner of
   its own batch (`is_best`, see backend/crossword_gen.py's `_sort_
   examples_by_process`) — at the user's explicit request, needed once the
   display order became always-by-process-number rather than by score:
   the winner's own position in the list no longer implies anything about
   rank on its own, so it needs an explicit visual marker instead. A
   slightly thicker border (3px vs. the plain 2px gray default) in a
   distinct green (`--best`, separate from `--correct-fg`'s own darker
   green used for the real playable grid's "Vérification" feedback —
   two unrelated concepts that only coincidentally share the same color
   family) so it reads clearly as a special outline, not just a color
   change easy to miss at this small a scale. */
.attempt-preview-grid.attempt-preview-best {
  border: 3px solid var(--best);
}

.attempt-preview-grid .cell {
  width: 1.1rem;
  height: 1.1rem;
  font-size: 0.6rem;
  font-weight: 500;
  cursor: default;
}

/* Cells whose shown letter is a statistical hint (sample_letter_biases,
   see CLAUDE.md), not a letter really placed by the search — see
   script.js's renderAttemptPreview(), build_partial_letters_grid() — at
   the user's explicit request. A thick inset border rather than a
   background fill (as it first shipped) — reworked at the user's explicit
   follow-up request specifically so this state survives being combined
   with .impossible's red background below on the same cell (a background-
   color would simply be overwritten by whichever of the two rules is
   declared last; a border composes independently of the fill underneath
   it, same technique already used for #grid's own .word-highlight). Reuses
   --accent (the same blue already used for that .word-highlight border on
   the real, playable grid) rather than --selected, since --selected is a
   pastel meant specifically for background fills, not a border color. */
.attempt-preview-grid .cell.white.forced {
  box-shadow: inset 0 0 0 2px var(--accent);
}

/* Cells of a *partially* locked slot (backend/crossword_gen.py's
   `_low_candidate_slot_cells`) whose intersection with the already-locked
   letters leaves fewer than PREFILL_LOCKED_MIN_WORD_COUNT (3) real
   dictionary candidates — at the user's explicit request, on the
   "Génération du motif de cases noires" (`pattern`) preview specifically,
   to make a fragile slot visible before pre-fill/nettoyage curatif even
   acts on it. A light orange background (`--low-candidates-bg`, a pastel
   tint of `--locked`'s own saturated orange) rather than reusing `--locked`
   itself outright as a fill — `--locked` is a *border*-only color (see
   `.locked` below) precisely so it composes with a background fill
   underneath it without visual conflict; the two share the same hue
   family on purpose (both signal a fragile/at-risk slot), just different
   treatments, matching the border-vs-fill convention already established
   between `.forced`/`.locked` and `.impossible`. Declared before
   `.impossible` so a cell that's ever both (not possible today — see
   backend's own comment — but kept future-proof) shows the more severe
   "zero candidates left" red rather than this softer "few candidates
   left" orange. */
.attempt-preview-grid .cell.white.low-candidates {
  background: var(--low-candidates-bg);
}

/* Cases où aucune lettre ne satisfait à la fois l'emplacement horizontal
   et l'emplacement vertical qui s'y croisent (backend/crossword_gen.py's
   `_noise_slot_cells`) — distinct de `.impossible` ci-dessous : chacun
   des deux emplacements pris séparément peut très bien avoir un domaine
   non vide (donc aucun des deux n'est lui-même `.impossible`), mais si
   aucun de leurs mots réellement jouables (ni déjà utilisés ailleurs
   dans la grille, ni du bruit statistique) ne partage la même lettre à
   cette case précise, elle reste en pratique injouable — un critère plus
   fin que le simple domaine vide, à la demande explicite de
   l'utilisateur. Fond violet (`--noise-bg`), une couleur dédiée plutôt
   que réutiliser l'orange de `.low-candidates` (un avertissement plus
   doux, "peu de candidats") ou le rouge de `.impossible` (un domaine
   brut totalement vide) — les trois signalent des degrés de gravité
   différents. Déclarée après `.low-candidates` et avant `.impossible`
   dans la cascade : les deux peuvent en principe se recouper sur la même
   case (un emplacement au domaine juste en dessous du seuil peut aussi
   être en cause dans un croisement injouable), auquel cas le violet
   l'emporte sur l'orange (signal plus sévère), mais s'efface devant le
   rouge (le cas le plus sévère de tous). */
.attempt-preview-grid .cell.white.noise {
  background: var(--noise-bg);
}

/* Cells of an "impossible zone" (a slot with no candidate word left at all
   at the shown snapshot — see script.js's renderAttemptPreview(),
   Filler.impossible_zone_cells()) at the user's explicit request — reuses
   --incorrect-bg (the same light red already used for a wrong letter on
   the real, playable grid) rather than introducing a new color token, so
   "something is wrong here" reads consistently across both contexts. */
.attempt-preview-grid .cell.white.impossible {
  background: var(--incorrect-bg);
}

/* Cells carried over verbatim from a previous palier's own real assignment
   via the "reprise telle-quelle" mechanism (see script.js's
   renderAttemptPreview(), crossword_gen.py's try_fill/preseed_assignment),
   at the user's explicit request — a genuinely different mechanism from
   .forced above (a real, previously-confirmed letter, not a statistical
   guess from sample_letter_biases), so it gets its own color (orange)
   rather than reusing --accent's blue, which stays specific to .forced. A
   thick inset border (like .forced), not a background fill — reworked at
   the user's own explicit follow-up request, from an initial background-
   fill version, so a .locked cell's border still lets .impossible's red
   background show through underneath rather than covering it outright,
   the same reasoning that already made .forced border-only instead of a
   fill. */
.attempt-preview-grid .cell.white.locked {
  box-shadow: inset 0 0 0 2px var(--locked);
}

/* Même traitement pour une case NOIRE verrouillée — à la demande
   explicite de l'utilisateur, une fois `_optimize_before_cleanup`
   (backend/crossword_gen.py) étendu pour verrouiller aussi les cases
   noires qui bordent un emplacement encore entièrement vide, pas
   seulement les cases blanches de cet emplacement lui-même. Même liseré
   orange (`--locked`) — reste visible sur le fond déjà sombre de
   `.cell.black` puisque `box-shadow: inset` se dessine par-dessus le
   fond, jamais masqué par lui. Règle distincte plutôt qu'un sélecteur
   commun `.cell.locked` : garde la même convention "un sélecteur par
   couleur de case" déjà en place pour chaque autre overlay de ce
   fichier, et rend explicite que .forced/.low-candidates/.noise restent,
   eux, volontairement réservés aux cases blanches (aucun appelant ne
   fournit aujourd'hui de case noire pour ces trois-là). */
.attempt-preview-grid .cell.black.locked {
  box-shadow: inset 0 0 0 2px var(--locked);
}

/* Wraps #attempt-preview-label and the prev/next navigation buttons in one
   row, at the user's explicit request to let the player step back/forward
   through the history of preview states already shown (script.js's
   previewHistory/previewHistoryIndex) instead of only ever seeing whichever
   one pollJob()'s own auto-reveal cadence currently shows. The wrapper, not
   the label itself, now owns the bottom margin that used to sit directly on
   #attempt-preview-label. */
#attempt-preview-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.4rem 0;
}

#attempt-preview-label {
  margin: 0;
  font-size: 0.9rem;
  color: #777;
  font-style: italic;
}

/* Status text paired with whichever previewHistory entry is currently on
   screen (script.js's showPreviewEntry()/renderPreviewStatus()) — the
   same localized "Tentative X/Y échouée (Z grilles échouées au total)..."
   text describeStep() already produces for the live #status line, at the
   user's explicit request so the history's own back/forward navigation
   always shows which cycle a given preview grid actually came from, not
   just the grid on its own. Same small/discreet treatment as
   .attempt-preview-stats below each mini-grid, for visual consistency
   between the two secondary-metadata lines in this section. */
#attempt-preview-status {
  margin: 0 0 0.5rem 0;
  font-size: 0.8rem;
  color: var(--fg);
  opacity: 0.7;
}

/* Small, unobtrusive prev/next controls next to #attempt-preview-label —
   deliberately not .toggle-btn (no on/off state to convey here, just an
   action) and much smaller than the page's main buttons, since this is a
   secondary navigation aid for a diagnostic preview, not a primary action.
   Disabled (not hidden) at either end of the history, so the player can
   always see both controls and understand at a glance which direction(s)
   remain available. */
.nav-btn {
  padding: 0.1rem 0.5rem;
  font-size: 0.8rem;
  line-height: 1.4;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--white-cell);
  color: var(--fg);
  cursor: pointer;
}

.nav-btn:disabled {
  opacity: 0.35;
  cursor: default;
}

/* Position readout ("Étape 5/13", script.js's renderPreviewPosition())
   between the ◀/▶ buttons and the ⏮/⏭ jump-to-start/end buttons flanking
   them — at the user's explicit request, so the player always knows
   where they are in the history without counting clicks. A fixed
   min-width keeps the buttons around it from jittering left/right as the
   digit count changes (e.g. "3/9" vs "12/103"). */
#attempt-preview-position {
  font-size: 0.8rem;
  color: #777;
  min-width: 3.5rem;
  text-align: center;
}

/* Diagnostic word-verification table (script.js's renderWordTable()),
   shown right below the "clues" step's own final-grid preview — at the
   user's explicit request: one row per grid word, sorted longest first,
   confirming it really exists in the language's own wordlist file and
   showing whichever candidate canonical form(s) has a real gloss entry.
   Same small/discreet treatment as the other secondary-metadata elements
   in this section (#attempt-preview-status, .attempt-preview-stats). Only
   ever visible on the one previewHistory entry that carries a word_table
   (see showPreviewEntry()) — hidden the rest of the time, same as this
   whole #attempt-preview section itself. */
#word-verification-wrap {
  margin-top: 0.75rem;
}

#word-verification-title {
  margin: 0 0 0.3rem 0;
  font-size: 0.9rem;
  color: #777;
  font-style: italic;
}

/* Wide content (many/long words) must scroll inside its own container
   rather than widen the page — the page body itself must never scroll
   horizontally. */
.table-scroll {
  overflow-x: auto;
}

#word-verification-table {
  border-collapse: collapse;
  font-size: 0.8rem;
}

#word-verification-table th,
#word-verification-table td {
  padding: 0.2rem 0.6rem;
  border-bottom: 1px solid var(--border);
  text-align: left;
  white-space: nowrap;
  vertical-align: top;
}

#word-verification-table th {
  color: #777;
  font-weight: 600;
}

/* Columns 2/3 (script.js's renderWordTable()) now show the *entire*
   verbatim reference-file line (the TSV row, the JSON Lines entry) rather
   than just the matched word/lemma, at the user's explicit request — a
   raw line can run much longer than a single word, so these two columns
   override the table's own default `white-space: nowrap` (kept for the
   short position column) with `pre-wrap`: long content wraps onto several
   visual lines within its own cell instead of forcing the whole table to
   scroll arbitrarily wide, while still preserving the line's own
   whitespace (TSV tabs, JSON structure) exactly as written on disk.
   `word-break: break-word` additionally lets an unbroken run (e.g. a
   fully quoted JSON blob with no natural wrap point) still wrap rather
   than overflow its cell; `max-width` keeps a single cell from itself
   dominating the table's overall width on a very long line. */
.raw-line {
  white-space: pre-wrap;
  word-break: break-word;
  max-width: 28rem;
}

/* "Bibliothèque" panel (#library-btn's click handler, script.js), at the
   user's explicit request — a plain toggled panel like #attempt-preview,
   listing every grid saved under GRID_STORE/ (backend/grid_store.py) so
   one can be reopened and played. Boxed distinctly (border/background,
   unlike #attempt-preview's plain inline layout) since, unlike the
   attempt preview, this can be opened at any time, independently of
   whether a generation is even running — a visible boundary makes it
   read clearly as its own self-contained panel rather than part of
   whatever's currently below it. */
#library {
  margin: 0.75rem 0;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  /* Fond transparent, à la demande explicite de l'utilisateur ("Afficher
     également la bibliothèque sur fond transparent"), le même traitement
     et la même justification déjà appliqués à #rss-panel juste au-dessus
     dans ce fichier : était `var(--white-cell)` (opaque), ce qui masquait
     entièrement le filigrane logo du corps de page (`body::before`) sous
     ce panneau. Aucune cellule de #library-table n'a de fond opaque
     propre (seul un survol/focus de ligne en donne un, voir plus bas), le
     texte reste donc parfaitement lisible sur le fond de page clair,
     filigrane compris. */
  background: transparent;
}

#library-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 0 0 0.5rem;
}

#library-label {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 600;
}

#library-table {
  border-collapse: collapse;
  font-size: 0.85rem;
  width: 100%;
}

#library-table th,
#library-table td {
  padding: 0.3rem 0.7rem;
  border-bottom: 1px solid var(--border);
  text-align: left;
  white-space: nowrap;
}

#library-table th {
  color: #777;
  font-weight: 600;
}

/* Every row loads its own grid on click (script.js's loadLibraryGrid()) —
   styled to read as clickable (pointer cursor, a hover tint) the same way
   a real link/button would, even though it's a plain <tr>. */
#library-table tbody tr {
  cursor: pointer;
}

#library-table tbody tr:hover,
#library-table tbody tr:focus-visible {
  background: var(--selected);
  outline: none;
}

#library-empty {
  margin: 0;
  font-size: 0.85rem;
  color: #777;
  font-style: italic;
}

/* Panneau "Dictionnaire" (#dictionary-btn's click handler, script.js), à
   la demande explicite de l'utilisateur — même carte transparente/bordée
   que #library juste au-dessus, membre du même groupe de panneaux
   centraux basculants. Chaque recherche empile son propre tableau. */
#dictionary {
  margin: 0.75rem 0;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: transparent;
}

#dictionary-header {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0 0 0.75rem;
}

#dictionary-form {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  margin: 0;
  flex: 1;
}

#dictionary-input {
  width: 14rem;
}

#dictionary-results {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.dictionary-result h3 {
  margin: 0 0 0.4rem;
  font-size: 1rem;
}

.dictionary-truncated,
.dictionary-empty {
  margin: 0 0 0.4rem;
  font-size: 0.85rem;
  color: #777;
  font-style: italic;
}

.dictionary-table {
  border-collapse: collapse;
  font-size: 0.85rem;
  width: 100%;
}

.dictionary-table th,
.dictionary-table td {
  padding: 0.3rem 0.7rem;
  border-bottom: 1px solid var(--border);
  text-align: left;
  vertical-align: top;
}

.dictionary-table th {
  color: #777;
  font-weight: 600;
}

.dictionary-form-cell {
  white-space: nowrap;
  font-weight: 600;
}

.dictionary-def-line + .dictionary-def-line {
  margin-top: 0.15rem;
}

/* Panneau "Actu Croisée" (flux RSS + agrégation de grilles, voir
   fetch_rss_feeds.py/fetch_grid_links.py), à la demande explicite de
   l'utilisateur. **Revirement assumé** par rapport à une décision
   antérieure de ce même projet : il vivait jusqu'ici comme une section
   persistante, jamais masquée par les sections centrales qui se
   basculent entre elles (#library/#attempt-preview/#result) — l'inverse
   a été explicitement redemandé plus tard ("Le journal d'actu doit
   disparaître quand quelque chose d'autre doit s'afficher, par exemple
   la Bibliothèque"), voir script.js's syncRssPanelVisibility() pour le
   mécanisme réel qui masque désormais ce panneau dès que l'un des trois
   autres est visible. Même look que #library (carte blanche, bordure
   fine) pour rester cohérent avec les autres panneaux de contenu de la
   page, mais avec sa propre hauteur bornée + défilement interne ("le
   panneau doit tenir sur la page d'accueil avec sa propre barre pour
   scroller") plutôt qu'une hauteur qui grandit avec le nombre
   d'articles. */
/* `flex: 1` (voir main's propre commentaire ci-dessus) : ce panneau
   grandit pour occuper tout l'espace vertical encore libre de la
   fenêtre, jamais une hauteur fixe. `min-height: 0` est nécessaire ici
   (un piège flexbox classique) : sans lui, un enfant flex ne peut jamais
   se laisser rétrécir sous la hauteur de son propre contenu, ce qui
   empêcherait #rss-list (voir plus bas) de jamais défiler en interne —
   il grandirait plutôt indéfiniment et ferait déborder la page entière.
   `display: flex; flex-direction: column` à son tour, pour que son
   propre titre (h2) garde une hauteur fixe pendant que #rss-list (avec
   son propre `flex: 1`) absorbe tout le reste de cet espace. */
#rss-panel {
  margin: 0.75rem 0 0;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  /* Fond transparent, à la demande explicite de l'utilisateur ("Afficher
     le fil Actu avec un fond transparent pour laisser voir le fond de
     page avec le logo") — était `var(--white-cell)` (opaque), ce qui
     masquait entièrement le filigrane logo du corps de page
     (`body::before`, voir plus haut dans ce fichier) partout où ce
     panneau s'affichait. Aucune ligne de #rss-list n'a son propre fond
     opaque (seul un survol/focus en donne un, voir plus bas) — le texte
     reste donc parfaitement lisible sur le fond de page clair, filigrane
     compris. */
  background: transparent;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* Le vrai bug derrière "le masquage du fil d'actu ne fonctionne pas",
   rapporté directement par l'utilisateur — la même classe de bug CSS déjà
   corrigée une fois pour #rss-detail[hidden], jamais réappliquée ici
   puisque #rss-panel n'existait pas encore à l'époque. `display: flex`
   ci-dessus est fixé inconditionnellement sur le sélecteur d'ID
   (spécificité 1-0-0), qui l'emporte toujours sur la règle native du
   navigateur `[hidden] { display: none }` (sélecteur d'attribut,
   spécificité 0-1-0), quel que soit l'ordre des règles dans le fichier —
   donc `rssPanel.hidden = true` (voir syncRssPanelVisibility() dans
   script.js) n'avait jamais d'effet visuel réel : le panneau restait
   affiché en permanence, peu importe la justesse de la logique JS qui
   décide quand il devrait disparaître. Corrigé par une règle plus
   spécifique (ID + attribut, spécificité 1-1-0) qui l'emporte enfin. */
#rss-panel[hidden] {
  display: none;
}

/* Titre + sélecteur de langue sur une même ligne, à la demande explicite
   de l'utilisateur ("A droite du titre des entrées RSS, afficher un
   sélecteur"). */
#rss-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0 0 0.5rem;
  flex-shrink: 0;
}

#rss-panel-header h2 {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 600;
}

#rss-language-filter {
  font-size: 0.8rem;
  padding: 0.15rem 0.4rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
}

#rss-list {
  list-style: none;
  margin: 0;
  padding: 0;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}

#rss-list li {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  padding: 0.3rem 0;
  border-bottom: 1px solid var(--border);
  font-size: 0.85rem;
  cursor: pointer;
}

#rss-list li:last-child {
  border-bottom: none;
}

#rss-list li:hover,
#rss-list li:focus-visible {
  background: var(--selected);
  outline: none;
}

/* Icône d'origine (lien externe vs article RSS), à la demande explicite
   de l'utilisateur : "ajoute à gauche une petite icône permettant de
   savoir si c'est un lien vers une page web ou une infos RSS." Taille
   fixe (`flex-shrink: 0`) pour ne jamais se comprimer si le titre est
   long — c'est le texte qui doit s'adapter, pas l'icône. */
#rss-list .rss-item-kind-icon {
  flex-shrink: 0;
  font-size: 1rem;
  line-height: 1.3;
}

#rss-list .rss-item-text {
  min-width: 0;
}

#rss-list .rss-item-source {
  color: #777;
  font-size: 0.75rem;
}

/* Entrée "grid" (lien direct) : la ligne sous le titre montre l'URL
   ouverte au clic — tenue sur une seule ligne avec ellipsis pour qu'une
   URL longue ne déforme pas le panneau. */
#rss-list .rss-item-url {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#rss-empty {
  margin: 0;
  font-size: 0.85rem;
  color: #777;
  font-style: italic;
}

/* Note de repli sur l'anglais (voir renderRssList() dans script.js) —
   même taille que #rss-empty mais sans l'italique/le gris estompé : ce
   n'est pas "rien à afficher", c'est une information sur ce qui s'affiche
   quand même juste en dessous. */
#rss-language-fallback-notice {
  margin: 0 0 0.4rem;
  font-size: 0.8rem;
  color: #777;
}

/* Détail d'un article, ouvert en cliquant une ligne de #rss-list — une
   simple carte superposée (pas une vraie boîte de dialogue modale native,
   cohérent avec le reste de cette page qui n'en utilise nulle part
   ailleurs) plutôt qu'un remplacement du panneau lui-même, pour ne
   jamais faire disparaître la liste sous-jacente. */
/* `display: flex; flex-direction: column` plutôt qu'un simple bloc à
   défilement direct, à la demande explicite de l'utilisateur ("doit
   garder son bouton pour fermer visible en haut à droite") : seul
   #rss-detail-body (voir plus bas) défile en interne — le bouton fermer,
   hors de cette zone défilante, reste toujours visible quelle que soit la
   longueur de l'article affiché. */
#rss-detail {
  position: fixed;
  inset: 0;
  z-index: 10;
  margin: 3rem auto;
  max-width: 640px;
  max-height: calc(100vh - 6rem);
  padding: 1rem 1.25rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--white-cell);
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
  display: flex;
  flex-direction: column;
}

/* Bug réel signalé en direct : un rechargement de page affichait ce
   panneau déjà ouvert, impossible à refermer. Cause : `display: flex`
   ci-dessus, fixé sans condition sur ce sélecteur d'ID (spécificité 100),
   l'emportait sur la règle par défaut du navigateur pour l'attribut
   `hidden` (`[hidden] { display: none }`, un simple sélecteur
   d'attribut, spécificité 10) — l'attribut `hidden` du HTML restait donc
   sans le moindre effet visuel, que ce soit au chargement de la page ou
   après un clic sur "Fermer" (`rssDetail.hidden = true` en JS, tout aussi
   inefficace pour la même raison). Cette règle, plus spécifique que la
   précédente (ID + attribut), rétablit le comportement attendu. Aucun
   autre panneau de cette page n'a ce problème : #library/#attempt-preview/
   #result ne déclarent jamais leur propre `display` du tout, laissant la
   règle par défaut du navigateur s'appliquer sans conflit. */
#rss-detail[hidden] {
  display: none;
}

#rss-detail-close-btn {
  align-self: flex-end;
  flex-shrink: 0;
  margin-bottom: 0.5rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  padding: 0.25rem 0.6rem;
  font-size: 0.8rem;
  cursor: pointer;
}

#rss-detail-body {
  overflow-y: auto;
  min-height: 0;
}

#rss-detail-title {
  margin: 0 0 0.25rem;
  font-size: 1rem;
}

#rss-detail-meta {
  margin: 0 0 0.75rem;
  font-size: 0.75rem;
  color: #777;
}

/* Contenu brut du flux (voir renderRssDetail() dans script.js) : un sous-
   ensemble de balises HTML autorisées, chaque lien forcé vers un nouvel
   onglet côté JS avant l'insertion — même esprit que les règles
   .chatbot-message-assistant ci-dessus, une simple tempérance de l'espacement
   par défaut du navigateur pour ce contenu inséré dynamiquement. */
#rss-detail-content img {
  max-width: 100%;
}

/* A word absent from the wordlist file — the one directly visible symptom
   of the rare "invented word" edge case documented in CLAUDE.md — is
   shown in the page's own error color rather than the normal text color,
   so it stands out unambiguously among otherwise-normal rows. */
.word-missing {
  color: var(--error);
  font-weight: 600;
}

.cell.white {
  background: var(--white-cell);
  color: var(--fg);
  cursor: pointer;
}

.cell.black {
  background: var(--black-cell);
}

.cell.white.selected {
  background: var(--selected);
}

.cell.white.correct {
  background: var(--correct-bg);
  color: var(--correct-fg);
}

.cell.white.incorrect {
  background: var(--incorrect-bg);
  color: var(--incorrect-fg);
}

/* Hover-only word outline (grid <-> clue list, see script.js's
   highlightWordAt()) — an inset border rather than a background fill, so
   it reads as "this is the word under the cursor" without being confused
   with .selected's solid blue fill or the green/red correctness states,
   and so it composes cleanly when a cell is simultaneously .selected/
   .correct/.incorrect and part of the hovered word. */
.cell.white.word-highlight {
  box-shadow: inset 0 0 0 2px var(--accent);
}

.cell-number {
  position: absolute;
  top: 1px;
  left: 2px;
  font-size: 0.55rem;
  font-weight: 400;
  line-height: 1;
}

.header-cell {
  background: var(--bg);
  color: var(--fg);
  font-size: 0.75rem;
  font-weight: 400;
  cursor: default;
}

#clues {
  min-width: 16rem;
  flex: 1 1 16rem;
}

#clues h2,
#down-clues-section h2 {
  font-size: 1rem;
  margin: 0 0 0.5rem;
}

#down-clues-section {
  margin-top: 2rem;
}

.clue-lines {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.clue-line {
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.4;
}

/* One word's own "(N) clue text" within a chained clue-line (see
   renderClueLines() in script.js) — its own hoverable unit, distinct from
   the " — " separator between entries, so hovering it highlights only
   that one word's cells in the grid, not the whole line. */
.clue-segment.hover-highlight {
  background: var(--selected);
  border-radius: 3px;
}

/* Row (across) / column (down) index, matching the grid's own header
   numbers — a fixed min-width keeps the clue text lining up in a column
   instead of drifting left/right as the number's digit-count changes. */
.clue-line-position {
  display: inline-block;
  min-width: 1.4em;
  color: var(--accent);
}

/* "Verticalement" spans the full width below the grid, balanced into 2
   columns instead of one long list — flex can't split children across
   columns, so this container switches to CSS multi-column layout instead. */
.clue-lines-columns {
  display: block;
  column-count: 2;
  column-gap: 2rem;
}

.clue-lines-columns .clue-line {
  break-inside: avoid;
  margin: 0 0 0.4rem;
}

/* "David FALCON" chat widget (#chatbot), at the user's explicit request:
   "En bas à droite de l'interface, ajoute un ChatBot (ouvert par défaut)
   avec l'icône de l'application." Fixed to the viewport's bottom-right
   corner (independent of page scroll, unlike every other section on the
   page, which flows normally inside <main>) — a floating widget rather
   than another in-flow section, matching the conventional look of a
   chat/help widget on the web. z-index: 5 (auto/0 for everything else
   in normal flow, -1 for the background watermark) keeps it above the
   page's own content without needing to touch anything else's stacking
   order. */
#chatbot {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  width: 480px;
  max-width: calc(100vw - 2rem);
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--white-cell);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  z-index: 5;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

#chatbot-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  background: var(--accent);
  color: var(--accent-fg);
}

#chatbot-icon {
  width: 1.5rem;
  height: 1.5rem;
}

#chatbot-title {
  flex: 1;
  font-weight: 600;
  font-size: 0.9rem;
}

/* Reuses .nav-btn's own small/neutral look, but on the accent-colored
   header it needs its own contrasting colors rather than .nav-btn's
   default white-on-border, which would be nearly invisible here. */
#chatbot-toggle-btn {
  background: transparent;
  border-color: var(--accent-fg);
  color: var(--accent-fg);
}

/* Collapsed state (toggled by #chatbot-toggle-btn, script.js) hides
   everything below the header — the widget shrinks down to just its own
   title bar, the same behavior as a typical chat-widget minimize button,
   rather than disappearing outright (so it stays a visible, one-click-
   away affordance instead of vanishing until some other trigger reopens
   it). */
#chatbot.chatbot-collapsed #chatbot-body {
  display: none;
}

/* Collapsed down to "a single simple icon", at the user's explicit
   request: "Une fois repliés, le clavier virtuel et le ChatBot ne
   doivent montrer qu'une simple icône" — the title bar used to stay a
   full-width strip (icon + title text + toggle button) even once
   collapsed, which read as a large, mostly-empty header rather than a
   compact icon. The title text disappears and the header shrinks to fit
   around just one visible element, instead of stretching to #chatbot's
   own full 480px width.

   Which single element that is was corrected right after, at the
   user's own explicit follow-up: "En mode 'réduit', le ChatBot doit
   afficher l'icône du site" — a first version kept the toggle button
   (its own "–" glyph, meant as a minimize indicator) and hid #chatbot-
   icon (the real app logo) instead, the opposite of what was asked.
   Fixed by hiding the toggle button here and keeping #chatbot-icon
   visible — script.js makes the icon itself clickable (toggling the
   exact same collapsed state as the button always did) so there's
   still a one-click affordance to re-expand, just presented as the
   site's own icon rather than a generic minimize dash. */
/* Une fois replié, l'en-tête accent (fond bleu) rendait l'icône du site
   peu lisible, à la demande explicite de l'utilisateur : en mode replié,
   juste un filet bleu sur fond blanc autour de l'icône (le fond accent
   ne sert plus qu'en mode déployé, où le titre en blanc le contrebalance). */
#chatbot.chatbot-collapsed {
  width: auto;
  border-color: var(--accent);
}

#chatbot.chatbot-collapsed #chatbot-header {
  padding: 0.4rem;
  gap: 0;
  background: var(--white-cell);
  color: var(--accent);
}

#chatbot.chatbot-collapsed #chatbot-title,
#chatbot.chatbot-collapsed #chatbot-toggle-btn {
  display: none;
}

#chatbot.chatbot-collapsed #chatbot-icon {
  cursor: pointer;
}

#chatbot-body {
  display: flex;
  flex-direction: column;
}

#chatbot-messages {
  max-height: 320px;
  overflow-y: auto;
  padding: 0.6rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

/* One bubble per message, at the user's explicit request that this read
   as a real conversation, not a plain transcript — the player's own
   messages align right (a light accent tint) and David FALCON's replies
   align left (a neutral, page-background tint), the same left/right
   convention as virtually every chat UI, so the two speakers are told
   apart at a glance without needing to re-read each bubble's own label. */
.chatbot-message {
  max-width: 85%;
  padding: 0.4rem 0.6rem;
  border-radius: 8px;
  font-size: 0.85rem;
  line-height: 1.35;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

.chatbot-message-user {
  align-self: flex-end;
  background: var(--selected);
}

.chatbot-message-assistant {
  align-self: flex-start;
  background: var(--bg);
}

/* David FALCON's own replies are rendered as real Markdown (see
   script.js's renderMarkdown()), not plain text, at the user's explicit
   request — these rules tame the browser's own default block-element
   spacing/list styling down to something that reads naturally inside a
   small chat bubble rather than a full page. */
.chatbot-message-assistant p {
  margin: 0 0 0.4rem;
}

.chatbot-message-assistant p:last-child {
  margin-bottom: 0;
}

.chatbot-message-assistant ul,
.chatbot-message-assistant ol {
  margin: 0 0 0.4rem;
  padding-left: 1.2rem;
}

.chatbot-message-assistant li {
  margin-bottom: 0.15rem;
}

.chatbot-message-assistant code {
  background: var(--white-cell);
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 0.05rem 0.25rem;
  font-size: 0.85em;
}

#chatbot-form {
  display: flex;
  gap: 0.4rem;
  padding: 0.6rem;
  border-top: 1px solid var(--border);
}

#chatbot-input {
  flex: 1;
  padding: 0.35rem 0.5rem;
  font-size: 0.85rem;
  border: 1px solid var(--border);
  border-radius: 4px;
}

/* Bouton "Envoyer" remplacé par une icône, plus un bouton icône
   "réinitialiser la conversation" à côté, à la demande explicite de
   l'utilisateur. Deux boutons carrés de la même taille que la hauteur du
   champ de saisie ; l'envoi garde le fond accent, la réinitialisation
   reste neutre/discrète pour ne pas concurrencer l'action principale. */
.chatbot-icon-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.9rem;
  height: 1.9rem;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg);
  color: var(--fg);
  cursor: pointer;
}
.chatbot-icon-btn svg {
  display: block;
}
#chatbot-send-btn {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-fg);
}
#chatbot-reset-btn:hover {
  background: var(--border);
}

/* Clavier virtuel, à la demande explicite de l'utilisateur : "En bas à
   gauche de l'interface, au même niveau que le ChatBot" — même
   positionnement fixe/même niveau vertical que #chatbot, mais côté
   gauche plutôt que droit, et sa propre largeur (bien plus étroite,
   26 lettres sur 2 rangs n'a pas besoin de la largeur d'une fenêtre de
   discussion). Même mécanisme de repli que #chatbot (en-tête toujours
   visible, corps caché en mode réduit) — voir #chatbot-collapsed
   ci-dessus pour le raisonnement déjà établi, repris ici à l'identique. */
/* Restructuré deux fois à la demande explicite de l'utilisateur : plus
   de bandeau de titre pleine largeur (voir #virtual-keyboard-toggle-btn
   ci-dessous, désormais un enfant direct plutôt que niché dans un
   en-tête), et les flèches de direction passent d'une rangée au-dessus
   des lettres à une colonne à droite de celles-ci ("mettre les flèches
   de direction sur la droite des lettres, pour gagner en hauteur"). Le
   widget entier devient une colonne flex alignée à droite
   (`align-items: flex-end`) : le petit bouton replier/déplier flotte
   ainsi naturellement au-dessus de la colonne de flèches (le seul autre
   enfant aligné à droite), sans bandeau ni titre dédiés — exactement
   "l'icône de fermeture en petit au-dessus des flèches". */
#virtual-keyboard {
  position: fixed;
  left: 1rem;
  bottom: 1rem;
  /* 60% de la largeur de l'écran, à la demande explicite de l'utilisateur
     ("Le clavier doit occupé 60% de la largeur de l'écran") — remplace
     l'ancienne largeur fixe de 280px, bien trop étroite pour que 13
     touches par rangée restent lisibles. */
  width: 60vw;
  max-width: calc(100vw - 2rem);
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--white-cell);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  z-index: 5;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.3rem;
  padding: 0.5rem;
  box-sizing: border-box;
}

/* Plus d'en-tête à colorer en accent (voir la note ci-dessus) : ce
   bouton vit désormais sur le fond clair du widget lui-même, donc plus
   besoin de la surcharge blanc-sur-transparent qui visait un fond
   accent — .nav-btn (déjà appliqué via la classe HTML) fournit déjà un
   contraste correct par défaut sur fond clair. */
#virtual-keyboard-toggle-btn {
  font-size: 0.9rem;
}

.virtual-keyboard-collapsed #virtual-keyboard-body {
  display: none;
}

/* Collapsed down to "a single simple icon", same request and same
   reasoning as #chatbot's own matching rule above. The glyph itself was
   already correct (confirmed once the user clarified "le clavier doit
   afficher une icône représentant un clavier" — "⌨" already is one) —
   but the *shrinking* never actually happened: a **real CSS-specificity
   bug**, the exact same class already fixed once before in this project
   for `#rss-detail[hidden]`. This rule used to use a bare `.virtual-
   keyboard-collapsed` class selector (specificity 0-1-0), while
   `#virtual-keyboard`'s own base rule (specificity 1-0-0, an ID
   selector) also sets `width: 60vw` — the ID selector always wins
   regardless of source order, so `width: auto` here never took effect.
   Fixed by compounding the selector with the id
   (`#virtual-keyboard.virtual-keyboard-collapsed`, specificity 1-1-0,
   now correctly beats the base rule) — mirroring `#chatbot.chatbot-
   collapsed`'s own selector, which was never affected by this bug
   precisely because it was already written this same way from the
   start. */
/* Même traitement que #chatbot replié, à la demande explicite de
   l'utilisateur ("Idem pour l'icône du clavier virtuel") : filet bleu
   sur fond blanc autour de l'icône ⌨ une fois le clavier replié. */
#virtual-keyboard.virtual-keyboard-collapsed {
  width: auto;
  border-color: var(--accent);
}

#virtual-keyboard.virtual-keyboard-collapsed #virtual-keyboard-toggle-btn {
  border-color: var(--accent);
  color: var(--accent);
}

#virtual-keyboard-body {
  display: flex;
  align-self: stretch;
  gap: 0.4rem;
}

#virtual-keyboard-rows {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.virtual-keyboard-row {
  display: flex;
  gap: 0.25rem;
}

/* Colonne de flèches (→ puis ↓), à droite des lettres — voir la note en
   tête de ce bloc. `align-items: stretch` par défaut sur
   #virtual-keyboard-body (aucune surcharge nécessaire) étire déjà cette
   colonne pour occuper exactement la même hauteur que les 2 rangées de
   lettres à sa gauche. */
#virtual-keyboard-direction-col {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

#virtual-keyboard-direction-col .toggle-btn {
  flex: 1;
  font-size: 1rem;
  padding: 0 0.6rem;
}

.virtual-keyboard-key {
  flex: 1;
  /* Touches agrandies pour rester lisibles à la largeur du clavier (60vw,
     voir #virtual-keyboard ci-dessus) — l'ancien padding/taille de police
     visait la précédente largeur fixe de 280px, bien trop petite une fois
     étirée. `color` explicite : la règle générique `button` (plus haut
     dans ce fichier) fixe `color: var(--accent-fg)` (blanc) pour TOUT
     bouton — sans cette surcharge, le texte des touches restait blanc sur
     un fond clair (`--bg`), donc invisible. C'était le bug rapporté par
     l'utilisateur ("on ne voit pas les lettres"), pas un simple défaut de
     contraste réglable en changeant juste le fond. */
  padding: 0.9rem 0;
  font-size: 1.4rem;
  font-weight: 700;
  color: var(--fg);
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  cursor: pointer;
}

.virtual-keyboard-key:hover,
.virtual-keyboard-key:focus-visible {
  background: var(--selected);
  outline: none;
}
