/* Marathon onboarder — arkusz główny.
   Kolejność sekcji: fonty → tokeny → reset → typografia → komponenty →
   powłoka → ekrany. Trzymaj się jej przy dopisywaniu, inaczej kaskada
   zacznie zaskakiwać. */

/* Inter jako font zmienny (variable font), podzielony wg unicode-range na
   podzbiory alfabetów zamiast na wagi. Przeglądarka pobiera tylko te pliki,
   których znaki faktycznie występują na stronie — polski użytkownik nigdy nie
   płaci transferem za cyrylicę, a jeden plik obsługuje od razu wszystkie wagi
   400-800 (regular/semibold/extrabold).

   UWAGA: te @font-face NIE SĄ DZIŚ UŻYWANE. Inter wypadł z --font-sans przy
   przejściu na humi, bo humi nie ładuje żadnego własnego pisma — bierze
   domyślny stos systemowy Tailwinda (patrz --font-sans w sekcji tokenów).
   Deklaracje zostają, bo mają unicode-range i przeglądarka nie pobiera pliku,
   którego rodzina nigdzie nie występuje: nic nie kosztują, a przywrócenie
   Intera to dopisanie jednego słowa do --font-sans. Jeżeli decyzja o piśmie
   systemowym się utrwali, usuń stąd Intera RAZEM z plikami woff2 i wpisem
   w licencji (pilnuje jej TestFontLicenseCoversEveryEmbeddedFont). */
@font-face {
  font-family: Inter;
  src: url("/static/c940764593d0/fonts/inter-latin.woff2") format("woff2");
  font-weight: 400 800; font-style: normal; font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: Inter;
  src: url("/static/a28eb6d3ccb5/fonts/inter-latin-ext.woff2") format("woff2");
  font-weight: 400 800; font-style: normal; font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: Inter;
  src: url("/static/aebf2ab4a4ce/fonts/inter-cyrillic.woff2") format("woff2");
  font-weight: 400 800; font-style: normal; font-display: swap;
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
  font-family: Inter;
  src: url("/static/fccca918fea4/fonts/inter-cyrillic-ext.woff2") format("woff2");
  font-weight: 400 800; font-style: normal; font-display: swap;
  unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

/* Inter nie ma alfabetu gruzińskiego — bez tego panel po gruzińsku
   renderowałby się fontem systemowym. Noto Sans Georgian (OFL), też zmienny,
   wzięty WYŁĄCZNIE w podzbiorze georgian: łacinę i cyrylicę pokrywa już
   Inter, a dociąganie ich drugi raz zmieniłoby wygląd polskiego tekstu.
   unicode-range sprawia, że przeglądarka pobiera ten plik tylko wtedy, gdy na
   stronie faktycznie są znaki gruzińskie — pl/en/uk/ru nie płacą za niego ani
   bajtem. Dlatego dopisanie go do --font-sans globalnie jest bezpieczne. */
@font-face {
  font-family: "Noto Sans Georgian";
  src: url("/static/471ae4eb863b/fonts/noto-sans-georgian.woff2") format("woff2");
  font-weight: 400 700; font-style: normal; font-display: swap;
  unicode-range: U+0589, U+10A0-10FF, U+1C90-1CBA, U+1CBD-1CBF, U+205A, U+2D00-2D2F, U+2E31;
}

:root {
  /* ══════════════════════════════════════════════════════════════════════
     PALETA I SKALE — 1:1 Z HUMI ONBOARDER
     ══════════════════════════════════════════════════════════════════════
     Źródłem prawdy dla całej warstwy wizualnej jest front humi onboardera
     (decyzja zamawiającego, 22.08.2026: „design sam w sobie ma być identyczny
     jak na humi"). Humi stoi na shadcn-svelte z bazą `slate` (components.json:
     "baseColor": "slate") i domyślnym motywem shadcn — czyli tym samym
     zestawem zmiennych --background/--foreground/--primary/…, tyle że
     zapisanym w HSL i mapowanym Tailwindem na klasy. Ten blok to ten sam
     zestaw, przeliczony na hex i podpięty pod NASZE nazwy tokenów, żeby
     3700 linijek reguł niżej nie musiało się zmieniać.

     Poprzednikiem był system „Cluo — Core" z Figmy (Inter, indygo, promienie
     10/6/16, neutralne Zinc). Zniknął stąd w całości — nie dlatego, że był
     zły, tylko dlatego, że mamy JEDNO źródło prawdy i jest nim humi. Brand
     book klienta dopasujemy później; wtedy zmienia się TEN blok, nie reguły.

     Przeliczenie HSL → hex (src/app.css w humi → tutaj):
       --background 0 0% 100%          → #ffffff
       --foreground 222.2 84% 4.9%     → #020817   (slate 950)
       --muted 210 40% 96.1%           → #f1f5f9   (slate 100)
       --muted-foreground 215.4 16.3% 46.9% → #64748b (slate 500)
       --border/--input 214.3 31.8% 91.4%   → #e2e8f0 (slate 200)
       --primary 222.2 47.4% 11.2%     → #0f172a   (slate 900)
       --primary-foreground 210 40% 98% → #f8fafc  (slate 50)
       --secondary/--accent 210 40% 96.1% → #f1f5f9
       --destructive 0 84.2% 60.2%     → #ef4444   (red 500)
       --ring 215 20.2% 65.1%          → #94a3b8   (slate 400)
       --radius 0.5rem                 → 8px (md = 6px, sm = 4px)   */
  /* color-scheme mówi przeglądarce, w jakim schemacie malować to, czego nie
     malujemy sami: klasyczne pasy przewijania, rozwiniętą listę <select>,
     tło autouzupełniania, wewnętrzne przyciski input[type=date/file].
     Bez tego w motywie ciemnym zostawały jasne. Wartość jest pojedyncza
     (light / dark), a nie „light dark", bo o motywie decyduje aplikacja
     (ciasteczko theme → data-theme), nie preferencja systemu. */
  color-scheme: light;

  /* ────────── SKALA NEUTRALNA: SLATE ──────────
     Baza kolorystyczna shadcn wybrana w humi. To jedyne miejsce, gdzie wolno
     wpisać surowy kolor — wszystko niżej odwołuje się do tokenów
     semantycznych, żeby zmiana motywu była zmianą jednego bloku. */
  --slate-50:  #f8fafc; --slate-100: #f1f5f9; --slate-200: #e2e8f0;
  --slate-300: #cbd5e1; --slate-400: #94a3b8; --slate-500: #64748b;
  --slate-550: #5b6b7f; /* patrz --color-fg-muted: slate-500 nie wyrabia kontrastu na --color-bg */
  --slate-600: #475569; --slate-700: #334155; --slate-800: #1e293b;
  --slate-900: #0f172a; --slate-950: #020817;
  /* Skala Gray (Tailwind) — humi maluje NIĄ pasek boczny, nie skalą Slate:
     `border-gray-200`, `text-gray-700`, `hover:bg-gray-50`, `bg-gray-100`
     w side-nav.svelte. Różnica wobec Slate jest o włos (Gray jest
     neutralniejszy, Slate wpada w niebieski), ale skoro kopiujemy 1:1, to
     nawigacja bierze dokładnie te tokeny, którymi jest tam pomalowana. */
  --gray-50:  #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb;
  --gray-300: #d1d5db; --gray-400: #9ca3af; --gray-500: #6b7280;
  --gray-700: #374151; --gray-900: #111827;
  /* ────────── SKALA ZINC: POWIERZCHNIE MOTYWU CIEMNEGO ──────────
     Motyw ciemny stał do 31.08.2026 na skali Slate: tło #020817, karty
     #0b1220. Slate ma w sobie niebieski i przy tak niskiej jasności czyta się
     to nie jako czerń, a jako GRANAT — i dokładnie tak zostało zgłoszone
     (Eryk, 31.08.2026: „nie podoba mi się ten granatowy"). Zinc to ta sama
     skala Tailwinda o niemal zerowym odchyleniu barwy: te same kroki
     jasności, bez zafarbu. Jasny motyw ZOSTAJE na Slate/Gray — jest
     zatwierdzony i 1:1 z humi — więc ta skala obsługuje wyłącznie blok
     [data-theme="dark"]. Zbieżność nie jest przypadkiem: zinc 100 to
     dzisiejsze --color-bg jasnego motywu (#f4f4f5), czyli obie strony
     przełącznika kończą na tej samej skali neutralnej.
     Cztery kroki POMIĘDZY krokami Tailwinda są dołożone świadomie: 950 jest
     u nas o włos ciemniejsze od tailwindowego #09090b (tło strony ma zostać
     najgłębszą powierzchnią), a 850, 825 i 750 wypełniają przeskok
     900→800→700, który na parze „karta / obrys karty" był za duży w jedną
     stronę, a na samym 800 za mały w drugą: obrys w kroku 800 miał na karcie
     1,19:1, czyli MNIEJ niż ta sama krecha w jasnym motywie (1,23:1).
     Pilnuje tego TestKontrastMotywuCiemnego — obrys w ciemnym nie ma prawa
     być subtelniejszy niż w jasnym. */
  --zinc-50:  #fafafa; --zinc-100: #f4f4f5; --zinc-200: #e4e4e7;
  --zinc-300: #d4d4d8; --zinc-400: #a1a1aa; --zinc-500: #71717a;
  --zinc-600: #52525b; --zinc-700: #3f3f46; --zinc-750: #33333a;
  --zinc-800: #27272a; --zinc-825: #2c2c31; --zinc-850: #1f1f23;
  --zinc-900: #18181b; --zinc-950: #0b0b0d;
  /* Skala Emerald — AKCENT EDYCJI ĆWICZENIA, nie akcent aplikacji. W humi
     wszystkie sześć formularzy ćwiczeń (QuizPreview, ReadingPreview,
     FillTheGapPreview, MatchingPreview, OpenQuestionPreview, VideoPreview)
     maluje nią to samo: pierścień fokusu pola, zaznaczoną poprawną odpowiedź,
     etykiety przycisków „Add Image"/„Add Option"/„Add Pair". Nigdzie indziej
     w tamtym interfejsie zieleni nie ma — akcentem jest slate 900, tak jak
     u nas. Dlatego te tokeny są tu, a nie w bloku akcentu.
     LICZBA, ŻEBY NIKT JEJ NIE MIERZYŁ OD NOWA: emerald 600 na bieli daje
     3,76:1 — ponad próg 3:1 dla elementów NIETEKSTOWYCH (WCAG 2.2 SC 1.4.11,
     czyli obrys, wypełnienie, pierścień fokusu), poniżej progu 4,5:1 dla
     tekstu. Etykiety „Dodaj obraz"/„Dodaj opcję" są w humi tym kolorem i
     zostają, bo decyzja brzmi 1:1 — ten sam świadomy koszt, co green 500
     w --color-progress wyżej. Napis na WYPEŁNIENIU emerald 600 jest biały
     i ma 3,77:1 — czyli tam progu ŁAMIEMY TAK SAMO, wbrew temu, co stało tu
     do 31.08.2026 („4,54:1, czyli tam progu nie łamiemy"). Iloraz kontrastu
     jest dla kolorów KRYJĄCYCH symetryczny, więc „emerald 600 na bieli
     3,76" i „biel na emerald 600 4,54" nie mogły być prawdziwe naraz: druga
     liczba była pomyłką, nie pomiarem. Kompromis ZOSTAJE (decyzja 1:1
     z humi i jeden bold-owy znak w kwadracie 48 px), ale jest teraz nazwany
     prawdziwą liczbą i przypięty asercją w TestKontrastMotywuCiemnego —
     żeby nie wrócił jako „przecież sprawdzone". */
  --emerald-50: #ecfdf5; --emerald-500: #10b981;
  --emerald-600: #059669; --emerald-700: #047857;
  /* Dwa kroki W GÓRĘ, wyłącznie dla motywu ciemnego: emerald 600 na czerni
     karty (#18181b) ma 3,4:1, czyli zdaje jako WYPEŁNIENIE (WCAG 2.2
     SC 1.4.11, próg 3:1), ale nie jako NAPIS — a napisami są tam „Dodaj
     opcję" i „Dodaj obraz". Emerald 400 daje na tej powierzchni 9,2:1. */
  --emerald-300: #6ee7b7; --emerald-400: #34d399;
  /* Kolory ikon TYPÓW ćwiczeń — getExerciseTypeIconColor z ExerciseEditSheet
     w humi, odcień 500 skali Tailwinda. Indygo jest WYJĄTKIEM: humi ma tam
     indigo 500 (#6366f1), a tego odcienia nie wolno w tym arkuszu użyć jako
     `color` (TestAccentContrast — 4,47:1 na bieli, poniżej progu AA). Bierzemy
     indigo 600: ta sama barwa, o stopień ciemniejsza, w ikonie 20 px nie do
     odróżnienia, a strażnik zostaje nietknięty. */
  --purple-500: #a855f7; --teal-500: #14b8a6; --indigo-600: #4f46e5;
  /* Pomarańcz dopasowania siedział dotąd literałem w regule .extype--
     dopasowanie; wchodzi do skali razem z resztą kolorów typów, bo od
     31.08.2026 wszystkie pięć idzie przez tokeny --color-extype-*.
     Kroki 400 to warianty dla motywu ciemnego — indigo 600 (#4f46e5) na
     karcie #18181b ma 3,0:1 i jako ikona z NAPISEM obok nie wystarcza. */
  --orange-500: #f97316; --orange-400: #fb923c;
  --purple-400: #c084fc; --teal-400: #2dd4bf; --indigo-400: #818cf8;
  /* Indygo 300 i dwa błękity — dołożone 24.08.2026 razem z przebudową paska
     bocznego (pozycja bieżąca) i linii wykresu przebiegu. LICZBY, ŻEBY NIKT
     ICH NIE MIERZYŁ OD NOWA: Indigo 600 (#4f46e5) na bieli 6,29:1, czyli
     wolno nim pisać TEKST (próg AA 4,5:1) — to samo sprawdzenie, które robi
     TestAccentContrast, i ten sam odcień, którym humi maluje ikony typów
     ćwiczeń. Blue 600 (#2563eb) na bieli 5,17:1: też ponad progiem tekstowym,
     a jako LINIA wykresu (element nietekstowy, próg 3:1 z WCAG 2.2 SC 1.4.11)
     ma zapas ponad półtora raza. Indigo 300 i Blue 400 są wyłącznie dla
     motywu ciemnego, gdzie tłem karty jest #18181b — tam odcienie 600
     schodzą poniżej progu i trzeba je rozjaśnić. */
  --indigo-300: #a5b4fc; --blue-600: #2563eb; --blue-400: #60a5fa;
  /* Bursztyn i róż — DRUGA I TRZECIA SERIA WYKRESU. LICZBY, ŻEBY NIKT ICH NIE
     MIERZYŁ OD NOWA: amber 600 (#d97706) na bieli daje 3,15:1, pink 600
     (#db2777) 4,77:1 — obie ponad progiem 3:1 dla elementów NIETEKSTOWYCH
     (WCAG 2.2 SC 1.4.11), a linia wykresu jest właśnie takim elementem.
     Odcienie 500 wyłącznie dla motywu ciemnego, gdzie tłem karty jest #18181b
     i kroki 600 gasną.
     To NIE JEST bursztyn ze statusów: --color-warn-fg to yellow 700 (#a16207)
     i zostaje zarezerwowany dla ostrzeżeń; róż też nie jest czerwienią błędu
     (--color-danger #ef4444). Kolor serii nie może dzielić tokenu ze stanem,
     bo linia zaczęłaby znaczyć „uwaga" albo „awaria". */
  --amber-600: #d97706; --amber-500: #f59e0b;
  --pink-600: #db2777; --pink-500: #ec4899;

  /* ────────── SKALA ODSTĘPÓW ──────────
     Kroki Tailwinda używane w humi: 0.5 (2px), 1 (4px), 2 (8px), 2.5 (10px),
     3 (12px), 4 (16px), 6 (24px), 8 (32px), 10 (40px). Nazwy zostają nasze,
     bo trzyma je cały arkusz; wartości i tak się pokrywały. */
  --space-1: 2px;  --space-2: 4px;  --space-3: 8px;   --space-4: 10px;
  --space-5: 12px; --space-6: 16px; --space-7: 24px;  --space-8: 32px;
  --space-9: 40px;

  /* ────────── SKALA TYPOGRAFICZNA ──────────
     Skala Tailwinda, w parach rozmiar/interlinia — dokładnie te klasy, których
     humi używa na ekranach admina:
       h1 stron        text-3xl font-bold      30/36
       tytuł karty     text-2xl font-semibold  24/1  (leading-none)
       h3              text-xl                 20/28
       h4 / text-lg    text-lg                 18/28
       treść, tabele   text-sm                 14/20
       podpisy, badge  text-xs                 12/16
     Poprzednio było 32/28/24/20 z Figmy — o stopień większe na każdym
     poziomie, czyli inna hierarchia niż w referencji. */
  --fs-h1: 30px; --lh-h1: 36px;
  --fs-h2: 24px; --lh-h2: 32px;
  --fs-h3: 20px; --lh-h3: 28px;
  --fs-h4: 18px; --lh-h4: 28px;
  --fs-lg: 18px; --lh-lg: 28px;
  --fs-md: 14px; --lh-md: 20px;
  --fs-sm: 12px; --lh-sm: 16px;
  /* Rozmiar BAZOWY dokumentu. Humi nie ustawia go w ogóle, więc obowiązuje
     domyślny Tailwind/przeglądarki: 16px z interlinią 1,5 (preflight). To NIE
     jest to samo co --fs-md: `text-sm` (14px) humi deklaruje jawnie tam, gdzie
     go chce — w tabelach, przyciskach, polach, plakietkach i opisach kart —
     a wszędzie indziej (akapity, treść kart, pozycje list) zostaje 16px.
     U nas cały dokument stał na 14px, więc każdy taki akapit był o dwa piksele
     mniejszy niż w referencji. To mierzalna część zarzutu „wszystko za
     skompresowane". */
  --fs-base: 16px; --lh-base: 24px;

  /* ────────── POWIERZCHNIE I TEKST ──────────
     Tło strony to bg-zinc-100 (#f4f4f5) — humi maluje nim kontener treści
     w routes/(navbar)/+layout.svelte, a karty i pasek boczny są białe.
     To JEDYNY kolor spoza palety slate/gray w tej warstwie i jest wzięty
     wprost stamtąd. */
  --color-bg: #f4f4f5;
  --color-surface: #ffffff;
  --color-surface-muted: var(--slate-100);
  --color-fg: var(--slate-950);
  /* SLATE-550, NIE 500 (31.08.2026, pomiar Lighthouse). Slate-500 (#64748b)
     daje na białej karcie 4,76 — zdaje — ale na TLE STRONY (--color-bg,
     #f4f4f5) tylko 4,32, czyli poniżej progu 4,5 dla tekstu poniżej 18,66 px.
     A na tle strony stoją dokładnie te napisy, które muted opisuje najczęściej:
     okruszki, podtytuł nagłówka i plakietki „czeka na…". Lighthouse zgłaszał
     z tego powodu 15 elementów na jednym ekranie.
     #5b6b7f to najmniejsze przyciemnienie, które zdaje na OBU powierzchniach
     (4,96 na tle strony, 5,45 na karcie) — dobrane pomiarem, żeby naprawić
     wadę, a nie przemalować aplikacji. */
  --color-fg-muted: var(--slate-550);
  /* ────────── LINIE JAKO NAKŁADKA, NIE JAKO WŁASNY KOLOR ──────────
     Obrysy i kreski są kolorem o CZĘŚCIOWYM KRYCIU nałożonym na powierzchnię
     pod nimi, a nie samodzielnym odcieniem ze skali. Powód jest w przejściu
     między motywami (31.08.2026, zgłoszenie „obramowanie […] tak jakby kolor
     zmienia się z opóźnieniem takim dziwnym").
     Obrys leży MIĘDZY dwiema powierzchniami: wypełnieniem przycisku i tłem
     strony. W jasnym motywie jest najciemniejszy z tej trójki, w ciemnym —
     najjaśniejszy. Kiedy każdy z trzech kolorów przenikał niezależnie, obrys
     musiał minąć OBU sąsiadów, i za każdym razem krawędź na chwilę znikała.
     Zmierzone na przycisku: t=150 ms obrys ≡ tło strony (1,007:1),
     t=225 ms obrys ≡ własne wypełnienie (1,010:1) — krawędź gasła, wracała
     i gasła znowu, co czyta się dokładnie jak opóźnienie.
     Nakładka nie ma jak się z podłożem zrównać: jest nim PLUS ustalony
     procent czerni (jasny motyw) albo bieli (ciemny). Kontrast obrysu do
     powierzchni jest więc stały przez całe przejście, a jedyna zmiana to
     przeskok polaryzacji w połowie — razem z napisami (patrz krok w liście
     transition). Dlatego te trzy tokeny stoją w grupie KROKU, choć malują
     powierzchnie: nie są powierzchnią, są linią NA powierzchni.
     KRYCIA DOBRANE TAK, ŻEBY WYGLĄD W SPOCZYNKU SIĘ NIE ZMIENIŁ: 11% czerni
     na bieli karty daje (227,229,230) wobec dotychczasowego slate-200
     (226,232,240) — ta sama jasność, o włos mniej niebieskiego na kresce
     grubości piksela. --color-border-strong: 18% zamiast gray-300 (209).
     --color-nav-line: 10% zamiast gray-200 (229). */
  --color-border: rgb(2 8 23 / 11%);
  /* ────────── WARSTWY DOŁOŻONE Z PRZEBUDOWĄ MOTYWU CIEMNEGO (31.08.2026) ──
     Nie powstały z potrzeby nowych kolorów w jasnym motywie. Powstały,
     bo kilkadziesiąt reguł niżej brało kolor WPROST ZE SKALI
     (`var(--gray-700)`, `var(--gray-200)`, `var(--gray-50)`,
     `var(--slate-400)`) — a skala jest jedna dla obu motywów, więc te reguły
     zostawały jasne po przełączeniu na ciemny. Objaw: cały arkusz edycji
     ćwiczenia (.exform__*) świecił białym polem z jasnoszarym napisem na
     czarnym tle, a etykiety sekcji paska bocznego gasły.
     WARTOŚCI W JASNYM MOTYWIE SĄ DOKŁADNIE TE, KTÓRE TAM STAŁY — jasny motyw
     nie drgnął ani o odcień. Nowe jest tylko to, że mają odpowiednik w bloku
     [data-theme="dark"].
     Pilnuje tego TestSkalaTylkoWTokenach (contrast_test.go): poza blokami
     tokenów żadna deklaracja niebędąca zmienną nie może już sięgnąć do skali. */
  --color-fg-soft: var(--gray-700);
  --color-fg-faint: var(--gray-400);
  --color-border-strong: rgb(2 8 23 / 18%);
  --color-surface-subtle: var(--gray-50);
  --color-surface-muted-hover: #f4f7fa;
  /* Stopka karty ćwiczenia jest w humi `text-slate-400` — jaśniejsza niż
     --color-fg-muted i to jest tam świadome (patrz .exform__meta). Token
     zostawia tę decyzję nietkniętą, a daje jej odpowiednik w ciemnym. */
  --color-meta-fg: var(--slate-400);
  /* PIERŚCIEŃ FOKUSU jako token, nie dwunasta kopia tej samej trójki liczb
     w arkuszu. W jasnym motywie slate 400 z połową krycia (tak stoi
     w shadcn); w ciemnym ta sama rola, ale odcieniem, który na czerni widać.
     UWAGA: to jest wartość gotowa do wstawienia w box-shadow/outline, nie
     sam kolor — dlatego niesie krycie. */
  --color-ring-shadow: rgb(148 163 184 / 50%);
  /* Cień przewijania (.u-scrollbox) i wygaszenie krawędzi pudełka.
     Wygaszenie MUSI być tym samym kolorem co powierzchnia pod nim: biel
     z kryciem 0 na ciemnym tle daje w połowie gradientu siwą smugę, bo
     przeglądarka interpoluje kanały RGB razem z alfą. */
  --color-scroll-shadow: rgb(15 23 42 / 12%);
  --color-surface-fade: rgb(255 255 255 / 0%);
  /* BĄBEL WIADOMOŚCI UŻYTKOWNIKA w czacie. Stało tam wprost `background:
     var(--color-fg)` — czyli KOLOR TEKSTU użyty jako tło. W jasnym motywie
     wychodzi z tego ciemny bąbel z białym napisem i tak ma być; w ciemnym ten
     sam zapis daje bąbel niemal BIAŁY (zinc 50), najgłośniejszy element całego
     ekranu — a to jest tylko czyjeś pytanie, nie akcja. Token rozdziela te dwie
     role: w jasnym zostaje 1:1 to, co było, w ciemnym bąbel jest jaśniejszą
     szarością, wciąż wyraźnie odróżnialną od bąbla asystenta (powierzchnia
     karty z obrysem). */
  --color-bubble-user-bg: var(--slate-950);
  --color-bubble-user-fg: #ffffff;

  /* ────────── AKCENT ──────────
     W humi `primary` to niemal czarny granat slate 900 — przyciski, wypełnienie
     paska postępu, aktywne kafelki ikon. Nie ma tu koloru marki: humi celowo
     trzyma neutralny akcent, a barwy (zieleń/żółć/czerwień) rezerwuje dla
     statusów. Bursztyn z logo-marathon.svg zostaje wyłącznie w samym logo.
     --color-accent-deep to `hover:bg-primary/90`, czyli slate 900 z 90%
     krycia na białym tle: 0,9·(15,23,42) + 0,1·255 = (39,46,63) = #272e3f.
     Trzy tokeny zamiast jednego zostają, bo cały arkusz się do nich odwołuje;
     dziś --color-accent i --color-accent-strong są tym samym kolorem, bo
     w humi nie ma między nimi różnicy. Biel na slate 900 ma 17,85:1. */
  --color-accent: var(--slate-900);
  --color-accent-strong: var(--slate-900);
  --color-accent-deep: #272e3f;
  --color-accent-fg: var(--slate-50);
  /* Pierścień fokusu: shadcn daje `focus-visible:ring-ring/50 ring-[3px]`,
     czyli 3 px slate 400 z połową krycia. */
  --color-ring: var(--slate-400);

  /* Pigułka tagu = Badge variant="secondary" z humi: miękkie szare
     wypełnienie, ciemny napis, bez obrysu. Wcześniej była indygowa. */
  --color-chip-bg: var(--slate-100);
  --color-chip-fg: var(--slate-900);
  --color-chip-add-bg: var(--slate-100);

  /* ────────── POWŁOKA (pasek boczny) ──────────
     Baza dalej z side-nav.svelte w humi: `bg-white border-r border-gray-200`,
     pozycje `text-gray-700 hover:bg-gray-50`. ODSTĘPSTWA od niego są dwa
     i oba wchodzą 24.08.2026 ze zrzutu paska, który przysłał Eryk:
     (1) pozycja bieżąca to indygowy NAPIS, a nie szara pigułka
         (--color-nav-active-accent niżej; token tła --color-nav-active-bg
         zniknął razem z pigułką);
     (2) ikony straciły szare kwadraciki 24×24 pod spodem, więc token
         --color-nav-chip nie ma już ani jednego czytelnika i też zszedł.
     --color-nav-chip-active ZOSTAJE, ale nie maluje już ikon w menu: trzyma
     dwa inne czarne kółka w tym pasku — numer otwartej lekcji w spisie treści
     kursu i monogram konta w stopce. */
  /* BIEL WPROST, nie var(--color-surface): tokeny animowane nie mogą być
     aliasami innych animowanych tokenów — patrz „TOKENY-ALIASY SĄ ZABRONIONE"
     przy warstwie przejścia. Wartość jest ta sama co --color-surface i ma nią
     zostać: pasek boczny jest w humi biały, tak jak karty. */
  --color-nav: #ffffff;
  --color-nav-fg: var(--gray-700);
  --color-nav-hover: var(--gray-50);
  --color-nav-active-fg: var(--gray-900);
  /* POZYCJA BIEŻĄCA MENU — kolor, nie kafel. Zmiana z 24.08.2026, na zrzucie
     paska, który przysłał Eryk: aktywna pozycja („Courses") jest tam SAMYM
     indygowym napisem i indygową ikoną, na tym samym tle co reszta listy.
     Wcześniej stała tu para --color-nav-active-bg (szara pigułka) +
     --color-nav-active-fg; pigułki nie ma i token tła zniknął razem z nią,
     bo nie miał już ani jednego czytelnika. --color-nav-active-fg ZOSTAJE:
     bierze go tytuł lekcji w spisie treści kursu (.sidebar__sub-title), który
     ma być najciemniejszym napisem spisu, a nie indygowym.
     Osobny token, a nie --color-accent: akcent aplikacji to slate 900 (kolor
     przycisku primary) i tym samym kolorem nie da się powiedzieć naraz
     „to jest akcja" i „tu jesteś". */
  --color-nav-active-accent: var(--indigo-600);
  --color-nav-chip-active: #000000;
  --color-nav-chip-active-fg: #ffffff;
  --color-nav-line: rgb(2 8 23 / 10%);
  /* PIERŚCIEŃ POSTĘPU wokół kółka z monogramem w stopce paska. Na zrzucie jest
     zapełniony mniej więcej w trzech czwartych — czyli rysuje jakąś miarę,
     a nie ozdobę. Dopóki nie ma czym jej karmić, stoi na 100% (patrz
     --sidebar-avatar-progress przy .sidebar__avatar-ring): pełny pierścień
     czyta się jako obwódka awatara, a nie jako fałszywy odczyt „75%".
     Tor (część niezapełniona) jest osobnym tokenem, bo bez niego przy
     wartościach poniżej 100% nie widać, ILE brakuje. */
  --color-avatar-ring: var(--gray-900);
  --color-avatar-ring-track: var(--gray-200);

  --color-danger: #ef4444;
  --color-danger-hover: #f15757;
  /* NAPIS NA CZERWIENI. W jasnym motywie czerwień jest ciemna (#ef4444)
     i napis jest biały (3,76:1 — patrz .btn--danger). W ciemnym czerwień
     musi być JASNA, żeby ją było widać na czerni, a wtedy biel na niej ma
     2,7:1. Bez tokenu przycisk „Usuń" byłby jedynym miejscem w aplikacji,
     w którym kontrast zależy od tego, jaki motyw ktoś włączył. */
  --color-danger-fg: #ffffff;

  /* AKCENT EDYCJI ĆWICZENIA (emerald — patrz skala wyżej) przez tokeny,
     nie wprost: --color-edit jest NAPISEM („Dodaj opcję", „Dodaj obraz")
     i w ciemnym motywie musi się rozjaśnić, a --color-edit-fill jest
     WYPEŁNIENIEM zaznaczonej poprawnej odpowiedzi i zostaje w obu motywach
     TEN SAM. Nie dlatego, że zdaje — biel na nim ma 3,77:1 (patrz skala
     Emerald wyżej) — a dlatego, że to kompromis odziedziczony po jasnym
     motywie i przebudowa ciemnego nie jest miejscem na cofanie decyzji 1:1.
     Rozjaśnienie wypełnienia w jednym motywie pogorszyłoby je zresztą
     jeszcze bardziej: im jaśniejsza zieleń, tym gorzej z bielą na niej.
     Rozdzielenie tych dwóch ról to cała poprawka: wcześniej jeden emerald
     600 robił oba i w ciemnym przegrywał jako NAPIS, gdzie da się to
     naprawić bez ruszania referencji. */
  --color-edit: var(--emerald-600);
  --color-edit-deep: var(--emerald-700);
  --color-edit-ring: var(--emerald-500);
  --color-edit-fill: var(--emerald-600);
  --color-edit-fill-fg: #ffffff;

  /* KOLORY TYPÓW ĆWICZEŃ — patrz .extype--* w sekcji ekranów. */
  --color-extype-czytanka: var(--emerald-500);
  --color-extype-quiz: var(--purple-500);
  --color-extype-otwarte: var(--indigo-600);
  --color-extype-luki: var(--teal-500);
  --color-extype-dopasowanie: var(--orange-500);

  /* Pasek postępu generowania: CourseCreationProgress.svelte to
     `bg-muted h-2` jako tor i `bg-green-500` jako wypełnienie.
     ODSTĘPSTWO OD POPRZEDNIEJ WERSJI, ŚWIADOME: stała tu wcześniej ciemniejsza
     zieleń #15803d, dobrana pod próg 3:1 z WCAG 2.2 SC 1.4.11 dla elementów
     nietekstowych. Green 500 na torze (#f1f5f9) daje 2,08:1, czyli poniżej
     tego progu. Wraca kolor z referencji, bo tak brzmi decyzja o 1:1 —
     ale liczba stoi tutaj, żeby nikt nie musiał jej mierzyć od nowa, gdyby
     wracał do tematu dostępności. */
  --color-progress: #22c55e;

  /* LINIA WYKRESU PRZEBIEGU — niebieska, nie w kolorze akcentu. Prośba Eryka
     (24.08.2026) dotyczyła wykresu na /dashboard, ale token siedzi TU i jest
     jeden, bo sam wykres jest jednym partialem („analytics-chart-figure"
     w analytics.html) używanym i przez dashboard, i przez pulpit sesji —
     dwa kolory dla tego samego rysunku byłyby usterką, nie ustawieniem.
     Dlaczego osobny token zamiast podmiany --color-accent-strong: akcentem
     są przyciski primary, wypełnienie paska postępu i krecha bieżącej lekcji
     w spisie — przemalowanie go na niebiesko przefarbowałoby pół aplikacji
     przy okazji zmiany jednej linii. */
  --color-chart-line: var(--blue-600);
  /* SERIE WYKRESU — kolejność STAŁA: seria pierwsza zawsze „a". Dołożenie
     czwartej linii nie przemalowuje trzech poprzednich, a filtr, który zdejmie
     serię ze środka, nie przesuwa kolorów pozostałych.
     Niebieski jest tym samym odcieniem, co dotychczasowa jedyna linia
     (--color-chart-line), więc wykres jednoseryjny nie zmienił koloru.

     TA TRÓJKA POZWALA NARYSOWAĆ WSZYSTKIE LINIE CIĄGŁE (uwaga Eryka
     30.08.2026: dwie pozostałe linie „takie średnie"). Poprzednia — niebieski,
     zieleń, bursztyn — miała parę bursztyn↔zieleń na ΔE 7,9 przy protanopii,
     czyli w paśmie granicznym 6–8, dopuszczalnym WYŁĄCZNIE z drugim nośnikiem
     tożsamości; stąd brało się kreskowanie, które wyglądało jak rysunek
     techniczny, a nie jak wykres.
     Niebieski + bursztyn + róż przechodzi walidator palet kategorycznych
     (dataviz/validate_palette) na WSZYSTKICH parach, nie tylko sąsiednich:
     najsłabsza para ma ΔE 14,1 (deuteranopia) i 9,7 (tritanopia), czyli ponad
     progiem 8, przy którym sam kolor wystarcza. Do tego pasmo jasności, próg
     nasycenia i kontrast ≥3:1 wobec białego tła karty. */
  --color-chart-a: var(--blue-600);
  --color-chart-b: var(--amber-600);
  --color-chart-c: var(--pink-600);

  /* Statusy = plakietki z knowledge/+page.svelte (funkcja getBadgeStyle):
     wypełnienie to kolor 500 z 10% krycia, napis to ten sam kolor w 700.
     Zapis rgb(… / 10%) zamiast wyliczonego hexa jest celowy — dokładnie tak
     jak w referencji plakietka przepuszcza swoje tło, więc ta sama klasa
     wygląda inaczej na białej karcie i na szarym tle strony. */
  --color-ok-bg: rgb(34 197 94 / 10%);    --color-ok-fg: #15803d;
  /* WARN-FG #8a5406, NIE yellow-700 #a16207 (31.08.2026, pomiar Lighthouse).
     Yellow-700 zdawał tylko na białej karcie (4,92). Na tle strony dawał 4,48
     — poniżej progu 4,5 o dwie setne — a na WŁASNYM tle ostrzeżenia
     (--color-warn-bg nałożone na kartę, czyli #f3eedd) tylko 4,24. Czyli
     najgorzej wychodził dokładnie tam, gdzie ostrzeżenia stoją najczęściej:
     w banerze podglądu i w podpowiedzi „odpowiadasz raz" nad ćwiczeniem.
     #8a5406 zdaje na WSZYSTKICH trzech powierzchniach (5,70 / 6,27 / 5,40)
     i jest o jeden ledwie widoczny stopień ciemniejszy — to naprawa kontrastu,
     nie zmiana barwy ostrzeżeń. Rezerwacja tokenu dla ostrzeżeń zostaje
     nietknięta (patrz komentarz przy --amber-600 wyżej). */
  --color-warn-bg: rgb(234 179 8 / 10%);  --color-warn-fg: #8a5406;
  --color-error-bg: rgb(239 68 68 / 10%); --color-error-fg: #b91c1c;

  /* Plansza po ukończeniu lekcji (learning-done.html) — kolor PEŁNY, nie
     10-procentowa poświata jak w plakietkach wyżej: to jest tło całego
     ekranu, a nie chip na karcie, więc rozcieńczona zieleń dałaby brudną
     szarość zamiast wrażenia „udało się". Wzorzec z humionboardera, gdzie
     ekran ukończenia jest nasyconą zielenią z białym napisem. */
  --color-done-from: #4ade80; --color-done-to: #34d399;  --color-done-fg: #fff;
  /* Gwiazdki z projektu: kremowo-żółte wypełnienie w cieplejszej obwódce. */
  --color-star-fill: #fef9c3; --color-star-stroke: #fde68a;
  /* Confetti: ciepłe barwy, które trzymają się na zielonej planszy. Biel
     jest wśród nich celowo — sama zieleń z żółcią zlewałaby się z tłem. */
  --confetti-1: #fde68a; --confetti-2: #fff; --confetti-3: #fca5a5;
  --confetti-4: #a5b4fc; --confetti-5: #fdba74;
  --color-done-retry-from: #fbbf24; --color-done-retry-to: #f59e0b; --color-done-retry-fg: #fff;

  /* Promienie: --radius z shadcn to 0.5rem, a Tailwind mapuje
     lg = var(--radius) = 8px, md = 6px, sm = 4px. W humi karty, dialogi
     i strefy zrzutu to rounded-lg (8), przyciski/pola/pozycje menu to
     rounded-md (6), pozycje list rozwijanych to rounded-sm (4).
     Nazwy tokenów zostają nasze: --radius to promień KART (rounded-lg),
     --radius-sm to promień KONTROLEK (rounded-md), --radius-lg to dawne
     „duże panele" — w humi nie ma promienia większego niż lg, więc jest
     równy --radius. Było 10/6/16. */
  --radius: 8px;
  --radius-sm: 6px;
  --radius-lg: 8px;
  /* Cienie Tailwinda. Karty w humi noszą shadow-xs (nie shadow-sm) —
     ledwie widoczna kreska pod krawędzią; shadow-md dla list rozwijanych
     i popoverów, shadow-lg dla dialogów i szuflad. */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 5%);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -2px rgb(0 0 0 / 10%);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 10%), 0 4px 6px -4px rgb(0 0 0 / 10%);

  /* Rodzina pisma: humi NIE ładuje własnego fontu — tailwind.config.ts ma
     `fontFamily: { sans: [...defaultTheme.fontFamily.sans] }`, czyli domyślny
     stos systemowy Tailwinda. Kopiujemy go co do pozycji.
     „Noto Sans Georgian" dopisane ZA stosem systemowym, bo aplikacja ma
     gruziński, a system-ui na macOS/Windows tego alfabetu nie pokrywa —
     @font-face niżej ma unicode-range ograniczony do gruzińskiego, więc dla
     pl/en/uk/ru ten plik nigdy się nie pobiera i wygląd tekstu łacińskiego
     jest bit w bit taki jak w humi. Inter wypadł ze stosu razem z systemem
     „Cluo — Core"; jego @font-face zostają, ale nic ich już nie używa. */
  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, "Noto Sans Georgian", "Noto Sans", sans-serif,
    "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
  /* CZAS PRZEJŚCIA MIĘDZY MOTYWAMI. 420 ms to nie ozdoba: przełącznik
     przemalowuje CAŁY ekran naraz, a skok z bieli na czerń w jednej klatce
     czyta się jak awaria, nie jak zmiana ustawienia (zgłoszenie Eryka
     31.08.2026: „ma mieć bardzo płynną animację, aby to nie było takie
     instant"). Poniżej ~250 ms oko nie łapie kierunku zmiany, powyżej ~600 ms
     interfejs zaczyna wyglądać na przymulony.

     KRZYWA MUSI BYĆ SYMETRYCZNA i to nie jest kwestia gustu. Kolory napisów
     przeskakują w POŁOWIE CZASU przejścia (patrz „NAPISY PRZESKAKUJĄ" przy
     liście transition niżej), a ten moment ma wypadać dokładnie w połowie
     drogi KOLORU. Poprzednia krzywa cubic-bezier(.4, 0, .2, 1) jest
     przyspieszona na starcie: w połowie czasu kolor był już na 69% drogi,
     więc krok wypadałby na tle znacznie ciemniejszym niż środek i napis przed
     nim miałby 2,4:1. */
  --theme-fade: 420ms;
  --theme-fade-ease: cubic-bezier(.45, 0, .55, 1);
  /* Krok dla kolorów NAPISÓW: `steps(2, jump-none)` daje dwie wartości —
     wyjściową do 50% czasu i docelową od 50% — czyli jedną, natychmiastową
     wymianę w połowie przejścia. */
  --theme-fade-krok: steps(2, jump-none);
  --sidebar-width: 256px;
  /* PROGI SZEROKOŚCI — jedna lista dla całego arkusza, żeby przestały się
     mnożyć. Zapytania medialne nie czytają zmiennych CSS, więc to jest
     dokumentacja, a nie mechanizm: liczby w @media trzeba wpisywać wprost.
       ≤ 767 px  — telefon: gęstsze odstępy, kolumny jedna pod drugą,
                   wyściółka treści 16 px.
       ≤ 1023 px — telefon I TABLET W PIONIE: pasek boczny jest szufladą,
                   nie kolumną układu (patrz „powłoka" niżej).
       ≥ 1024 px — pełny układ z paskiem bocznym obok treści.
     Granica szuflady stoi na 1023, a nie 767, bo pasek 256 px zostawiał
     tabletowi w portrecie 448 px na treść — tabela kursów gubiła w tym
     wszystkie kolumny poza nazwą. Cel dotykowy 44 px ma WŁASNĄ, szerszą
     granicę (`pointer: coarse`, sekcja komponentów) i te dwie rzeczy
     świadomie się nie pokrywają: laptop dotykowy ma zostać desktopem. */
  /* Kontener treści: Tailwindowy `container` w humi ma center: true,
     padding md: 2rem i screens 2xl: 1400px — czyli na 1440 px i 1920 px
     kolumna ma 1400 px zewnętrznie, a po odjęciu paddingu 1336 px treści.
     Było 1280 px. */
  --content-max: 1400px;
  /* Szerokość kolumny czatu — DECYZJA ANTKA (13.08.2026): „normalna szerokość,
     która sięga do sidebara", czyli bez górnego ograniczenia w ogóle
     (.content--chat zdejmuje niżej także wspólny limit --content-max). */
  --chat-max-width: none;
}

/* ═══════════════════════════════════════════════════════════════════════════
   MOTYW CIEMNY — DRUGI Z DWÓCH, NIE TRZECI Z TRZECH
   ═══════════════════════════════════════════════════════════════════════════
   Aplikacja ma DOKŁADNIE dwa motywy: jasny (domyślny) i ciemny. Stanu
   „idź za systemem" nie ma — ani tutaj (@media prefers-color-scheme nie
   występuje w tym arkuszu ani razu), ani na serwerze (themeFromRequest
   zwraca zawsze „light" albo „dark", nigdy pustego), ani w szablonie
   (<html> nosi data-theme przy KAŻDYM renderze). Wcześniej stan trzeci
   istniał w praktyce — brak ciasteczka znaczył „bez stempla na <html>" —
   i był źródłem osobnej klasy usterek: reguła, która definiowała kolor
   TYLKO w bloku [data-theme="dark"], renderowała się wtedy na powierzchni
   drugiego motywu. Dwa stany, oba jawne, nie mają tego problemu.

   PALETA: ZINC, NIE SLATE (31.08.2026). Poprzednia wersja brała tło
   #020817 i karty #0b1220 wprost z bloku `.dark` humi — oba to slate,
   czyli czerń z niebieskim. Zgłoszenie brzmiało „nie podoba mi się ten
   granatowy" i było trafne: przy jasności rzędu 2–5% odchylenie barwy jest
   jedyną rzeczą, którą oko jeszcze widzi, więc slate na dużej powierzchni
   czyta się jako granat, a nie jako neutralne tło. Skala zinc ma te same
   kroki jasności bez zafarbu — patrz komentarz przy --zinc-* wyżej.

   ROZWARSTWIENIE POWIERZCHNI jest odbiciem jasnego motywu, nie kopią humi:
   tam --card == --background, u nas tło strony jest NAJGŁĘBSZE (zinc 950),
   karty i pasek boczny o krok wyżej (zinc 900), powierzchnia stonowana
   jeszcze wyżej (zinc 800). Stosunek jasności karty do tła wynosi 1,11:1 —
   dokładnie tyle, ile w jasnym motywie ma biel na #f4f4f5 (1,10:1), więc
   „kartę widać, ale delikatnie" znaczy w obu motywach to samo, a krawędź
   niesie obrys.
   ══════════════════════════════════════════════════════════════════════════ */
:root[data-theme="dark"] {
  /* color-scheme: pasy przewijania, rozwinięte <select>, tło
     autouzupełniania i wewnętrzne przyciski input[type=date/file] maluje
     przeglądarka, nie my — bez tego zostawały jasne. */
  color-scheme: dark;

  /* ── powierzchnie i tekst ── */
  --color-bg: var(--zinc-950);
  --color-surface: var(--zinc-900);
  --color-surface-muted: var(--zinc-800);
  --color-surface-subtle: var(--zinc-850);
  --color-surface-muted-hover: var(--zinc-750);
  --color-fg: var(--zinc-50);
  --color-fg-soft: var(--zinc-200);
  /* Zinc 400 na karcie 6,9:1 i na tle strony 7,7:1 — z zapasem nad progiem
     AA 4,5:1 dla tekstu poniżej 18,66 px, czyli dla okruszków, podtytułów
     i plakietek, które w jasnym motywie wymusiły slate 550. */
  --color-fg-muted: var(--zinc-400);
  --color-fg-faint: var(--zinc-500);
  --color-meta-fg: var(--zinc-500);
  /* Zinc 825, nie 800: krok 800 dawał na karcie 1,19:1, czyli obrys BLEDSZY
     niż slate 200 na bieli w jasnym motywie (1,23:1) — a to ta sama krecha
     i ma nieść tyle samo. 825 daje 1,28:1 na karcie i 1,42:1 na tle strony. */
  /* Nakładka bieli zamiast odcienia ze skali — uzasadnienie przy tokenach
     jasnego motywu. Krycia dobrane pod dotychczasowy wygląd: 12% bieli na
     karcie (#18181b) daje (52,52,54) wobec zinc-825 (44,44,49) — o włos
     mocniejsza kreska w środku karty, o włos słabsza na tle strony, w obie
     strony poniżej progu dostrzegalności na jednym pikselu.
     Zinc-825 nie ma już przez to ani jednego czytelnika i zostaje w skali
     tylko jako punkt odniesienia dla liczb w komentarzach. */
  --color-border: rgb(255 255 255 / 12%);
  --color-border-strong: rgb(255 255 255 / 17%);

  /* ── akcent ──
     Odwrócony, tak jak w shadcn: w jasnym motywie akcentem jest niemal
     czarny slate 900, w ciemnym niemal biały zinc 50. Napis na przycisku
     ma wtedy 17:1 i jest to najmocniejsza para w całym interfejsie —
     przycisk główny ma być najgłośniejszym elementem ekranu w obu
     motywach, a nie tylko w jednym. */
  --color-accent: var(--zinc-50);
  --color-accent-strong: var(--zinc-50);
  --color-accent-deep: var(--zinc-300);
  --color-accent-fg: var(--zinc-900);
  --color-ring: var(--zinc-500);
  /* Pierścień fokusu: slate 400 z 50% krycia (wartość z jasnego motywu)
     składa się na czerni w ledwie widoczną szarość. Zinc 400 z 65% daje
     na karcie 4,4:1 jako obwódka, czyli dobrze ponad progiem 3:1 dla
     elementów nietekstowych. */
  --color-ring-shadow: rgb(161 161 170 / 65%);

  --color-chip-bg: var(--zinc-800);
  --color-chip-fg: var(--zinc-50);
  --color-chip-add-bg: var(--zinc-800);

  /* ── powłoka (pasek boczny) ──
     Pasek jest o krok JAŚNIEJSZY od tła strony, tak jak w jasnym motywie
     jest biały na szarym tle: w obu wypadkach czyta się jako panel leżący
     na stronie, a nie jako jej wycięcie. */
  --color-nav: var(--zinc-900);
  --color-nav-fg: var(--zinc-300);
  --color-nav-hover: var(--zinc-800);
  --color-nav-active-fg: var(--zinc-50);
  /* Indygo 300 (8,9:1 na pasku) zamiast 600 z jasnego motywu — pozycja
     bieżąca jest NAPISEM, nie pigułką, więc musi zdać próg tekstowy. */
  --color-nav-active-accent: var(--indigo-300);
  --color-nav-chip-active: var(--zinc-50);
  --color-nav-chip-active-fg: var(--zinc-900);
  --color-nav-line: rgb(255 255 255 / 9%);
  --color-avatar-ring: var(--zinc-50);
  --color-avatar-ring-track: var(--zinc-800);

  /* ── stany ──
     Czerwień, zieleń i żółć idą o dwa–trzy stopnie w górę skali: odcienie
     500/600 z jasnego motywu mają na czerni 2–3:1 i jako napis przepadają.
     Napis NA czerwieni zmienia się razem z nią: jasny czerwony wymaga
     ciemnego napisu (zinc 900 na #f87171 daje 6,4:1; biel — 2,7:1). */
  --color-danger: #f87171;
  --color-danger-hover: #fca5a5;
  --color-danger-fg: var(--zinc-900);
  --color-progress: #4ade80;
  --color-ok-bg: rgb(34 197 94 / 16%);    --color-ok-fg: #4ade80;
  --color-warn-bg: rgb(234 179 8 / 16%);  --color-warn-fg: #facc15;
  --color-error-bg: rgb(239 68 68 / 16%); --color-error-fg: #f87171;

  /* ── akcent edycji ćwiczenia ──
     Napis w górę (emerald 400, 9,2:1). Wypełnienie ZOSTAJE na emerald 600 —
     patrz komentarz przy --color-edit-fill: to kompromis 1:1 z jasnego
     motywu, a nie liczba, którą tu poprawiamy. */
  --color-edit: var(--emerald-400);
  --color-edit-deep: var(--emerald-300);
  --color-edit-ring: var(--emerald-500);

  /* ── kolory typów ćwiczeń ──
     Kroki 400 zamiast 500/600: te same barwy, o stopień jaśniejsze, bo
     ikona typu stoi obok tytułu i czyta się razem z nim. */
  --color-extype-czytanka: var(--emerald-400);
  --color-extype-quiz: var(--purple-400);
  --color-extype-otwarte: var(--indigo-400);
  --color-extype-luki: var(--teal-400);
  --color-extype-dopasowanie: var(--orange-400);

  /* ── wykresy ──
     Serie o stopień jaśniejsze, bo na tle karty kroki 600 gasną. Ta sama
     trójka (niebieski + bursztyn + róż) przeszła walidator palet dla tej
     powierzchni: kontrast ≥3:1 i rozróżnialność WSZYSTKICH par przy
     protanopii, deuteranopii i tritanopii. */
  --color-chart-line: var(--blue-400);
  --color-chart-a: var(--blue-400);
  --color-chart-b: var(--amber-500);
  --color-chart-c: var(--pink-500);

  /* ── plansza po ukończeniu lekcji ──
     Ta sama plansza przygaszona, żeby nie świeciła w oczy w nocy; napis
     zostaje jasny, bo tło jest tu ciemniejsze od niego. */
  --color-done-from: #15803d; --color-done-to: #047857;  --color-done-fg: #fff;
  --color-star-fill: #fde68a; --color-star-stroke: #fbbf24;
  --color-done-retry-from: #b45309; --color-done-retry-to: #92400e; --color-done-retry-fg: #fff;

  /* ── cień przewijania i wygaszenie krawędzi ──
     Cień musi być MOCNIEJSZY niż w jasnym motywie (12% czerni na bieli
     widać, 12% czerni na czerni — nie), a wygaszenie musi być kolorem
     powierzchni: biel z kryciem 0 dawałaby na ciemnym siwą smugę. */
  --color-scroll-shadow: rgb(0 0 0 / 60%);
  --color-surface-fade: rgb(24 24 27 / 0%);
  /* Zinc 700: napis ma na nim 10:1, a sam bąbel odstaje od tła strony
     (1,88:1) mocniej niż karta — czyli dalej czyta się jako „to napisałem
     ja", tylko bez świecenia w oczy. */
  --color-bubble-user-bg: var(--zinc-700);
  --color-bubble-user-fg: var(--zinc-50);
}

/* ═══════════════════════════════════════════════════════════════════════════
   PRZEJŚCIE MIĘDZY MOTYWAMI
   ═══════════════════════════════════════════════════════════════════════════
   Animujemy TOKENY na :root, a nie właściwości na elementach. Ta różnica jest
   sednem, nie stylem zapisu — pierwsza wersja (31.08.2026) miała
   `:root.motyw-przejscie *  { transition: color …, background-color …; }`
   i dawała MRUGNIĘCIE TEKSTU pod koniec przejścia. Zgłoszone przez Eryka,
   zmierzone klatka po klatce: napis dochodził do koloru docelowego w 480 ms,
   w 516 ms wracał prawie do wyjściowego i dojeżdżał drugi raz do 950 ms.

   DLACZEGO TAK BYŁO. `color` jest właściwością DZIEDZICZONĄ. Reguła na
   gwiazdce dawała transition KAŻDEMU elementowi, więc tę samą wartość
   animowały niezależnie: <body> (u którego `color: var(--color-fg)` jest
   wpisane wprost) i każdy jego potomek (u którego wartość jest dziedziczona,
   czyli JEJ CELEM jest bieżąca — wciąż animowana — wartość rodzica). Potomek
   przejeżdżał więc swoją własną animację do wartości końcowej, a kiedy mu się
   skończyła, przeglądarka przeliczała jego styl z wartości ZADEKLAROWANEJ,
   czyli z rodzica, który był jeszcze w drodze — i napis skakał wstecz, żeby
   dojechać po raz drugi razem z nim. Żadne skrócenie czasu ani inny timer
   tego nie naprawiały, bo to nie był problem timingu.

   Rejestracja tokenów przez @property zdejmuje problem u źródła: interpolacją
   zajmuje się DOKŁADNIE JEDEN element (:root), a wszystkie reguły niżej
   czytają `var(--color-…)` i dostają wartość pośrednią za darmo, bez własnej
   animacji. Nie ma czego gonić, nie ma czego dosynchronizowywać, tło i tekst
   idą w jednym kroku. Przy okazji znika `!important` na gwiazdce — własne
   przejścia komponentów (hover przycisku, .15 s) nie są już podmieniane, więc
   interfejs nie robi się na te 420 ms gumowy.

   @property WYMAGA absolutnej wartości w initial-value (var() jest tam
   zabronione), dlatego niżej stoją hexy, a nie nazwy ze skali. Blok jest
   GENEROWANY z bloków :root/[data-theme="dark"] — patrz
   TestNapisyPrzeskakujaTlaPrzenikaja, który pilnuje, że lista pokrywa
   wszystkie tokeny różniące się między motywami ORAZ że każdy stoi we
   właściwej grupie (napis czy powierzchnia). Token dopisany bez wpisu tutaj
   przeskakiwałby, gdy reszta ekranu jeszcze się przelewa.

   TOKENY-ALIASY SĄ ZABRONIONE dla tokenów animowanych. `--color-nav:
   var(--color-surface)` wyglądało niewinnie, ale skoro --color-surface
   animuje, to cel --color-nav zmieniałby się w każdej klatce i wracałby
   dokładnie ten sam problem gonienia, tylko o poziom wyżej. Dlatego trzy
   takie aliasy zostały rozwinięte do skali (patrz komentarze przy nich). */
@property --color-bg { syntax: "<color>"; inherits: true; initial-value: #f4f4f5; }
@property --color-surface { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
@property --color-surface-muted { syntax: "<color>"; inherits: true; initial-value: #f1f5f9; }
@property --color-fg { syntax: "<color>"; inherits: true; initial-value: #020817; }
@property --color-fg-muted { syntax: "<color>"; inherits: true; initial-value: #5b6b7f; }
@property --color-border { syntax: "<color>"; inherits: true; initial-value: #e2e8f0; }
@property --color-fg-soft { syntax: "<color>"; inherits: true; initial-value: #374151; }
@property --color-fg-faint { syntax: "<color>"; inherits: true; initial-value: #9ca3af; }
@property --color-border-strong { syntax: "<color>"; inherits: true; initial-value: #d1d5db; }
@property --color-surface-subtle { syntax: "<color>"; inherits: true; initial-value: #f9fafb; }
@property --color-surface-muted-hover { syntax: "<color>"; inherits: true; initial-value: #f4f7fa; }
@property --color-meta-fg { syntax: "<color>"; inherits: true; initial-value: #94a3b8; }
@property --color-ring-shadow { syntax: "<color>"; inherits: true; initial-value: rgb(148 163 184 / 50%); }
@property --color-scroll-shadow { syntax: "<color>"; inherits: true; initial-value: rgb(15 23 42 / 12%); }
@property --color-surface-fade { syntax: "<color>"; inherits: true; initial-value: rgb(255 255 255 / 0%); }
@property --color-bubble-user-bg { syntax: "<color>"; inherits: true; initial-value: #020817; }
@property --color-bubble-user-fg { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
@property --color-accent { syntax: "<color>"; inherits: true; initial-value: #0f172a; }
@property --color-accent-strong { syntax: "<color>"; inherits: true; initial-value: #0f172a; }
@property --color-accent-deep { syntax: "<color>"; inherits: true; initial-value: #272e3f; }
@property --color-accent-fg { syntax: "<color>"; inherits: true; initial-value: #f8fafc; }
@property --color-ring { syntax: "<color>"; inherits: true; initial-value: #94a3b8; }
@property --color-chip-bg { syntax: "<color>"; inherits: true; initial-value: #f1f5f9; }
@property --color-chip-fg { syntax: "<color>"; inherits: true; initial-value: #0f172a; }
@property --color-chip-add-bg { syntax: "<color>"; inherits: true; initial-value: #f1f5f9; }
@property --color-nav { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
@property --color-nav-fg { syntax: "<color>"; inherits: true; initial-value: #374151; }
@property --color-nav-hover { syntax: "<color>"; inherits: true; initial-value: #f9fafb; }
@property --color-nav-active-fg { syntax: "<color>"; inherits: true; initial-value: #111827; }
@property --color-nav-active-accent { syntax: "<color>"; inherits: true; initial-value: #4f46e5; }
@property --color-nav-chip-active { syntax: "<color>"; inherits: true; initial-value: #000000; }
@property --color-nav-chip-active-fg { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
@property --color-nav-line { syntax: "<color>"; inherits: true; initial-value: #e5e7eb; }
@property --color-avatar-ring { syntax: "<color>"; inherits: true; initial-value: #111827; }
@property --color-avatar-ring-track { syntax: "<color>"; inherits: true; initial-value: #e5e7eb; }
@property --color-danger { syntax: "<color>"; inherits: true; initial-value: #ef4444; }
@property --color-danger-hover { syntax: "<color>"; inherits: true; initial-value: #f15757; }
@property --color-danger-fg { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
@property --color-edit { syntax: "<color>"; inherits: true; initial-value: #059669; }
@property --color-edit-deep { syntax: "<color>"; inherits: true; initial-value: #047857; }
@property --color-extype-czytanka { syntax: "<color>"; inherits: true; initial-value: #10b981; }
@property --color-extype-quiz { syntax: "<color>"; inherits: true; initial-value: #a855f7; }
@property --color-extype-otwarte { syntax: "<color>"; inherits: true; initial-value: #4f46e5; }
@property --color-extype-luki { syntax: "<color>"; inherits: true; initial-value: #14b8a6; }
@property --color-extype-dopasowanie { syntax: "<color>"; inherits: true; initial-value: #f97316; }
@property --color-progress { syntax: "<color>"; inherits: true; initial-value: #22c55e; }
@property --color-chart-line { syntax: "<color>"; inherits: true; initial-value: #2563eb; }
@property --color-chart-a { syntax: "<color>"; inherits: true; initial-value: #2563eb; }
@property --color-chart-b { syntax: "<color>"; inherits: true; initial-value: #d97706; }
@property --color-chart-c { syntax: "<color>"; inherits: true; initial-value: #db2777; }
@property --color-ok-bg { syntax: "<color>"; inherits: true; initial-value: rgb(34 197 94 / 10%); }
@property --color-ok-fg { syntax: "<color>"; inherits: true; initial-value: #15803d; }
@property --color-warn-bg { syntax: "<color>"; inherits: true; initial-value: rgb(234 179 8 / 10%); }
@property --color-warn-fg { syntax: "<color>"; inherits: true; initial-value: #8a5406; }
@property --color-error-bg { syntax: "<color>"; inherits: true; initial-value: rgb(239 68 68 / 10%); }
@property --color-error-fg { syntax: "<color>"; inherits: true; initial-value: #b91c1c; }
@property --color-done-from { syntax: "<color>"; inherits: true; initial-value: #4ade80; }
@property --color-done-to { syntax: "<color>"; inherits: true; initial-value: #34d399; }
@property --color-star-fill { syntax: "<color>"; inherits: true; initial-value: #fef9c3; }
@property --color-star-stroke { syntax: "<color>"; inherits: true; initial-value: #fde68a; }
@property --color-done-retry-from { syntax: "<color>"; inherits: true; initial-value: #fbbf24; }
@property --color-done-retry-to { syntax: "<color>"; inherits: true; initial-value: #f59e0b; }
@property --persona-preview-bg { syntax: "<color>"; inherits: true; initial-value: #f1f5f9; }
@property --persona-preview-fg { syntax: "<color>"; inherits: true; initial-value: #020817; }
@property --persona-preview-photo-bg { syntax: "<color>"; inherits: true; initial-value: rgb(15 23 42 / 8%); }
@property --persona-preview-border { syntax: "<color>"; inherits: true; initial-value: #e2e8f0; }

/* Klasę `motyw-przejscie` nakłada i zdejmuje theme.js — tylko na czas
   przełączenia. Bez tego warunku KAŻDA zmiana tokenu (dziś żadna, ale to
   kwestia jednej przyszłej funkcji) przelewałaby się przez 420 ms. */
@media (prefers-reduced-motion: no-preference) {
  /* NA CZAS PRZEŁĄCZENIA ŻADEN ELEMENT NIE ANIMUJE NIC WŁASNEGO.
     Kilkanaście reguł niżej ma własne krótkie przejścia kolorów — przycisk
     (`transition: background-color .15s`), litera odpowiedzi w quizie, wiersz
     tabeli zużycia, przełącznik, kafelek wyboru. Kiedy token pod nimi jedzie
     przez 420 ms, taka reguła nie przemalowuje się razem z nim: traktuje każdą
     klatkę jako NOWĄ zmianę i zaczyna do niej własne 150-milisekundowe
     dojście, czyli działa jak filtr opóźniający. Zmierzone na przycisku
     primary: rozjazd do 113 (na 255) wobec wartości tokenu i koniec 130 ms po
     resztą ekranu — zgłoszone jako „przyciski dziwnie zmieniają kolor i nie
     pasuje to do naszej animacji".
     Zerowanie ich przejść jest bezpieczne właśnie dlatego, że interpolacją
     zajmuje się :root: element ma po prostu NARYSOWAĆ wartość, którą token ma
     w tej klatce. `!important` jest konieczne, bo reguły komponentów mają
     wyższą swoistość niż gwiazdka.
     `:root.motyw-przejscie *` to selektor POTOMKA — nie trafia w :root, więc
     animacja tokenów, którą włączamy zaraz niżej, zostaje nietknięta. To nie
     jest szczegół, na którym można oszczędzić: `*` samo w sobie trafia także
     w :root i skasowałoby całe przejście.
     UWAGA: dotyczy WYŁĄCZNIE `transition`. `animation` (kropki pisania czatu,
     poblask kafla okładki, pasek nieokreślony) chodzi dalej — to inna
     właściwość i nie ma jej na tej liście. */
  :root.motyw-przejscie *,
  :root.motyw-przejscie *::before,
  :root.motyw-przejscie *::after { transition: none !important; }

  :root.motyw-przejscie {
    /* POWIERZCHNIE przenikają — tło strony, karty, pasek, obrysy, wypełnienia,
       cienie. To jest ta płynna zmiana, o którą chodzi. */
    transition:
      --color-bg var(--theme-fade) var(--theme-fade-ease),
      --color-surface var(--theme-fade) var(--theme-fade-ease),
      --color-surface-muted var(--theme-fade) var(--theme-fade-ease),
      --color-surface-subtle var(--theme-fade) var(--theme-fade-ease),
      --color-surface-muted-hover var(--theme-fade) var(--theme-fade-ease),
      --color-ring-shadow var(--theme-fade) var(--theme-fade-ease),
      --color-scroll-shadow var(--theme-fade) var(--theme-fade-ease),
      --color-surface-fade var(--theme-fade) var(--theme-fade-ease),
      --color-bubble-user-bg var(--theme-fade) var(--theme-fade-ease),
      --color-accent var(--theme-fade) var(--theme-fade-ease),
      --color-accent-strong var(--theme-fade) var(--theme-fade-ease),
      --color-accent-deep var(--theme-fade) var(--theme-fade-ease),
      --color-ring var(--theme-fade) var(--theme-fade-ease),
      --color-chip-bg var(--theme-fade) var(--theme-fade-ease),
      --color-chip-add-bg var(--theme-fade) var(--theme-fade-ease),
      --color-nav var(--theme-fade) var(--theme-fade-ease),
      --color-nav-hover var(--theme-fade) var(--theme-fade-ease),
      --color-nav-chip-active var(--theme-fade) var(--theme-fade-ease),
      --color-avatar-ring var(--theme-fade) var(--theme-fade-ease),
      --color-avatar-ring-track var(--theme-fade) var(--theme-fade-ease),
      --color-danger var(--theme-fade) var(--theme-fade-ease),
      --color-danger-hover var(--theme-fade) var(--theme-fade-ease),
      --color-progress var(--theme-fade) var(--theme-fade-ease),
      --color-chart-line var(--theme-fade) var(--theme-fade-ease),
      --color-chart-a var(--theme-fade) var(--theme-fade-ease),
      --color-chart-b var(--theme-fade) var(--theme-fade-ease),
      --color-chart-c var(--theme-fade) var(--theme-fade-ease),
      --color-ok-bg var(--theme-fade) var(--theme-fade-ease),
      --color-warn-bg var(--theme-fade) var(--theme-fade-ease),
      --color-error-bg var(--theme-fade) var(--theme-fade-ease),
      --color-done-from var(--theme-fade) var(--theme-fade-ease),
      --color-done-to var(--theme-fade) var(--theme-fade-ease),
      --color-done-retry-from var(--theme-fade) var(--theme-fade-ease),
      --color-done-retry-to var(--theme-fade) var(--theme-fade-ease),
      --persona-preview-bg var(--theme-fade) var(--theme-fade-ease),
      --persona-preview-photo-bg var(--theme-fade) var(--theme-fade-ease),
      --persona-preview-border var(--theme-fade) var(--theme-fade-ease),
      /* ────────── NAPISY PRZESKAKUJĄ, NIE PRZENIKAJĄ ──────────
         Kolory tekstu wymieniają się JEDNYM krokiem w połowie przejścia.
         Powód jest arytmetyczny i to on stał za zgłoszeniem „kolory
         przycisków się glitchują": napis i jego tło idą w PRZECIWNE strony
         jasności (przycisk primary jest w jasnym motywie ciemny z jasnym
         napisem, w ciemnym odwrotnie), więc przenikając oba naraz mijają się
         W TEJ SAMEJ SZAROŚCI. Zmierzone wewnątrz przycisku przy poprzedniej
         wersji: t=140 ms, tło #7a7f89, napis #929395, kontrast 1,31:1 —
         napis fizycznie znikał na kilka klatek i wracał odwrócony. To samo
         działo się z każdym napisem na stronie, bo --color-fg i --color-bg
         też są taką parą.
         Krok w połowie zdejmuje to całkowicie: tło płynie dalej, a napis
         wymienia się w jednej klatce — DOKŁADNIE w momencie, w którym jego
         kontrast jest i tak najniższy, czyli najmniej to widać. Najgorsza
         klatka ma teraz ~3,3:1 zamiast 1,31:1, czyli napis jest czytelny
         przez cały czas przejścia.
         Dlatego też krzywa powyżej musi być symetryczna — patrz komentarz
         przy --theme-fade-ease.
         KTÓRE tokeny tu trafiają: te używane WYŁĄCZNIE jako kolor tekstu
         (color/fill/stroke). Token, który jest gdzieś tłem, MUSI przenikać —
         inaczej przeskakiwałaby powierzchnia, a to widać dużo mocniej niż
         napis. Pilnuje tego TestNapisyPrzeskakujaTlaPrzenikaja. */
      --color-fg var(--theme-fade) var(--theme-fade-krok),
      --color-fg-soft var(--theme-fade) var(--theme-fade-krok),
      --color-fg-muted var(--theme-fade) var(--theme-fade-krok),
      --color-fg-faint var(--theme-fade) var(--theme-fade-krok),
      --color-meta-fg var(--theme-fade) var(--theme-fade-krok),
      --color-accent-fg var(--theme-fade) var(--theme-fade-krok),
      --color-chip-fg var(--theme-fade) var(--theme-fade-krok),
      --color-nav-fg var(--theme-fade) var(--theme-fade-krok),
      --color-nav-active-fg var(--theme-fade) var(--theme-fade-krok),
      --color-nav-active-accent var(--theme-fade) var(--theme-fade-krok),
      --color-nav-chip-active-fg var(--theme-fade) var(--theme-fade-krok),
      --color-danger-fg var(--theme-fade) var(--theme-fade-krok),
      --color-bubble-user-fg var(--theme-fade) var(--theme-fade-krok),
      --color-edit var(--theme-fade) var(--theme-fade-krok),
      --color-edit-deep var(--theme-fade) var(--theme-fade-krok),
      --color-extype-czytanka var(--theme-fade) var(--theme-fade-krok),
      --color-extype-quiz var(--theme-fade) var(--theme-fade-krok),
      --color-extype-otwarte var(--theme-fade) var(--theme-fade-krok),
      --color-extype-luki var(--theme-fade) var(--theme-fade-krok),
      --color-extype-dopasowanie var(--theme-fade) var(--theme-fade-krok),
      --color-ok-fg var(--theme-fade) var(--theme-fade-krok),
      --color-warn-fg var(--theme-fade) var(--theme-fade-krok),
      --color-error-fg var(--theme-fade) var(--theme-fade-krok),
      --color-star-fill var(--theme-fade) var(--theme-fade-krok),
      --color-star-stroke var(--theme-fade) var(--theme-fade-krok),
      --persona-preview-fg var(--theme-fade) var(--theme-fade-krok),
      --color-border var(--theme-fade) var(--theme-fade-krok),
      --color-border-strong var(--theme-fade) var(--theme-fade-krok),
      --color-nav-line var(--theme-fade) var(--theme-fade-krok);
  }
}

*, *::before, *::after { box-sizing: border-box; }
/* [hidden] { display: none !important; } — ZNALEZISKO BLOKUJĄCE z re-recenzji
   (H1). Arkusz UA (domyślne style przeglądarki) niesie WŁASNĄ regułę
   `[hidden] { display: none }`, o swoistości atrybutu (0,1,0) — dokładnie tej
   samej, co jedna klasa. Kaskada CSS rozstrzyga NAJPIERW pochodzenie
   (author > user-agent), DOPIERO POTEM swoistość w obrębie tego samego
   pochodzenia — więc reguła AUTORSKA bije regułę UA NIEZALEŻNIE OD
   SWOISTOŚCI. Skutek: każda reguła niżej w tym arkuszu, która ustawia
   `display` na klasie noszącej ZARAZ obok atrybut `hidden` w markupie (np.
   .work-bar__error-row { display: flex; ... } w sekcji paska robót w tle),
   bije regułę UA i `hidden` przestaje wizualnie znaczyć cokolwiek — mimo że
   atrybut jest poprawnie ustawiany/zdejmowany w DOM-ie. Bez tej reguły: (1)
   KAŻDY zdrowy wiersz paska renderował pusty czerwony akapit błędu i ŻYWY
   przycisk × (I1 miał je chować `hidden`-em przy renderze, ale
   .work-bar__error-row — a od migracji 0024 także .work-bar__done-row,
   zielony wiersz „gotowe” — nadpisywała to na `display: flex`); (2)
   `errorRow.hidden = true` w workbar.js (powrót z błędu na żywo) nie robił
   NIC wizualnie; (3) kliknięcie × na zdrowym wierszu zapisywało
   error_dismissed_at i uzbrajało CICHE ukrycie wiersza dopiero wtedy, gdy
   plik NAPRAWDĘ utknie.
   Naprawa: jedna globalna reguła resetu z `!important` — reguła autorska z
   !important bije DOWOLNĄ inną regułę autorską bez !important, niezależnie
   od jej swoistości i pozycji w pliku, więc `[hidden]` znowu naprawdę chowa,
   obojętnie co inna reguła gdzie indziej w arkuszu ustawia na `display` tego
   samego selektora. BEZPIECZNE dla wszystkiego, co już dziś ma być chowane
   przez `hidden` (sprawdzone grepem po `hidden` w szablonach/JS):
   .work-bar__stale, .work-bar__percent/.work-bar__stage-stale (layout.html/
   workbar.js), workbar-course-labels/workbar-file-labels/
   workbar-error-fallback (kontenery tłumaczeń dla JS-u, nigdy nie mają być
   widoczne same), data-progress-track i status-labels w knowledge.html —
   żadne z nich nie ma wzorca „hidden w markupie + JS/CSS pokazuje przez
   WŁASNY inline style="display:..." bez własnego !important" (taki wzorzec
   ta reguła by złamała). TestHiddenAttributeAlwaysHidesElement
   (workbar_test.go) pilnuje, że ta deklaracja zostaje w arkuszu. */
[hidden] { display: none !important; }
body {
  margin: 0;
  background: var(--color-bg);
  color: var(--color-fg);
  /* 16px/24px — domyślny rozmiar dokumentu w humi (Tailwind nie ustawia go
     nigdzie, więc obowiązuje preflight). Patrz --fs-base w sekcji tokenów. */
  font: 400 var(--fs-base)/var(--lh-base) var(--font-sans);
  -webkit-text-size-adjust: 100%;
}

/* Wagi i zaciskanie liter za humi: `text-3xl font-bold` na h1 stron
   (np. „Team Management", „Resources"), `font-semibold tracking-tight` na
   tytułach kart i dialogów. Poprzednio wszystkie poziomy miały 700 — h2/h3/h4
   schodzą na 600, bo w referencji bold jest zarezerwowany dla h1 strony. */
h1 { font-size: var(--fs-h1); line-height: var(--lh-h1); font-weight: 700; letter-spacing: -.025em; margin: 0 0 var(--space-2); }
h2 { font-size: var(--fs-h2); line-height: var(--lh-h2); font-weight: 600; letter-spacing: -.025em; margin: 0 0 var(--space-6); }
h3 { font-size: var(--fs-h3); line-height: var(--lh-h3); font-weight: 600; letter-spacing: -.025em; margin: 0 0 var(--space-5); }
h4 { font-size: var(--fs-h4); line-height: var(--lh-h4); font-weight: 600; margin: 0 0 var(--space-3); }
/* NAGŁÓWKI ŁAMIĄ SIĘ W ŚRODKU SŁOWA, gdy inaczej się nie da. W nagłówkach stoją
   DANE UŻYTKOWNIKA — nazwa pliku (file.html), nazwa działu (department.html),
   tytuł kursu (course.html) — a nazwa pliku bywa jednym nierozdzielnym tokenem
   bez spacji. Bez tej reguły „MARATHON_First_Day__PL__2026__1__20260825…pdf"
   rozpychał /knowledge/{id} do 865 px przy oknie 390 (zmierzone 30.08.2026)
   i przeglądarka pomniejszała całą stronę — ta sama awaria co przy .u-scroll-x
   niżej, tylko z innej strony. Reguła osobna, nie dopisek do bloków wyżej: te
   opisują STOPIEŃ i wagę, ta zachowanie przy braku miejsca.
   `anywhere`, nie `break-word`: tylko pierwsze pozwala łamać także wtedy, gdy
   liczy się szerokość minimalna zawartości — czyli dokładnie w tabelach
   i kolumnach elastycznych, gdzie ten problem powstaje. */
h1, h2, h3, h4 { overflow-wrap: anywhere; }
p  { margin: 0 0 var(--space-6); }
a  { color: inherit; text-underline-offset: 2px; }
/* Pierścień fokusu z humi: shadcn daje kontrolkom
   `focus-visible:ring-ring/50 focus-visible:ring-[3px]`, czyli 3 px obwódkę
   w kolorze --ring (slate 400) z połową krycia. Zastąpiło to dwukolorowy
   pierścień (2 px akcentu + 2 px --color-fg), który istniał dlatego, że dawny
   akcent — najpierw bursztyn, potem Indigo 500 — sam nie dobijał do progu 3:1
   z WCAG 2.2 SC 1.4.11 na jasnym tle. Po przejściu na humi akcentem jest
   slate 900 i ten powód zniknął, ale JEDEN przypadek z tamtego uzasadnienia
   trzeba było zastąpić czym innym, nie po prostu skasować: pierścień musi
   kontrastować także z WŁASNYM tłem elementu, a przycisk primary ma tło
   slate 900. Sam slate 400 daje na nim 5,04:1 — wystarczy z zapasem, więc
   drugi pierścień faktycznie przestał być potrzebny.
   outline zostaje `solid transparent` (nie `none`), żeby wskaźnik przetrwał
   tryb wysokiego kontrastu Windows (forced-colors), który ignoruje box-shadow,
   ale honoruje outline.
   UWAGA: box-shadow to JEDNA właściwość, nie lista warstw — każda reguła
   ustawiająca box-shadow na klasie, którą nosi element fokusowalny, PODMIENI
   ten pierścień. Dlatego przyciski i pola NIE dostają `shadow-xs` z humi
   (0 1px 2px rgb(0 0 0 / 5%) — przy obramowanej kontrolce i tak niewidoczne);
   pilnuje tego TestNoBoxShadowClassOnFocusableElement. */
:focus-visible {
  outline: 2px solid transparent;
  outline-offset: 2px;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
@media (prefers-reduced-motion: reduce) {
  /* iteration-count: 1 dokłada się do skróconego czasu trwania: bez tego
     animacja `infinite` (kropki pisania czatu) przy .01ms nie znika, tylko
     stroboskopuje — cykl kręci się dalej, szybciej niż klatki ekranu. */
  *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

/* ---------- przyciski ---------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 36px; padding: 0 16px; border-radius: var(--radius-sm);
  border: 1px solid transparent; background: none; color: inherit;
  font: 500 14px/1 var(--font-sans); cursor: pointer; text-decoration: none;
  transition: background-color .15s, border-color .15s;
  /* `shadow-xs` z buttonVariants. box-shadow na elemencie fokusowalnym
     PODMIENIŁBY globalny pierścień z :focus-visible (to jedna właściwość,
     nie lista warstw), dlatego zaraz niżej stoi własna reguła fokusu, która
     niesie pierścień I cień naraz. Bez tamtej reguły ta deklaracja kasuje
     wskaźnik fokusu; pilnuje tego TestNoBoxShadowClassOnFocusableElement. */
  box-shadow: var(--shadow-sm);
}
.btn:focus-visible { box-shadow: 0 0 0 3px var(--color-ring-shadow), var(--shadow-sm); }
/* Warianty bez wypełnienia nie mają czego cieniować — `ghost` i `link`
   w humi jako jedyne nie niosą `shadow-xs`. */
.btn--ghost, .btn--link { box-shadow: none; }
.btn--ghost:focus-visible, .btn--link:focus-visible { box-shadow: 0 0 0 3px var(--color-ring-shadow); }
/* Warianty 1:1 z buttonVariants (ui/button/button.svelte w humi). Waga
   napisu jest w BAZIE (`font-medium`, 500) i żaden wariant jej nie zmienia —
   poprzednio primary podbijał się do 600. Hovery to półprzezroczyste wersje
   koloru bazowego złożone na białym tle, policzone tutaj, żeby nie zależeć
   od przezroczystości (przycisk potrafi stać na karcie albo na tle strony):
     primary     hover:bg-primary/90     → #272e3f
     secondary   hover:bg-secondary/80   → #f4f7fa (jaśniej, nie ciemniej — tak
                                            działa /80 na białym; to nie pomyłka)
     destructive hover:bg-destructive/90 → #f15757
     outline / ghost hover:bg-accent     → --color-surface-muted */
.btn--primary   { background: var(--color-accent-strong); color: var(--color-accent-fg); }
.btn--primary:hover { background: var(--color-accent-deep); }
.btn--secondary { background: var(--color-surface-muted); color: var(--color-fg); }
.btn--secondary:hover { background: var(--color-surface-muted-hover); }
.btn--outline   { background: var(--color-surface); border-color: var(--color-border); }
.btn--outline:hover { background: var(--color-surface-muted); }
.btn--ghost:hover { background: var(--color-surface-muted); }
.btn--danger    { background: var(--color-danger); color: var(--color-danger-fg); }
.btn--danger:hover { background: var(--color-danger-hover); }
/* size="sm" → h-8 gap-1.5 px-3; size="lg" → h-10 px-6. Rozmiar pisma NIE
   zmienia się z rozmiarem przycisku (baza `text-sm` obowiązuje wszystkie
   trzy) — wcześniej sm miał 13px, a lg 15px, czyli trzy różne stopnie tam,
   gdzie referencja ma jeden. */
.btn--sm { min-height: 32px; padding: 0 12px; gap: 6px; }
.btn--lg { min-height: 40px; padding: 0 24px; }
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; pointer-events: none; }

/* ---------- tabela kursów (układ Humi) + modal potwierdzenia ---------- */
.page-header__subtitle { margin: var(--space-2) 0 0; color: var(--color-fg-muted); font-size: var(--fs-lg); line-height: var(--lh-lg); }

/* .table-wrap NIE ma już własnej ramki ani promienia. Tabela stała wcześniej
   w obramowanym pudełku WEWNĄTRZ obramowanej .card — pudełko w pudełku, dwie
   linie o milimetr od siebie. W humi tabela leży wprost na płachcie: jedyne
   linie to podkreślenie nagłówka kolumn i separatory wierszy.
   Białą płachtę zostawia .card i to jest ŚWIADOME odejście od dosłownego 1:1:
   w humi tło treści jest białe, a u nas szare (#f4f4f5, tak jak w
   routes/(navbar)/+layout.svelte), więc tabela bez płachty leżałaby na
   szarym i wyglądałaby GORZEJ, nie lepiej.
   overflow-x zostaje — to on, a nie ramka, jest powodem istnienia tego diva.
   GEOMETRIA (wysokości wierszy, paddingi, nagłówki) NIE STOI JUŻ TUTAJ.
   Aplikacja miała cztery niezależne implementacje tabeli — .courses-table,
   .list-table, .usage-table i .progress-table — każdą z innym rytmem, co jest
   dosłownie zarzut „jednorazowe, lokalne style". Zostały dwie, dokładnie tyle,
   ile ma humi:
     • LISTA GŁÓWNA (.list-table + .courses-table) = ui/table-custom, czyli
       wiersze 64 px, nagłówek 48 px, komórki 16 px — kursy, działy, pliki;
     • TABELA ZWARTA (.usage-table i jej pochodne) = ui/table, czyli nagłówek
       40 px i komórki 8 px — lekcje, członkowie, zgłoszenia, zużycie AI.
   Wspólne reguły listy głównej stoją w sekcji „lista działów" niżej, przy
   opisie, z których klas Tailwinda są przepisane. */
.table-wrap,
.list-table-wrap { position: relative; width: 100%; overflow: auto; }
/* PLAKIETKA TYLKO DLA STANÓW WYJĄTKOWYCH — najważniejsza rzecz z referencji.
   W Humi „Published" jest gołym przygaszonym tekstem, a kolorową pigułkę
   dostają wyłącznie „4 reported" i „Generating…", czyli to, co wymaga uwagi.
   U nas KAŻDY status był zieloną pigułką, więc kolumna Status świeciła się na
   zielono w każdym wierszu i przestała cokolwiek znaczyć — dokładnie „za dużo
   niepotrzebnych informacji".
   Reguła jest ZAKRESOWA (tylko w .courses-table), nie zmienia partiala
   file-badge-class: na stronie pliku i w pasku robót w tle zielone „gotowy"
   jest właśnie tym, po co tam patrzysz. */
.courses-table .badge--ok {
  background: transparent; border-color: transparent;
  color: var(--color-fg-muted); font-weight: 400; padding-left: 0; padding-right: 0;
}
/* WYJĄTEK od reguły wyżej, dla tabeli PLIKÓW (nie kursów) — decyzja Eryka
   z 22.08: „status pliku »gotowy« powinien tez byc jako taki kolorowy babelek
   jak inne statusy a nie jako zwykly tekst, daj kolor zielony".
   Powód, dla którego to nie kłóci się z akapitem wyżej: tam problemem było, że
   KAŻDY kurs jest zwykle „gotowy", więc kolumna świeciła się na zielono w
   całości i przestawała nieść informację. Lista plików czyta się inaczej —
   przez nią przechodzi ruch (wgrany → parsowanie → osadzanie → gotowy/błąd)
   i patrzy się na nią WŁAŚNIE po to, żeby jednym rzutem oka zobaczyć, co już
   przeszło, a co nie. Zieleń jest tu odpowiedzią na pytanie, nie ozdobą.
   Zakres zostaje ciasny (modyfikator na tabeli), więc lista kursów, dla której
   tamtą regułę napisano, zachowuje się bez zmian. */
.courses-table--files .badge--ok {
  background: var(--color-ok-bg); color: var(--color-ok-fg);
  font-weight: 600; padding-left: 10px; padding-right: 10px;
}
.courses-table .num { font-variant-numeric: tabular-nums; color: var(--color-fg); white-space: nowrap; }
/* LISTA PLIKÓW MA SIĘ MIEŚCIĆ W EKRANIE — zgłoszenie z 29.08.2026: „podczas
   wgrywania pliku, gdy jest status przetwarzania, status wychodzi poza ekran
   i tworzy się lista do scrollowania w bok. A tego nie chcesz — żeby był
   statyczny ekran bez scrollowania".

   ZMIERZONE (okno 1440, kolumna treści 1118 px): wiersz w toku potrzebował
   1203 px, czyli o 85 px za dużo, i .table-wrap robił z tego poziomy pasek
   przewijania. Winne są dwie komórki, obie NIEŚCIŚLIWE:

     • NAZWA. Nazwa z eksportu to jedno nieprzełamywalne słowo
       („MARATHON_First_Day__PL__2026__1__20260825_130846-1.pdf" — 394 px),
       a bez punktu podziału minimalna szerokość kolumny RÓWNA SIĘ jej pełnej
       długości. Dlatego tabela była za szeroka także dla pliku gotowego;
       przetwarzanie tylko dokładało do już przekroczonej miary.
     • STATUS. Napisy etapów są ZDANIAMI, uzgodnionymi z zamawiającym
       i pilnowanymi testami (etapy_pliku.go): „Tekst gotowy, grafiki
       w drodze… 83%" to 277 px w jednej linii.

   ZWĘŻA SIĘ NAZWA, NIE PLAKIETKA. Pierwsze podejście pozwalało łamać się
   plakietce (`white-space: normal` tylko w tej tabeli) — zamawiający odrzucił
   je z miejsca: „wygląda bardzo brzydko". Miał rację, bo pigułka z promieniem
   999 px rozlana na trzy linijki przestaje czytać się jak pigułka. Kolumna
   nazwy jest za to jedynym miejscem w tym wierszu, które ma naturalny sposób
   na zwężenie: obcięcie wielokropkiem, znane z każdego menedżera plików. Pełna
   nazwa zostaje w atrybucie title i na stronie pliku, a to, co odróżnia dwa
   sąsiednie eksporty (ogon nazwy), jest i tak dostępne po najechaniu.

   JAK to działa: `max-width` na komórce zeruje jej apetyt na szerokość (kolumna
   przestaje żądać całej nazwy), a `width: 100%` każe jej wziąć CAŁĄ resztę,
   która zostanie po kolumnach o stałej treści. Bez tej drugiej deklaracji
   nadmiar rozlewa się po sąsiadach i nazwa dostaje 43 px (zmierzone), bo
   kolumna o zerowym apetycie nie dostaje nic z podziału wolnego miejsca.

   Numeracja nth-child odpowiada kolumnom <tbody> w knowledge.html:
   1 ikona, 2 nazwa, 3 dział, 4 rozmiar, 5 dodano, 6 status, 7 menu wiersza —
   i dlatego selektory pytają o `tbody td`, a nie o `th`: w nagłówku nazwa
   siedzi w JEDNEJ komórce z colspan=2, więc numery są tam przesunięte o jeden
   i ta sama liczba trafiałaby w inną kolumnę. */
.courses-table--files tbody td:nth-child(2) { width: 100%; max-width: 1px; }
/* .kb-upload__name obok <a>: nazwa WGRYWANEGO pliku nie jest odnośnikiem (nie
   ma jeszcze adresu — patrz [data-upload-row] w knowledge.html), a skracać się
   musi tak samo, bo inaczej długa nazwa rozpycha tabelę poza ekran akurat na
   czas wgrywania. */
.courses-table--files tbody td:nth-child(2) > a,
.courses-table--files tbody td:nth-child(2) > .kb-upload__name {
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* „5 godzin temu" w jednej linii: kolumna jest wąska, a łamane na trzy linijki
   podnosiło wiersz bardziej, niż oszczędzało szerokości. */
.courses-table--files tbody td:nth-child(5) { white-space: nowrap; }
td.courses-table__thumb-cell, th.courses-table__thumb-cell { width: 56px; padding-right: 0; }

/* Okładka w wierszu listy. 56 px i promień 4 px — dokładnie rozmiar, jaki humi
   daje kursowi ZE zdjęciem (patrz komentarz przy .list-table__thumb-cell);
   u nas ta gałąź była martwa do czasu, aż kursy dostały okładki. Trasa
   /okladki/... zawsze coś zwraca (obraz albo placeholder w tej samej
   stylistyce), więc szablon nie ma wariantu „bez obrazu" i wiersz nigdy nie
   skacze wysokością. */
.cover-thumb {
  display: block; width: 56px; height: 56px; flex: none;
  border-radius: 4px; object-fit: cover; background: var(--color-surface-muted);
}
/* Lista lekcji stoi gęściej niż lista kursów — tam miniatura zjadałaby wiersz. */
.cover-thumb--sm { width: 40px; height: 40px; border-radius: var(--radius); }
/* MIEJSCE NA OKŁADKĘ w trakcie rysowania (course-heading, .cover-slot). Kwadrat
   dziedziczy rozmiar i promień z .cover-thumb, więc nagłówek NIE SKACZE
   w chwili, gdy obraz wskoczy na miejsce lodera. Ruch przez `transform`, nigdy
   przez `background-position` ani `width` — ten sam powód co przy
   .progress--indeterminate niżej (animowanie właściwości układu wymusza reflow
   w każdej klatce i szarpie). */
.cover-slot { position: relative; overflow: hidden; }
.cover-slot--rysuje::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(100deg, transparent 20%, rgb(255 255 255 / 60%) 50%, transparent 80%);
  transform: translateX(-100%);
  animation: cover-shimmer 1.5s ease-in-out infinite;
}
@keyframes cover-shimmer { to { transform: translateX(100%); } }
/* Bez ruchu: globalna reguła prefers-reduced-motion zeruje czas animacji
   i zostawiłaby poblask ZA kadrem, czyli pusty prostokąt. Zamiast tego
   statyczna, przygaszona plama — nadal czytelna jako „coś tu powstaje". */
@media (prefers-reduced-motion: reduce) {
  .cover-slot--rysuje::after { transform: none; animation: none; opacity: .35; }
}
.courses-table__name a { color: var(--color-fg); text-decoration: none; font-weight: 600; }
.courses-table__name a:hover { text-decoration: underline; }
.courses-table__variant { margin-left: 8px; color: var(--color-fg-muted); font-size: 12px; }
.courses-table__date { color: var(--color-fg-muted); white-space: nowrap; }
.courses-table__reports { text-decoration: none; }
td.courses-table__actions, th.courses-table__actions { text-align: right; width: 44px; }

/* Kafelek-miniatura (placeholder — nie mamy zdjęć kursów jak Humi): ikona
   w przygaszonym FG (adaptuje się do motywu) na tle barwionym wg poziomu.
   Amber tylko jako TŁO (guard kontrastu dopuszcza), nigdy jako color: tekstu. */
.course-thumb {
  display: grid; place-items: center; width: 40px; height: 40px; flex: none;
  border-radius: var(--radius-sm); color: var(--color-fg-muted); background: var(--color-bg);
}
.course-thumb svg { width: 20px; height: 20px; }
.course-thumb--przewodnik { background: rgb(34 197 94 / 15%); }
.course-thumb--podstawy   { background: rgb(59 130 246 / 15%); }
.course-thumb--masterclass { background: rgb(255 196 0 / 22%); }

/* Nasz modal potwierdzenia (confirm-modal.js) — <dialog> stylowany naszymi
   tokenami, przyciemniony backdrop. box-shadow siedzi na .confirm__box (<form>),
   nie na elemencie fokusowalnym (pilnuje focus_ring_test). */
.confirm { border: 0; background: transparent; padding: 0; }
.confirm::backdrop { background: rgb(0 0 0 / 45%); }
.confirm__box {
  width: 420px; max-width: 92vw; background: var(--color-surface);
  border: 1px solid var(--color-border); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md); padding: 22px;
  display: flex; flex-direction: column; gap: 10px;
}
.confirm__title { font-size: 18px; margin: 0; }
.confirm__msg { margin: 0; color: var(--color-fg-muted); line-height: 1.5; }
.confirm__actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }

/* ---------- karty ---------- */
/* box-shadow niżej stoi ZA regułą :focus-visible z sekcji typografii i ma tę
   samą swoistość, więc na elemencie fokusowalnym PODMIENIŁBY pierścień
   fokusu, a nie dołożył cień (box-shadow to jedna właściwość, nie lista
   warstw). Dlatego .card siedzi wyłącznie na <div>-ach — kafel kursu jest
   <div>, a linkiem jest jego tytuł. Pilnuje tego test, nie tylko ten
   komentarz: TestNoBoxShadowClassOnFocusableElement (focus_ring_test.go)
   zbiera klasy z box-shadow wprost z tego arkusza i wywala się, gdy któraś
   trafi na <a href>/<button>/<input>/<select>/<textarea> w szablonach. */
.card {
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); box-shadow: var(--shadow-sm);
}
/* Dolny padding NIE jest już zerem. Tytuł karty jest od tej zmiany H4 (20px),
   a stał wprost na nagłówku tabeli pod nim — na zrzucie „Baza wiedzy" napis
   dotykał wiersza z nazwami kolumn. */
/* CardHeader `p-6 pb-0` + CardTitle `text-2xl leading-none font-semibold
   tracking-tight` + CardDescription `text-sm text-muted-foreground`, między
   nimi `space-y-1.5` (6px). Odstęp od treści niesie padding CardContent. */
.card__header { padding: var(--space-7) var(--space-7) 0; }
/* Tytuł karty = H4 ze skali (20/28). Było 15px, czyli dokładnie tyle co
   treść — karta nie miała widocznego nagłówka. */
.card__title  { font-size: var(--fs-h2); line-height: 1; font-weight: 600; letter-spacing: -.025em; margin: 0; }
.card__subtitle { margin: 6px 0 0; font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-fg-muted); }
.card__body   { padding: var(--space-7); }
.card__footer { padding: 0 var(--space-7) var(--space-7); display: flex; align-items: center; gap: var(--space-3); }

/* ---------- plakietki ---------- */
.badge {
  display: inline-flex; align-items: center; gap: 4px;
  border: 1px solid transparent; border-radius: 999px; padding: 2px 10px;
  font-size: 12px; font-weight: 600; white-space: nowrap;
  /* Plakietka nigdy szersza od tego, w czym stoi. Większość napisów jest
     krótka („gotowy", „błąd"), ale nie wszystkie: „Odpowiedź opisowa czeka na
     ocenę" na stronie postępów kursanta ma 327 px i przy `nowrap` bez limitu
     rozpychała CAŁĄ stronę do 392 px — na telefonie 320 px kończyło się to
     pomniejszeniem całego widoku przez shrink-to-fit. Tłumaczenia bywają
     jeszcze dłuższe (ru/ka), więc to nie jest wyjątek jednego napisu. */
  max-width: 100%;
}
/* Na telefonie długa plakietka ŁAMIE SIĘ zamiast wystawać. Zawijanie tylko
   tutaj, bo w tabelach na szerokim ekranie `nowrap` trzyma statusy w jednej
   linii i tak ma zostać — a tabela na wąskim ekranie i tak przewija się
   w bok, więc niższy wiersz jest czystym zyskiem. */
@media (max-width: 767px) {
  .badge { white-space: normal; }
}
.badge--ok      { background: var(--color-ok-bg);    color: var(--color-ok-fg); }
.badge--running { background: var(--color-warn-bg);  color: var(--color-warn-fg); }
.badge--pending { background: var(--color-bg);       color: var(--color-fg-muted); border-color: var(--color-border); }
.badge--error   { background: var(--color-error-bg); color: var(--color-error-fg); }
/* Plakietka BEZ znaczenia semantycznego — etykieta danych, nie stan. Dział
   w wierszu zespołu nie jest „w toku" ani „gotowy", więc nie może pożyczać
   kolorów od --running czy --ok; te same wartości co --pending, ale nazwa
   mówi, do czego to jest. */
.badge--neutral { background: var(--color-bg); color: var(--color-fg-muted); border-color: var(--color-border); }
/* Brak wartości: obrys kreskowany zamiast pustej komórki, żeby „nie ma" dało
   się odróżnić od „nie wczytało się". */
.badge--empty   { background: transparent; color: var(--color-fg-muted); border-color: var(--color-border); border-style: dashed; }
/* "Do poprawy" (finalny przegląd zadania 2 planu progu zaliczenia, I3):
   dzieliła klasę z .badge--running ("w toku"), więc kolor przestawał
   odróżniać dwa różne stany na TEJ SAMEJ liście lekcji kursanta. Zamiast
   piątej pary kolorów — te same tokeny warn co .badge--running/
   .alert--warning, tylko z ODWRÓCONĄ rolą wypełnienia: mocna, wypełniona
   plakietka zamiast bladej.

   NAPIS NIE MOŻE BYĆ --color-warn-bg (zgłoszenie Eryka 28.08.2026: „status
   »do poprawy« zlewa tekst z tłem"). Pierwsza wersja tej reguły odwracała
   OBA tokeny i opierała się na tym, że iloraz kontrastu jest symetryczny —
   co jest prawdą tylko dla dwóch kolorów KRYJĄCYCH. --color-warn-bg jest
   żółcią o 10% krycia; na białej karcie składa się do bladego kremu (stąd
   czytelna .badge--running), ale położona na #a16207 składa się do #a86a07,
   czyli 1,06:1 — brązowy napis na brązowym tle. To nie było przeoczenie
   liczby, tylko liczenie z tokenu, a nie z faktycznie wyrenderowanego
   koloru; strażnikiem tej pomyłki jest TestPrzezroczysteTloJakoKolorTekstu.

   Napis idzie na --color-surface, czyli kolor powierzchni strony: biel na
   #a16207 daje 4,92:1 (AA dla tekstu), a w ciemnym motywie ta sama reguła
   sama się odwraca — czerń karty #18181b na jasnej żółci #facc15 to 11,57:1.
   Kolor kryjący w obu motywach, więc składanie warstw nie ma tu już nic do
   powiedzenia. */
.badge--needs-retry { background: var(--color-warn-fg); color: var(--color-surface); border-color: var(--color-warn-fg); }

/* ---------- formularze ---------- */
/* Pole 1:1 z ui/input/input.svelte + ui/label/label.svelte w humi:
     Label  `text-sm leading-none font-medium`        → 14px, waga 500
     Input  `h-9 rounded-md border px-3 py-1 text-sm` → 36px, 6px, 12px, 14px
     odstęp etykieta↔pole w formularzach humi to `grid gap-2` → 8px
   Poprzednio: etykieta 600, pole 44px wysokości i 16px pisma. 16px wracają
   niżej w zapytaniu dotykowym — na telefonie mniejsze pismo w polu każe
   Safari przybliżyć całą stronę przy wejściu w input, a humi rozwiązuje to
   tak samo (`text-base md:text-sm`). */
.field { display: block; margin-bottom: var(--space-6); }
.field__label { display: block; font-size: var(--fs-md); line-height: 1; font-weight: 500; margin-bottom: var(--space-3); }
.field__control {
  width: 100%; min-height: 36px; padding: 6px var(--space-5);
  background: var(--color-surface); color: var(--color-fg);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  font: 400 var(--fs-md)/var(--lh-md) var(--font-sans);
  /* `shadow-xs` z input.svelte — patrz nota przy .btn: reguła fokusu niżej
     musi nieść pierścień I cień, inaczej cień skasuje wskaźnik. */
  box-shadow: var(--shadow-sm);
}
.field__control::placeholder { color: var(--color-fg-muted); }
.field__control:focus-visible {
  border-color: var(--color-ring);
  box-shadow: 0 0 0 3px var(--color-ring-shadow), var(--shadow-sm);
}
/* FormDescription i komunikat błędu w humi to `text-sm`, nie 12px. */
.field__hint  { font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-fg-muted); margin-top: var(--space-3); }
.field__error { font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-danger); margin-top: var(--space-3); }
/* Textarea: `min-h-[80px] px-3 py-2`. */
textarea.field__control { min-height: 80px; padding: 8px var(--space-5); resize: vertical; }

/* input[type=file] samo w sobie nie ma tła/krawędzi — .field__control mu je
   daje jak każdemu innemu polu. Sam przycisk "Wybierz plik" w środku to
   jednak NATYWNY element systemu (bez tego pseudo-elementu wygląda jak
   szary przycisk z Windows 95 na tle resztą stylizowanej strony). */
input[type="file"].field__control { padding: 6px 10px; }
input[type="file"].field__control::file-selector-button {
  /* appearance: none jest KLUCZOWE — bez niego Chrome renderuje własne
     tło/obramowanie przycisku POD tymi, które tu ustawiamy (znalezisko na
     żywo: reszta reguł się parsowała, przycisk i tak wyglądał jak natywny). */
  appearance: none; -webkit-appearance: none;
  font: 600 14px/1.4 var(--font-sans); color: var(--color-fg);
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius-sm); padding: 6px 12px; margin-right: 10px;
  cursor: pointer;
}
/* Wszystkie hovery na POWIERZCHNIACH (karty, listy rozwijane, dialogi,
   przyciski ikonowe) biorą --color-surface-muted, nie --color-bg. W humi to
   `hover:bg-accent` / `hover:bg-muted`, czyli kolor o stopień jaśniejszy od
   powierzchni — a --color-bg to tło STRONY, które w motywie ciemnym jest
   CIEMNIEJSZE od kart. Pod starą regułą najechanie na pozycję menu w ciemnym
   motywie ją przygaszało zamiast podświetlić. Podmienione hurtem w całym
   arkuszu (19 reguł). */
input[type="file"].field__control::file-selector-button:hover { background: var(--color-surface-muted); }

/* ---------- nasz <select> (select.js) ----------
   Natywny <select> nie pozwala ostylować ROZWINIĘTEJ listy (to popup systemu,
   na macOS ciemny i niespójny). select.js buduje nad nim przycisk + białą
   listę; natywny select zostaje w DOM (display:none, ale nadal się submituje)
   jako źródło prawdy formularza i wersja bez-JS. Przycisk dziedziczy wygląd
   pola (wspólna klasa .field__control) — tu tylko układ ze strzałką i panel. */
.select { position: relative; display: block; width: 100%; }
.select__native { display: none; }
.select__button {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  cursor: pointer; text-align: left;
}
.select__button:disabled { cursor: not-allowed; opacity: .6; }
.select__value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select__chevron { flex: none; display: inline-flex; color: var(--color-fg-muted); transition: transform .15s ease; }
.select__chevron svg { width: 18px; height: 18px; }
.select--open .select__chevron { transform: rotate(180deg); }

.select__panel {
  /* SZEROKOŚĆ PANELU LICZY SIĘ Z POZYCJI, nie z przycisku. Było tu `right: 0`,
     czyli panel dokładnie szerokości przycisku — a przycisk bywa wąski, bo
     wymiaruje go otoczenie: „Dodaj ćwiczenie" w nagłówku lekcji ma 81 px, tyle
     co napis „Quiz". Nazwy typów łamały się w nim na dwie linie („Uzupełnij /
     luki", „Skrypt / wideo") i z listy nie dało się nic wybrać wzrokiem.
     min-width: 100% pilnuje, żeby panel nigdy nie był WĘŻSZY od przycisku
     (krótkie opcje przy szerokim polu), a max-width tnie go przy bardzo
     długich pozycjach — np. przy wyborze lekcji, gdzie w opcji stoi cały jej
     tytuł. Tam pozycja się zawinie i to jest w porządku: alternatywą byłby
     panel szerszy niż okno. */
  position: absolute; z-index: 40; top: calc(100% + 4px); left: 0;
  min-width: 100%; width: max-content; max-width: min(360px, calc(100vw - 24px));
  margin: 0; padding: 4px; list-style: none;
  max-height: 16rem; overflow-y: auto;
  background: var(--color-surface); color: var(--color-fg);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  box-shadow: var(--shadow-md);
}
/* Otwarcie w górę, gdy pod przyciskiem brak miejsca (select.js dokłada .select--up),
   np. przełącznik języka na dole paska bocznego. */
.select--up .select__panel { top: auto; bottom: calc(100% + 4px); }
/* Zakotwiczenie z PRAWEJ, gdy panel szerszy od przycisku nie zmieściłby się do
   prawej krawędzi okna (select.js dokłada .select--right) — ten sam mechanizm
   co .select--up dla pionu. Bez tego lista przy prawej krawędzi (nagłówek
   lekcji, status zgłoszenia w tabeli) wychodziłaby za ekran. */
.select--right .select__panel { left: auto; right: 0; }

.select__option {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 9px 12px; border-radius: var(--radius-sm); cursor: pointer;
  font: 400 15px/1.4 var(--font-sans);
}
/* Podświetlenie klawiaturą (--active) i myszą (:hover) tym samym neutralnym
   tłem strony — spójne, bez systemowego niebieskiego. */
.select__option:hover, .select__option--active { background: var(--color-surface-muted); }
/* Znacznik wyboru „✓": pogrubienie + ptaszek w przygaszonym FG (NIE amber jako
   color: — guard kontrastu, TestAccentContrast, słusznie łapie bursztyn na
   jasnym tle). */
.select__option[aria-selected="true"] { font-weight: 600; }
.select__option[aria-selected="true"]::after { content: "\2713"; color: var(--color-fg-muted); font-weight: 700; }
.select__option[aria-disabled="true"] { opacity: .5; cursor: not-allowed; }

/* ---------- pasek postępu ---------- */
/* `<Progress class="h-2" />` — tak stoi w humi w każdym miejscu poza
   kreatorem: `bg-secondary relative w-full overflow-hidden rounded-full`
   jako tor, `bg-primary` jako wypełnienie, wysokość nadpisana na h-2 (8 px).
   Tor był na --color-border; secondary (#f1f5f9) jest o stopień jaśniejszy
   i to on jest w referencji. */
.progress { height: 8px; border-radius: 999px; background: var(--color-surface-muted); overflow: hidden; }
/* Tor syntetyczny etapów 'pytania'/'plan' (course.html, course-stage-progress)
   ma odstęp od tekstu etapu pod nim — N5 z przeglądu: był to dotąd inline
   style="margin-bottom:8px" w szablonie, bez odpowiednika w arkuszu. */
.progress--stage { margin-bottom: 8px; }
/* Poczekajka etapów bez znanego procentu (course.html, course-waiting):
   pasek indeterminate. Wąski segment .progress__bar (jego kolor bierze z
   reguły niżej) przesuwa się w kółko po torze z overflow:hidden — sam ruch
   mówi „pracuję", bez obiecywania czasu. margin-bottom jak .progress--stage,
   żeby alert etapu pod spodem miał ten sam odstęp. */
.progress--indeterminate { margin-bottom: 8px; }
/* GLITCH NAPRAWIONY. Były tu DWIE usterki naraz, obie widoczne jako szarpanie
   segmentu w poczekajce generowania kursu:
     1. Kolizja animacji z przejściem. .progress__bar niesie
        `transition: width .3s`, a ta klatkowa animacja też zmieniała `width`
        (40% → 65% → 40%). Przeglądarka próbowała jednocześnie interpolować
        przejściem i odtwarzać keyframe'y TEJ SAMEJ właściwości — segment
        „doganiał sam siebie" i co cykl przeskakiwał.
     2. Animowanie właściwości układu. `margin-left` i `width` wymuszają
        przeliczenie układu (reflow) w KAŻDEJ klatce, a nie tylko złożenie
        warstw; przy 60 Hz to gubione klatki nawet bez kolizji wyżej.
   Naprawa jest tą samą, którą stosuje humi w ui/progress (`transform:
   translateX(...)` zamiast szerokości): segment ma stałą szerokość i jedzie
   `transform`-em, czyli w kompozytorze, bez dotykania układu. `transition:
   none` zdejmuje przejście po szerokości WYŁĄCZNIE temu wariantowi — pasek
   z konkretnym procentem dalej płynnie dojeżdża do nowej wartości. */
.progress--indeterminate .progress__bar {
  width: 40%;
  transition: none;
  animation: progress-indeterminate 1.4s ease-in-out infinite;
}
@keyframes progress-indeterminate {
  from { transform: translateX(-100%); }
  to   { transform: translateX(250%); }
}
/* Bez ruchu (prefers-reduced-motion — globalna reguła wyżej zeruje czas
   animacji i zatrzymałaby segment ZEPCHNIĘTY za tor, czyli niewidoczny):
   wymuszamy statyczny, pełny pasek — nadal czytelny jako „w toku". */
@media (prefers-reduced-motion: reduce) {
  .progress--indeterminate .progress__bar { width: 100%; transform: none; animation: none; }
}
/* Wyśrodkowany panel poczekajki (Humi „Deepening questions"): duży nagłówek,
   pasek postępu na całą szerokość kolumny, podtekst — zamiast dawnego lewego
   alert--info + paska pod spodem (status 'pytania' w course.html). Odstępy
   niesie gap panelu, więc reużyty pasek gubi tu własny margin-bottom. Bez
   width:100% .progress skurczyłby się do zera jako element flex align-center. */
.wait-panel { max-width: 520px; margin: 56px auto; display: flex; flex-direction: column; align-items: center; gap: 18px; text-align: center; }
.wait-panel__title { margin: 0; font-size: 26px; font-weight: 800; letter-spacing: -0.01em; }
.wait-panel__text { margin: 0; color: var(--color-fg-muted); font-size: 15px; }
.wait-panel .progress { width: 100%; margin-bottom: 0; }
.wait-panel .progress__stage { margin-top: 0; }

/* Odznaka pokrycia nad pytaniami szczegółowymi (Humi „Coverage: …") —
   wyśrodkowany wiersz etykieta + plakietka. Kolor plakietki niesie poziom
   (coverage-badge-class w course.html). */
.coverage { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 16px; }
.coverage__label { color: var(--color-fg-muted); font-weight: 600; }
/* Notka „słabe pokrycie" tuż nad pytaniami (nie pełną kartą). */
.coverage-notice { margin-bottom: 14px; }
/* „Continue"/„Wyślij" pełnej szerokości pod kartą (Humi). btn--block to ogólny
   modyfikator — przycisk na całą szerokość rodzica. */
.wait-submit { margin-top: 16px; }
.btn--block { width: 100%; }
/* Prawdziwy pasek postępu odpowiedzi w kreatorze (course.html, gałąź
   'odpowiedzi'): reużywa .progress/.progress__bar, dokłada tylko odstęp pod
   listą pytań i przygaszoną etykietę „N / M · P%" pod paskiem. Wartości
   podnosi answers-progress.js na żywo. */
.answer-progress { margin-bottom: 18px; }
.answer-progress__label { margin-top: 6px; font-size: 13px; color: var(--color-fg-muted); }

/* ── „Baza wiedzy" (Resources) — układ z Humi, nasze tokeny ──────────────
   Podtytuł nagłówka, karty plików (ikona + nazwa/meta + czas/status/menu)
   i strefa wgrywania z przerywaną ramką. */
.page-header__subtitle { margin: var(--space-2) 0 0; color: var(--color-fg-muted); font-size: var(--fs-lg); line-height: var(--lh-lg); }
.card__subtitle { margin: 4px 0 0; color: var(--color-fg-muted); font-size: 13px; }

/* Ze starej, KARTOWEJ listy plikow bazy wiedzy zostalo tu jedno gniazdo:
   kafelek ikony w komorce tabeli. Reszta bloku (.kb-list, .kb-file,
   .kb-file__main/__name/__meta/__side/__ago — 10 regul) odeszla razem
   z kartami, gdy ekran przeszedl na tabele 17.08; zadna z tych klas nie
   wystepowala juz w zadnym szablonie ani skrypcie. */
.kb-file__icon { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border-radius: var(--radius-sm); background: var(--color-surface-muted); color: var(--color-fg-muted); }
.kb-file__icon svg { width: 20px; height: 20px; }

/* Menu „…" — cały komponent stoi w sekcji edytora lekcji niżej. Stała tu
   JEGO DRUGA, pełna kopia (sześć reguł: .row-menu, __btn, __panel, __item,
   :hover, --danger), różniąca się paddingiem, szerokością i rozmiarem pisma.
   Wygrywała ta dalsza, więc ta była martwa — i myliła każdego, kto czytał
   arkusz od góry. */

/* „Uprawnienia ról" (zakładka Zespół, Humi: „Role Permissions") — karta na
   rolę, wypunktowanie tego, co dana rola może zrobić. */
/* 1:1 z sekcją „Role Permissions" w team/+page.svelte (humi):
     pudełko `rounded-lg border p-4`, tytuł `mb-2 font-semibold text-lg`,
     lista `text-muted-foreground space-y-1 text-sm`. Było 14/16 px paddingu,
     tytuł 14 px waga 700, lista 13 px. */
.role-permissions { border: 1px solid var(--color-border); border-radius: var(--radius); padding: var(--space-6); }
.role-permissions__title { margin: 0 0 var(--space-3); font-size: var(--fs-lg); line-height: var(--lh-lg); font-weight: 600; text-transform: capitalize; }
.role-permissions__list { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: var(--space-2); font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-fg-muted); }
/* Role JEDNA POD DRUGĄ, każda na pełną szerokość (Eryk 24.08.2026: „nie chcę,
   żeby było tak poziomo rozpisane, tylko jak wcześniej pionowo"). Trzy kolumny
   łamały wypunktowanie po dwa-trzy słowa w linii; w pionie każdy punkt mieści
   się w jednej. Ten sam odstęp 12 px, który miała karta przed zwinięciem. */
.role-permissions-stack { display: flex; flex-direction: column; gap: var(--space-5); }

/* ---------- rozwijany pasek (.disclosure) ----------
   Nagłówek, który JEST przyciskiem: klikasz pasek, treść rozwija się POD nim.
   Natywny <details>/<summary>, więc działa bez JS, ma klawiaturę i stan
   „rozwinięte" dla czytnika ekranu za darmo.

   Dlaczego osobny komponent, a nie <details class="card"> (Archiwum na /team,
   sekcje chunków w file.html): tamten wzorzec nie ma ŻADNEGO znaku, że da się
   go kliknąć — wygląda dokładnie jak nagłówek zwykłej karty i trzeba na niego
   wpaść. Tu chevron mówi „rozwiń" i obraca się po otwarciu, tak samo jak
   w naszym <select> (.select__chevron wyżej) — ten sam gest w tym samym
   designie.

   Pasek jest CELOWO niższy od .card__header (14 px zamiast 24 px pionowego
   oddechu, tytuł 16 px zamiast 20 px): zwinięty stan ma być paskiem w stosie
   kart, a nie pustą kartą udającą treść. */
.disclosure {
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); box-shadow: var(--shadow-sm);
}
.disclosure__bar {
  display: flex; align-items: center; gap: var(--space-5);
  padding: 14px var(--space-6); cursor: pointer; border-radius: var(--radius);
  /* Domyślny trójkącik przeglądarki znika na rzecz naszego chevronu — obie
     składnie są potrzebne (::marker w Firefoksie/Chrome, ::-webkit-details-marker
     w starszym Safari). */
  list-style: none;
}
.disclosure__bar::-webkit-details-marker { display: none; }
.disclosure__bar::marker { content: ""; }
.disclosure__bar:hover { background: var(--color-surface-muted); }
/* Po otwarciu pasek zrasta się z panelem: dolne rogi na płasko, kreska
   oddziela go od treści (tak samo jak nasz .select__panel odcina się od
   przycisku). */
.disclosure[open] > .disclosure__bar { border-radius: var(--radius) var(--radius) 0 0; border-bottom: 1px solid var(--color-border); }
.disclosure__title { margin: 0; font-size: var(--fs-lg); line-height: var(--lh-lg); font-weight: 600; letter-spacing: -.01em; }
/* Podpowiedź przy tytule, nie pod nim — to jedna linia, a pasek ma zostać
   paskiem. margin-left: auto dosuwa ją do chevronu, więc tytuł i strzałka
   trzymają krawędzie. */
.disclosure__hint { margin-left: auto; color: var(--color-fg-muted); font-size: var(--fs-md); line-height: var(--lh-md); }
.disclosure__chevron { flex: none; color: var(--color-fg-muted); transition: transform .15s ease; }
.disclosure[open] .disclosure__chevron { transform: rotate(180deg); }
.disclosure__panel { padding: var(--space-6); }
/* Na telefonie podpowiedź znika, a nie zawija pasek na trzy linie — tytuł
   i chevron niosą całą funkcję, podpowiedź jest dodatkiem. */
@media (max-width: 640px) {
  .disclosure__hint { display: none; }
}

/* Okno modalne (Humi: "Invite Team Member") — natywny <dialog> zamiast
   pierwszej wersji przez CSS :target (naprawa: Antek zauważył, że
   href="#invite-modal" zmienia adres i przeskakuje/„odświeża" widok —
   zmiana fragmentu w URL to realna nawigacja, której :target nie da się
   uniknąć). <dialog>.showModal() (mała ilość JS w team.html) daje za darmo
   to, czego CSS-owy trik nie miał: pułapkę fokusu, zamykanie Escape-em i
   ::backdrop bez dotykania adresu strony — ŻADNEGO przeładowania/skoku.
   .modal resetuje domyślny wygląd przeglądarki (ramka/padding/tło) do zera,
   bo <dialog> ma być tylko NIEWIDOCZNYM kontenerem dokładnie wielkości
   .modal__panel — dzięki temu klik na .modal__panel NIGDY nie trafia
   w sam <dialog> (kliknięcie w tło jest jedynym sposobem, w jaki event.target
   może wskazywać na <dialog>, patrz nasłuch w team.html). Wyśrodkowanie
   (poziome i pionowe) zostaje domyślne z arkusza przeglądarki dla
   `dialog:modal` — nie nadpisujemy go. */
/* DialogContent w humi: `w-full max-w-lg gap-4 border p-6 shadow-lg
   sm:rounded-lg`, nakładka `bg-black/80`. max-w-lg to 32rem = 512 px
   (było 480), a przyciemnienie tła 80% (było 55%). */
.modal { display: none; border: none; padding: 0; background: transparent; color: inherit; max-width: 512px; width: calc(100% - 32px); }
.modal[open] { display: block; }
.modal::backdrop { background: rgb(0 0 0 / 80%); }
.modal__panel {
  position: relative; max-height: 84vh; overflow-y: auto;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: var(--space-7);
}
.modal__close {
  position: absolute; top: 16px; right: 16px; width: 32px; height: 32px;
  display: grid; place-items: center; border-radius: var(--radius-sm);
  border: none; background: none; cursor: pointer;
  color: var(--color-fg-muted); font-size: 20px; line-height: 1;
}
.modal__close:hover { background: var(--color-surface-muted); color: var(--color-fg); }
/* DialogTitle `text-lg leading-none font-semibold tracking-tight`,
   DialogDescription `text-sm text-muted-foreground`, między nimi
   `space-y-1.5`, a od treści `gap-4`. DialogFooter `sm:justify-end
   sm:space-x-2`. Tytuł miał dotąd rozmiar dziedziczony z <h2>/<h3>, więc
   w każdym dialogu wychodził inny. */
/* Odstęp pod tytułem niesie .modal__subtitle, więc KAŻDE okno musi go mieć —
   bez podtytułu etykieta pierwszego pola przykleja się do nagłówka. Nie ma tu
   reguły `:has(+ .modal__subtitle)`, która załatwiłaby oba przypadki: :has()
   jest w tym arkuszu świadomie niedozwolone (patrz TestContentReservesSpaceForWorkBar
   — podnosi próg przeglądarki wyżej niż reszta pliku), a margines na samym
   tytule zlałby się przez collapse z marginesem podtytułu. Podtytuł nazywa
   edytowany byt, tak jak w oknach na /departments. */
.modal__title { margin: 0; padding-right: 32px; font-size: var(--fs-h4); line-height: 1; font-weight: 600; letter-spacing: -.025em; }
.modal__subtitle { color: var(--color-fg-muted); font-size: var(--fs-md); line-height: var(--lh-md); margin: 6px 0 var(--space-6); }
.modal__footer { display: flex; justify-content: flex-end; gap: var(--space-3); margin-top: var(--space-6); }
/* Pusty stan modala (dziś: „Sekcje kursu" bez sekcji) — ta sama szarość co
   podtytuł, wyśrodkowany, z oddechem, żeby okno nie wyglądało na zepsute. */
.modal__empty { color: var(--color-fg-muted); text-align: center; padding: var(--space-6) 0; }
/* Lista sekcji w modalu „Sekcje kursu" — wiersz: nazwa + licznik lekcji po
   lewej, ikony akcji po prawej. Kreska między wierszami jak w .list-table. */
.sections-list { list-style: none; margin: 0; padding: 0; }
.sections-list__item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-3) 0; }
.sections-list__item + .sections-list__item { border-top: 1px solid var(--color-border); }
.sections-list__text { min-width: 0; }
.sections-list__name { display: block; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sections-list__count { color: var(--color-fg-muted); font-size: var(--fs-sm); line-height: var(--lh-sm); }
/* Rząd ikon NIE ZAWIJA SIĘ (flex: none): cztery pełne przyciski z napisami
   łamały się na dwie linie w każdym wierszu i okno wyglądało jak ściana
   guzików zamiast listy sekcji. */
.sections-list__actions { display: flex; align-items: center; gap: 2px; flex: none; }
/* Przycisk ikonowy — kwadrat 32 px bez ramki, obrys dopiero na najechaniu.
   Sam kształt niczego nie obiecuje, więc etykietę niosą aria-label i title. */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; padding: 0;
  border: 0; border-radius: var(--radius-sm); background: transparent;
  color: var(--color-fg-muted); cursor: pointer;
}
.icon-btn:hover { background: var(--color-surface-muted); color: var(--color-fg); }
.icon-btn svg { width: 16px; height: 16px; }
.icon-btn--danger:hover { color: var(--color-danger); }

/* OKNO EDYCJI ĆWICZENIA (.exedit) — <dialog> na ~90% ekranu, wyśrodkowany.

   Nazwa bloku zmieniała się razem z kształtem, bo nazwa, która kłamie
   o kształcie, jest gorsza od żadnej: .drawer (szuflada, luty→sierpień),
   potem .sheet (arkusz, wzór `<Sheet.Content side="right">` z humi), teraz
   .exedit — edytor ćwiczenia, i tyle. UWAGA: `drawer` znaczy w tym repo
   jeszcze jedno, NIETKNIĘTE: data-drawer/drawer.js/.drawer-backdrop to
   szuflada MOBILNEJ NAWIGACJI.

   Dlaczego 90%, a nie 672 px przy krawędzi. Zamawiający odrzucił szufladę
   dwa razy tymi samymi słowami („nie chcemy tego toast z prawej strony,
   chcemy okienko na środku") i za drugim razem podał miarę: okno na ~90%
   szerokości i wysokości ekranu. Powód jest praktyczny, nie estetyczny —
   w tym oknie pisze się AKAPITY czytanki. Kolumna 672 px łamała zdanie na
   trzy linie i to przy niej treść wyglądała na „surowe dane", a przy prawej
   krawędzi okno zasłaniało listę ćwiczeń, od której się je otwiera.

   Natywny <dialog>.showModal() (ten sam wzorzec co .modal wyżej) daje za
   darmo pułapkę fokusu, Escape i ::backdrop bez dotykania adresu strony.
   .exedit resetuje domyślny wygląd <dialog> z tego samego powodu co .modal:
   klik na .exedit__panel nigdy nie trafia w sam <dialog>, więc „klik w tło
   zamyka" (lesson.html) bezpiecznie testuje event.target === dialog.

   Nadpisania inset/margin/max-* są KONIECZNE, nie kosmetyczne: arkusz
   przeglądarki ogranicza <dialog> do `max-width: calc(100% - 6px - 2em)`
   i `max-height` liczonego tak samo, więc bez nich okno nie dochodzi do
   90% ani w szerokości, ani w wysokości. `margin: auto` przy `inset: 0`
   to samo wyśrodkowanie — w obu osiach. */
.exedit {
  display: none; border: none; padding: 0; background: transparent; color: inherit;
  position: fixed; inset: 0; margin: auto;
  width: 90vw; max-width: none;
  /* dvh, nie vh: na telefonie pasek adresu zjada część vh i stopka okna
     (Zapisz/Anuluj) wychodziła pod krawędź ekranu. */
  height: 90dvh; max-height: none;
}
.exedit[open] { display: block; }
/* Przyciemnienie jak przy .modal (bg-black/80 z DialogOverlay): okno zajmuje
   90% ekranu, więc to, co zostaje widoczne wokół, jest już tylko ramką — nie
   ma czego pokazywać przez jaśniejszą nakładkę. */
.exedit::backdrop { background: rgb(0 0 0 / 80%); }
/* overflow: hidden na panelu, przewijają się DOPIERO kolumny w środku
   (.exedit__main). Inaczej przewijałby się cały panel razem
   z nagłówkiem i stopką, a te dwie rzeczy mają stać w miejscu: nagłówek mówi,
   które ćwiczenie się edytuje, a stopka trzyma „Zapisz" pod ręką przy treści
   na kilka ekranów. */
.exedit__panel {
  display: flex; flex-direction: column; height: 100%; overflow: hidden;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-lg);
  animation: exedit-in .18s ease-out;
}
@media (prefers-reduced-motion: reduce) { .exedit__panel { animation: none; } }
/* Przybliżenie od ŚRODKA, nie wjazd z krawędzi: okno pojawia się tam, gdzie
   jest — nic nie przyjeżdża, więc ruch nie ma czego udawać. Krótko (180 ms),
   bo to okno otwiera się kliknięciem w wiersz listy i czeka się na nie. */
@keyframes exedit-in { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } }
.exedit__head {
  flex: none; display: flex; align-items: flex-start; justify-content: space-between;
  gap: 12px; padding: var(--space-6) var(--space-7);
  border-bottom: 1px solid var(--color-border);
}
.exedit__heading { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.exedit__title { margin: 0; font-size: var(--fs-h4); line-height: 1.2; font-weight: 600; letter-spacing: -.025em; }
.exedit__sub { margin: 0; font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-fg-muted); }
/* Ikona TYPU ćwiczenia przy tytule — `<ExerciseIcon size={20} class={...}/>`
   z nagłówka ExerciseEditSheet w humi. Kolor niesie klasa .extype--* (sekcja
   listy ćwiczeń niżej), ta sama, którą maluje się ikona w wierszu listy: jeden
   typ = jeden kolor i jeden rysunek w całej aplikacji. */
.exedit__icon { flex: none; width: 20px; height: 20px; margin-top: 3px; }
.exedit__icon svg { width: 20px; height: 20px; }
.exedit__head-left { display: flex; align-items: flex-start; gap: var(--space-3); min-width: 0; }
.exedit__close {
  width: 32px; height: 32px; flex: none; display: grid; place-items: center; border-radius: var(--radius-sm);
  border: none; background: none; cursor: pointer; color: var(--color-fg-muted); font-size: 20px; line-height: 1;
}
.exedit__close:hover { background: var(--color-surface-muted); color: var(--color-fg); }
/* KORPUS: JEDNA kolumna z kartą formularza (.exform niżej), wyśrodkowana.
   Była tu przez jeden dzień druga kolumna („kontekst": podgląd dla kursanta,
   zdjęcia, podsumowanie) — mój wymysł, nie referencja. Wypada, bo zamówienie
   brzmi: layout formularzy ćwiczeń 1:1 z humi, „różnice tylko w danych, nie
   w layoucie", a w humi cała edycja jednego ćwiczenia to JEDNA karta:
   obrazki, pola, znacznik ostatniej edycji, i tyle.
   min-height: 0 jest WARUNKIEM PRZEWIJANIA, nie kosmetyką — element flex ma
   domyślnie min-height: auto, więc bez tego korpus rozpycha panel na wysokość
   treści i przewijanie przenosi się na całe okno (czyli nigdzie, bo panel ma
   overflow: hidden).
   Tło STRONY, nie panelu: karta formularza jest wtedy kartką na biurku,
   a nie białym prostokątem na białym. Przy 1920 px, gdzie miara wiersza
   (niżej) zostawia po bokach szeroki margines, to jedyna rzecz, która
   odróżnia „marginesy" od „nie doczytało się". */
.exedit__main { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; padding: 0 var(--space-7); background: var(--color-bg); }
/* Miara wiersza, nie cała szerokość kolumny. Przy 1920 px okno ma 1728 px,
   a wiersz tekstu przez cały ekran czyta się źle w każdym edytorze — dlatego
   Docs, Notion i Word robią dokładnie to samo: pole pisania wyśrodkowane,
   marginesy po bokach. 880 px to ~90 znaków w tym kroju. */
/* Odstęp GÓRNY I DOLNY siedzi tutaj, nie w .exedit__main — i to jest warunek
   działania przyklejonego paska narzędzi. Element `position: sticky` przykleja
   się do KRAWĘDZI OBSZARU TREŚCI portu przewijania, czyli za jego paddingiem:
   dopóki padding-top był na .exedit__main, nad paskiem zostawał pasek 24 px,
   w którym przejeżdżała przewijana treść — pierwszy wiersz wyglądał na ucięty
   w połowie. Odstęp postawiony na kolumnie po prostu odjeżdża z treścią. */
.exedit__col { max-width: 880px; margin-inline: auto; padding-block: var(--space-7); display: flex; flex-direction: column; }
/* STOPKA na poziomie PANELU, nie w formularzu: przy czytance na kilka ekranów
   „Zapisz" schowany na dole przewijanej kolumny trzeba by szukać przewijaniem.
   Przyciski wiąże z formularzem atrybut `form=` (ten sam sposób, w który
   formularz weryfikacji stoi obok, a nie w środku — HTML nie pozwala
   zagnieżdżać <form>). Weryfikacja po lewej, zapis/anuluj po prawej. */
.exedit__footer {
  flex: none; display: flex; align-items: center; justify-content: space-between;
  gap: 10px; flex-wrap: wrap; padding: var(--space-5) var(--space-7);
  border-top: 1px solid var(--color-border); background: var(--color-surface);
}
/* Lewa strona stopki: weryfikacja i usunięcie ćwiczenia. Dwie akcje o różnym
   ciężarze stoją razem po lewej, żeby prawa strona została tym, czym jest
   w referencji — wyjściem z okna („Anuluj" i „Zapisz zmiany"). */
.exedit__footer-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.exedit__footer-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.exedit__footer-right .editor__autosave-status { margin: 0; }
/* ══════════════════════════════════════════════════════════════════════════
   FORMULARZ ĆWICZENIA (.exform) — PRZENIESIONY 1:1 Z HUMI ONBOARDER
   ══════════════════════════════════════════════════════════════════════════
   Źródło: onboarder-frontend/src/lib/components/exercises/*Preview.svelte
   (QuizPreview, OpenQuestionPreview, FillTheGapPreview, MatchingPreview,
   ReadingPreview, VideoPreview) — paczka warstwy UI od Kamila. Zamówienie
   brzmiało: „Kradniemy UI z humionboarder 1:1. Ten sam layout formularzy
   ćwiczeń, ta sama lista odpowiedzi z zaznaczeniem poprawnej, te same odstępy
   i typografia. Nie wymyślamy nic nowego".

   Klasy tamtejsze są Tailwindowe (w atrybucie class), więc „1:1" znaczy tu:
   te same wartości, przepisane na reguły. Przy każdej regule stoi jej
   odpowiednik w referencji — inaczej za tydzień nikt nie odtworzy, skąd się
   wzięło 48 px zamiast 40 i dlaczego zieleń jest tu, a nie w akcencie.

   CZEGO NIE DA SIĘ PRZENIEŚĆ, i dlaczego (świadome odstępstwa):
   1. EDYTOWALNY TYTUŁ w karcie. W humi każda karta ma u góry contenteditable
      z tytułem ćwiczenia, a PATCH /api/exercises/{id} przyjmuje pole `title`.
      U nas tytuł ćwiczenia NIE JEST przechowywany: liczy go Go z treści
      (exerciseRowTitle w courses.go — quiz i pytanie otwarte dostają swoje
      pytanie, czytanka i wideo „Typ — tytuł lekcji", dopasowanie liczbę par).
      Pole, które nie ma gdzie się zapisać, jest gorsze niż jego brak, a samo
      pytanie quizu edytuje się i tak — w miejscu, w którym w humi stoi tytuł.
   2. „Add Image" otwiera w humi modal z biblioteką obrazów całej organizacji
      (ImageSelectorModal.svelte). U nas ten sam przycisk rozwija <details>
      z pulą obrazów WYCIĄGNIĘTYCH Z PLIKÓW ŹRÓDŁOWYCH kursu — inny zbiór
      i inna trasa, bo naszej biblioteki obrazów nie ma.
   3. Pasek narzędzi czytanki. Humi ma pływające menu pojawiające się nad
      ZAZNACZENIEM (TiptapEditor.svelte), bez linku, cytatu i kodu. U nas jest
      pasek przyklejony u góry pola i z tymi trzema narzędziami, bo tak brzmiał
      wcześniejszy, osobno odebrany zakres („Integracja Tip Tap z toolbarem:
      H1–H3, bold, italic, listy, link, cytat, kod") — i dlatego zostaje.
      Samo POLE edycji dostaje wygląd stamtąd (patrz „POLE EDYCJI CZYTANKI"
      na końcu tego bloku).
   4. Luki. W humi fill_the_gap to JEDNA luka i lista opcji z jedną poprawną.
      U nas luk jest N ({{1}}, {{2}}…), a każda ma swój zestaw dopuszczalnych
      odpowiedzi (alternatywy po ukośniku) — model jest inny, więc przenosimy
      język wizualny (nagłówek pomocy, pole pytania, wiersze z koszem, guzik
      z przerywaną ramką), a nie liczbę pól. */

/* Karta = `<Card class="p-8">` / `p-6`. Sam prymityw Card to w humi
   `bg-card text-card-foreground rounded-lg border shadow-xs`. p-8 mają quiz
   i pytanie otwarte, p-6 czytanka, luki, dopasowanie i wideo — stąd modyfikator
   nazwany wprost tamtą klasą, żeby porównanie ze źródłem było mechaniczne. */
.exform {
  background: var(--color-surface); color: var(--color-fg);
  border: 1px solid var(--color-border); border-radius: var(--radius);
  box-shadow: var(--shadow-sm); padding: var(--space-7);
}
.exform--p8 { padding: var(--space-8); }
/* `space-y-6` / `space-y-4` — odstęp MIĘDZY dziećmi, nie margines na nich. */
.exform__stack > * + *:not(dialog) { margin-top: var(--space-7); }
/* Formularz jest w karcie zwykłym pojemnikiem — bez ramki, tła i paddingu.
   Klasy .editor__form (obramowana, przygaszona karta z sekcji formularzy
   niżej) świadomie tu nie ma: karta jest jedna, .exform, a druga ramka wokół
   pól dawałaby czwarte zagnieżdżenie z rzędu. Sam <form> nosi tylko jeden ze
   stosów odstępów (space-y-6 albo space-y-4, zależnie od typu). */
.exform__form { display: block; }
.exform__stack--4 > * + *:not(dialog) { margin-top: var(--space-6); }
/* Zdanie pomocy pod tytułem: `text-sm text-slate-500` (luki, dopasowanie). */
.exform__hint { margin: 0; font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-fg-muted); }
/* Etykieta pola: `mb-2 text-sm font-medium text-gray-700`. Blokowa, bo w
   referencji jest osobnym <div> nad polem, nie inline'em przy nim. */
.exform__label { display: block; margin-bottom: var(--space-3); font-size: var(--fs-md); line-height: var(--lh-md); font-weight: 500; color: var(--color-fg-soft); }
/* Pole treści: `w-full rounded-md border border-gray-200 p-3 text-base
   leading-relaxed text-gray-700`, fokus `border-emerald-500 ring-1
   ring-emerald-500`. Pierścień robimy box-shadow — tak samo działa `ring-1`
   w Tailwindzie (to nie outline). */
.exform__box {
  display: block; width: 100%; box-sizing: border-box;
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-surface); color: var(--color-fg-soft);
  padding: var(--space-5); font: inherit; font-size: var(--fs-base); line-height: 1.625;
  resize: vertical;
}
.exform__box:focus, .exform__box:focus-visible {
  outline: none; border-color: var(--color-edit-ring);
  box-shadow: 0 0 0 1px var(--color-edit-ring);
}
/* Wysokości minimalne z referencji: pytanie luk `min-h-[80px]`, odpowiedź
   modelowa (u nas: kryteria oceny) `min-h-32` (128), podsumowanie czytanki
   `min-h-[60px]`, skrypt wideo `min-h-[200px]`. Pola `min-h-24` nie ma, bo
   pytanie otwarte trzyma treść w tytule karty, nie w polu pod nim. */
.exform__box--80 { min-height: 80px; }
.exform__box--128 { min-height: 128px; }
.exform__box--60 { min-height: 60px; }
.exform__box--200 { min-height: 200px; }
/* Odpowiedź modelowa stoi w humi na `bg-gray-50` — jedyne pole z własnym
   tłem, i to jest jego jedyne oznaczenie „to nie jest pole do wpisywania
   przez kursanta". */
.exform__box--muted { background: var(--color-surface-subtle); }
/* Podpis pod polem: `mt-1 text-xs text-slate-500`. */
.exform__note { margin: var(--space-2) 0 0; font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted); }

/* Tytuł karty: `-m-3 min-h-[3rem] rounded-md p-3 text-lg font-medium` z fokusem
   `ring-1 ring-emerald-600` i BEZ obramowania — w referencji to contenteditable
   <h2>, które wygląda jak nagłówek, a nie jak pole. Ujemny margines cofa
   padding, żeby tekst stał w linii z resztą karty, dopóki się w niego nie
   kliknie.
   U nas jest tu <textarea>, bo to pole formularza, nie stan w JS: pytanie quizu
   i pytanie otwarte JEST tym, co w humi nosi tytuł (tytuł ćwiczenia liczy się
   u nas z treści, patrz odstępstwo 1 wyżej). Pozostałe trzy typy tego pola nie
   mają — ich tytuł to „Czytanka — <lekcja>", „Dopasowanie — N par" i sam tekst
   z lukami, czyli rzeczy wyliczane albo stojące już w polu niżej; wstawienie
   drugiej kopii tego samego byłoby powrotem do podwójnego podglądu, który
   zniknął stąd 21.08. */
.exform__title {
  /* resize: none i wysokość liczona ze treści (data-autogrow w lesson.html):
     w referencji to contenteditable, które rośnie samo, więc ani uchwytu
     zmiany rozmiaru, ani paska przewijania w polu tam nie ma. */
  display: block; box-sizing: border-box; resize: none; overflow: hidden;
  margin: -12px; padding: var(--space-5); min-height: 48px;
  border: 0; border-radius: var(--radius-sm); background: none;
  color: var(--color-fg); font: inherit;
  font-size: var(--fs-lg); line-height: var(--lh-lg); font-weight: 500;
  /* Szerokość musi urosnąć o oba ujemne marginesy, inaczej pole kończy się
     24 px przed prawą krawędzią karty. */
  width: calc(100% + 24px);
}
.exform__title:focus, .exform__title:focus-visible { outline: none; box-shadow: 0 0 0 1px var(--color-edit-fill); }

/* ————— QUIZ: litera + opcja (QuizPreview) —————
   `flex items-start`; przycisk litery `h-12 w-12 shrink-0 rounded-md border`,
   pole opcji `-ml-2 h-12 grow rounded-md border p-3 pl-5`. Ujemny margines
   wsuwa pole POD kwadrat z literą, żeby stykały się bez szpary — dlatego
   litera ma w referencji `z-10`. */
.exform__opts > * + *:not(dialog) { margin-top: var(--space-6); }
.exform__opt { display: flex; align-items: flex-start; }
/* Litera jest <label> sprzężoną z ukrytym radiem (kontrakt formularza to
   `correct` = indeks, patrz generatedExerciseFromForm) — nie <button>, jak
   w humi, bo tam stan trzyma JS. Radio zostaje w drzewie dostępności (.sr-only,
   nie display:none), więc Tab i strzałki działają natywnie, a `:checked ~`
   przemalowuje literę BEZ :has() — ten sam zakaz i ten sam idiom, co przy
   „✕ Usuń" w edytorze planu (patrz .lesson-editor wyżej). */
.exform__letter {
  position: relative; z-index: 1; flex: none;
  width: 48px; height: 48px; display: grid; place-items: center; cursor: pointer;
  border: 1px solid var(--color-border-strong); border-radius: var(--radius-sm);
  background: var(--color-surface-muted); color: var(--color-fg-faint);
  font-size: var(--fs-base); line-height: 1;
  transition: background-color .15s, color .15s, border-color .15s;
}
/* Poprawna odpowiedź: `border-emerald-600 bg-emerald-600 font-bold text-white`.
   To jest CAŁE „widać na pierwszy rzut oka, która odpowiedź jest poprawna" —
   wypełniony kwadrat, nie kropka w radiu. */
.exform__radio:checked ~ .exform__letter {
  border-color: var(--color-edit-fill); background: var(--color-edit-fill);
  color: var(--color-edit-fill-fg); font-weight: 700;
}
/* Fokus MUSI być widoczny na etykiecie: samo radio jest schowane, więc bez
   tego nawigacja klawiaturą po opcjach quizu nie pokazywałaby, gdzie stoi. */
.exform__radio:focus-visible ~ .exform__letter { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.exform__optbox {
  margin-left: -8px; flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box;
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-surface); color: var(--color-fg-muted);
  padding: var(--space-5) var(--space-5) var(--space-5) 20px; font: inherit; font-size: var(--fs-base);
}
.exform__optbox:focus, .exform__optbox:focus-visible {
  outline: none; border-color: transparent; box-shadow: 0 0 0 1px var(--color-edit-fill);
}

/* ————— WIERSZ Z KOSZEM (luki: opcje; dopasowanie: pary) —————
   `flex items-center gap-2` + przycisk-duszek `h-8 w-8 text-gray-500
   hover:text-red-500`. */
.exform__rows > * + *:not(dialog) { margin-top: var(--space-3); }
.exform__row { display: flex; align-items: center; gap: var(--space-3); }
.exform__row .exform__box { flex: 1 1 auto; min-width: 0; padding: var(--space-3); font-size: var(--fs-md); line-height: var(--lh-md); min-height: 0; }
.exform__iconbtn {
  flex: none; width: 32px; height: 32px; display: grid; place-items: center;
  border: 0; border-radius: var(--radius-sm); background: none; cursor: pointer;
  color: var(--color-fg-muted);
}
.exform__iconbtn:hover { color: var(--color-danger); background: var(--color-surface-muted); }
.exform__iconbtn[disabled] { opacity: .4; cursor: default; }
.exform__iconbtn svg { width: 16px; height: 16px; }
/* Strzałki kolejności par: `h-7 w-7 text-gray-400`, jedna nad drugą. */
.exform__move { display: flex; flex-direction: column; }
.exform__move .exform__iconbtn { width: 28px; height: 28px; color: var(--color-fg-faint); }
.exform__move .exform__iconbtn:hover:not([disabled]) { color: var(--color-fg); }
/* Numer luki w miejscu, w którym referencja ma kółko „to jest poprawna
   odpowiedź" — nasz model luk nie ma opcji do zaznaczania, ma NUMER luki
   ({{1}} w tekście) i zestaw dopuszczalnych odpowiedzi w wierszu obok.
   Ta sama szerokość co tamten przycisk (32 px), żeby wiersze stały w kolumnie. */
.exform__num {
  flex: none; width: 32px; height: 32px; display: grid; place-items: center;
  font-size: var(--fs-md); font-weight: 500; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
/* ————— DOPASOWANIE: siatka par —————
   `grid grid-cols-[auto_1fr_1fr_auto] items-center gap-3`, nagłówki kolumn
   w pierwszym wierszu, „mb-3" między parami. */
.exform__grid { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto; align-items: center; gap: var(--space-5); }
.exform__grid + .exform__grid { margin-top: var(--space-5); }
/* Wiersz nagłówków kolumn: `mb-4` w referencji, czyli więcej niż odstęp
   między parami — nagłówek nie jest jedną z par. */
.exform__grid--head { margin-bottom: var(--space-6); }
.exform__grid .exform__box { padding: var(--space-3) var(--space-5); font-size: var(--fs-md); line-height: var(--lh-md); min-height: 0; }
.exform__colhead { font-size: var(--fs-md); line-height: var(--lh-md); font-weight: 500; color: var(--color-fg-soft); }

/* ————— GUZIKI DODAWANIA —————
   „Add Option"/„Add Pair": `mt-2 flex w-full items-center justify-center
   gap-1.5 rounded-md border border-dashed border-gray-300 p-2 text-sm
   text-emerald-600 hover:bg-gray-50`. */
.exform__add {
  margin-top: var(--space-3); width: 100%;
  display: flex; align-items: center; justify-content: center; gap: 6px;
  border: 1px dashed var(--color-border-strong); border-radius: var(--radius-sm);
  background: none; cursor: pointer; padding: var(--space-3);
  font: inherit; font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-edit);
}
.exform__add:hover { background: var(--color-surface-subtle); }
.exform__add svg { width: 16px; height: 16px; }
/* „Add Image": bez ramki, `gap-1.5 px-3 py-1.5 text-sm text-emerald-600`.
   U nas to <summary> rozwijanego <details> z pulą obrazów kursu. */
.exform__addimg {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  padding: 6px var(--space-5); border-radius: var(--radius-sm);
  font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-edit); list-style: none;
}
.exform__addimg::-webkit-details-marker { display: none; }
.exform__addimg:hover { color: var(--color-edit-deep); }
.exform__addimg svg { width: 18px; height: 18px; }

/* ————— STOPKA KARTY —————
   `flex justify-between text-sm text-slate-400` — po lewej „Correct Answer: A"
   (tylko quiz), po prawej „Last edit: …". Kolor jest JAŚNIEJSZY niż
   --color-fg-muted, bo w referencji to slate 400, nie 500. */
.exform__meta { display: flex; align-items: center; justify-content: space-between; gap: var(--space-5); font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-meta-fg); }
.exform__meta--right { justify-content: flex-end; }

/* ————— POLE EDYCJI CZYTANKI —————
   Wygląd pola z TiptapEditor.svelte: `min-h-[200px] w-full rounded-md border
   border-gray-200 p-3 text-base leading-relaxed text-gray-700` i fokus
   `focus-within:border-emerald-500 focus-within:ring-1`. Nadpisuje .tiptap
   z sekcji wyżej (nasz pasek narzędzi zostaje, patrz odstępstwo 3 na górze
   tego bloku); dlatego to nie jest osobny komponent, tylko dwie deklaracje
   na tym samym elemencie. */
.exform .tiptap { border-color: var(--color-border); }
.exform .tiptap:focus-within { border-color: var(--color-edit-ring); box-shadow: 0 0 0 1px var(--color-edit-ring); }
.exform .tiptap .ProseMirror { padding: var(--space-5); color: var(--color-fg-soft); font-size: var(--fs-base); line-height: 1.625; }

/* ————— WIDEO (VideoPreview) —————
   Podgląd: `h-96` (384 px) i styl wpisany w referencji wprost w atrybucie
   style — promień 20 px, cień 0 0 20px rgba(0,0,0,.2). „Download video" to
   `inline-flex gap-2 rounded-md border border-gray-200 px-3 py-1.5
   font-medium text-gray-700 hover:bg-gray-50`. */
.exform__video { display: block; width: 100%; height: 384px; border-radius: 20px; box-shadow: 0 0 20px rgb(0 0 0 / 20%); background: #000; }
.exform__download {
  display: inline-flex; align-items: center; gap: var(--space-3);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  padding: 6px var(--space-5); font-size: var(--fs-md); line-height: var(--lh-md);
  font-weight: 500; color: var(--color-fg-soft); text-decoration: none;
}
.exform__download:hover { background: var(--color-surface-subtle); }
.exform__download svg { width: 16px; height: 16px; }

/* Wąskie okno: dwie kolumny tekstu obok siebie się nie zmieszczą, więc prawa
   strona pary schodzi POD lewą. Strzałki i kosz obejmują wtedy oba wiersze
   (grid-row: span 2), a nagłówki kolumn znikają — przy ułożeniu jedno pod
   drugim nie opisują już tego, co jest pod nimi; rozpoznanie pola bierze na
   siebie placeholder („Lewa"/„Prawa"), tak samo jak w referencji
   („Left column item"/„Right column item"). */
@media (max-width: 700px) {
  .exform { padding: var(--space-6); }
  .exform--p8 { padding: var(--space-6); }
  .exform__grid { grid-template-columns: auto minmax(0, 1fr) auto; }
  .exform__grid > .exform__box { grid-column: 2; }
  .exform__grid > .exform__move, .exform__grid > .exform__iconbtn { grid-row: span 2; }
  .exform__grid--head { display: none; }
}
/* Telefon i wąskie okno: okno na CAŁY ekran (na 90% zostaje ramka, w której
   nic nie widać, a miejsca na pisanie brakuje), kolumny jedna pod drugą,
   kontekst POD polami — pisanie jest tym, po co się tu wchodzi. */
@media (max-width: 900px) {
  .exedit { width: 100vw; height: 100dvh; }
  .exedit__panel { border: 0; border-radius: 0; }
  .exedit__head { padding: var(--space-6); }
  .exedit__main { padding: 0 var(--space-6); }
}

/* Strefa wgrywania z przerywaną ramką. Input pliku ukryty (hidden) — klik
   w <label class="dropzone__choose"> i tak otwiera okno wyboru. */
.dropzone { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-5); border: 2px dashed var(--color-border); border-radius: var(--radius-lg); padding: var(--space-8) var(--space-7); }
.dropzone__icon { width: 32px; height: 32px; color: var(--color-fg-muted); }
.dropzone__title { font-size: var(--fs-h4); line-height: var(--lh-h4); font-weight: 700; }
.dropzone__hint { margin: 0; max-width: 520px; font-size: 13px; color: var(--color-fg-muted); line-height: 1.5; }
.dropzone__dept { margin: 4px 0 0; }
.dropzone__dept .field__control { min-width: 220px; }
.dropzone__choose { cursor: pointer; }
/* POSTĘP WGRYWANIA WYPROWADZONY Z RAMKI (30.08.2026, zgłoszenie „nie chcę,
   żeby jakakolwiek informacja pojawiała się w tym okienku — chcę wgrywanie
   od razu w tabeli i cały progres tam"). Nazwa pliku, pasek, procenty
   i odliczanie jadą teraz w tymczasowym WIERSZU tabeli (knowledge.html,
   [data-upload-row]), więc ubierają je te same reguły, co każdy inny wiersz
   listy: .progress, .badge--running i .file-eta niżej. Stąd zniknięcie
   .dropzone__uploading, .dropzone__file, .dropzone__state, .dropzone__stage,
   .dropzone__percent i .dropzone__eta — nie miały już czego malować.
   W ramce zostaje wyłącznie .dropzone__error (odmowa przyjęcia pliku). */
/* Odliczanie w WIERSZU listy plików (knowledge.html, [data-file-eta]). Pod
   paskiem, przygaszone — ma być dostępne dla oka, które go szuka, a nie
   konkurować z nazwą pliku. tabular-nums: bez tego liczba drga w poziomie
   przy każdej zmianie cyfry, a ta zmienia się co sekundę. */
.file-eta { margin-top: var(--space-2); font-size: 12px; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
.dropzone__error { margin: 0; font-size: 13px; color: var(--color-danger); }
/* STREFA UPUSZCZENIA NA CAŁĄ KOLUMNĘ TREŚCI (knowledge.html,
   [data-drop-overlay]) — w chwili, gdy nad okno wjeżdża przeciągany plik, cały
   obszar treści robi się niebieski i mówi „wrzuć tutaj". Wzorzec z WeTransfer,
   powód ten sam: ramka wybieraka stoi POD listą plików, więc przy dłuższej
   liście trzeba było celować w prostokąt poza ekranem.

   Niebieski jest tu jedynym kolorem SPOZA palety interfejsu (ta jest szara,
   akcent to slate 900) — i to celowo: plansza ma czytać się jako stan
   przejściowy „upuść, a coś się stanie", a nie jako kolejna karta. Ten sam
   kolor w obu motywach, bo plansza zasłania to, co pod nią, i nie ma czego
   dopasowywać do tła. */
.drop-overlay {
  /* BEZ paska nawigacji: plansza zaczyna się tam, gdzie kończy się pasek, bo
     tylko kolumna treści jest tym, w co się upuszcza — zalanie paska ukryłoby
     nawigację, która z przeciąganiem pliku nie ma nic wspólnego. Dlatego
     `left: var(--sidebar-width)`, a nie `inset: 0`; ten sam token trzyma
     szerokość samego paska (.sidebar), więc obie krawędzie idą z jednej liczby.
     Na telefonie i tablecie w pionie pasek jest szufladą wysuwaną z lewej,
     nie kolumną układu (@media max-width: 1023px niżej) — tam plansza bierze
     całą szerokość. */
  position: fixed; top: 0; right: 0; bottom: 0; left: var(--sidebar-width);
  z-index: 100;
  /* Górny margines zdejmuje WYJĄTEK w regule rytmu pionowego (`.content >
     * + *:not(dialog):not(.drop-overlay)`, sekcja powłoki), a nie `margin: 0`
     tutaj: tamten selektor ma wyższą swoistość i wygrałby z każdą deklaracją
     w tym bloku. Bez wyjątku plansza stoi na y=32, z paskiem tła strony nad
     sobą — dokładnie ten objaw, dla którego <dialog> jest tam wyłączony. */
  display: flex; align-items: center; justify-content: center;
  padding: var(--space-6);
  /* PRZEZROCZYSTOŚĆ 92%, nie mniej: przez planszę ma być widać, że strona pod
     nią stoi na swoim miejscu, ale biały napis musi zostać czytelny. Zmierzone
     po złożeniu z jasnym tłem strony (#f4f4f5): 4,55:1 — ledwie powyżej progu
     AA 4,5:1 dla tekstu. Przy 90% wychodzi 4,32:1, czyli już poniżej, więc to
     nie jest liczba do „lekkiego podkręcenia" bez policzenia jej od nowa. */
  background: rgb(37 99 235 / 92%); color: #ffffff;
  /* Wjazd bez ruchu wyglądał jak przemalowanie ekranu w połowie
     przeciągnięcia; .12s wystarcza, żeby oko złapało, że to warstwa NAD
     stroną. Skrócony ruch (prefers-reduced-motion) zeruje czas globalną
     regułą na górze pliku. */
  animation: drop-overlay-in .12s ease-out;
}
/* [hidden] MUSI stać tu jawnie: `display: flex` wyżej bije wbudowane
   `display: none` atrybutu hidden (ta sama swoistość, dalsza pozycja w pliku),
   więc bez tej linii plansza zasłaniałaby treść OD ZAŁADOWANIA strony. */
.drop-overlay[hidden] { display: none; }
@media (max-width: 1023px) { .drop-overlay { left: 0; } }
@keyframes drop-overlay-in { from { opacity: 0; } to { opacity: 1; } }
/* Przerywana ramka wewnątrz planszy — ten sam znak „tu się upuszcza", co ramka
   wybieraka na dole strony, tylko odbity na biało. */
.drop-overlay__box {
  display: flex; flex-direction: column; align-items: center;
  text-align: center; gap: var(--space-4);
  width: 100%; max-width: 560px;
  padding: var(--space-8) var(--space-7);
  border: 3px dashed rgb(255 255 255 / 55%); border-radius: var(--radius-lg);
}
.drop-overlay__icon { width: 48px; height: 48px; }
.drop-overlay__title { font-size: var(--fs-h1); line-height: var(--lh-h1); font-weight: 700; letter-spacing: -.025em; }
/* Podpowiedź PEŁNĄ bielą, nie przygaszoną: na przezroczystym tle 80% biel
   zeszłaby z 4,55:1 na 3,6:1, a to zdanie (14 px) nie jest dużym tekstem
   i progu 4,5:1 nie ma z czego nadrobić. */
.drop-overlay__hint { margin: 0; font-size: 14px; line-height: 1.5; color: #ffffff; }
.progress__bar {
  display: block; height: 100%;
  /* `bg-primary h-full w-full flex-1 transition-all` — pełny kolor, nie
     gradient. Gradient z dwóch tokenów akcentu i tak przestał cokolwiek
     znaczyć: po przejściu na humi --color-accent i --color-accent-strong to
     TEN SAM slate 900. */
  background: var(--color-accent);
  transition: width .3s;
}
/* Etap tekstowy pod paskiem (course.html, "Generuję lekcję N z M") — bez tej
   reguły <p class="muted"> nie miał żadnego dopasowania w arkuszu i renderował
   się zwykłym, pełnym kolorem tekstu zamiast przygaszonym. */
.progress__stage { color: var(--color-fg-muted); }
/* Syntetyczny postęp czasowy (course.html, etapy 'pytania'/'plan') —
   "trwa dłużej niż zwykle" po przekroczeniu mediany, ten sam token
   ostrzegawczy co .work-bar__stale niżej (chwilowe wydłużenie, nie awaria —
   kolor błędu byłby tu przesadą). Modyfikator na .progress__stage, nie osobna
   reguła: reszta rozmiaru/marginesu ma zostać taka sama jak "Zwykle trwa
   około X min", zmienia się wyłącznie kolor i waga. */
.progress__stage--stale { color: var(--color-warn-fg); font-weight: 600; }
/* Licznik czasu raportu w toku (report.html, data-stage-clock) — mieszka
   WEWNĄTRZ .wait-panel (tego samego, co etapy kreatora kursu), więc szerokość
   i wyśrodkowanie bierze od panelu, a nie ustawia własnych: stąd width 100%
   i zero marginesów, odstępy niesie gap panelu.
   Pierwsza wersja stała luzem w kolumnie treści pod alert--info i to właśnie
   wyglądało „tak sobie" (zgłoszenie Antka 23.08) — patrz komentarz przy
   .wait-panel w report.html.
   Cyfry tabelarycznie (font-variant-numeric) z tego samego powodu co
   .file-eta: bez tego napis dyga przy każdej zmianie cyfry, a ta zmienia się
   co sekundę. */
.report-clock { width: 100%; display: flex; flex-direction: column; gap: var(--space-2); }
.report-clock .progress { margin-bottom: 0; }
.report-clock .progress__stage { margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; }
/* Licznik („~2 min 10 s pozostało") czyta się MOCNIEJ niż procent nad nim: to
   on jest odpowiedzią na pytanie „ile jeszcze czekać", z którym człowiek na
   ten ekran patrzy. Pełny kolor tekstu zamiast przygaszonego --color-fg-muted
   z .progress__stage, waga 600 — bez zmiany rozmiaru, żeby nie konkurował
   z nagłówkiem panelu. */
.report-clock .progress__stage--eta { color: var(--color-fg); font-weight: 600; }

/* ---------- lista stanu lekcji (course.html, pod paskiem generacji) ---------- */
.lesson-states { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.lesson-states__item { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
/* min-width: 0 pozwala tytułowi skurczyć się w wierszu flex zamiast
   rozpychać plakietkę poza kontener — domyślne min-width: auto na dziecku
   flex liczy się od długości najdłuższego nieprzełamanego słowa (ta sama
   pułapka co .list-row__main wyżej). */
.lesson-states__title { min-width: 0; overflow-wrap: anywhere; }

/* ---------- komunikaty ---------- */
/* Alert z humi: `relative w-full rounded-lg border p-4`. Było 12/14 px. */
.alert { border: 1px solid var(--color-border); border-radius: var(--radius); padding: var(--space-6); margin-bottom: var(--space-6); }
.alert--info    { background: var(--color-surface-muted); }
.alert--warning { background: var(--color-warn-bg);  color: var(--color-warn-fg);  border-color: transparent; }
/* BANER PODGLĄDU KURSU JAKO UCZEŃ — przyklejony do góry, NAD wszystkim (także
   nad paskiem górnym i graczem lekcji w trybie skupienia): to jedyna rzecz na
   ekranie, która mówi „patrzysz cudzymi oczami, nic się nie zapisuje", więc
   nie może odjechać z przewijaniem. Ostrzegawcza paleta, ta sama co
   .alert--warning — pasek informacyjny w szarości ginął w interfejsie. */
.preview-banner {
  position: sticky; top: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center; gap: var(--space-5);
  padding: var(--space-3) var(--space-6);
  background: var(--color-warn-bg); color: var(--color-warn-fg);
  font-size: var(--fs-sm); line-height: var(--lh-sm);
}
.preview-banner__text { font-weight: 600; }
.alert--error   { background: var(--color-error-bg); color: var(--color-error-fg); border-color: transparent; }

/* ---------- wiersz-karta (listy) ---------- */
.list-row {
  display: flex; align-items: center; gap: var(--space-5);
  /* 24 px z boków, nie 16: wiersz siedzi WPROST w .card (bez .card__body),
     więc bez tego jego treść stała o 8 px w lewo od tytułu karty — widać to
     było na /team jako uskok między „Użytkownicy" a nazwiskami pod spodem.
     W humi wiersze członków zespołu leżą w CardContent (`p-6`) i mają
     `py-4`, czyli dokładnie 24 px z boków i 16 px w pionie. */
  padding: var(--space-6) var(--space-7); border-top: 1px solid var(--color-border);
}
.list-row:first-child { border-top: 0; }
/* Wiersz, który CAŁY jest linkiem (lista raportów): kolor i podkreślenie
   dziedziczone, treść rozepchnięta na krawędzie. `.list-row` daje już flex
   i align-items: center, więc wariant dokłada tylko to, czego brakuje.
   Zastąpiło sześcioczłonowy atrybut style= na tym <a>. */
.list-row--link { justify-content: space-between; color: inherit; text-decoration: none; }
.list-row--link:hover { background: var(--color-surface-muted); }
/* Baza 200 px, nie 0: przy `flex: 1` (baza 0) rodzeństwo, które się nie
   zwęża (plakietka, <select>, przycisk), zostawiało treści 71 px na 768 px
   i adresy e-mail rozsypywały się na 3-5 linii po jednym znaku. Wiersz miał
   już flex-wrap: wrap, ale bez bazy flexbox nie ma czego zawijać — zna tylko
   ściskanie. Z bazą 200 px linia, na której nie mieści się treść razem
   z akcjami, po prostu się łamie. min-width: 0 zostaje, żeby długi adres
   dalej mógł się zwęzić poniżej bazy i złamać przez overflow-wrap.
   UWAGA: `.list-row__main` jest jedna, dzielona klasa — 200 px jest więc
   bazą GLOBALNĄ dla każdego `.list-row` w aplikacji (działy, zaproszenia,
   pliki bazy wiedzy, lekcje, panel zdrowia, zespół), nie tylko dla listy
   zespołu, na której zmierzono usterkę. Reguła jest bezpieczna z natury
   rzeczy (baza to tylko PROPOZYCJA szerokości, flex ściska ją dalej, gdy
   trzeba), ale przy zmianie tej liczby sprawdź więcej niż jeden ekran. */
.list-row__main { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; }
.list-row__title { font-weight: 600; font-size: 14px; }
.list-row__meta  { font-size: 12px; color: var(--color-fg-muted); }
/* Powód błędu na liście plików: ta sama wielkość co meta, ale kolorem błędu —
   inaczej ginie wśród nazwy działu i nikt go nie czyta. */
.list-row__meta--error { color: var(--color-danger); margin-top: 4px; }
/* Akcja pod komunikatem błędu wiersza (dziś: „Ponów" w bazie wiedzy). Odstęp
   wyraźnie większy niż między wierszami metadanych (--space-2), bo to nie
   kolejna linia opisu, tylko rzecz do kliknięcia — a jednocześnie na tyle
   bliski, żeby było widać, że należy do błędu wyżej, a nie do sąsiedniej
   kolumny. --space-5 zamiast dawnych 8 px: przy 8 px przycisk wciąż wyglądał
   na doklejony do akapitu (uwaga Eryka, 22.08). */
.list-row__error-action { margin-top: var(--space-5); }
.list-row__actions { display: flex; gap: 6px; flex: none; }

/* ---------- stan pusty ---------- */
/* Stan pusty 1:1 z humi — reports/+page.svelte i courses/+page.svelte mają go
   identycznie: `flex flex-col items-center justify-center py-12 text-center`
   w kolorze stonowanym, ikona `mb-4 h-12 w-12 opacity-50`, tytuł
   `text-lg font-medium`, tekst `text-sm`, przycisk `mt-4`.
   Było: padding 40/20, ikona 32 px bez wyciszenia, tytuł wagi 600 w rozmiarze
   treści. Cały blok jest teraz przygaszony i to TYTUŁ, nie ikona, niesie
   uwagę — w referencji ikona jest tłem, nie bohaterem. */
.empty-state {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; padding: 48px 20px; color: var(--color-fg-muted);
}
.empty-state__icon  { font-size: 48px; line-height: 1; opacity: .5; margin-bottom: var(--space-6); }
.empty-state__title { font-size: var(--fs-lg); line-height: var(--lh-lg); font-weight: 500; color: var(--color-fg); margin-bottom: var(--space-2); }
.empty-state__text  { font-size: var(--fs-md); line-height: var(--lh-md); margin-bottom: var(--space-6); }

/* ══════════════ KOLEJKA RĘCZNEJ OCENY (/courses/{id}/progress) ══════════════
   Przebudowa 31.08.2026 po zgłoszeniu Eryka: „wszystko trochę się zlewa i jest
   ciężko się połapać, o co może chodzić; niewygodnie się czyta".

   Poprzedni układ był listą .list-row: metryczka, treść odpowiedzi, plakietka
   i formularz jeden pod drugim, w jednej kolumnie, bez ram i bez podpisów —
   przy trzech pozycjach na ekranie nie było widać, gdzie kończy się jedna
   odpowiedź, a zaczyna druga.

   Teraz KARTA z trzema strefami rozdzielonymi kreską i tłem:
     • nagłówek — kto, z której lekcji, kiedy, w jakim stanie;
     • treść — pytanie, kryteria, odpowiedź (ta jako cytat z belką);
     • decyzja — na wygaszonym tle, bo to jedyne miejsce, w które się klika.

   Odstęp MIĘDZY kartami (gap) większy niż odstępy wewnątrz karty: to on robi
   grupowanie, którego brakowało. */
.grade-list { display: flex; flex-direction: column; gap: var(--space-7); }

/* GRUPA = jedno ćwiczenie. Pytanie i kryteria w nagłówku, pod nimi odpowiedzi
   kolejnych kursantów. Ramka obejmuje CAŁĄ grupę, więc widać, dokąd sięga
   jedno pytanie — to ono zastąpiło ramkę wokół pojedynczej odpowiedzi. */
.grade-group {
  border: 1px solid var(--color-border); border-radius: var(--radius-lg, 10px);
  background: var(--color-surface); overflow: hidden;
}
/* Grupa z archiwum jest CICHSZA, nie wyszarzona: treść musi zostać czytelna
   (prowadzący wraca tu, żeby zmienić decyzję), ale nie ma konkurować
   z kolejką. Stąd samo tło, bez ruszania koloru tekstu. */
.grade-group--done { background: var(--color-bg); }

/* ── nagłówek grupy: gdzie, o co pytaliśmy, czego oczekujemy ── */
/* PASEK, NIE KOLEJNY WIERSZ (zgłoszenie Eryka 31.08.2026). Nagłówek dzieliła
   od odpowiedzi tylko kreska 1px na tym samym białym tle, a pytanie (18/600)
   stało blisko nazwiska kursanta (16/600) — dwa bloki o tej samej wadze jeden
   pod drugim czytają się jak jedna lista, nie jak temat i zgłoszenia do niego.
   Wypełnienie oddziela strefy tak, jak kreska nie potrafi. */
.grade-group__head {
  padding: var(--space-5) var(--space-6) var(--space-6);
  border-bottom: 1px solid var(--color-border);
  background: var(--color-surface-muted);
}
/* W archiwum pasek idzie W DRUGĄ STRONĘ: cała grupa stoi na --color-bg
   (#f4f4f5), a --color-surface-muted to #f1f5f9 — 3 punkty różnicy, czyli
   pasek byłby niewidoczny. Biel na szarym daje ten sam podział bez nowego
   koloru w palecie. */
.grade-group--done .grade-group__head { background: var(--color-surface); }
.grade-group__where {
  margin: 0 0 var(--space-5); font-size: var(--fs-sm); color: var(--color-fg-muted);
}
/* PODPIS STREFY. Wersaliki i rozstrzelenie, nie pogrubienie: podpis ma się
   odróżniać od treści pod nim SPOSOBEM, nie wagą — inaczej rywalizowałby
   z pytaniem, które jest tu najważniejszym zdaniem. */
.grade-card__label {
  margin: 0 0 var(--space-2); font-size: 11px; font-weight: 700;
  letter-spacing: .06em; text-transform: uppercase; color: var(--color-fg-muted);
}
.grade-card__label:not(:first-child) { margin-top: var(--space-5); }
/* SZEROKOŚĆ WIERSZA DO CZYTANIA. Karta zajmuje całą kolumnę treści (na 1440 px
   to ~1490 px), a zdanie rozciągnięte na taką szerokość gubi się przy
   przeskoku do następnego wiersza. 75ch to ~90 znaków: górna granica tego,
   co czyta się bez wysiłku. Dotyczy TYLKO bloków tekstu ciągłego; metryczka,
   podpisy stref i formularz zostają na pełnej szerokości. */
.grade-card__question,
.grade-card__criteria,
.grade-card__missing,
.grade-answer__text { max-width: 75ch; }
.grade-card__question { margin: 0; font-weight: 700; font-size: var(--fs-lg); line-height: var(--lh-lg); }
.grade-card__criteria { margin: 0; color: var(--color-fg-muted); }
.grade-card__missing { margin: 0; color: var(--color-warn-fg); font-weight: 600; }
.grade-card__sep { margin: 0 4px; }

/* ── jedna odpowiedź w grupie ── */
/* Wcięcie od lewej WIĘKSZE niż w nagłówku (32 vs 16 px): lewa krawędź
   odpowiedzi wchodzi pod temat, więc przynależność widać z układu, bez
   dodatkowej ramki. 24 px sprawdziłem na zrzucie i 8 px różnicy ginęło —
   przy 16 px odstępu schodek widać bez szukania. Pion 16 zamiast 12 px, bo
   w kolejce pod każdą odpowiedzią stoi jeszcze formularz decyzji i przy 12 px
   sąsiednie zgłoszenia zlewały się w jeden słup. */
.grade-answer { padding: var(--space-6) var(--space-6) var(--space-6) var(--space-8); }
/* Kreska MIĘDZY odpowiedziami, nie pod każdą: ostatnia w grupie graniczy już
   z ramką grupy i druga linia obok niej wyglądałaby jak usterka. */
.grade-answer + .grade-answer { border-top: 1px solid var(--color-border); }
.grade-answer__head { display: flex; align-items: flex-start; gap: var(--space-4); margin-bottom: var(--space-4); }
/* TOŻSAMOŚĆ W JEDNEJ LINII, nie w dwóch (zgłoszenie Eryka 31.08.2026).
   Imię stało nad wierszem „e-mail · data", więc każda odpowiedź otwierała się
   dwoma wierszami metryczki, zanim doszło do treści. W jednym rzędzie te same
   trzy fakty zajmują tyle samo pikseli w poziomie i o wiersz mniej w pionie.
   E-mail ZOSTAJE, choć to najmniej istotny fakt: na ekranie jest tylko
   users.first_name, więc w kursie na 117 osób „Katarzyna" powtórzy się i bez
   adresu nie da się rozstrzygnąć, czyją odpowiedź się właśnie zalicza.
   baseline, nie center: imię 14 px i metryczka 12 px mają siedzieć na jednej
   linii pisma, a nie być centrowane względem siebie. */
.grade-answer__who {
  display: flex; flex-wrap: wrap; align-items: baseline;
  column-gap: var(--space-3); row-gap: 2px; min-width: 0; flex: 1;
}
.grade-answer__name { font-weight: 600; font-size: var(--fs-md); }
.grade-answer__meta { font-size: var(--fs-sm); color: var(--color-fg-muted); overflow-wrap: anywhere; }
.grade-answer__head > .badge { flex: none; margin-top: 2px; }
@media (max-width: 560px) {
  .grade-answer__head { flex-wrap: wrap; }
  .grade-answer__who { flex-basis: 100%; }
  /* Tu metryczka wraca POD imię: adres e-mail obok imienia nie mieści się
     w jednym rzędzie na telefonie i tak by się zawinął, tylko brzydziej. */
  .grade-answer__meta { flex-basis: 100%; }
}
/* ODPOWIEDŹ JAKO CYTAT z belką po lewej — jedyna treść na ekranie, której nie
   napisaliśmy my, więc jedyna, która dostaje własną ramkę. */
/* CYTAT NA BIELI, NIE NA SZAROŚCI. Miał to samo --color-surface-muted co
   nowy pasek tematu, więc w grupie z trzema odpowiedziami oko dostawało
   CZTERY równorzędne szare bloki i temat znów przestawał wygrywać. Pasek jest
   teraz jedynym wypełnieniem w grupie; cytat trzyma się belką akcentu i cienką
   ramką, czyli słabszym środkiem, i o to chodzi — to treść podrzędna wobec
   pytania, którym ją mierzymy. */
.grade-answer__text {
  margin: 0 0 var(--space-5); padding: var(--space-4) var(--space-5);
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-accent-strong);
  background: var(--color-surface);
  border-radius: 0 var(--radius) var(--radius) 0;
  font-size: var(--fs-md); line-height: 1.55;
}
/* W ARCHIWUM ODWROTNIE, I TO NIE JEST SYMETRIA DLA SYMETRII. Grupa stoi na
   --color-bg, więc jej pasek jest BIAŁY (reguła wyżej) — gdyby cytat też
   został biały, wróciłby dokładnie ten sam remis tonów, który tu naprawiamy,
   tylko odbity. Cytat zrzuca więc wypełnienie do tła grupy i trzyma się samej
   ramki: biały pasek > ramka bez wypełnienia, ta sama kolejność co w kolejce. */
.grade-group--done .grade-answer__text { background: transparent; }

/* ── propozycja modelu (migracja 0059) ──
   JEDNA LINIA, celowo słabsza od cytatu i od przycisków. Hierarchia w tej
   odpowiedzi ma być: co kursant napisał (cytat) → co proponuje model (to) →
   co robi człowiek (przyciski). Propozycja przebijająca cytat odwracałaby
   sens ekranu: prowadzący ma potwierdzać ocenę cudzego tekstu, a nie czytać
   ocenę zamiast tekstu.
   Bez wypełnienia i bez ramki — sam kolor plakietki wystarcza, a kolejne
   pudełko w odpowiedzi (obok cytatu i pola) robiło z niej znów słup ramek. */
.grade-ai { margin: 0 0 var(--space-3); }
/* Plakietka trzyma się w jednej linii — ta sama zasada co przy innych
   pigułkach w aplikacji: łamana pigułka czyta się jak dwie. */
.grade-ai__verdict {
  display: inline-block; white-space: nowrap;
  font-size: var(--fs-sm); font-weight: 700; letter-spacing: .01em;
  padding: 2px var(--space-3); border-radius: 999px;
}
.grade-ai__verdict--pass { background: var(--color-ok-bg); color: var(--color-ok-fg); }
.grade-ai__verdict--fail { background: var(--color-error-bg); color: var(--color-error-fg); }

/* ── decyzja ──
   Formularz to KOLUMNA: podpis, rząd (pole + przyciski), podpowiedź.
   Wyrównanie przycisków do pola wynika z tego, że oba mają min-height: 36px
   i stoją w tym samym rzędzie flex — nie z dopisanego odstępu. Poprzednia
   wersja liczyła flex-end do dolnej krawędzi kolumny RAZEM z podpowiedzią,
   więc przyciski siadały o jej wysokość za nisko (zgłoszenie Eryka 31.08). */
.grade-form { display: flex; flex-direction: column; }
.grade-form__line { display: flex; align-items: center; gap: var(--space-4); }
/* Pole zabiera całą wolną szerokość, przyciski tylko tyle, ile potrzebują.
   min-width: 0 na polu, bo bez niego flex nie pozwoli mu zejść poniżej
   szerokości wewnętrznej i rząd wypchnie przyciski za krawędź grupy. */
.grade-form__line .field__control { flex: 1; min-width: 0; }
.grade-form__actions { display: flex; gap: var(--space-3); flex: none; }
/* Wąskie okno: przyciski schodzą pod pole i dzielą jego szerokość na pół —
   dwa cele dotykowe pełnej wysokości zamiast dwóch ściśniętych obok pola. */
@media (max-width: 640px) {
  .grade-form__line { flex-wrap: wrap; }
  .grade-form__line .field__control { flex-basis: 100%; }
  .grade-form__actions { width: 100%; }
  .grade-form__actions .btn { flex: 1; }
  /* Wcięcie odpowiedzi wraca do 16 px. Na 390 px szerokości zabierało 16 z 274
     px cytatu (zmierzone), a kupowało cue, którego tu nie potrzeba: grupy stoją
     w jednej kolumnie jedna pod drugą, więc przynależność odpowiedzi do tematu
     wynika już z kolejności. Szerokość wiersza jest na telefonie rzadsza niż
     miejsce na ozdobę. */
  .grade-answer { padding-left: var(--space-6); }
}
/* Werdykt sprzed zmiany decyzji: widoczny BEZ rozwijania, bo to on tłumaczy
   plakietkę przy nazwisku. */
.grade-card__verdict { margin: 0; font-size: var(--fs-sm); }
.grade-card__by { font-weight: 600; }
/* Uzasadnienie W OSOBNYM WIERSZU, nie za myślnikiem po „oceniono ręcznie".
   To zdanie, które zobaczy KURSANT — bywa długie i samo zawiera myślniki
   („oceniono ręcznie — Dobrze — najpierw zakres…" czytało się jak jedno
   poszatkowane zdanie). W cudzysłowie, bo to cytat z decyzji, nie nasz opis. */
.grade-card__reason { display: block; margin-top: 2px; color: var(--color-fg-muted); max-width: 75ch; }
.grade-card__reason::before { content: "„"; }
.grade-card__reason::after  { content: "”"; }
.grade-card__regrade > summary {
  cursor: pointer; font-size: var(--fs-sm); font-weight: 600; color: var(--color-accent-strong);
  width: fit-content; margin-top: var(--space-3);
}
.grade-card__regrade[open] > summary { margin-bottom: var(--space-5); }

/* ── stopka zgłoszenia: czego dotyczy + co z tym zrobić ──
   Odnośnik do lekcji i przycisk statusu w JEDNYM rzędzie, w kolejności użycia:
   najpierw wejdź i popraw, potem zamknij. Oba są przyciskami tej samej
   wysokości, więc rząd nie ma czego wyrównywać ręcznie. */
.report-card__foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-5); flex-wrap: wrap;
}
/* Odnośnik do lekcji bywa długi (numer + tytuł), więc łamie się w środku
   przycisku zamiast wypychać stopkę za krawędź karty. */
.report-card__foot .btn { max-width: 100%; height: auto; min-height: 36px; padding: 6px 16px; white-space: normal; text-align: left; }
.report-card__where { display: flex; align-items: center; gap: var(--space-4); font-size: var(--fs-sm); color: var(--color-fg-muted); min-width: 0; }
.report-card__where-label { flex: none; }
.report-card__action { flex: none; margin-left: auto; }
@media (max-width: 560px) {
  /* Na telefonie decyzja schodzi pod odnośnik i zajmuje całą szerokość —
     dwa przyciski obok siebie nie mieszczą się czytelnie. */
  .report-card__foot { align-items: stretch; }
  .report-card__where { flex-wrap: wrap; gap: var(--space-2); }
  .report-card__where .btn, .report-card__action, .report-card__action .btn { width: 100%; margin-left: 0; }
  .report-card__foot .btn { text-align: center; }
}

/* Licznik w nagłówku sekcji — „ile mi jeszcze zostało" jest pierwszym
   pytaniem, z którym się na ten ekran wchodzi. */
.grade-count {
  display: inline-block; margin-left: var(--space-3); padding: 2px 10px;
  border-radius: 999px; background: var(--color-accent-strong); color: var(--color-accent-fg);
  font-size: var(--fs-sm); font-weight: 600; vertical-align: middle;
}

/* ---------- siatki ---------- */
/* 200 px, nie 260: przy 260 px kolumna treści na tablecie w pionie (768 px
   minus pasek 256 px minus padding 48 px = 464 px) nie mieściła dwóch kart
   (2×260 + 16 gap = 536 px) i siatka spadała do jednej kolumny — karta kursu
   na 460 px z pustą bursztynową okładką. 220 px wchodziło tylko teoretycznie
   (456 px przy 464 px dostępnych): klasyczny pasek przewijania zjada 15 px
   i siatka DALEJ spadała do jednej kolumny — zmierzone w przeglądarce, nie
   policzone. 2×200 + 16 = 416 px ma zapas na pasek i na węższe okno.
   Skutek uboczny na 1280 px: cztery kolumny zamiast trzech (karty ~229 px),
   sprawdzone — tytuły kursów mają overflow-wrap i mieszczą się.
   UWAGA: `.grid` to jedna, dzielona reguła — 200 px jest więc minimum
   GLOBALNYM, nie tylko dla kart kursu. Ten sam arkusz stawia nią też galerię
   obrazów pliku (`file.html`, karty `<figure>` z miniaturą i podpisem);
   miniatury skalują się do 100% szerokości kolumny (inline `width:100%` na
   `<img>` w szablonie), więc ten sam próg im nie szkodzi — nie zakładaj przy
   zmianie tej liczby, że jedyny konsument to karta kursu. */
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
/* :not(dialog) z tego samego powodu co przy `.content > * + *` niżej: arkusze
   edycji ćwiczeń (lesson.html) są dziećmi .stack, a nakładka pozycjonowana
   względem okna nie ma się zsuwać rytmem kolumny — 16 px marginesu spychało
   arkusz w dół i tyle samo jego wysokości wychodziło poza dolną krawędź. */
.stack > * + *:not(dialog) { margin-top: 16px; }
/* `mb-8 flex items-center justify-between` z humi. Dwie zmiany naraz i obie
   są wprost ze zgłoszenia: align-items z `flex-start` na `center` sadza
   przycisk akcji NA OSI nagłówka („przyciski wyrównane do nagłówków"),
   a margines 20 → 32 px daje nagłówkowi ten sam oddech co w referencji. */
.page-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-6); margin-bottom: var(--space-8); flex-wrap: wrap; }
/* Nadkreślenie nad tytułem strony (ekran lekcji: nazwa sekcji, do której
   lekcja należy). Mniejsze i przygaszone — ma prowadzić wzrok do tytułu,
   a nie z nim konkurować. */
.page-header__eyebrow { font-size: 12px; color: var(--color-fg-muted); margin: 0 0 2px; }

/* ---------- okruszki ---------- */
/* Ścieżka „Kursy / Kurs / Lekcja" nad nagłówkiem strony. Bez box-shadow:
   linki w środku są fokusowalne, a box-shadow ZASTĄPIŁBY (nie dołożył)
   pierścień fokusu z sekcji typografii — patrz TestNoBoxShadowClassOnFocusableElement. */
.breadcrumbs {
  display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
  font-size: 12px; color: var(--color-fg-muted); margin-bottom: 12px;
}
.breadcrumbs a { color: var(--color-fg-muted); }
.breadcrumbs a:hover { color: var(--color-fg); }
.breadcrumbs [aria-current="page"] { color: var(--color-fg); font-weight: 600; }
.breadcrumbs__separator { color: var(--color-border); }

/* ---------- zakładki ---------- */
/* Zakładki to DWIE STRONY, nie dwa panele przełączane JS-em (patrz
   course.html) — stąd <a> obok <span aria-current>, a nie role="tab".
   Podkreślenie robi border-bottom, nie box-shadow: <a> łapie fokus. */
/* ui/custom-tabs w humi — to ten prymityw, nie „pigułkowy" ui/tabs, ma układ
   z podkreśleniem, którego używamy:
     List    `inline-flex h-10 w-full items-center rounded-md border-b p-1`
     Trigger `px-3 py-1.5 text-sm font-medium` + `data-[state=active]:text-black
              data-[state=active]:border-b-2 data-[state=active]:border-black`
   Było: gap 20 px zamiast paddingu w zakładce i waga 600 zamiast 500.
   Nieaktywna zakładka to `text-gray-400` (#9ca3af) — dosłownie jak
   w referencji. Zmierzone: 2,85:1 na białym, czyli poniżej progu AA 4,5:1
   dla tekstu. Świadoma decyzja zamawiającego („dosłownie zasady designu jak
   na humi", 22.08) — nie podmieniaj tego na --color-fg-muted bez pytania. */
.tabs { display: flex; align-items: center; height: 40px; border-bottom: 1px solid var(--color-border); margin-bottom: var(--space-7); }
.tabs__tab {
  padding: 6px var(--space-5); font-size: var(--fs-md); line-height: var(--lh-md);
  /* NIEAKTYWNA ZAKŁADKA na --color-fg-muted, nie na --gray-400 (#9ca3af).
     Tamten kolor dawał na tle strony kontrast 2,31 — na elemencie, w który się
     KLIKA, czyli tam, gdzie czytelność jest warunkiem użycia, nie ozdobą.
     Semantycznie to zresztą ten sam byt co reszta przygaszonego tekstu, więc
     token jest właściwy sam z siebie. */
  font-weight: 500; color: var(--color-fg-muted);
  border-bottom: 2px solid transparent; margin-bottom: -1px; text-decoration: none;
}
.tabs__tab:hover { color: var(--color-fg); }
.tabs__tab--active { color: var(--color-fg); border-bottom-color: var(--color-fg); }
/* Wąskie okno: pasek zakładek ZAWIJA SIĘ, nie przewija. Kurs ma pięć zakładek
   („Przegląd / Kursanci / Raporty / Lektor / Sesje") i na 320 px ostatnia
   wystawała 35 px poza stronę — to była druga przyczyna pomniejszania widoku
   na telefonie. Zawijanie zamiast `overflow-x: auto`, bo poziomy pasek
   przewijania ukryłby zakładki bez żadnego znaku, że tam coś jeszcze jest,
   a podkreślenie zakładki bieżącej (`margin-bottom: -1px` na kresce pojemnika)
   zostałoby przycięte przez to samo przewijanie.
   height: auto — stała wysokość 40 px trzymała dwa rzędy w jednym.
   Cel dotykowy: przy sterowaniu palcem zakładka rośnie do 44 px, stąd druga
   granica `pointer: coarse` (ta sama para co przy .btn wyżej). */
@media (max-width: 767px), (pointer: coarse) {
  .tabs { height: auto; min-height: 40px; flex-wrap: wrap; }
  .tabs__tab { display: inline-flex; align-items: center; min-height: 44px; }
}

/* Cel dotykowy 44 px. Granica jest PODWÓJNA i to nie jest przeoczenie:
   `max-width: 767px` łapie telefon, a `pointer: coarse` — tablet w pionie
   (768 px) i każde inne urządzenie sterowane palcem, niezależnie od
   szerokości. Sama granica szerokości zostawiała iPada w portrecie
   z przyciskami 32-36 px i polami 40 px, mimo że globalne wymaganie projektu
   („min. 44×44 px") nie mówi nic o szerokości ekranu. `pointer` opisuje
   PODSTAWOWY wskaźnik, więc laptop z ekranem dotykowym i myszą raportuje
   `fine` i zachowuje gęstość desktopu — świadomą decyzję z zadania 3
   (wymiary aplikacji źródłowej).
   .field__control obok przycisków: to też cel dotykowy (input, select).
   .sortlink dopisany 30.08.2026: nagłówek sortujący to SAMODZIELNA kontrolka
   w komórce, nie odnośnik w zdaniu, więc wyjątek WCAG 2.5.8 dla linków
   w tekście go nie obejmuje — a zmierzone wymiary przy `pointer: coarse` to
   39×16, 29×16 i 47×16 px, czyli poniżej wymaganych 24 px i grubo poniżej
   naszych 44. Rośnie przez to wiersz nagłówka tabeli na telefonie i to jest
   świadomy koszt, ten sam, który płaci .tabs__tab wyżej. */
@media (max-width: 767px), (pointer: coarse) {
  .btn, .btn--sm, .field__control, .sidebar__item, .sidebar__sub, .sortlink { min-height: 44px; }
  /* `text-base md:text-sm` z humi — 16px w polu to jedyny sposób, żeby Safari
     na iOS nie przybliżało strony przy wejściu w input. */
  .field__control { font-size: 16px; }
}

/* ---------- powłoka ---------- */
/* UWAGA przy dopisywaniu: ta sekcja stoi PO regule 44 px wyżej, więc
   bezwarunkowe `min-height` na .btn/.btn--sm wygrałoby z nią kaskadą
   (ta sama swoistość, dalsza pozycja) i po cichu skasowało cel dotykowy.
   Nie ustawiaj tu wysokości przycisków. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.shell { display: flex; min-height: 100dvh; }
/* `container py-8` z humi: kolumna wyśrodkowana, 32 px odstępu w pionie
   i 32 px na boki (Tailwind `container` ma padding md: 2rem). Było 24 px
   dookoła i limit 1280 px. Na 1440 px i 1920 px kolumna ma teraz 1400 px
   zewnętrznie i 1336 px treści — tak samo jak w referencji. */
/* min-width: 0 — POJEDYNCZA REGUŁA, KTÓRA NAPRAWIAŁA CAŁĄ APLIKACJĘ NA
   TABLECIE. Element flex ma domyślnie `min-width: auto`, a przy jawnym
   `width: 100%` przeglądarka bierze tę deklarację jako „minimalny rozmiar
   sugerowany treścią" — czyli kolumna treści NIE MOGŁA zejść poniżej 100%
   szerokości .shell. Obok paska 256 px dawało to stronę szerszą od okna
   dokładnie o te 256 px, w każdym oknie od 768 do ~1200 px.
   Na desktopie nie było tego widać (limit --content-max i tak przycinał),
   a na tablecie skutek był dramatyczny i mylący: iPad w portrecie ma
   `width=device-width`, więc Safari przy treści szerszej od okna POMNIEJSZA
   CAŁĄ STRONĘ, żeby ją zmieścić (shrink-to-fit). Aplikacja nie „przewijała
   się w bok" — ona po prostu renderowała się w skali ~75%, z tekstem 12 px
   zamiast 16 i celami dotykowymi 33 px zamiast 44. Zmierzone: przy oknie
   768×1024 układ liczył się dla 1024×1366.
   `width: 100%` zostaje: bez niego kolumna przy `margin: 0 auto` (auto
   marginesy w osi głównej pochłaniają wolne miejsce ZAMIAST flex-grow)
   zwężałaby się do treści. */
.content { flex: 1; min-width: 0; padding: var(--space-8); max-width: var(--content-max); margin: 0 auto; width: 100%; }
/* RYTM PIONOWY TREŚCI — jedna reguła na całą aplikację.
   Do tej pory .card, .dropzone i sekcje NIE MIAŁY ŻADNEGO marginesu, a .content
   był samym paddingiem, więc kolejne kontenery na stronie stykały się
   krawędziami: na „Bazie wiedzy" karta z tabelą dosłownie dotykała strefy
   wgrywania. To jest cały mechanizm zarzutu „kontenery nie mają spacingu
   między sobą".
   Selektor sąsiedztwa (`* + *`), a nie margines na .card: margines na
   komponencie trzeba by dopisywać każdemu nowemu kontenerowi z osobna i
   zdejmować przy pierwszym na stronie. Tu odstęp należy do UKŁADU STRONY,
   którym jest .content, i pojawia się WYŁĄCZNIE między rodzeństwem — pierwszy
   element nigdy nie dostaje zbędnego marginesu u góry.
   Świadomie NIE zmieniam .content na flex column z gap: .content--centered
   (ekran logowania) jest już flexem o innym kierunku, a każda zmiana `display`
   przewraca sposób, w jaki dzieci się układają. Margines nie rusza niczyjego
   układu. Z margin-bottom .page-header (20px) ten margines się SKLEJA do
   większej z wartości, więc nagłówek nie dostaje 44px — tak działa zwijanie
   marginesów sąsiadujących bloków. */
/* 32 px, nie 24: karty na ekranach humi stoją w `grid gap-8`.

   :not(dialog) — NAKŁADKI NIE NALEŻĄ DO RYTMU STRONY. <dialog> jest dzieckiem
   .content (arkusz z logiem sesji, panel edycji ćwiczenia, modale „dodaj…"),
   ale pozycjonuje się względem OKNA, nie względem sąsiada w kolumnie. Ta
   reguła dokładała mu 32 px górnego marginesu i wygrywała z jego własnymi
   deklaracjami przez samą kolejność w pliku (obie miały swoistość 0,1,0):
     • arkusz (`inset: 0 0 0 auto; height: 100dvh; margin: 0`) zjeżdżał
       o 32 px w dół, a jego pełna wysokość okna zwisała poza dolną krawędź —
       ostatnie wiersze logu wychodziły pod ekran (zgłoszenie Antka: „max
       height jest złe"). Zmierzone: dialog y=32 przy oknie 900 px;
     • modal, który centruje się arkuszem przeglądarki (`margin: auto`),
       dostawał 32 px od góry zamiast wyśrodkowania.
   Elementy PO dialogu dalej dostają swój margines (warunek dotyczy elementu
   stylowanego, nie poprzednika), więc rytm kolumny się nie zmienia.

   :not(.drop-overlay) — DRUGA nakładka, ta sama zasada. Pełnoekranowa plansza
   upuszczania pliku („Baza wiedzy") jest zwykłym <div> w kolumnie treści, więc
   dialogowy wyjątek jej nie obejmował: dostawała 32 px od góry i stała na
   y=32, z paskiem tła strony widocznym nad sobą. Wyjątek MUSI być tutaj, a nie
   jako `margin: 0` we własnej regule planszy — ten selektor ma swoistość
   (0,1,1) przez :not(dialog), więc bije samą klasę (0,1,0) niezależnie od
   kolejności w pliku. */
.content > * + *:not(dialog):not(.drop-overlay) { margin-top: var(--space-8); }
/* `max-w-6xl` (1152 px) z organization/+page.svelte i account/+page.svelte —
   ekrany, które są w całości formularzem, mają w humi węższą kolumnę niż
   listy. U nas to /settings: pole na 1336 px szerokości to pasek, w którym
   nie widać, gdzie się zaczyna i kończy wpisana wartość. */
.content--narrow { max-width: 1152px; }
/* Region zakładek, który niesie CAŁĄ stronę (Ustawienia: nagłówek + pasek
   zakładek + panel — bo akcja w nagłówku jest inna na każdej zakładce, więc
   musi się podmieniać razem z treścią). Opakowanie w jeden <div> odcina te
   elementy od `.content > * + *` wyżej i cały pionowy rytm strony by zniknął;
   ta reguła odtwarza go W ŚRODKU opakowania, z tym samym odstępem i tym samym
   wyjątkiem dla <dialog>. Klasa, nie sam [data-tab-panel]: na ekranie kursu
   ten sam atrybut opakowuje pojedynczą kartę i 32 px między jej dziećmi nie
   ma tam czego poprawiać. */
.tab-page > * + *:not(dialog) { margin-top: var(--space-8); }
.content--centered { display: flex; align-items: center; justify-content: center; min-height: 100dvh; }

.topbar { display: none; }

.sidebar {
  width: var(--sidebar-width); flex: none;
  background: var(--color-nav); color: var(--color-nav-fg);
  /* Granicę paska robi krawędź RAZEM z wypełnieniem, nie sama krawędź —
     poprzednia wersja tego komentarza obiecywała, że „twarda krawędź działa
     niezależnie od kontrastu wypełnień", i to była nieprawda. Zmierzone:
     w ciemnym pasek vs strona 1,21:1, krawędź (#27272a) vs strona 1,34:1
     i vs pasek 1,10:1; w jasnym pasek (biel) vs strona (#f4f4f5) 1,10:1,
     krawędź (#e4e4e7) vs pasek 1,27:1 i vs strona 1,15:1. Nigdzie na styku
     nie ma 3:1 i nie musi być: to granica dekoracyjnego panelu, nie
     wskaźnik ani kontrolka, więc WCAG 1.4.11 się do niej nie stosuje.
     Praktycznie pasek jest odróżnialny (inna powierzchnia + kreska), ale
     jeśli ktoś kiedyś zechce tu MOCNEJ granicy, musi zmienić kolor krawędzi,
     a nie liczyć na to, że sama jej obecność wystarczy.
     Szerokość zostaje 256 px — box-sizing: border-box (reguła globalna
     wyżej) wlicza tę krawędź w --sidebar-width. */
  border-right: 1px solid var(--color-border);
  /* Padding NIE stoi na samym pasku, tylko na jego częściach — dokładnie jak
     w humi, gdzie <nav> jest samym `flex h-[100dvh] w-64 flex-col`, a odstępy
     niosą blok marki (`px-4 py-4`), kontener sekcji (`px-2 py-2`) i stopka
     (`px-2 py-1`). Wspólny padding na pasku sprawiał, że kreska stopki nie
     dochodziła do jego krawędzi. */
  display: flex; flex-direction: column; gap: 0; padding: 0;
  position: sticky; top: 0; height: 100dvh;
  /* RYTM PIONOWY MENU W CZTERECH ZMIENNYCH, a nie wprost w regułach niżej.
     Powód: pasek ma być WIDOKIEM STATYCZNYM — całe menu widoczne naraz, bez
     własnego paska przewijania (zgłoszenie Eryka 24.08.2026: „Ustawienia lekko
     wyjeżdżają poza swój kontener", zrzut lewej kolumny z pozycją przeciętą
     kreską stopki). Statyczność da się utrzymać tylko wtedy, gdy menu MIEŚCI
     SIĘ w oknie, a przy dziesięciu pozycjach i czterech nagłówkach pełny rytm
     (24/8/4) potrzebuje 765 px wysokości okna — na 13-calowym laptopie zostaje
     ~740 px i brakuje właśnie tych kilkunastu pikseli. Zamiast chować resztę
     pod scroll, na niższym oknie ŚCIEŚNIAMY ODSTĘPY (progi niżej, przy
     .sidebar__section-label). Same pozycje zostają 40-piksełowe — to ich
     wysokość naturalna (24 px interlinii + 2×8 px wyściółki), więc niżej i tak
     nie zejdą bez zmiany wyściółki, a cel dotykowy 44 px z (pointer: coarse)
     ma zostać nietknięty. */
  --nav-list-pad: var(--space-3);
  --nav-gap: var(--space-2);
  --nav-section-gap: var(--space-7);
  --nav-label-gap: var(--space-3);
  /* Pasek NIE przewija się nigdzie — ani w całości, ani środkiem. Marka, menu
     i stopka stoją w miejscu, a menu mieści się dzięki progom ścieśniania
     odstępów (patrz --nav-* niżej). Historia dwóch nieudanych podejść, żeby
     nie wracać do żadnego: (1) przewijaliśmy CAŁY pasek — na niskim oknie logo
     odjeżdżało do góry, a konto uciekało pod dolną krawędź, więc żeby się
     wylogować trzeba było najpierw przewinąć menu; (2) przewijał się sam
     środek (`overflow-y: auto` na .sidebar__list, za humi) — wtedy ostatnia
     pozycja menu chowała się pod kreską stopki, bez widocznego paska
     przewijania, który by o tym uprzedził. Własne overflow-y zostaje jeszcze
     spisowi treści kursu (.sidebar__outline) — ten jest treścią rozwijaną,
     nie stałym menu aplikacji.

     Sam pasek zostaje z overflow: visible (czyli bez reguły) ŚWIADOMIE:
     `hidden` przycinałby panel menu konta ze stopki, a ten musi wyjść na
     treść. Przypinanie marki i stopki nie potrzebuje przycinania — robi to
     układ flex i własne overflow-y środka. */
}
/* `flex items-center justify-between gap-2 px-4 py-4` z humi. */
/* Wyściółka 12, nie 16 px: blok marki to koszt STAŁY paska, doliczany do
   każdego progu ścieśniania menu niżej, więc każdy piksel zdjęty tutaj wraca
   jako piksel odstępu w menu (uwaga Eryka 24.08.2026: „teraz wszystko jest za
   małe"). Logo ma 28 px, więc blok schodzi z 60 na 52 px i dalej ma powietrze
   dookoła. */
.sidebar__brand { padding: var(--space-5); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
/* Krzyżyk zamykający szufladę — `md:hidden` u humi, więc u nas pokazuje go
   dopiero zapytanie (max-width: 767px) niżej, TO SAMO co reszta szuflady. */
.sidebar__close { display: none; }
.sidebar__brand .brand-logo { height: 28px; width: auto; }

/* ---------- logo: dwa pliki, przełączane motywem ---------- */
/* <img> NIE da się przemalować CSS-em (zewnętrzny SVG jest osobnym dokumentem,
   currentColor do niego nie sięga), więc wariantów są DWA PLIKI:
   logo-marathon.svg — napis grafitowy #1a1818, na jasne powierzchnie;
   logo-marathon-light.svg — ten sam plik z napisem #fafafa, na ciemne.
   Sygnet (20 ścieżek bursztynowych) jest w obu identyczny.
   Szablon renderuje OBA znaczniki (partial „brand-logo" w layout.html),
   a widocznością steruje ten blok. Dlaczego nie inaczej:
     • jeden <img> i podmiana obrazu właściwością `content` — Firefox długo
       tego nie umiał na elementach zastąpionych, ryzyko regresji bez testu;
       (zapis z nawiasem świadomie pominięty: rewriteCSSAssets w assets.go
       skanuje odwołania regexem po CAŁYM pliku, także po komentarzach, i
       przykład w komentarzu wywołałby ostrzeżenie o nieznanym assecie);
     • tło CSS (background-image + role="img") — jeden plik mniej do pobrania,
       ale znika w trybach, które wyłączają obrazy tła, i wymaga aria-label
       zamiast natywnego alt;
     • wybór po stronie serwera z .Theme — dziś BY ZADZIAŁAŁ (.Theme jest
       zawsze „light" albo „dark"), ale wymagałby logiki w każdym szablonie,
       który stawia logo, a przede wszystkim zamarłby na wariancie sprzed
       kliknięcia: przełącznik przestawia motyw BEZ przeładowania strony.
       Jedna reguła CSS jest i tańsza, i jedyna, która nadąża.
   Koszt: przeglądarka pobiera oba pliki (~12 kB każdy, `display: none` nie
   powstrzymuje pobrania), raz, z Cache-Control immutable.
   Ścieżek jest DOKŁADNIE DWIE, obie jawne — <html> nosi data-theme przy
   każdym renderze (patrz themeFromRequest) i zmienia go w miejscu przy
   przełączeniu, więc wariant na ciemne tło wystarczy chować domyślnie. */
.brand-logo { display: block; }
.brand-logo--on-dark { display: none; }
:root[data-theme="dark"] .brand-logo--on-light { display: none; }
:root[data-theme="dark"] .brand-logo--on-dark { display: block; }
/* Kontener sekcji: `flex flex-1 flex-col gap-6 overflow-y-auto px-2 py-2`,
   pozycje w sekcji `flex flex-col gap-1`. Odstęp MIĘDZY sekcjami (24 px)
   niesie margines nagłówka sekcji niżej, bo u nas nagłówki i pozycje są
   rodzeństwem w jednej <ul>, a nie osobnymi blokami jak w humi. */
/* Kontener sekcji: `flex flex-1 flex-col gap-6 overflow-y-auto px-2 py-2`,
   pozycje w sekcji `flex flex-col gap-1`. Odstęp MIĘDZY sekcjami (24 px)
   niesie margines nagłówka sekcji niżej, bo u nas nagłówki i pozycje są
   rodzeństwem w jednej <ul>, a nie osobnymi blokami jak w humi.

   Lista menu ma dziś całą wysokość paska dla siebie — spis treści kursu siedzi
   w rozwijanym kafelku w stopce (.sidebar__outline-fold niżej), więc nie
   odbiera jej ani piksela. Dwie próby trzymania spisu na stałe w pasku
   (najpierw „weź, co zostało po menu", potem podział proporcjonalny) skończyły
   się tym samym: jedna z dwóch nawigacji była ścięta. */
.sidebar__list {
  list-style: none; margin: 0; padding: var(--nav-list-pad);
  display: flex; flex-direction: column; gap: var(--nav-gap);
  /* `flex: 0 1 auto`, NIE `flex: 1` — lista bierze dokładnie tyle wysokości,
     ile ma treści, i ani piksela więcej. Wcześniej stało tu `flex: 1` i to ono
     robiło z paska LISTĘ: rozdymało kontener do całej wolnej wysokości, a gdy
     tej wysokości brakowało, ścinało treść i oddawało ją przewijaniu. Efekt
     widać na zrzucie Eryka — „Ustawienia" schowane pod kreską stopki, bez
     żadnego sygnału, że coś tam jeszcze jest (pasek przewijania pokazuje się
     w macOS dopiero w ruchu).

     Stopkę do dołu paska dosuwa teraz `margin-top: auto` z .sidebar__footer,
     a nie rozpychanie się listy — ten sam skutek, ale bez przewijalnego środka.

     `min-height: 0` i `overflow-y: auto` ZOSTAJĄ, świadomie, jako zawór
     ostateczny na trzy sytuacje, których progi niżej nie obsługują: okno niższe
     niż ~650 px (a na stronach kursu ~700 px), rozwinięty spis treści kursu
     (stopka zabiera wtedy pół paska) i szuflada na telefonie, gdzie pozycje
     mają 44 px celu dotykowego i menu nigdy się nie zmieści. Przy mieszczącym się menu ta para
     nie robi NIC (przeglądarka nie rysuje paska przewijania, gdy nie ma czego
     przewijać), więc na desktopie widok jest statyczny; gdy zaś zabraknie
     miejsca, lepiej dać się przewinąć niż wypchnąć wiersz konta pod krawędź
     okna, skąd nie da się wylogować. */
  flex: 0 1 auto; min-height: 0; overflow-y: auto;
}
/* Stopka (spis treści kursu, konto) jest przypięta do dołu paska przez
   `flex: 1` na liście wyżej — dokładnie tak jak w humi, gdzie kontener sekcji
   ma `flex-1` i stopka siada pod nim.
   USUNIĘTO STĄD pionową kreskę kropkowaną (`.sidebar__list::after` z
   repeating-linear-gradient), którą dorobiono 21.08 po uwadze Antka, że dla
   ról z krótkim menu pusta przestrzeń „wygląda jak urwana lista". W humi tej
   kreski nie ma — wolna wysokość zostaje pusta. Jeżeli uwaga wróci, wraca
   też ta reguła, ale wtedy jako świadome odstępstwo od referencji, a nie po
   cichu. */
/* Nagłówek sekcji menu (Humi „MAIN / LEARNING / ASSISTANT / OTHER"): mały,
   wersaliki, wyszarzony; wcięcie zgrane z paddingiem .sidebar__item (12px).
   Górny odstęp oddziela sekcje; pierwsza nie potrzebuje dużego luzu. */
/* BEZ WERSALIKÓW I BEZ ROZSTRZELENIA — zmiana z 24.08.2026, wprost ze zrzutu
   paska, który przysłał Eryk: nagłówki brzmią tam „Learning", „Sales",
   „Other", czyli zwykłym zapisem zdaniowym, tym samym stopniem pisma co
   pozycje pod nimi, tylko wyszarzone. Wcześniej stało tu
   `text-transform: uppercase` + `letter-spacing: .05em` (za humi) i to CSS
   robił z katalogu wersaliki.
   Sama zamiana `uppercase` na nic by nie wystarczyła: wartości w katalogu też
   były zapisane wersalikami („GŁÓWNE", „MAIN", „ГЛАВНОЕ"), więc przy samej
   zmianie arkusza nagłówki zostałyby krzyczące w czterech z pięciu języków.
   Zeszły więc razem — patrz nav.section.* w internal/i18n/locale/*.json.
   Gruzińskiego to nie dotyczy: pismo mchedruli nie ma wielkich liter, więc
   `uppercase` i tak nic tam nie robił. */
.sidebar__section-label {
  padding: 0 var(--space-5); margin: var(--nav-section-gap) 0 var(--nav-label-gap);
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 500;
  color: var(--color-fg-muted);
}
.sidebar__list > .sidebar__section-label:first-child { margin-top: 0; }

/* ---------- progi ścieśniania menu (żeby pasek został statyczny) ---------- */
/* Menu ma stałą część nieściśliwą: 10 pozycji × 40 px + 4 nagłówki × 16 px =
   464 px. Reszta to odstępy — i to one schodzą, gdy okno jest niskie.
   Zapotrzebowanie liczone jako blok marki (60 px) + menu + stopka:
     pełny rytm  (24/8/4, wyściółka 8) → menu 636 px → okno ≥ 813 px
     stopień 1   (20/7/3, wyściółka 8) → menu 607 px → okno ≥ 784 px
     stopień 2   (16/6/2, wyściółka 8) → menu 578 px → okno ≥ 755 px
     stopień 3   (14/5/1, wyściółka 8) → menu 555 px → okno ≥ 732 px
     stopień 4   (12/4/0, wyściółka 8) → menu 532 px → okno ≥ 709 px
     stopień 5   (10/3/0, wyściółka 6) → menu 518 px → okno ≥ 695 px
   (minima liczone dla stopki z kafelkiem spisu — patrz akapit niżej).
   Stopni jest PIĘĆ, a nie dwa, właśnie dlatego, że są wspólne dla wszystkich
   stron: skoro próg musi zaskoczyć wcześnie (z zapasem na kafelek spisu), to
   niech schodzi drobnym krokiem. Przy dwóch stopniach menu poza kursem trafiało
   od razu na najciaśniejszy rytm i wyglądało na wyduszone przy 98 px pustki
   nad wierszem konta — „teraz wszystko jest za małe" (Eryk, 24.08.2026).
   Progi w regułach stoją 8 px WYŻEJ niż te minima świadomie: rytm ma się
   zmieniać, ZANIM ostatnia pozycja dotknie kreski stopki. Bez tego zapasu przy
   dokładnie minimum zostawało nad stopką 0 px luzu i wyglądało to jak dokładnie
   ta usterka, którą naprawiamy — „Ustawienia za blisko profilu".
   PROGI SĄ JEDNE, WSPÓLNE DLA WSZYSTKICH STRON, i liczone dla NAJWYŻSZEJ
   stopki, jaka się w pasku zdarza: na stronie kursu dochodzi do niej kafelek
   spisu treści (stopka 121 px zamiast 69), więc do każdego minimum wyżej
   trzeba doliczyć te 52 px — stąd 832 i 774, a nie 780 i 722.
   Krótko stały tu DWA komplety progów, przesunięte o kafelek tylko dla paska
   ze spisem (klasa .sidebar--with-outline). Rachunkowo było to oszczędniejsze
   — poza kursem menu trzymało luźniejszy rytm dłużej — ale w oczach wyglądało
   źle: wejście w kurs ściskało menu w miejscu, samym pojawieniem się kafelka
   (uwaga Eryka, 24.08.2026: „gdy pojawia się spis treści, rzeczy w tym panelu
   się nie pomniejszają, bo dziwnie to wygląda"). Rytm menu ma zależeć WYŁĄCZNIE
   od wysokości okna, nie od tego, na której stronie się stoi — kosztem jest
   jeden stopień ścieśnienia wcześniej poza kursem.
   Wartości ścieśnione są w pikselach, nie w tokenach --space-*, świadomie:
   6 i 3 px to kroki pośrednie skali, których w niej nie ma, a zaokrąglenie ich
   do najbliższego tokenu (8 i 4) kosztowałoby 20 px i przewróciło rachunek. */
@media (max-height: 812px) {
  .sidebar { --nav-section-gap: 20px; --nav-label-gap: 7px; --nav-gap: 3px; }
}
@media (max-height: 783px) {
  .sidebar { --nav-section-gap: var(--space-6); --nav-label-gap: 6px; --nav-gap: 2px; }
}
@media (max-height: 754px) {
  .sidebar { --nav-section-gap: 14px; --nav-label-gap: 5px; --nav-gap: 1px; }
}
@media (max-height: 731px) {
  .sidebar { --nav-section-gap: var(--space-5); --nav-label-gap: var(--space-2); --nav-gap: 0px; }
}
@media (max-height: 708px) {
  .sidebar {
    --nav-list-pad: 6px; --nav-section-gap: 10px; --nav-label-gap: 3px; --nav-gap: 0px;
  }
}
.sidebar__item {
  /* `group flex items-center gap-3 rounded-md px-3 py-2 text-sm` — pozycja ma
     w humi 40 px wysokości (8 + kafelek 24 + 8), nie 44. Cel dotykowy 44 px
     wraca w zapytaniu (pointer: coarse) niżej, razem z przyciskami i polami. */
  display: flex; align-items: center; gap: var(--space-5);
  padding: var(--space-3) var(--space-5); border-radius: var(--radius-sm);
  /* BEZ krawędzi. Stała tu przezroczysta 1px „na wypadek", gdyby stan
     aktywny chciał dołożyć border-color — nie dokłada (maluje samym tłem,
     jak humi), a krawędź podbijała pozycję do 42 px przy ich 40 (zmierzone
     w przeglądarce 23.08: `getComputedStyle` obu pasków obok siebie). */
  color: var(--color-nav-fg); text-decoration: none; font-size: var(--fs-md); min-height: 40px;
}
.sidebar__item:hover { background: var(--color-nav-hover); }
/* POZYCJA BIEŻĄCA = KOLOR, NIE KAFEL (24.08.2026, ze zrzutu Eryka). Aktywna
   pozycja jest tam indygowym napisem z indygową ikoną na tym samym tle co
   reszta listy — żadnej szarej pigułki pod spodem. Waga 500 (nie 700) dokłada
   drugi, niekolorowy sygnał „tu jesteś": kto nie rozróżnia indyga od grafitu,
   dalej widzi cięższy napis. Trzeci sygnał, dla czytnika ekranu, niesie
   aria-current="page" z szablonu — kolor nie jest tu więc jedynym nośnikiem
   informacji (WCAG 2.2 SC 1.4.1).
   Ikona NIE ma własnej reguły: svg rysuje się `stroke="currentColor"`, więc
   kolor spływa z tej deklaracji sam. To jest cały powód, dla którego zniknęły
   stąd dwie reguły przemalowujące kafelek ikony.
   Tło na hover ZOSTAJE wspólne dla wszystkich pozycji — .sidebar__item:hover
   ma swoistość (0,2,0) i bije tę regułę (0,1,0), a to jest teraz zachowanie
   POŻĄDANE: najechanie na bieżącą pozycję ma ją podświetlić tak samo jak
   każdą inną. Wcześniej stał tu osobny `--active:hover`, który tego bronił,
   bo aktywna pozycja miała własne tło do obronienia. Nie ma go już czym
   nadpisać. */
.sidebar__item--active { color: var(--color-nav-active-accent); font-weight: 500; }
.sidebar__icon {
  /* BEZ KAFELKA POD IKONĄ (24.08.2026, ze zrzutu Eryka). Stało tu wcześniej
     `border-radius` + `background: var(--color-nav-chip)`, czyli szary
     kwadracik 24×24 za każdą ikoną, który pod kursorem i na pozycji bieżącej
     czerniał (`--color-nav-chip-active`). Na zrzucie ikony są gołe: sam wektor
     w kolorze napisu, bez podkładki i bez zmiany na hover. Jedenaście
     ciemnych kwadracików w kolumnie ważyło w pasku więcej niż same nazwy
     pozycji, a rozpoznać pozycję i tak można było wyłącznie po napisie.
     PUDEŁKO 24×24 ZOSTAJE, mimo że nic już nie maluje: to ono trzyma kolumnę
     wyrównania po lewej — tę samą, w której stoi numer lekcji w spisie treści
     kursu (.sidebar__sub-num, 24 px). Zejście do 16 px rozjechałoby obie
     nawigacje w jednym pasku o cztery piksele.
     Koloru nie ustawiamy wcale: ikona dziedziczy go po pozycji przez
     currentColor, więc szarzeje i indygowieje razem z napisem. */
  width: 24px; height: 24px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: var(--fs-sm);
}
/* `<IconComponent size={16} />` z humi — rozmiar nadaje kafelek, nie atrybut
   na każdym z jedenastu wektorów. currentColor w kresce sprawia, że ikona
   bieleje razem z napisem, gdy kafelek czernieje. */
.sidebar__icon svg { width: 16px; height: 16px; display: block; }

/* ---------- spis treści otwartego kursu (w pasku bocznym) ---------- */
/* Renderuje się wyłącznie tam, gdzie handler wstawił .Outline — gotowy kurs,
   podgląd lekcji i raport lekcji (layout.html).
   Żadnej reguły box-shadow w całym tym bloku: link lekcji jest fokusowalny,
   a box-shadow zastąpiłby pierścień fokusu (TestNoBoxShadowClassOnFocusableElement). */
/* KAFELEK w stopce paska — jedyne, co spis treści zajmuje w spoczynku.
   Wygląda i mierzy się jak wiersz konta pod nim (.sidebar__user): ikona 24 px,
   dwie linie tekstu, szewron. To celowo ta sama forma: oba są wejściami do
   panelu wychodzącego z tego samego miejsca paska. */
.sidebar__outline-fold { display: block; }
.sidebar__outline-tile {
  /* W POZIOMIE dokładnie jak .sidebar__item: wyściółka i odstęp po --space-5,
     żeby ikona kafelka stała w tej samej kolumnie co jedenaście ikon menu nad
     nim i awatar konta pod nim, a napis zaczynał się na wspólnej krawędzi
     tekstu. Stało tu --space-3 i kolumna wychodziła o 4 px w lewo od menu
     (a napis o 8 px) — patrz rachunek przy .sidebar__user niżej. */
  display: flex; align-items: center; gap: var(--space-5);
  /* 6 px w pionie, nie 8: kafelek to koszt stały stopki na stronach kursu,
     a progi ścieśniania menu liczą się właśnie od NAJWYŻSZEJ stopki (patrz
     „progi ścieśniania menu" wyżej) — te 4 px wracają do menu na każdej
     stronie aplikacji, nie tylko w kursie. Poziomu ta oszczędność nie
     dotyczyła nigdy: w poziomie pasek ma zapas, a kolumna ikon to nie koszt. */
  padding: 6px var(--space-5); border-radius: var(--radius-sm);
  cursor: pointer; list-style: none; min-height: 40px;
}
/* Trójkącik znacznika <summary> — u nas rolę wskaźnika pełni szewron po prawej
   (jak w wierszu konta), więc natywny znacznik schodzi. ::-webkit-details-marker
   dla Safari, `list-style: none` wyżej dla pozostałych przeglądarek. */
.sidebar__outline-tile::-webkit-details-marker { display: none; }
.sidebar__outline-tile:hover { background: var(--color-nav-hover); }
.sidebar__outline-fold[open] > .sidebar__outline-tile { background: var(--color-nav-hover); }
/* Ikona kafelka idzie ZA ikoną pozycji menu (.sidebar__icon wyżej): też bez
   podkładki, odkąd kwadraciki wypadły z paska. Kafelek ma wyglądać jak wiersz
   menu, a jedna ikona z podkładką w kolumnie jedenastu bez byłaby w tym pasku
   jedyną taką. */
.sidebar__outline-tile-icon {
  flex: none; width: 24px; height: 24px;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--color-nav-fg);
}
.sidebar__outline-tile-icon svg { width: 16px; height: 16px; display: block; }
.sidebar__outline-tile-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.sidebar__outline-tile-label {
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
/* Nazwa kursu w kafelku JEDNĄ linią z wielokropkiem: kafelek ma być drobny
   („mały kafelek nad profilem"), a nie rosnąć do trzech linii przy długim
   tytule — pełna nazwa stoi w nagłówku panelu, który się rozwija. */
.sidebar__outline-tile-course {
  font-size: var(--fs-md); line-height: var(--lh-md); font-weight: 600;
  color: var(--color-nav-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Szewron pokazuje KIERUNEK ruchu, tak jak w wierszu konta pod spodem
   (.sidebar__user-chevron) — tylko odwrotnie, bo lista rozwija się W DÓŁ,
   pod kafelkiem, a panel konta wychodzi w górę. W spoczynku w dół („tu się
   rozwinie"), po otwarciu w górę („tym samym ruchem to zwiniesz"). */
.sidebar__outline-tile-chevron {
  flex: none; color: var(--color-fg-muted);
  transform: rotate(90deg); transition: transform .15s;
}
.sidebar__outline-fold[open] .sidebar__outline-tile-chevron { transform: rotate(-90deg); }

/* KADR ROZWINIĘTEGO SPISU — w pasku, nie nad treścią. Żadnego position: fixed
   (był, przez row-menu.js — i to było „nowe okienko obok", które wypadło
   w uwadze z 24.08): kadr jest zwykłym blokiem w stopce, więc rozwinięcie
   POWIĘKSZA stopkę, a listę menu nad nią kurczy jej własne `flex: 1`.

   Wyraźnie INNA POWIERZCHNIA niż pasek (tło --color-bg na białym pasku,
   zaokrąglenie, wewnętrzna wyściółka) — druga połowa tej samej uwagi:
   „strasznie jest to zlane teraz i trochę nie wiadomo, o co tam chodzi".
   Spis na tym samym białym tle, co menu nad nim, czytał się jako dalszy ciąg
   menu; wcięty szary blok mówi „to jest osobna lista", zanim ktokolwiek
   przeczyta choć jeden wyraz.

   max-height ogranicza kadr, gdy kurs ma kilkanaście lekcji: wtedy przewija się
   sam, a przyklejony nagłówek (niżej) zostaje na wierzchu. min() z pikselami,
   bo na wysokim ekranie 50vh znaczyłoby, że jedna lista zjada pół paska bez
   powodu — treść i tak się mieści. */
.sidebar__outline-body {
  margin-top: var(--space-2);
  max-height: min(50vh, 420px); overflow-y: auto; overscroll-behavior: contain;
  background: var(--color-bg); border-radius: var(--radius);
  padding: 0 var(--space-3) var(--space-3);
}
/* ROZWIJANIE I CHOWANIE Z ANIMACJĄ — „żeby to nie było takie instant, jak na
   niego najeżdżamy" (Eryk, 24.08). Lista wyjeżdża w dół i wjeżdża z powrotem,
   a nie skacze o kilkaset pikseli w jednej klatce.

   PRZEJŚCIE NA `height`, którą liczy js/outline.js — nie klatkówka na
   `max-height`. Dwie próby wcześniej i dwa różne pudła, oba warte zapisania:

     • klatkówka `max-height: 0 → min(50vh, 420px)` NIE JEST płynna, bo
       wysokość pudełka to `min(max-height, treść)`: przy kursie na sześć
       lekcji (ok. 340 px) pudło rosło tylko przez pierwsze 80% czasu
       animacji, a potem stało — ruch kończył się szarpnięciem;
     • chowanie tą samą klatkówką odtwarzaną wstecz (`reverse`) w ogóle się
       nie odtwarzało: animacja o TEJ SAMEJ nazwie, która już raz przebiegła
       na tym elemencie, nie startuje ponownie po zmianie samego kierunku —
       lista zamykała się natychmiast, bez ani jednej klatki.

   Zmierzona wysokość w pikselach nie ma żadnego z tych problemów: przejście
   biegnie dokładnie tyle, ile pudło ma naprawdę urosnąć, i restartuje się przy
   każdej zmianie wartości, w obie strony. Po zakończeniu skrypt zdejmuje
   wysokość z atrybutu style, żeby lista wróciła do naturalnego rozmiaru
   (i mogła się przewijać, gdy kurs jest długi).

   overflow: hidden TYLKO na czas ruchu (data-animating): przy przewijalnym
   pudełku o wysokości mniejszej od treści przeglądarka pokazałaby na te 200 ms
   pasek przewijania, który zaraz znika.

   `interpolate-size: allow-keywords` z `::details-content` zrobiłoby to bez
   ani jednej linii JS-u, ale wymaga Chrome 129+ i nie ma go w Firefoksie —
   próg wyższy niż cała reszta tego front-endu (patrz uzasadnienie przy
   :has() i popover API).
   Skrócony ruch (prefers-reduced-motion) zeruje czas globalną regułą z sekcji
   typografii; js/outline.js czyta to samo zapytanie i wtedy nie animuje wcale,
   zamiast czekać na przejście, którego nie ma. */
.sidebar__outline-body {
  /* margin-top I padding-bottom jadą w przejściu RAZEM z wysokością. Bez nich
     na końcu chowania zostawał odstęp nad listą (4 px) oraz — co ważniejsze —
     wyściółka dolna (8 px), której `height: 0` nie zabiera, bo `box-sizing:
     border-box` wlicza ją w wysokość. Pudło stawało wtedy na 12 px, dojeżdżało
     do zera skokiem w chwili zamknięcia <details> i to od tego skoku kafelek
     „dziwnie podskakiwał na chwilkę" (zmierzone w Chrome klatka po klatce). */
  /* Krzywa symetryczna (delikatny rozbieg, miękkie dojście), nie ostro
     przyspieszona: przy cubic-bezier(.2,.7,.3,1) pudło gubiło 84 px w PIERWSZEJ
     klatce, a ostatnie 30 px pełzły przez 90 ms — zmierzone klatka po klatce
     w Chrome. Ruch czytał się jako „szarpnięcie i doczołganie", mimo poprawnej
     wysokości docelowej. Ta sama krzywa obsługuje oba kierunki, bo rozwijanie
     i chowanie to jedno i to samo przejście puszczone w dwie strony. */
  transition: height .24s cubic-bezier(.4, 0, .2, 1),
              margin-top .24s cubic-bezier(.4, 0, .2, 1),
              padding-bottom .24s cubic-bezier(.4, 0, .2, 1),
              opacity .18s ease;
}
.sidebar__outline-fold[data-animating] > .sidebar__outline-body { overflow: hidden; }
.sidebar__outline { display: block; }
/* Nagłówek spisu PRZYKLEJONY do górnej krawędzi kadru: przewinięta lista lekcji
   inaczej traci kontekst (same tytuły, bez nazwy kursu nad nimi).
   Ujemne marginesy poziome i doliczony padding: pasek ma kryć CAŁĄ szerokość
   panelu, a panel wnosi po 8 px paddingu z boków — bez tej korekty pod
   przyklejonym nagłówkiem przeświecałyby dwie ośmiopikselowe szczeliny
   z jadącą pod nimi treścią.
   background NIEPRZEZROCZYSTE (--color-surface, czyli tło panelu) — bez tego
   przyklejony nagłówek jest przezroczysty i lekcje jadą wprost pod napisem.
   z-index: 1 — tło nagłówka musi być NAD treścią, która pod nim przejeżdża. */
.sidebar__outline-head {
  position: sticky; top: 0; z-index: 1;
  display: flex; flex-direction: column;
  margin: 0 calc(-1 * var(--space-3)) var(--space-3);
  padding: var(--space-5) var(--space-5) var(--space-3);
  background: var(--color-bg);
  border-bottom: 1px solid var(--color-border);
}
/* Etykieta bloku — ta sama typografia co .sidebar__section-label, bo to ten sam
   rodzaj napisu (nagłówek sekcji paska). */
.sidebar__outline-label {
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 500;
  letter-spacing: 0.05em; text-transform: uppercase; color: var(--color-fg-muted);
}
/* „6 lekcji · 5 sekcji" — SKALA kursu. Z samych widocznych tytułów nie wynikało,
   czy w pasku jest cały kurs, czy jego kawałek; ta linia odpowiada na to jednym
   zdaniem, tym samym, którego szuka się w nagłówku listy kursów. */
.sidebar__outline-summary {
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
/* Nazwa kursu: LINK do jego strony, więc czyta się jak nazwa, a nie jak
   etykieta. Wcześniej brała styl .sidebar__section-label (12 px, wersaliki,
   szarość) i przez to ginęła — a jest jedynym wyjściem ze spisu na stronę
   kursu. */
.sidebar__outline-title {
  display: block;
  font-size: var(--fs-md); line-height: var(--lh-md); font-weight: 600;
  color: var(--color-nav-fg); text-decoration: none; overflow-wrap: anywhere;
}
.sidebar__outline-title:hover { text-decoration: underline; }
/* GRUPA = sekcja z jej lekcjami. Kreska i odstęp NA GRUPIE, nie na nagłówku:
   bez nich sekcje i lekcje stały jedna pod drugą w równych odstępach i cały
   spis czytał się jako jedna zbita kolumna tytułów — uwaga Eryka (23.08):
   „strasznie zbity ze sobą (…) nie da się nic z tego wywnioskować".
   Pierwsza grupa bez kreski: nad nią jest już kreska nagłówka spisu. */
.sidebar__outline-group + .sidebar__outline-group {
  margin-top: var(--space-3); padding-top: var(--space-3);
  border-top: 1px solid var(--color-nav-line);
}
/* Nagłówek sekcji jest LINKIEM do kotwicy w treści kursu — „do wszystkiego
   możemy wejść" dotyczy też poziomu sekcji, nie tylko lekcji.
   Mniejszy i wyszarzony (było 13-14 px, waga 700, kolor napisu menu): sekcja
   jest RAMĄ dla lekcji, a nie celem konkurującym z nimi o wzrok. Nazwa lekcji
   ma być w tym bloku najciemniejszym napisem — do niej się wchodzi. */
.sidebar__group {
  display: flex; align-items: baseline; gap: var(--space-3);
  padding: 0 var(--space-5) var(--space-2);
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 600;
  letter-spacing: .02em; color: var(--color-fg-muted); text-decoration: none;
}
.sidebar__group:hover { color: var(--color-nav-fg); }
.sidebar__group-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* Licznik lekcji w sekcji — wyrównany do prawej krawędzi, więc kolumna liczb
   daje się przeczytać z góry na dół („2, 1, 1, 2") i od razu widać, gdzie
   w kursie jest mięso, a gdzie jedna lekcja na doczepkę. */
/* Kafelek BIAŁY (--color-surface), nie szary: kadr spisu ma dziś szare tło,
   na którym szary kafelek po prostu nie istnieje. To samo dotyczy numeru
   lekcji i stanu aktywnego niżej. */
.sidebar__group-count {
  flex: none; min-width: 18px; padding: 0 var(--space-2);
  border-radius: var(--radius-sm); background: var(--color-surface);
  font-size: var(--fs-sm); line-height: var(--lh-sm); text-align: center;
}
/* Kreski MIĘDZY pozycjami (nie nad pierwszą i nie pod ostatnią): dwie lekcje
   pod rząd, każda dwuwierszowa, zlewały się w akapit — druga połowa uwagi
   z 24.08 („strasznie jest to zlane, mało czytelne"). Kreska jest hierarchicznie
   SŁABSZA od tej między sekcjami, bo dzieli pozycje wewnątrz jednej sekcji:
   stąd brak dodatkowego odstępu, który sekcje mają. */
.sidebar__sublist { list-style: none; margin: 0; padding: 0; }
.sidebar__sublist > li + li { border-top: 1px solid var(--color-border); }
/* Pozycja lekcji: DWULINIOWA — numer i tytuł, pod tytułem miara materiału
   („6 ćwiczeń"). Wcześniej był to jednowierszowy tytuł 13 px z szewronem: na
   256 px paska dłuższe tytuły łamały się na trzy linie, sąsiednie pozycje
   zlewały się w ścianę tekstu i nie było z niej czego wyczytać poza nazwami.
   Cel dotykowy 44 px dokłada zapytanie (pointer: coarse) wyżej — dopisane
   razem z tą zmianą, bo do tej pory pozycja lekcji była JEDYNYM linkiem
   nawigacji, którego globalne wymaganie 44×44 px nie obejmowało. */
.sidebar__sub {
  display: flex; align-items: flex-start; gap: var(--space-3);
  padding: var(--space-2) var(--space-5); border-radius: var(--radius-sm);
  font-size: var(--fs-md); line-height: var(--lh-md);
  color: var(--color-nav-fg); text-decoration: none;
}
/* Numer lekcji w kursie — ten sam kafelek 24×24 co ikona pozycji menu
   (.sidebar__icon), żeby oba spisy w jednym pasku miały jedną kolumnę
   wyrównania po lewej. Liczba, nie kropka listy: mówi POZYCJĘ w kursie,
   ciągłą przez wszystkie sekcje. */
.sidebar__sub-num {
  flex: none; width: 24px; height: 24px; border-radius: var(--radius-sm);
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--color-surface); color: var(--color-fg-muted);
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.sidebar__sub-text { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); }
/* Tytuł lekcji łamie się na NAJWYŻEJ dwie linie, dalej wielokropek: pełne
   łamanie robiło z długiego tytułu trzy-czterowierszowy akapit, a rytm listy
   (i możliwość porównania pozycji wzrokiem) rozsypywał się przy pierwszej
   takiej lekcji. Pełny tytuł zostaje w atrybucie title przeglądarki? NIE —
   siedzi w treści elementu, więc czytnik ekranu i wyszukiwanie w stronie mają
   go w całości; skraca wyłącznie warstwa wizualna. */
.sidebar__sub-title {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; overflow-wrap: anywhere;
  /* NAJCIEMNIEJSZY napis w całym spisie i cięższy od reszty: to jest cel
     klikania, a stał w tym samym szarym co nazwa sekcji nad nim i liczba
     ćwiczeń pod nim — trzy odcienie szarości jeden pod drugim zlewały się
     w jeden blok. */
  color: var(--color-nav-active-fg); font-weight: 500;
}
/* Miara materiału w lekcji: PLAKIETKA .badge--neutral (obrys, pełne
   zaokrąglenie, 12 px), nie drugi wiersz tekstu — uzasadnienie przy tym
   elemencie w layout.html. Ta reguła dokłada do plakietki tylko dwie rzeczy:
   węższą wyściółkę (pigułka stoi w pasku 256 px, nie w wierszu tabeli)
   i lżejsze pismo, żeby nie licytowała się z tytułem nad sobą.
   Stoi w arkuszu PO .badge, więc wygrywa kaskadą przy równej swoistości. */
.sidebar__sub-meta {
  padding: 0 var(--space-3); font-weight: 500;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
.sidebar__sub:hover { background: var(--color-surface); }
/* Pozycja bieżąca w spisie treści kursu: szare tło + krecha z lewej w kolorze
   akcentu. NIE ten sam kafel co .sidebar__item--active — tamten jest
   zarezerwowany dla pozycji GŁÓWNEGO menu i dwa identyczne kafle jeden pod
   drugim (raz „Kursy", raz lekcja) czytałyby się jak dwie równorzędne
   nawigacje. Krecha bierze --color-accent-strong, czyli slate 900 — akcent
   jako wypełnienie, nie jako tekst. */
.sidebar__sub--active {
  background: var(--color-surface); font-weight: 600;
  border-left: 3px solid var(--color-accent-strong); padding-left: calc(var(--space-5) - 3px);
}
/* Numer otwartej lekcji czernieje — ten sam ruch, co kafelek ikony na bieżącej
   pozycji menu (.sidebar__item--active .sidebar__icon). Dzięki temu „gdzie
   jestem" widać z odległości, po samej kolumnie numerów, bez czytania tytułów. */
.sidebar__sub--active .sidebar__sub-num {
  background: var(--color-nav-chip-active); color: var(--color-nav-chip-active-fg);
}

/* `border-t border-gray-200 px-2 py-1` z humi. Kreska ma dochodzić do
   krawędzi paska, więc padding poziomy stoi TU, nie na .sidebar. */
/* `border-t px-2 py-1` z humi — stopka to dziś jeden wiersz konta. */
/* `margin-top: auto` PRZYPINA stopkę do dołu paska. Wcześniej robiło to
   `flex: 1` na liście menu (rozpychała się i spychała stopkę w dół) — i to
   właśnie ono zamieniało menu w kontener przewijania. Margines daje ten sam
   układ przy liście o wysokości własnej treści: wolna wysokość zostaje pustym
   polem MIĘDZY menu a stopką, dokładnie jak w referencji z humi. */
.sidebar__footer { border-top: 1px solid var(--color-nav-line); padding: var(--space-3); flex: none; margin-top: auto; }
/* MENU KONTA w stopce (details.row-menu) — u humi to `DropdownMenu` otwierany
   kliknięciem w wiersz z awatarem, z panelem wychodzącym W GÓRĘ. Panel
   pozycjonuje js/row-menu.js (fixed + odwrócenie nad przycisk, gdy pod nim
   nie ma miejsca — a w stopce nigdy nie ma), więc tu zostaje sam wiersz.
   display: block zamiast inline-block z .row-menu: wiersz konta ma trzymać
   całą szerokość paska, a nie szerokość swojej treści.

   DWIE KLASY W SELEKTORZE, nie jedna, i to nie kosmetyka — dokładnie ta sama
   pułapka, którą niżej opisuje komentarz przy .sidebar__account-panel.
   `.row-menu { display: inline-block }` stoi w arkuszu PÓŹNIEJ, więc przy
   równej swoistości (0,1,0) wygrywało ono, a nie ta reguła: <details> konta
   dobierał szerokość do TREŚCI (zmierzone: 288 px przy pasku 256 px minus
   8 px wyściółki, czyli 240 px dostępnych) i dłuższy adres e-mail wylewał się
   razem ze strzałką POZA pasek, na treść strony (zgłoszenie Eryka 23.08.2026
   ze zrzutem lewego dolnego narożnika). Pilnuje tego
   TestSidebarAccountRowFitsSidebar. */
.sidebar__footer .sidebar__account { display: block; }
/* Adres bierze CAŁĄ resztę wiersza i tam się ucina (flex: 1), zamiast zostać
   przy swojej naturalnej szerokości: samo `min-width: 0` pozwala kurczyć się
   dopiero pod naciskiem, a strzałka po prawej ma `margin-left: auto`, więc
   nacisku nie ma — wiersz po prostu rósł. */
.sidebar__user-name { flex: 1; }
/* Wiersz konta — `flex items-center gap-3` z UserNav w humi, plus rzeczy,
   których <summary> potrzebuje, żeby przestać być trójkącikiem. */
.sidebar__user {
  /* KOLUMNA IKON PASKA MA 24 px, A AWATAR Z PIERŚCIENIEM 36 (32 + 2×2 px
     pierścienia) — więc wiersz konta NIE może mieć tej samej wyściółki co
     pozycja menu, bo wtedy jego grafika zaczyna się w tym samym miejscu, ale
     jest o 12 px szersza i jej ŚRODEK ucieka o 6 px w prawo. Widać to
     dokładnie tak, jak to zgłosił Eryk (25.08.2026, zrzut lewego dolnego
     narożnika): „ikona profilu jest nieco bardziej po prawej niż ikona ze
     spisu treści". Razem z kafelkiem spisu, który przed tą zmianą stał 4 px
     w LEWO od menu, robiło to 10 px rozjazdu między dwoma sąsiadującymi
     wierszami tej samej stopki.

     Ściągamy więc o połowę różnicy — (36 − 24) / 2 = 6 px — i wyściółkę,
     i odstęp. Wyściółka centruje pierścień na kolumnie ikon (8 px stopki
     + 6 + 18 = 32 px od krawędzi paska, tyle samo co środek ikony menu:
     8 px listy + 12 + 12), a odstęp zdjęty o tyle samo oddaje te 6 px
     napisowi, więc nazwa wraca na wspólną krawędź tekstu (56 px).
     Wartość liczona z tokenu, nie wpisana jako 6 px: gdy --space-5 się
     zmieni, wiersz konta pójdzie za pozycjami menu sam. */
  --sidebar-avatar-overhang: 6px;
  display: flex; align-items: center; gap: calc(var(--space-5) - var(--sidebar-avatar-overhang));
  padding: var(--space-3) var(--space-5)
           var(--space-3) calc(var(--space-5) - var(--sidebar-avatar-overhang));
  min-width: 0;
  list-style: none; cursor: pointer; border-radius: var(--radius-sm);
}
.sidebar__user::-webkit-details-marker { display: none; }
.sidebar__user:hover,
.sidebar__account[open] > .sidebar__user { background: var(--color-nav-hover); }
/* Strzałka obraca się w dół po otwarciu — jedyny sygnał, że wiersz jest
   przełącznikiem, a nie podpisem. */
.sidebar__user-chevron {
  margin-left: auto; flex: none; color: var(--color-fg-muted);
  transform: rotate(-90deg); transition: transform .15s;
}
.sidebar__account[open] .sidebar__user-chevron { transform: rotate(90deg); }
/* Panel na szerokość paska minus wyściółka stopki — inaczej menu konta byłoby
   węższe od wiersza, z którego wychodzi. Otwiera się W GÓRĘ (`side="top"`
   u humi): stopka stoi przy dolnej krawędzi okna, więc panel schodzący w dół
   wychodziłby poza ekran. Te wartości działają BEZ JS-u; js/row-menu.js po
   otwarciu przelicza je na position: fixed (i sam też odwraca panel nad
   przycisk), żeby nie przycinał go żaden przodek. */
/* DWIE KLASY W SELEKTORZE, nie jedna, i to nie kosmetyka: .row-menu__panel
   (0,1,0) stoi w arkuszu PÓŹNIEJ niż ten blok, więc przy równej swoistości
   wygrywałby swoim `top: 100%; right: 0; min-width: 190px` i panel konta
   otwierałby się w dół, wąsko i pod dolną krawędź okna (zmierzone: 190×14 px
   na y=896 przy oknie 900 px). Zejście z nim niżej w pliku rozdzieliłoby
   deklaracje menu konta na dwa miejsca; podniesienie swoistości trzyma je
   razem i jest odporne na kolejność. */
.sidebar__account .sidebar__account-panel {
  min-width: 232px;
  top: auto; bottom: 100%; left: 0; right: auto;
  margin: 0 0 4px;
}
/* Nagłówek panelu = DropdownMenu.GroupHeading u humi: nazwa i pod nią adres. */
.sidebar__account-head {
  display: flex; flex-direction: column; gap: 2px;
  padding: 6px var(--space-3) var(--space-5);
  margin-bottom: 4px; border-bottom: 1px solid var(--color-border);
  min-width: 0;
}
.sidebar__account-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar__account-mail {
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Przełącznik języka jest polem formularza, nie pozycją menu — zostaje
   kontrolką, tylko dosuniętą do szerokości panelu, z ikonką jako etykietą
   wiersza (u humi ikonę ma każda pozycja menu konta). */
.sidebar__account-row { display: flex; align-items: center; gap: var(--space-3); padding: 2px var(--space-3) 4px; }
.sidebar__account-row form { flex: 1; min-width: 0; }
.sidebar__account-panel .field__control { width: 100%; }
/* `flex items-center gap-2` z ich DropdownMenu.Item — ikona 16 px w kolorze
   wyszarzonym, żeby nie konkurowała z napisem. */
.sidebar__account-panel .row-menu__item { display: flex; align-items: center; gap: var(--space-3); }
.sidebar__account-icon { flex: none; display: inline-flex; color: var(--color-fg-muted); }
.sidebar__account-icon svg { width: 16px; height: 16px; display: block; }
/* PIERŚCIEŃ WOKÓŁ MONOGRAMU — obwódka ze zrzutu, który przysłał Eryk
   (24.08.2026). Na zrzucie jest zapełniona mniej więcej w trzech czwartych,
   czyli rysuje jakąś miarę; czym ją karmić, jeszcze nie wiadomo („na razie ją
   wypełnij, a potem podepniemy pod nią jakąś funkcję").

   Stąd 100% JAKO WARTOŚĆ DOMYŚLNA, a nie 75: pierścień zamknięty czyta się
   jako obwódka awatara i nic nie obiecuje, a zatrzymany na trzech czwartych
   twierdziłby, że ktoś ma 75% czegoś — i byłaby to nieprawda, dopóki nic tej
   liczby nie liczy.

   PODPIĘCIE DANYCH TO JEDNA LINIA W SZABLONIE, bez ruszania tego arkusza:
   własność --sidebar-avatar-progress (0-100) stoi tu tylko jako wartość
   zapasowa, więc handler wystarczy, że doda wierszowi konta atrybut
   style="--sidebar-avatar-progress: 42". Dlatego liczba jest BEZ jednostki
   i mnożona niżej przez 1% — custom property z jednostką (`42%`) nie da się
   użyć w innym działaniu, gdyby kiedyś przyszło ją przeliczać.

   conic-gradient, nie <svg> ze stroke-dasharray: pierścień nie niesie treści
   (wiersz konta ma własne aria-label), więc nie potrzebuje węzła w drzewie
   dostępności, a gradient rysuje się bez dokładania znacznika do szablonu.
   Kolor toru jest osobnym tokenem — bez niego przy wartościach poniżej 100%
   nie widać, ILE brakuje, tylko sam zapełniony łuk wiszący w powietrzu. */
.sidebar__avatar-ring {
  --sidebar-avatar-progress: 100;
  flex: none; display: inline-flex; padding: 2px; border-radius: 999px;
  background: conic-gradient(
    var(--color-avatar-ring) calc(var(--sidebar-avatar-progress) * 1%),
    var(--color-avatar-ring-track) 0);
}
/* Kółko z monogramem: `bg-black text-white` z ich Avatar.Fallback. Te same
   tokeny co czarny kafelek numeru lekcji w spisie, żeby w pasku był jeden
   czarny, nie dwa.
   KRAWĘDŹ W KOLORZE PASKA to szczelina między monogramem a pierścieniem —
   bez niej czarne kółko dotykałoby prawie czarnego łuku i pierścień znikał
   (a kolory obu są celowo bliskie: pierścień ma być obwódką awatara, nie
   drugim akcentem). Krawędź, nie box-shadow — cień na elemencie w pasku
   wywraca TestNoBoxShadowClassOnFocusableElement. box-sizing: border-box
   (reguła globalna) wlicza ją w 32 px, więc sam krążek ma 28 px, a całość
   z pierścieniem 36 px. */
.sidebar__avatar {
  width: 32px; height: 32px; flex: none; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--color-nav-chip-active); color: var(--color-nav-chip-active-fg);
  border: 2px solid var(--color-nav);
  font-size: var(--fs-sm); font-weight: 600; letter-spacing: .02em;
}
/* Nazwa ucina się zamiast rozpychać pasek — adres bez imienia bywa długi. */
.sidebar__user-name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-weight: 500; color: var(--color-nav-fg);
}
/* <select class="field__control" name="language"> maluje własne tło z
   --color-surface, a od odwrócenia powłoki (zadanie 11) --color-nav W JASNYM
   MOTYWIE to dokładnie ten sam token — biel na bieli, 1,00:1, z samą
   krawędzią (--color-border) na 1,27:1 wobec paska. Oba poniżej progu 3:1
   z WCAG 2.2 SC 1.4.11 dla granicy kontrolki. Przed odwróceniem to samo pole
   miało 17,68:1 (grafitowy pasek pod białym polem) — regresja, której grep
   po `--color-nav` nie mógł złapać, bo reguła popsuła się przez to, że
   --color-surface ZACZĘŁO znaczyć to samo co --color-nav, a nie przez zmianę
   nazwy tokenu w samym polu.
   W ciemnym motywie pole też jest słabe (choć to nie regresja z tego
   zadania, tylko stan sprzed niego): --color-surface #18181b na
   --color-nav #1f1f23 to 1,08:1, a sama krawędź (--color-border #27272a)
   ma na WŁASNYM wypełnieniu pola tylko 1,19:1 — więc nawet krawędź, która
   już tam jest, ledwie odróżnia się od środka pola. Naprawa obu motywów
   naraz jest tańsza niż wracanie tu drugi raz.
   Tylko border-color, nie tło i nie font-size: 16px zostaje (mniejszy
   font powoduje zoom przy tapnięciu na iOS), a box-shadow tu nie wolno
   postawić — zastąpiłby, nie dołożył, pierścień fokusu z sekcji typografii.
   --color-nav-fg: ten sam token, który już niesie tekst nawigacji — 17,68:1
   na jasnym pasku, 11,11:1 na ciemnym, w obu przypadkach dużo ponad 3:1. */
.sidebar__footer .field__control { border-color: var(--color-nav-fg); }
/* Motyw i wylogowanie są dziś pozycjami menu konta (.row-menu__item, panel
   na powierzchni karty), nie przyciskami .btn na tle paska — reguły, które
   nadawały im kolor i hover paska, zniknęły razem z nimi. */

/* SZUFLADA ZAMIAST KOLUMNY — do 1023 px włącznie, czyli także tablet
   w pionie (768) i telefon w poziomie (568-896). Granica była 767 px
   i zostawiała iPada w portrecie z kolumną treści 448 px: tabela kursów
   pokazywała w niej nazwę i połowę drugiej kolumny, reszta uciekała pod
   krawędź karty. Po zmianie treść ma na tym samym ekranie 768 px, a menu
   jest o jedno dotknięcie hamburgera dalej — dokładnie tak, jak na telefonie.
   Progi opisane przy --sidebar-width na górze pliku. */
@media (max-width: 1023px) {
  .topbar {
    display: flex; align-items: center; gap: 12px; padding: 8px 12px;
    /* color z tokenu, nie #fff: pasek jest w motywie jasnym biały, więc biały
       hamburger byłby niewidzialny (1,00:1). Kreska pod spodem, bo topbar
       jest sticky i przy przewijaniu treść wjeżdżała pod niego bez żadnej
       granicy — biel na #f4f4f5 to 1,10:1. */
    background: var(--color-nav); color: var(--color-nav-fg);
    border-bottom: 1px solid var(--color-nav-line);
    position: sticky; top: 0; z-index: 30;
  }
  .topbar__menu {
    min-width: 44px; min-height: 44px; background: none; border: 0;
    color: inherit; font-size: 20px; cursor: pointer;
  }
  .topbar__logo { height: 24px; }
  /* Wyściółka treści maleje dopiero na telefonie (blok ≤767 px niżej):
     na tablecie 32 px z desktopu jest w sam raz i bez niej karty siadały
     na krawędzi ekranu. */
  /* Krzyżyk w bloku marki — widoczny wyłącznie w szufladzie, jak `md:hidden`
     u humi. 44 px celu dotykowego, bo to jest przycisk dla kciuka. */
  .sidebar__close {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 44px; min-height: 44px; margin-right: -8px;
    background: none; border: 0; border-radius: var(--radius-sm);
    color: var(--color-nav-fg); cursor: pointer;
  }
  .sidebar__close:hover { background: var(--color-nav-hover); }
  .sidebar__close svg { width: 20px; height: 20px; }
  .sidebar {
    position: fixed; inset: 0 auto 0 0; z-index: 50;
    transform: translateX(-100%);
    /* Sam transform NIE wyjmuje szuflady z obiegu: przesunięta treść dalej
       łapie fokus i dalej siedzi w drzewie dostępności, a że jest fixed, to
       przeglądarka nie ma jak przewinąć zafokusowanego linku na ekran —
       klawiatura wpadała w dziesięć niewidzialnych przystanków Tab.
       visibility: hidden zabiera i fokus, i czytnik ekranu.
       Animacja zostaje: przy CHOWANIU visibility siedzi w transition, więc
       zmienia się skokowo dopiero po .2s i cały slajd w bok jest widoczny.
       Przy POKAZYWANIU stan [data-open] celowo nie ma visibility w swojej
       liście transition — atrybut wchodzi natychmiast, bo drawer.js zaraz po
       ustawieniu data-open przenosi fokus do środka, a focus() na elemencie
       jeszcze policzonym jako hidden jest cichym no-opem. */
    visibility: hidden;
    transition: transform .2s ease-in-out, visibility .2s;
    /* box-shadow wolno tu postawić, bo <nav> nigdy nie dostaje fokusu.
       Na czymkolwiek fokusowalnym ta sama właściwość ZASTĄPIŁABY pierścień
       fokusu z sekcji typografii (box-shadow jest jedną właściwością, nie
       listą warstw do dołożenia) i wskaźnik zniknąłby bez śladu. */
    box-shadow: var(--shadow-md);
  }
  .sidebar[data-open] {
    transform: translateX(0); visibility: visible;
    transition: transform .2s ease-in-out;
  }
  /* Tło pod szufladą: żadnego box-shadow — to <button> (patrz drawer.js). */
  .drawer-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / 45%); z-index: 40; border: 0; }
}

/* Sam telefon: 32 px wyściółki po bokach zjadałoby dziesiątą część ekranu
   przy 320 px, więc kolumna treści schodzi do 16. Osobny blok, bo szuflada
   wyżej obejmuje też tablet, na którym pełna wyściółka ma zostać. */
@media (max-width: 767px) {
  .content { padding: 16px; }
}

/* ---------- karta wejścia (login, zaproszenie) ---------- */
/* .card zapewnia tło/obramowanie/cień; .content--centered z powłoki (zadanie
   4) centruje tę kartę w oknie — tu tylko szerokość, żadnego własnego
   centrowania. */
.auth-card { width: 100%; max-width: 380px; }
.auth-card__logo { height: 32px; margin-bottom: 20px; }

/* ---------- strefa zrzutu pliku (zadanie 6, baza wiedzy) ---------- */
/* Natywny input[type=file] stylowany na strefę przeciągnij-upuść — bez
   JS-owej obsługi dragover/drop: klik dalej otwiera systemowy selektor
   plików, otoczka tylko sygnalizuje, gdzie plik ma trafić. Zero zależności
   zewnętrznych (spec), więc żadnego własnego drag&drop ponad to, co
   przeglądarka i tak daje za darmo. */
.file-drop {
  display: block; width: 100%; padding: 24px 16px;
  border: 2px dashed var(--color-border); border-radius: var(--radius);
  background: var(--color-surface-muted); color: var(--color-fg-muted);
  font: 400 13px/1.4 var(--font-sans); text-align: center; cursor: pointer;
  transition: border-color .15s, background-color .15s;
}
.file-drop:hover { border-color: var(--color-accent-strong); background: var(--color-surface-muted); }
/* border-style, nie box-shadow — pierścień fokusu globalny (patrz sekcja
   typografii) jest jedną właściwością box-shadow, więc dopisanie tu innego
   box-shadow by go podmieniło zamiast uzupełnić. */
.file-drop:focus-visible { border-style: solid; }
.file-drop::file-selector-button {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 36px; padding: 0 16px; margin-right: 12px;
  border-radius: var(--radius-sm); border: 1px solid var(--color-border);
  background: var(--color-surface); color: var(--color-fg);
  font: 500 14px/1 var(--font-sans); cursor: pointer;
}
.file-drop::file-selector-button:hover { background: var(--color-surface-muted); }
/* Ta sama podwójna granica co przy .btn — patrz komentarz w sekcji
   komponentów. */
@media (max-width: 767px), (pointer: coarse) {
  .file-drop::file-selector-button { min-height: 44px; }
}

/* ---------- kursy: kroki kreatora, ćwiczenia ---------- */
/* UWAGA: żadna reguła w tej sekcji nie ustawia box-shadow na elemencie
   fokusowalnym ani bezwarunkowego min-height na .btn/.btn--sm — jedno
   skasowałoby pierścień fokusu, drugie cel dotykowy 44 px z media query
   wyżej.
   USUNIĘTO STĄD .course-card* (kafel kursu z okładką w gradiencie, plakietką
   poziomu i odznaką zgłoszeń, ~35 linii). Lista kursów jest tabelą od czasu
   uwagi Antka z 17.08 („w tych głównych rzeczach ma być tabela jak w Lesson
   Sessions") i od tamtej pory ŻADEN szablon ani skrypt tych klas nie używał —
   sprawdzone grepem po internal/ i cmd/. Zostawały jako martwy, jednorazowy
   styl, a przy przejściu na paletę humi zaczęły dodatkowo kłamać: okładka
   brała gradient z dwóch tokenów akcentu, które są dziś TYM SAMYM kolorem
   (slate 900), a napis poziomu miał wpisany na sztywno grafit #1a1818, czyli
   byłby na niej praktycznie niewidoczny. */
/* Wiersz z checkboxem (wybór plików i działów w kreatorze): cały <label>
   jest klikalny, więc realny cel dotykowy to cała wysokość wiersza
   (12 px + 12 px paddingu + treść), a nie sam kwadracik. Kwadracik i tak
   powiększamy, bo domyślne ~13 px gubi się przy 16-pikselowym tekście. */
label.list-row { cursor: pointer; }
label.list-row > input[type="checkbox"] { width: 20px; height: 20px; flex: none; }
/* Treść wiersza to <span>-y, nie <div>-y: model treści <label> to phrasing
   content, więc blokowe dzieci byłyby niepoprawnym HTML-em. Układ w pionie
   robimy wobec tego CSS-em — inaczej nazwa i metadane lecą jednym ciągiem
   i na wąskim ekranie łamią się w połowie frazy. */
label.list-row :where(.list-row__title, .list-row__meta) { display: block; }

/* Wielowyborowa lista działów w formularzu zaproszenia (team.html): ta sama
   sztuczka co przy label.list-row wyżej (kwadracik 20x20, cały <label>
   klikalny), bo bez niej pole to zwykły .field z domyślnym ~13-pikselowym
   checkboxem obok 13-pikselowej etykiety — reguła label.list-row >
   input[type=checkbox] się tu nie stosuje (to .field, nie .list-row),
   a wspólna reguła 44 px niżej pokrywa tylko .btn/.btn--sm/.field__control.
   Na telefonie dokładamy wysokość całego wiersza do 44 px, żeby palec
   mierzący w jeden dział na liście nie trafiał w sąsiedni.
   UWAGA przy zmianie: do tej sesji ta klasa siedziała też pod wierszem
   „Usuń" w edytorze planu lekcji (course.html) — uwaga Antka ("normalny X
   zamiast checkboxa") przeniosła TĘ kontrolkę na .lesson-editor__delete
   niżej, więc .field--checkbox dziś opisuje wyłącznie listę działów. */
.field--checkbox { display: flex; align-items: center; gap: 8px; margin-bottom: 0; }
.field--checkbox input[type="checkbox"] { width: 20px; height: 20px; flex: none; }
.field--checkbox .field__label { margin-bottom: 0; }
/* Pozycja schowana przez szukanie w grupie (js/grupa-szukaj.js) MUSI mieć
   własną regułę: [hidden] z arkusza przeglądarki to display:none o tej samej
   swoistości, ale z arkusza o niższym pierwszeństwie niż ten — więc
   display:flex wyżej wygrywa i „schowana" etykieta zostaje na ekranie. */
.field--checkbox[hidden] { display: none; }
@media (max-width: 767px), (pointer: coarse) {
  .field--checkbox { min-height: 44px; }
}

/* Uwaga Antka (właściciel, przegląd na żywo): "nie podoba mi się ten guzik
   usuń, ma być normalny X po prawej stronie tytułu lekcji". Checkbox zostaje
   JEDYNYM nośnikiem stanu — handleSavePlan (internal/web/courses.go) czyta
   pole delete_N przez FormValue == "on", ten kontrakt się nie zmienia — ale
   znika z oczu za .sr-only (sekcja powłoki wyżej): position:absolute + clip,
   NIE display:none, więc Tab wciąż na niego trafia i Spacja go przełącza —
   natywne zachowanie <input type="checkbox">, zero JS potrzebne do samego
   przełączania. Widoczną kontrolką jest <label for="...">×</label> — klik
   gdziekolwiek na etykiecie przełącza sprzężony checkbox tak samo, jak klik
   wprost na niego.

   .lesson-editor to DODATKOWY wrapper wokół .card, nie sam .card: checkbox
   musi być poprzedzającym RODZEŃSTWEM .lesson-editor__delete (nie jego
   potomkiem), żeby `:checked ~` mogło przemalować X BEZ :has() — ten sam
   zakaz, z tego samego powodu, co przy pasku robót wyżej
   (.shell--with-dock): próg przeglądarkowy tego arkusza jest niższy niż
   Firefox 121, którego :has() wymaga. .stack > * + * (sekcja powłoki) liczy
   odstępy między BEZPOŚREDNIMI dziećmi .stack — .lesson-editor jest jednym
   takim dzieckiem (dokładnie jak wcześniej sam .card), więc dodanie
   checkboxa i X jako rodzeństw .card WEWNĄTRZ .lesson-editor nie dokłada
   drugiego odstępu między lekcjami.

   X POZA KARTĄ (znalezisko z przeglądu): `opacity: .55` niżej (stan
   zaznaczony do usunięcia) przygaszał też SAM X, gdy ten był potomkiem
   `.lesson-editor__card` — kontrast spadał do ok. 2,5:1 w jasnym motywie,
   gorzej w ciemnym, dokładnie na elemencie, który ma nieść informację "ta
   lekcja zniknie po zapisie" i powinien być NAJLEPIEJ widocznym elementem
   karty, nie najsłabszym. `.lesson-editor__delete` jest bezpośrednim
   dzieckiem `.lesson-editor`, rodzeństwem `.lesson-editor__card`, w
   normalnym przepływie (kolumna flex, patrz niżej) — opacity karty (którą
   dostaje WYŁĄCZNIE `.lesson-editor__card`, nie jej rodzic) go już nie
   dotyka niezależnie od tego, jaki kolor/tło X akurat ma. */
/* .lesson-editor__title-field: znalezisko z re-recenzji tej sesji — TA
   klasa dotąd niosła `margin-bottom: 14px`, MARTWĄ deklarację, bo to
   dokładnie ta sama wartość, którą już daje domyślne `.field` (reguła
   sekcji formularzy wyżej) — usuwana klasa i tak zawsze stoi RAZEM z
   `.field` (`<label class="field lesson-editor__title-field">`, course.html),
   więc druga deklaracja niczego nie zmieniała, tylko kusiła, żeby kiedyś
   zmienić jedną z dwóch liczb i zdziwić się, że nic się nie rusza. Klasa
   ZOSTAJE bez własnych deklaracji — to wyłącznie ZACZEP selektora dla
   `.lesson-editor__delete-input:checked ~ .lesson-editor__card
   .lesson-editor__title-field .field__control` niżej (przekreślenie tytułu
   w stanie "do usunięcia"), nie duplikat `.field`. */
/* Uwaga Antka (przegląd na żywo, poprzednia runda): "nie podoba mi się ten
   guzik usuń, ma być normalny X po prawej stronie tytułu lekcji" — checkbox
   zostaje jedynym nośnikiem stanu, ukryty za .sr-only, widoczną kontrolką
   jest <label>. Sub-projekt C (dopasowanie do Figmy): Figma pokazuje
   WIDOCZNY TEKST "✕ Usuń", nie sam glif — .lesson-editor__delete przechodzi
   z małego, absolutnie pozycjonowanego badge'a w rogu karty na zwykły,
   przepływowy przycisk tekstowy PO karcie, wyrównany do prawej. Prościej niż
   poprzednia wersja: bez position:absolute nie trzeba już ręcznie liczonego
   `top`, ani rezerwy `padding-right` na tytule karty (ta klasa,
   .lesson-editor__title-row, nie ma już żadnej własnej deklaracji — została
   wyłącznie jako zaczep selektora, patrz .lesson-editor__title-field niżej)
   — .lesson-editor jest teraz kolumną flex (karta, potem przycisk),
   align-self: flex-end dosuwa przycisk do prawej krawędzi bez wpływu na
   szerokość karty. Checkbox MUSI zostać poprzedzającym rodzeństwem obu
   elementów, na które wpływa (.lesson-editor__card i .lesson-editor__delete)
   — dokładnie jak wcześniej — żeby `:checked ~` działało bez :has() (próg
   przeglądarkowy tego arkusza niższy niż Firefox 121). */
.lesson-editor { display: flex; flex-direction: column; gap: 8px; position: relative; }
/* „✕ Usuń" w PRAWYM GÓRNYM rogu karty (Humi „Remove"). Element ZOSTAJE
   bezpośrednim dzieckiem .lesson-editor (po karcie) — tego wymaga strażnik i
   selektory `:checked ~ .lesson-editor__delete` — a że jest POZA .lesson-editor__card,
   opacity karty w stanie „do usunięcia" go nie przygasza. Absolutny nad rogiem
   karty; miejsce robi mu padding-right pola nazwy niżej. */
.lesson-editor__delete {
  position: absolute; top: 12px; right: 12px;
  display: inline-flex; align-items: center; gap: 4px;
  padding: 5px 12px; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--color-border); background: var(--color-surface);
  color: var(--color-fg-muted); font-size: 13px; font-weight: 600;
}
/* Rezerwa na „✕ Usuń" po prawej — tylko w edytowalnych sekcjach (.lesson-editor),
   NIE w karcie „nowa sekcja" (tam nie ma przycisku usuwania). */
.lesson-editor .lesson-editor__title-field { padding-right: 120px; }
.lesson-editor__delete:hover { background: var(--color-surface-muted); color: var(--color-danger); }
@media (max-width: 767px), (pointer: coarse) {
  .lesson-editor__delete { min-height: 44px; padding: 10px 12px; }
}
.lesson-editor__delete-input:focus-visible ~ .lesson-editor__delete {
  outline: 2px solid transparent; outline-offset: 2px;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
.lesson-editor__delete-input:checked ~ .lesson-editor__card { opacity: .55; }
.lesson-editor__delete-input:checked ~ .lesson-editor__card .lesson-editor__title-field .field__control {
  text-decoration: line-through;
}
.lesson-editor__delete-input:checked ~ .lesson-editor__delete {
  color: var(--color-danger-fg); background: var(--color-danger);
}

/* Pytania kontrolne (sub-projekt C, dopasowanie do Figmy): numerowany
   wiersz zamiast bloku textarea — Figma pokazuje zwykłą numerowaną listę,
   ale admin musi móc edytować/usuwać pojedyncze pytanie, więc pole tekstowe
   zostaje (bez wyraźnej ramki w spoczynku, żeby wyglądać jak lista, ramka
   pojawia się na :hover/:focus jako podpowiedź "to jest edytowalne").
   Checkbox usuwania MUSI poprzedzać oba elementy, na które wpływa
   (.control-question-row__input i .control-question-row__delete) jako
   rodzeństwo — ten sam wzorzec co .lesson-editor__delete-input wyżej. */
.control-questions { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
/* Wiersz pytania kontrolnego jak w Humi: numer, OBRAMOWANA textarea (nie goły
   input — Humi ma pełne pola), „✕ Usuń". align-items:flex-start bo textarea
   bywa wysoka; numer/usuń dostają padding-top, żeby stały równo z pierwszą
   linią pola. */
.control-question-row { display: flex; align-items: flex-start; gap: 8px; }
.control-question-row__num { flex: none; padding-top: 9px; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
.control-question-row__input {
  flex: 1; min-width: 0; border: 1px solid var(--color-border); background: var(--color-surface);
  padding: 8px 10px; border-radius: var(--radius-sm); font: inherit; color: inherit;
  resize: vertical; min-height: 2.6em; line-height: 1.4;
}
.control-question-row__input:hover, .control-question-row__input:focus {
  border-color: var(--color-fg-muted);
}
.control-question-row__input:focus-visible {
  outline: 2px solid transparent; outline-offset: 2px;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
.control-question-row__delete {
  flex: none; margin-top: 5px; font-size: 12px; color: var(--color-fg-muted); cursor: pointer;
  padding: 4px 6px; border-radius: var(--radius-sm);
}
.control-question-row__delete:hover { color: var(--color-danger); background: var(--color-surface-muted); }
.control-question-row__delete-input:focus-visible ~ .control-question-row__delete {
  outline: 2px solid transparent; outline-offset: 2px;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
.control-question-row__delete-input:checked ~ .control-question-row__input { text-decoration: line-through; opacity: .55; }
.control-question-row__delete-input:checked ~ .control-question-row__delete { color: var(--color-danger-fg); background: var(--color-danger); }
/* Wiersz „+ Dodaj pytanie" (JS-free odpowiednik „Add question" Humi): „+"
   zamiast numeru + przerywana ramka pola, żeby czytał się jak dodawanie, a nie
   kolejne wypełnione pytanie. Kolor „+" przygaszony (nie akcent — strażnik
   kontrastu zabrania żółtego jako color na jasnym tle). */
.control-question-row__num--add { color: var(--color-fg-muted); font-weight: 700; padding-top: 8px; }
.control-question-row--new .control-question-row__input { border-style: dashed; color: var(--color-fg-muted); }

/* ---------- kreator kursu (course-new.html), układ z humi ---------- */
/* Zielony pasek postępu z Figmy: na jednej scrolowanej stronie (a nie
   osobnych ekranach per krok, spec poprawki §2) nie ma jednostki, do
   której mógłby się odnosić — statyczny, czysto dekoracyjny akcent. */
/* .wizard rozciąga się na CAŁĄ wysokość i sam układa dzieci w kolumnę, zamiast
   dać się wyśrodkować w całości przez .content--centered. Powód wprost
   z makiet: pasek postępu i belka akcji siedzą przy GÓRNEJ krawędzi ekranu,
   a pływa — wyśrodkowana w tym, co zostanie — tylko karta. Dotąd cały blok
   (belka + pasek + karta + przycisk) był centrowany razem, więc belka wisiała
   w połowie ekranu i całość wyglądała na zbitą w środku. */
/* `relative flex min-h-screen flex-col p-8` z humi. Padding niesie .content,
   więc pełna wysokość to okno minus jego 2 × 32 px. */
/* WYŚCIÓŁKA W PIONIE — MIEJSCE NA BELKĘ I PODPIS KROKU (naprawa 02.09.2026,
   zgłoszenie Eryka: „przy tworzeniu kursu »Krok 4 z 7« nachodzi na ikonkę
   kursu"). Belka (.wizard-topbar) i podpis (.wizard-steps) są
   `position: absolute`, więc NIE zajmują miejsca w kolumnie — karta centruje
   się w pełnej wysokości okna i na monitorze zaczyna się grubo pod nimi.
   Ale gdy treść kroku jest wyższa od okna, `margin: auto` na .wizard-shell nie
   ma czego dzielić, karta dosuwa się do GÓRY kolumny i wchodzi w ten sam pas
   co podpis. Krok „Odpowiedzi" z dwoma pytaniami to robił: zmierzone przy
   1440×698 okładka 32-112 px, podpis 32-68 px, oba wyśrodkowane w poziomie
   (680-760 wobec 655-785) — napis lądował pod obrazkiem.

   SYMETRYCZNA, nie samo `padding-top` — i to jest cały sens tej reguły.
   `margin: auto` dzieli WOLNĄ przestrzeń na równo, więc wyściółka tylko u góry
   zabiera 56 px z jednej strony i zsuwa wyśrodkowaną kartę o 28 px w dół,
   psując „główne panele mają być pośrodku" z 24.08. Wyściółka z obu stron
   zmniejsza przestrzeń symetrycznie: środek karty zostaje na środku okna
   (zmierzone: krótki krok 349 px przy oknie 698 px, czyli bez zmiany), a długi
   krok dostaje 56 px odstępu u góry (karta 32 → 88 px, koniec kolizji).
   Wyściółka mieści się w `min-height` (border-box), więc nie dokłada paska
   przewijania krokom, które dotąd go nie miały.

   56 px = 44 px celu dotykowego belki + 12 px oddechu. Wyściółka na
   pojemniku, nie margines na karcie: pojemnik jest jeden, a kart-kroków
   kilka, w osobnych szablonach. Wcześniej ta sama liczba stała pod
   `@media (max-width: 767px)` jako samo `padding-top` — telefon miał ten
   problem zawsze (treść kroku nie mieści się w oknie), monitor dopiero przy
   długim kroku. Jedna reguła obsługuje oba przypadki. */
.wizard {
  position: relative; width: 100%; align-self: stretch;
  display: flex; flex-direction: column;
  min-height: calc(100dvh - 2 * var(--space-8));
  padding-block: 56px;
}
/* FANTOMOWY ODSTĘP NAD KREATOREM (naprawa 24.08.2026, zgłoszenie Eryka
   „główne panele mają być pośrodku, teraz są trochę na górze").
   `.content > * + *` daje 32 px odstępu KAŻDEMU dziecku poza pierwszym —
   a course.html otwiera się od <div data-course-page-status hidden>, czyli
   znacznika dla skryptu, który nic nie zajmuje na ekranie. Kolumna kreatora
   była więc jego SĄSIADEM i dostawała 32 px marginesu: pchało to kartę w dół
   i JEDNOCZEŚNIE robiło z `min-height: 100dvh - 64px` wysokość o 32 px za
   dużą, więc strona zaczynała się przewijać, a `margin: auto` na .wizard-shell
   nie miał już z czego centrować. Kroki 1 i 2 (osobne szablony) tego nie
   miały — .wizard jest tam PIERWSZYM dzieckiem — i stąd cała różnica
   w położeniu, którą widać było między krokami.
   Reguła ogólna, nie łatka na .wizard: element poprzedzony wyłącznie
   znacznikiem `hidden` nie ma powodu zaczynać się od odstępu.

   SELEKTOR POWTARZA OBA `:not()` Z REGUŁY, KTÓRĄ ZNOSI — i to nie jest
   ozdoba. `:not(.drop-overlay)` niesie swoistość KLASY, więc tamta reguła
   waży (0,2,1); samo `.content > [hidden] + *` waży (0,2,0) i PRZEGRYWA
   mimo dalszego miejsca w pliku (kolejność rozstrzyga dopiero przy równej
   swoistości). Ta sama para wykluczeń plus atrybut daje (0,3,1) — wygrywa
   jednoznacznie i przy okazji mówi wprost, którą regułę znosi. */
.content > [hidden] + *:not(dialog):not(.drop-overlay) { margin-top: 0; }
/* Na telefonie .content ma padding 16 px, nie 32 — bez tego kolumna kreatora
   jest o 32 px wyższa niż okno i strona przewija się o tę różnicę, choć
   treść by się zmieściła. */
@media (max-width: 767px) {
  .wizard { min-height: calc(100dvh - 2 * var(--space-6)); }
}
/* Górna belka kreatora (Figma / Humi „Create a new course"): strzałka wstecz
   jako IKONA po lewej, miejsce na akcje po prawej. Wcześniej był tu goły
   tekstowy link „← Kursy" (.page-header) — zgłoszenie Antka „design nie jest
   tak samo jak w Humi". justify-content: space-between trzyma strzałkę przy
   lewej krawędzi kolumny kreatora niezależnie od tego, co (jeśli cokolwiek)
   stanie po prawej. */
/* `absolute left-8 right-8 top-8` — belka WYCHODZI z układu, dzięki czemu
   karta niżej centruje się w PEŁNEJ wysokości okna, a nie w tym, co po
   belce zostało. Tak stoi w courses/new/+page@app.svelte. */
.wizard-topbar {
  position: absolute; top: 0; left: 0; right: 0; z-index: 1;
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-5);
}
/* Strzałka wstecz bywa <a> (wyjście z kreatora) albo <button type="submit">
   (cofnięcie kroku — zmiana stanu kursu, więc POST, nie GET). Reset tła,
   obramowania i paddingu jest po to drugie; ten sam zabieg co przy
   .wizard-action obok. */
.wizard-back {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; border-radius: var(--radius-sm);
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--color-fg); text-decoration: none;
}
.wizard-back:hover { background: var(--color-surface-muted); }
.wizard-back svg { width: 20px; height: 20px; }
/* Akcje po prawej stronie belki (Humi „Delete | Save as draft"): przygaszone
   przyciski tekstowe z ikoną, oddzielone kreską. „Usuń" czerwienieje na hover.
   Przyciski są submitami z formaction (patrz course-new.html), stąd reset tła. */
.wizard-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.wizard-action {
  display: inline-flex; align-items: center; gap: 6px;
  background: none; border: 0; padding: 6px 4px; cursor: pointer;
  font-size: 14px; font-weight: 600; color: var(--color-fg-muted);
}
.wizard-action:hover { color: var(--color-fg); }
.wizard-action svg { width: 16px; height: 16px; }
.wizard-action--danger:hover { color: var(--color-danger); }
.wizard-actions__sep { color: var(--color-border); }
/* Pasek postępu jako TOR (szary) z małym wypełnionym SEGMENTEM — krok 1 z
   kilku. Wcześniej całość była pełnym paskiem w kolorze akcentu, więc
   wyglądała na „gotowe/100%", a nie na początek kreatora (uwaga Antka: to nie
   wygląda jak w Humi). Kolor wypełnienia zostaje nasz (akcent) — zmiana jest
   w KSZTAŁCIE, nie w kolorze. overflow:hidden przycina segment do
   zaokrąglonych rogów toru. */
/* PRZYKLEJONY DO GÓRNEJ KRAWĘDZI OKNA, na całą szerokość i bez zaokrągleń —
   tak stoi w każdej z sześciu klatek „Course creation" w Figmie. Dotąd był
   zaokrągloną kreską wewnątrz kolumny 646 px, czyli czymś zupełnie innym:
   ozdobą obok treści zamiast wskaźnika postępu całego kreatora.
   position: fixed, nie sticky ani negatywne marginesy: kolumna kreatora ma
   padding od .content, a pasek ma dotykać krawędzi OKNA, nie kolumny. */
/* CourseCreationProgress.svelte w humi: `bg-muted h-2 w-full overflow-hidden`
   jako tor i `bg-green-500 h-full transition-all duration-300` jako
   wypełnienie — 8 px, nie 6, tor na muted, nie na krawędzi. */
.wizard-progress { position: fixed; top: 0; left: 0; right: 0; height: 8px; background: var(--color-surface-muted); z-index: 30; }
.wizard-progress__bar { display: block; height: 100%; background: var(--color-progress); transition: width .3s; }
/* PODPIS KROKU nad kartą kreatora — „Krok 3 z 7 · Pytania".
   Sam pasek mówi wyłącznie „coś jeszcze przede mną"; ile tego jest i co
   będzie dalej, mówi dopiero ten napis (partial „wizard-steps").

   POZYCJONOWANY BEZWZGLĘDNIE, nie w kolumnie: karta kreatora jest wyśrodkowana
   w pionie przez `margin: auto` na .wizard-shell, więc każdy element W TOKU
   przed nią zabiera jej wysokość i przesuwa ją z osi. Podpis siada więc w tym
   samym pasie co .wizard-topbar (też absolutny), na jego środku — belka trzyma
   po bokach strzałkę wstecz i akcje, środek stoi pusty. */
.wizard-steps {
  position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  z-index: 2; margin: 0; padding: 10px 0;
  display: flex; align-items: baseline; gap: var(--space-2);
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
  white-space: nowrap;
}
.wizard-steps__name { font-weight: 600; color: var(--color-fg); }
/* Wąskie okno: belka zajmuje całą szerokość strzałką i akcjami, więc podpis
   nie ma gdzie stanąć pośrodku — schodzi pod nią, dalej poza tokiem karty. */
@media (max-width: 640px) {
  .wizard-steps { position: static; transform: none; justify-content: center; padding: 44px 0 0; }
}
/* margin: auto w kolumnie flex centruje kartę w pionie W TYM, CO ZOSTAŁO po
   belce — i w poziomie naraz. Stąd „pływająca" karta z makiety. */
/* `max-w-2xl` z humi = 42rem = 672 px (było 646 — liczba z makiety Figmy).
   margin: auto w kolumnie flex centruje kartę w pionie w tym, co zostało po
   belce, i w poziomie naraz. */
/* KROKI OPAKOWANE FORMULARZEM (naprawa 24.08.2026, zgłoszenie Eryka: „widok
   »cel kursu« musi być wyśrodkowany, a nie przyklejony do góry ekranu"; to samo
   o kroku lektora).

   .wizard jest kolumną flex na pełną wysokość, a .wizard-shell centruje się
   w niej przez `margin: auto`. Ale w krokach 1, 2 i lektor między nimi stoi
   <form class="wizard-form"> — obejmuje belkę i kartę, bo przyciski belki są
   submitami z formaction. Formularz nie miał ANI JEDNEJ reguły: był zwykłym
   blokiem o wysokości treści, więc `margin: auto` rozdzielało wolne miejsce
   wewnątrz NIEGO, czyli zero. Karta lądowała pod belką i tyle.

   Formularz musi więc przenieść na siebie oba zadania kolumny: rosnąć do
   pełnej wysokości (flex: 1) i sam być kolumną flex, żeby auto-margines karty
   miał w czym pływać. Kroki renderowane przez course.html formularza wokół
   kolumny nie mają i działały dobrze — stąd różnica między krokami, którą
   widać było na ekranie. */
.wizard-form { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.wizard-shell { max-width: 672px; width: 100%; margin: auto; }
/* Karta kreatora nie ma już własnego paddingu — w humi to zwykły
   `<Card><CardHeader><CardContent>`, czyli te same 24 px co każda inna karta
   w aplikacji. Wcześniej stało tu 40 px z makiety Figmy („Course creation");
   Figma przestała być źródłem prawdy. Zostaje wyłącznie odstęp między
   POLAMI, bo humi daje im w tym kroku `space-y-6`. */
/* Pola w kreatorze oddychają mocniej niż w gęstych formularzach edytora. */
.wizard-shell .field { margin-bottom: var(--space-7); }
/* `> .field:last-child`, nie `.field:last-child` (naprawa 24.08.2026,
   zgłoszenie Eryka: „tytuł lekcji i opis — ten tekst jest trochę za blisko
   tych kontenerów, rozdziel to, żeby był jakiś spacing").
   Reguła miała zdejmować ZAWIS na dole karty, ale `:last-child` liczy się
   względem RODZICA, a w edytorze lekcji pole tytułu jest jedynym dzieckiem
   .lesson-editor__title-row — więc łapało się na tę regułę i traciło CAŁY
   odstęp do etykiety „Opis" pod spodem. Pola stykały się wtedy krawędziami:
   kontrolka kończyła się dokładnie tam, gdzie zaczynała się następna etykieta.
   Zawężenie do bezpośredniego dziecka treści karty załatwia jedno i drugie. */
.wizard-shell .card__body > .field:last-child { margin-bottom: 0; }
/* Przycisk „Dalej" pełnej szerokości POD kartą (Humi: „Generate course
   agenda" na całą szerokość karty), zamiast małego guzika dosuniętego do
   lewej. Zostaje w .wizard-shell, więc szerokość = szerokość karty (646px). */
/* Przycisk POD kartą, na jej szerokość i WYSOKI (makieta: ~60 px) — to jest
   jedyna akcja główna na ekranie, więc ma wyglądać na cel, a nie na guzik
   dosunięty do treści. */
/* Kwadrat okładki nad kartą kroku — 96 px, wyśrodkowany. Większy niż
   miniatura w nagłówku gotowego kursu (56 px), bo tutaj jest jedynym obrazem
   na ekranie i w trakcie kreatora człowiek patrzy, jak powstaje. */
/* 80 px, nie 96: kwadrat okładki jest w kreatorze ozdobą i wskaźnikiem
   postępu, a nie treścią kroku — każdy jego piksel zabiera wysokość, przy
   której .wizard-shell może jeszcze WYŚRODKOWAĆ kartę zamiast dosunąć ją do
   góry (patrz .wizard wyżej). Na laptopie te 16 px potrafi rozstrzygnąć. */
.wizard-cover { display: flex; justify-content: center; margin-bottom: var(--space-5); }
.wizard-cover__slot { width: 80px; height: 80px; border-radius: var(--radius-lg, 10px); box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%); }
.wizard-cover__slot img { display: block; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.wizard-submit { margin-top: var(--space-7); }
/* `<Button class="mt-6 w-full">Continue</Button>` — zwykły przycisk
   w domyślnym rozmiarze, tylko na pełną szerokość. Było 56 px wysokości,
   18 px pisma i waga 600, czyli czwarty rozmiar przycisku w aplikacji,
   którego nie ma ani w humi, ani w naszym własnym zestawie wariantów. */
.wizard-submit__btn { width: 100%; }
/* Toggle „użyj wszystkich plików" przeniesiony DO karty (Humi trzyma go w
   karcie pod polem języka). Oddzielony od pól nad nim linią i odstępem;
   checkbox/etykieta/panel dalej są rodzeństwem pod .files-toggle, więc
   `:checked ~ .files-toggle__panel` działa jak dotąd. */
.files-toggle--incard { margin-top: 4px; padding-top: 16px; border-top: 1px solid var(--color-border); }
/* Lista plików/działów w karcie: zwykły obrys zamiast zagnieżdżonej .card
   (karta-w-karcie wyglądała ciężko po przeniesieniu toggle do środka). */
.files-picker { margin-top: 12px; border: 1px solid var(--color-border); border-radius: var(--radius); overflow: hidden; }
.files-picker__title { padding: 10px 14px; font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--color-border); background: var(--color-surface-muted); }
.files-picker .list-row { padding-left: 14px; padding-right: 14px; }

/* Przełącznik "użyj wszystkich plików": checkbox-hack bez JS. Selektor `~`
   wymaga, żeby .files-toggle__checkbox i .files-toggle__panel były
   RODZEŃSTWEM pod tym samym rodzicem — .files-toggle jest tym wspólnym
   rodzicem (flex + wrap): checkbox i etykieta zajmują pierwszy "wiersz"
   (naturalna szerokość), a panel dostaje `flex-basis: 100%`, więc zawsze
   spada na WŁASNY pełnej szerokości wiersz poniżej — mimo to checkbox,
   etykieta i panel zostają PRAWDZIWYM rodzeństwem w drzewie DOM (nie
   zagnieżdżeniem), więc selektor sąsiedztwa działa. Bez tego zagnieżdżenie
   checkboxa w <label> (jak .field--checkbox) zrywa relację rodzeństwa
   z panelem, a Ten arkusz świadomie nie używa :has() (próg przeglądarkowy
   niższy niż Firefox 121), więc "sięgnięcie w górę" do rodzica nie wchodzi
   w grę. Domyślnie zaznaczony w HTML = panel domyślnie UKRYTY (częsty
   przypadek to "użyj wszystkiego"). */
.files-toggle { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* Prawdziwy toggle-switch (Figma), nie wygląd checkboxa — mechanizm
   `:checked ~ .files-toggle__panel` (niżej) zostaje BEZ ZMIAN, zmienia
   się wyłącznie to, jak wygląda checkbox sam w sobie. appearance:none
   zdejmuje natywny wygląd i pozwala na ::before jako "kółko" — bez
   :has(), bez JS. */
.files-toggle__checkbox {
  appearance: none; -webkit-appearance: none;
  width: 40px; height: 22px; border-radius: 999px; flex: none;
  background: var(--color-border); position: relative; cursor: pointer;
  margin: 0; transition: background .15s;
}
.files-toggle__checkbox::before {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 18px; height: 18px; border-radius: 50%; background: #fff;
  border: 1px solid var(--color-border); transition: transform .15s;
}
.files-toggle__checkbox:checked { background: var(--color-accent-strong); }
.files-toggle__checkbox:checked::before { transform: translateX(18px); border-color: var(--color-accent-strong); }
.files-toggle__label { font-size: 13px; font-weight: 600; cursor: pointer; }
.files-toggle__hint { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--color-fg-muted); }
.files-toggle__panel { flex-basis: 100%; }
.files-toggle__checkbox:checked ~ .files-toggle__panel { display: none; }

/* Poziom trudności jako pigułki (Figma Step 2 v2.0: Quick guide/Essentials/
   Masterclass) zamiast <select> — funkcjonalnie to nadal input radio
   name=level, handleCreateCourse nie widzi różnicy. Zaznaczenie przemalowuje
   TEKST (<span>), nie całą etykietę — selektor sąsiedztwa `~`, nie `:has()`
   (ten sam zakaz co przy przełączniku plików wyżej): <input> jest
   POPRZEDZAJĄCYM rodzeństwem <span> wewnątrz tej samej etykiety. Tło+grafit
   zamiast bursztynowego tekstu na jasnym tle: bursztyn jako KOLOR TEKSTU na
   jasnym tle łamie kontrast WCAG AA (TestAccentContrast to egzekwuje). */
.level-pills { display: flex; gap: var(--space-3); flex-wrap: wrap; }
/* Wybrany stan wypełnia CAŁĄ pigułkę, nie rysuje drugiej w środku. Dotąd
   reguła malowała `input:checked ~ span` z WŁASNYM paddingiem i promieniem
   wewnątrz już opakowanej etykiety, więc powstawała pigułka w pigułce.
   Naprawa bez :has(): padding, ramka i promień przenoszą się z <label> na
   <span>, który w szablonie i tak opakowuje ikonę z tekstem. Wtedy zwykły
   kombinator rodzeństwa (input:checked ~ span) maluje CAŁY widoczny kształt.
   :has() byłoby krótsze, ale ten arkusz go NIE UŻYWA świadomie — wymaga
   Firefoksa 121, czyli progu wyższego niż reszta reguł, i pilnuje tego
   TestContentReservesSpaceForWorkBar.
   Kolory z tokenów pigułki, nie pełne indygo jak przycisk główny: makieta ma
   tu DELIKATNE wypełnienie, a te tokeny są dokładnie takim wypełnieniem
   istniejącym już w systemie (Indigo 700 na Indigo 50 = 7,07:1). */
.level-pill { display: inline-flex; cursor: pointer; }
.level-pill > span {
  display: inline-flex; align-items: center;
  padding: var(--space-4) var(--space-6); border-radius: 999px;
  border: 1px solid var(--color-border); background: var(--color-surface);
  font-size: var(--fs-md); line-height: var(--lh-md);
}
/* Radio ORAZ checkbox: pigułka bywa wyborem jednej z kilku wartości (poziom
   kursu w kreatorze), ale też przełącznikiem wielokrotnym (kubełki statusu
   w filtrach logu sesji — „zaliczone LUB w toku" to normalne pytanie).
   Schowane wizualnie, NIE display:none — zostają w kolejności tabulacji. */
.level-pill input[type="radio"], .level-pill input[type="checkbox"] { position: absolute; opacity: 0; width: 1px; height: 1px; }
.level-pill input:checked ~ span {
  background: var(--color-chip-bg); border-color: var(--color-chip-fg);
  color: var(--color-chip-fg); font-weight: 600;
}
.level-pill:focus-within > span { border-color: var(--color-accent-strong); }
/* Podpowiedź pod etykietą grupy: ujemny margines był wpisany inline
   w course-goal.html (style="margin-top:-4px"). Etykieta ma własny
   margin-bottom, więc podpowiedź pod nią potrzebuje go zdjąć — to jest reguła
   układu, nie jednorazowa łatka w szablonie. */
.field__hint--under-label { margin-top: calc(-1 * var(--space-2)); margin-bottom: var(--space-4); }
.level-pill__icon { vertical-align: -3px; margin-right: 4px; }

.exercise {
  border: 1px solid var(--color-border); border-radius: var(--radius);
  padding: 16px 18px; background: var(--color-surface);
}
.exercise__type {
  font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
  color: var(--color-fg-muted); font-weight: 700; margin-bottom: 8px;
}
/* Czytanka to jedyny blok z HTML-em spoza szablonu (renderMarkdown), więc
   jedyny, w którym szerokość treści jest nieznana. overflow-x kiedyś siedział
   na całym .exercise__reading, ale overflow-x: auto sparowany z domyślnym
   (visible) overflow-y na tym samym elemencie awansuje OBA kierunki do auto —
   cały blok czytanki zamieniał się w kontener przewijania, przycinając
   pierścień fokusu linku w pierwszym akapicie (p ma margin-top: 0) i, bez
   żadnej treści fokusowalnej w środku, sam nie dawał się przewinąć z
   klawiatury. Zawężone do table/pre: to jedyne elementy, które faktycznie
   mogą się rozepchnąć (szeroka tabela, długi wiersz kodu bez spacji) —
   zwykły tekst i tak łamie się dzięki overflow-wrap niżej. */
/* ————— powierzchnia edycji TipTapa (lesson.html, [data-tiptap]) —————
   Wygląda jak .field__control, bo JEST polem formularza — tylko takim, które
   pokazuje sformatowaną treść zamiast surowego markdowna. Obramowanie
   i promień z tych samych tokenów co pozostałe pola, żeby okno edycji nie
   wyglądało na wklejone z innej aplikacji. */
.tiptap { border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); }
/* focus-within, nie :focus: fokus bierze .ProseMirror w środku, więc ramka
   samego pudełka nie dowiedziałaby się o nim nigdy. */
.tiptap:focus-within { border-color: var(--color-accent-strong); }
.tiptap .ProseMirror { padding: var(--space-6); min-height: 220px; outline: none; overflow-wrap: anywhere; }
.tiptap .ProseMirror > :first-child { margin-top: 0; }
.tiptap .ProseMirror > :last-child { margin-bottom: 0; }
.tiptap .ProseMirror :where(ul,ol) { padding-left: var(--space-7); margin: 0 0 var(--space-5); }
.tiptap .ProseMirror li { margin-bottom: var(--space-2); }
.tiptap .ProseMirror li > p { margin: 0; }
.tiptap .ProseMirror blockquote { margin: 0 0 var(--space-5); padding-left: var(--space-6); border-left: 3px solid var(--color-border); color: var(--color-fg-muted); }
.tiptap .ProseMirror code { background: var(--color-bg); border-radius: var(--radius-sm); padding: 1px 4px; font-size: .9em; }
.tiptap .ProseMirror pre { background: var(--color-bg); border-radius: var(--radius); padding: var(--space-6); overflow-x: auto; }
.tiptap .ProseMirror pre code { background: none; padding: 0; }
.tiptap .ProseMirror hr { border: 0; border-top: 1px solid var(--color-border); margin: var(--space-7) 0; }

/* ————— PASEK NARZĘDZI (.tiptap__bar) —————
   Formatowanie musi być KLIKALNE, nie tylko dostępne skrótem: bez paska
   pogrubienie robiło się wyłącznie przez Ctrl+B, a nagłówek przez „### " na
   początku wiersza — czyli przez składnię markdowna, której ten edytor miał
   przestać wymagać. Zestaw dokładnie z zamówienia: H1–H3, pogrubienie,
   kursywa, lista punktowana i numerowana, link, cytat, kod.

   position: sticky — pasek zostaje na wierzchu, gdy przewija się kolumna
   pisania (.exedit__main to element z overflow-y i to on jest tu portem
   przewijania). Czytanka ma zwykle kilka ekranów treści; pasek, który
   odjeżdża w górę, jest przy niej bezużyteczny.
   Osobne tło i dolna kreska, bo pasek przewijaną treść PRZYKRYWA — bez tła
   litery przejeżdżałyby pod przyciskami. */
.tiptap { display: flex; flex-direction: column; }
.tiptap__bar {
  position: sticky; top: 0; z-index: 2;
  display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
  padding: 6px; border-bottom: 1px solid var(--color-border);
  background: var(--color-surface);
  border-radius: var(--radius) var(--radius) 0 0;
}
/* Kreska dzieląca grupy (nagłówki | pismo | listy | wstawki) — czytanie
   paska „po grupach" jest szybsze niż po jednym rzędzie dziesięciu ikon. */
.tiptap__sep { width: 1px; align-self: stretch; margin: 2px 4px; background: var(--color-border); }
.tiptap__btn {
  min-width: 30px; height: 30px; padding: 0 7px; display: grid; place-items: center;
  border: 1px solid transparent; border-radius: var(--radius-sm);
  background: none; cursor: pointer; color: var(--color-fg);
  font-size: 13px; font-weight: 600; line-height: 1;
}
.tiptap__btn:hover { background: var(--color-surface-muted); }
/* aria-pressed niesie stan DLA CZYTNIKA EKRANU, a selektor bierze go z tego
   samego atrybutu — jeden stan, jedno źródło. Klasa .is-active obok byłaby
   drugim, które można rozjechać. */
.tiptap__btn[aria-pressed="true"] { background: var(--color-fg); color: var(--color-surface); }
.tiptap__btn[disabled] { opacity: .4; cursor: default; }
.tiptap__btn svg { width: 16px; height: 16px; }
/* Litery „B" i „I" pokazują NA SOBIE, co robią — to najstarsza konwencja
   paska formatowania i nie wymaga tłumaczenia (nazwa i tak jedzie w title
   oraz aria-label, z katalogu i18n). */
.tiptap__btn--b { font-weight: 800; }
.tiptap__btn--i { font-style: italic; font-family: Georgia, "Times New Roman", serif; }
/* Pasek startuje z `hidden` i odsłania go skrypt PO zbudowaniu edytora.
   Reguła jest konieczna, bo `display: flex` z klasy wygrywa z `[hidden]`
   z arkusza przeglądarki — bez niej pasek byłby widoczny od razu, także wtedy,
   gdy vendor się nie wczytał i przyciski nie miałyby czego wywołać. */
.tiptap__bar[hidden] { display: none; }
/* Wiersz adresu linku. Rozwija się POD paskiem po kliknięciu „Link" — zamiast
   systemowego prompt(), którego nie da się ostylować (ta sama zasada, co przy
   confirm-modal.js: „nasz pop up, nie systemowy"). */
.tiptap__link { display: flex; gap: 6px; padding: 6px; border-bottom: 1px solid var(--color-border); background: var(--color-surface-muted); }
.tiptap__link[hidden] { display: none; }
.tiptap__link .field__control { height: 30px; padding: 0 8px; font-size: 13px; }
/* Powierzchnia pisania w oknie edycji dostaje wysokość EKRANOWĄ, nie 220 px:
   okno ma 90% wysokości ekranu i pole na 220 px zostawiało pod sobą pustą
   kolumnę, a treść przewijało w wąskim okienku w środku dużego okna. */
.exedit .tiptap .ProseMirror { min-height: 46vh; }

/* NAGŁÓWKI WEWNĄTRZ TREŚCI są mniejsze niż nagłówki STRONY — i to nie jest
   niespójność, a rozróżnienie dwóch różnych hierarchii. Skala z Figmy
   (32/28/24/20) opisuje nagłówki EKRANU: tytuł strony, tytuł karty. Czytanka
   ma własną, zagnieżdżoną hierarchię: „### Streszczenie" wewnątrz karty
   ćwiczenia wewnątrz strony. Gdyby brało 24 px ze skali, śródtytuł treści
   krzyczałby głośniej niż tytuł karty, w której siedzi.
   Reguła obejmuje OBA miejsca renderowania czytanki — edytor i podgląd — bo
   inaczej treść zmieniałaby rozmiar w chwili zapisania. */
.tiptap .ProseMirror h1, .exercise__reading h1 { font-size: var(--fs-h4); line-height: var(--lh-h4); margin: var(--space-7) 0 var(--space-3); }
.tiptap .ProseMirror h2, .exercise__reading h2 { font-size: var(--fs-lg); line-height: var(--lh-lg); margin: var(--space-7) 0 var(--space-3); }
.tiptap .ProseMirror h3, .exercise__reading h3,
.tiptap .ProseMirror h4, .exercise__reading h4 { font-size: var(--fs-md); line-height: var(--lh-md); margin: var(--space-6) 0 var(--space-2); }

.exercise__reading { overflow-wrap: anywhere; }
.exercise__reading img { max-width: 100%; height: auto; border-radius: var(--radius); }
.exercise__reading pre { white-space: pre-wrap; overflow-x: auto; }
/* display: block na <table> (zamiast domyślnego table) to jedyny sposób,
   żeby overflow-x zadziałał BEZ opakowującego <div>, którego tu nie ma
   (treść leci wprost z renderMarkdown): przy display: table szerokość
   tabeli po prostu rośnie ponad kontener zamiast się przycinać, bo
   auto-layout traktuje 'width' jako minimum, nie limit. Jako display: block
   <table> staje się zwykłym pudełkiem blokowym z ograniczoną szerokością, a
   wewnętrzna (anonimowa) siatka wierszy/komórek może w nim swobodnie
   przewijać się w bok. */
.exercise__reading table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
.exercise__reading :where(td,th) { border: 1px solid var(--color-border); padding: 6px 8px; text-align: left; }

/* --reading--card: podgląd czytanki w podglądzie lekcji ADMINA (lesson.html)
   jako WYRÓŻNIONY blok treści wewnątrz karty ćwiczenia — wzorem humi
   ReadingPreview.svelte (Card > "Content" label > edytor WYSIWYG we WŁASNEJ
   ramce). Marathon MA już edytor WYSIWYG — TipTap w oknie edycji ćwiczenia,
   od 21.08 (patrz .tiptap wyżej); ten podgląd dostaje
   TĘ SAMĄ ramkę co edytor formularza niżej (.editor__form: --color-surface-muted
   + obwódka) — spójny język "to jest osobna treść w środku karty", zamiast
   gołego tekstu na tle karty. Uwaga Antka: "ma być ładnie zrobiona jak
   w onboarderze, nie zwinięta" — NIE zwijaj tego z powrotem w <details>. */
.exercise__reading--card {
  padding: 16px 18px; background: var(--color-surface-muted);
  border: 1px solid var(--color-border); border-radius: var(--radius);
}

/* ---------- podsumowanie, galeria zdjęć, ostatnia edycja (migracja 0037) ----------
   Trzy kawałki jednej prośby ("dawaj" po opisaniu, co ma humi) — Antek
   zdecydował: bez Tiptap/WYSIWYG (zostaje architektura zero-frameworków),
   ale z resztą. Kolory z tokenów Marathona, jak wszędzie w tym pliku. */

/* Podsumowanie — dymek POD treścią (wzorem humi ReadingDisplay.svelte:
   bg-emerald-50 tam, akcent Marathona tu), lewy pasek koloru zamiast tła
   na całości — ten sam język co blockquote w .exercise__reading, żeby dwa
   "wyróżnione cytaty" w tej samej karcie nie konkurowały dwoma różnymi
   wzorami wizualnymi. */
.exercise__summary {
  margin-top: 14px; padding: 10px 14px; border-radius: 0 var(--radius) var(--radius) 0;
  background: var(--color-surface-muted); border-left: 3px solid var(--color-accent);
}
.exercise__summary-label {
  margin: 0 0 4px; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
  font-weight: 700; color: var(--color-fg-muted);
}
.exercise__summary p { margin: 0; font-size: 14px; line-height: 1.55; }

/* Galeria zdjęć — grid miniatur, wzorem humi (exercise-images grid, aspect-square).
   --view (kursant, learning-lesson.html): tylko oglądanie, bez usuwania.
   Bez modyfikatora (admin, lesson.html): każda miniatura niesie formularz
   usunięcia w rogu + jest draggable (JS w lesson.html czyta te same węzły). */
.exercise-gallery {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 10px; margin-bottom: 14px;
}
.exercise-gallery img {
  display: block; width: 100%; aspect-ratio: 1; object-fit: cover;
  border-radius: var(--radius); border: 1px solid var(--color-border);
}
.exercise-gallery__item { position: relative; cursor: grab; }
.exercise-gallery__item--dragging { opacity: 0.4; }
.exercise-gallery__remove-form { position: absolute; top: 4px; right: 4px; margin: 0; }
.exercise-gallery__remove {
  width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%; cursor: pointer;
  background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 14px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
}
.exercise-gallery__remove:hover { background: var(--color-error-bg); color: var(--color-error-fg); }
/* --picker (admin, "Dodaj obraz"): miniatury mniejsze — to lista wyboru
   z całej puli obrazów kursu, nie galeria docelowa, więc nie musi zajmować
   tyle samo miejsca. */
.exercise-gallery--picker { grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); margin: 10px 0 0; }
.exercise-gallery__pick-form { margin: 0; }
.exercise-gallery__pick {
  padding: 0; border: 1px solid var(--color-border); border-radius: var(--radius);
  background: none; cursor: pointer; overflow: hidden;
}
.exercise-gallery__pick:hover { border-color: var(--color-accent-strong); }

/* Ostatnia edycja — mały napis nad "Edytuj ćwiczenie", wzorem humi
   ReadingPreview.svelte (formatLastEdit, "Last edit: X"). */

/* Wskaźnik autosave (uwaga Antka: "autosave zamiast przycisku Zapisz") —
   pod przyciskiem "Zapisz", nie nad formularzem: to jest odpowiedź NA
   akcję kursanta, ma się pojawić tam, gdzie oko już patrzy po wpisaniu
   tekstu, nie wymagać przewinięcia wzroku w górę. */
.editor__autosave-status { min-height: 16px; margin: 8px 0 0; font-size: 12px; color: var(--color-fg-muted); }

/* ---------- lista ćwiczeń lekcji (uwaga Antka 2026-08-15) ----------
   Ekran lekcji to kompaktowa LISTA WIERSZY, wzorem referencyjnego Humi
   Onboardera — jeden wiersz na ćwiczenie, treść dopiero po otwarciu edytora.
   Wcześniej każde ćwiczenie renderowało się rozwinięte i lekcja z ośmioma
   ćwiczeniami zajmowała kilka ekranów, po których nie dało się przebiec
   wzrokiem ani zobaczyć, czego jeszcze nie zweryfikowano. */
/* Wiersz ćwiczenia to dziś <tr> we WSPÓLNEJ tabeli (.list-table, ta sama co
   Działy i Zespół) — własny stos kart .ex-list zniknął razem z układem, który
   był tylko tutaj. Zostaje wyłącznie to, czego tabela sama nie daje: wiersz
   jest klikalny (otwiera arkusz ćwiczenia) i przeciągalny (zmienia kolejność),
   więc niesie kursor łapki i widoczny fokus — ma role="button" i tabindex,
   czyli dla klawiatury jest kontrolką jak każda inna. */
.ex-row { cursor: grab; }
.ex-row:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.ex-row--dragging { opacity: .4; }
/* UCHWYT PRZECIĄGANIA. Wiersz ćwiczenia go już nie ma (przeciąga się za cały
   wiersz), ale klasa ZOSTAJE: noszą ją uchwyty w liście kursów (courses.html)
   i lekcji (course.html), a obie mają regułę `:hover … { opacity: .9 }`,
   która bez tej bazy nie miałaby czego rozjaśniać — uchwyt świeciłby na
   pełnej sile zawsze. Reguła zniknęła stąd przy przenoszeniu listy ćwiczeń na
   wspólną tabelę; wraca przy scaleniu 23.08, bo tamte dwie listy jej nie
   przestały używać. */
.ex-row__grip { flex: none; display: flex; color: var(--color-fg-muted); opacity: .5; }
.ex-row:hover .ex-row__grip { opacity: .9; }

/* Ikona typu w gnieździe miniatury: kolor niesie znaczenie RAZEM z kształtem
   (każdy typ ma inny rysunek) i razem z chipem obok, nigdy sam. */
.ex-icon {
  width: 40px; height: 40px; border-radius: var(--radius-sm);
  display: grid; place-items: center;
  background: var(--color-bg); border: 1px solid var(--color-border);
}
.ex-icon svg { width: 22px; height: 22px; }
/* KOLORY TYPÓW — getExerciseTypeIconColor z ExerciseEditSheet.svelte w humi,
   czyli dokładnie ta funkcja, która maluje ikonę w nagłówku okna edycji.
   Klasa nazywa się .extype--*, a nie .ex-icon--*, bo nosi ją DWA razy to samo:
   ikona w wierszu listy (w gnieździe .ex-icon wyżej) i ikona przy tytule
   w oknie edycji ćwiczenia. Jeden typ = jeden kolor i jeden rysunek w całej
   aplikacji; rozejście się tych dwóch miejsc byłoby widoczne przy każdym
   otwarciu okna.
   Były tu wcześniej #8b5cf6 (violet), #3b82f6 (blue) i emerald na lukach —
   trzy odcienie obok referencji. Zostaje emerald na czytance i pomarańcz na
   dopasowaniu (te się zgadzały), reszta przechodzi na humi. */
.extype--czytanka { color: var(--color-extype-czytanka); }
.extype--quiz { color: var(--color-extype-quiz); }
.extype--pytanie_otwarte { color: var(--color-extype-otwarte); }
.extype--luki { color: var(--color-extype-luki); }
/* Pomarańcz jako własny kolor typu, nie akcent aplikacji: ikony typów niosą
   znaczenie przez to, że każdy typ ma INNY kolor, więc żaden z nich nie może
   być kolorem akcentu — inaczej jeden typ udawałby stan aktywny. */
.extype--dopasowanie { color: var(--color-extype-dopasowanie); }
.extype--wideo { color: var(--color-danger); }

/* ODTWARZACZ FILMU LEKCJI. Od 22.08 filmy są PIONOWE (9:16, wzorem
   humionboardera — wideo/src/Root.tsx), więc ograniczeniem jest wysokość:
   rolka puszczona na całą szerokość kolumny treści urosłaby do półtora ekranu
   i kursant przewijałby własny film, zamiast go oglądać. max-width zostaje dla
   nagrań sprzed zmiany i dla wgranych z ręki — jedna reguła obsługuje oba
   kształty, bo film sam wybiera, który limit go zatrzyma.

   Czarne tło zamiast przezroczystego: pionowy film w poziomym oknie i tak
   zostawia pasy po bokach, a szare tło strony w tym miejscu wygląda jak
   niedograny obraz. */
.lesson-video {
  display: block;
  margin-inline: auto;
  max-width: 100%;
  max-height: 70vh;
  border-radius: var(--radius);
  background: #000;
}
/* W arkuszu i w edytorze film dzieli miejsce z formularzami, więc dostaje
   mniej wysokości niż na ekranie nauki, gdzie jest jedyną treścią kroku. */
.lesson-video--sheet { max-height: 46vh; }

/* ARKUSZ ĆWICZENIA WIDEO — dwie sekcje w jednym oknie: sam film (stan, skrypt,
   render, wgranie gotowego nagrania) i osobno KTO GO PROWADZI. Ten sam podział
   co przy karcie lektora: decyzja o człowieku obok akcji, która kosztuje.
   Od scalenia 23.08 te dwie sekcje siedzą w TYM SAMYM oknie (.exedit), co
   formularze pozostałych typów ćwiczeń — wideo jest jednym z nich, więc nie ma
   powodu, żeby otwierało inny kształt okna niż czytanka czy quiz. */
.video-sheet { display: flex; flex-direction: column; gap: var(--space-8); }
.video-sheet__section { display: flex; flex-direction: column; gap: var(--space-6); }
.video-sheet__section + .video-sheet__section { border-top: 1px solid var(--color-border); padding-top: var(--space-7); }
.video-sheet__title { margin: 0; font-size: var(--fs-lg); line-height: var(--lh-lg); font-weight: 600; }

/* Chip typu — NAZWA rodzaju ćwiczenia obok ikony (Antek 22.08: „lista ćwiczeń
   z typem ćwiczenia jako chip"). Sama ikona wymagała nauczenia się, co znaczy
   kolor, przy sześciu typach w jednej lekcji. Neutralny, nie kolorowy: kolor
   już niesie ikona, a sześć kolorowych plakietek w kolumnie zamieniłoby listę
   w witraż. */
.type-chip {
  display: inline-block; padding: 3px 10px; border-radius: 999px;
  background: var(--color-bg); color: var(--color-fg-muted);
  border: 1px solid var(--color-border);
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 600;
  white-space: nowrap;
}

/* "Dodaj ćwiczenie" — wybór typu + guzik w nagłówku lekcji (referencja:
   „+ Add Exercise" w prawym górnym rogu). */
/* BEZ flex-wrap: wybór typu i „+ Dodaj ćwiczenie" mają stać OBOK SIEBIE,
   zawsze. Miałem tu na chwilę zawijanie (żeby nic nie wypadło z wiersza przy
   wąskim oknie) i przycisk zeskakiwał pod pole — zamawiający odrzucił ten
   kształt od razu. Wiersz nagłówka nad nami zawija się i tak (.page-header
   i .u-row mają flex-wrap), więc przy ciasnocie na następną linię przechodzi
   CAŁY formularz, w jednym kawałku. */
.add-exercise { display: flex; gap: 8px; align-items: center; }
/* WYMIENIONY TU JEST TAKŻE .select — i to nie jest ozdoba. Po wzbogaceniu
   selecta (select.js) elementem FLEX tego wiersza jest wrapper .select, a nie
   .field__control (tę klasę nosi przycisk WEWNĄTRZ wrappera, gdzie własności
   flex nic nie znaczą). Dopóki reguła nazywała samo .field__control, wybór typu
   kurczył się do szerokości napisu — 81 px przy „Quiz".
   200 px zamiast 180: tyle zajmuje najdłuższa nazwa typu („Uzupełnij luki")
   bez wielokropka. Kurczenie ZOSTAJE (flex-shrink 1, jak było), bo to ono
   trzyma pole i przycisk w jednym wierszu, gdy miejsca jest mniej — a wiersz
   nagłówka bywa ciasny i realnie z tego korzysta. 160 px to DOLNA granica
   policzona, nie zgadnięta: najdłuższa nazwa typu („Pytanie otwarte") ma
   101 px, plus 24 px paddingu, 8 px odstępu, 18 px strzałki i 2 px obrysu =
   153 px. Poniżej tej liczby wybrany typ chowa się pod wielokropkiem.
   Szerokość ROZWINIĘTEJ LISTY nie zależy już od szerokości pola (patrz
   .select__panel wyżej: width max-content), więc skurczone pole nie psuje
   czytelności wyboru — a to było całe zgłoszenie. */
.add-exercise .field__control, .add-exercise .select { flex: 0 1 200px; min-width: 160px; }
/* Bez nowrap „+ Dodaj ćwiczenie" łamie się na dwie linie i rozpycha wiersz
   nagłówka w pionie — przycisk akcji ma zostać jednolinijkowy. */
.add-exercise .btn { white-space: nowrap; }
/* TELEFON — jedyne miejsce, w którym „obok siebie, zawsze" (komentarz wyżej)
   fizycznie się nie da: dolna granica pola 160 px + odstęp 8 px + przycisk
   146 px to 314 px, a treść na ekranie 320 px ma do dyspozycji 288.
   Wybór typu i przycisk wystawały poza stronę i pomniejszały cały widok.
   Kształt odrzucony przez zamawiającego (przycisk zeskakujący pod pole na
   szerokim oknie) NIE WRACA — tam wiersz dalej jest jednym kawałkiem; tutaj
   oba elementy biorą PEŁNĄ szerokość kolumny, więc czyta się to jak formularz,
   a nie jak rozjechany wiersz. */
@media (max-width: 767px) {
  .add-exercise { flex-wrap: wrap; width: 100%; }
  .add-exercise .field__control, .add-exercise .select { flex: 1 1 100%; min-width: 0; }
  .add-exercise .btn { flex: 1 1 100%; }
}

/* ---------- stepper "jedno ćwiczenie na ekran" (nauka) ----------
   Układ skopiowany 1:1 z referencyjnego humi-sales-onboarder (learn/*Display.svelte:
   wyśrodkowana wąska kolumna, BEZ obwódki karty, dużo pustej przestrzeni,
   duża typografia czytanki) — Antek: "design skopiuj dosłownie z humi, kolory
   Marathona". Paleta i typografia niżej biorą WYŁĄCZNIE z tokenów tego pliku
   (--color-*), nigdy z palety referencji (ta jest jasna/zielona, cała reszta
   apki — nav, przyciski, karty — jest ciemna/złota; wklejenie cudzych kolorów
   tu wyglądałoby jak inna aplikacja w środku tej samej strony). */
.exercise__reading :where(h1,h2,h3) { margin-top: 24px; margin-bottom: 10px; font-weight: 700; line-height: 1.3; }
.exercise__reading h1 { font-size: 26px; margin-top: 0; }
.exercise__reading h2 { font-size: 21px; }
.exercise__reading h3 { font-size: 17px; }
.exercise__reading p { margin: 0 0 14px; line-height: 1.65; }
.exercise__reading :where(ul,ol) { margin: 6px 0 14px; padding-left: 22px; }
.exercise__reading li { margin-bottom: 6px; }
.exercise__reading blockquote {
  margin: 14px 0; padding-left: 14px; border-left: 3px solid var(--color-accent);
  font-style: italic; color: var(--color-fg-muted);
}

/* Pasek postępu playera — cienka linia PRZYKLEJONA DO SZCZYTU OKNA, przez
   całą jego szerokość, wzorem playera humionboardera (zgłoszenie Antka
   23.08: „to tak nie jest popsute w designie napewno"). Poprzednia wersja
   stała w kolumnie treści z max-width 640px i wisiała pod kartą tytułu
   ciągnącą się przez całą szerokość — dwa różne układy jeden pod drugim.
   Na szczycie okna pasek nie musi się z niczym wyrównywać: jest tłem
   postępu, nie elementem siatki. */
.player-bar {
  position: fixed; inset: 0 0 auto 0; z-index: 20;
  height: 3px; background: var(--color-border);
}
.player-bar__fill {
  display: block; height: 100%; width: 0;
  background: var(--color-fg); transition: width 200ms ease;
}
/* Belka „✕ Zakończ naukę / Zgłoś problem" schodzi spod paska. */
.player-top { padding-top: 3px; }
.stepper-progress__label {
  max-width: 640px; margin: 0 auto; font-size: 12px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums; text-align: right;
}
/* .step — odpowiednik .card w stepperze, ale BEZ obwódki/tła: humi pokazuje
   każde ćwiczenie jako gołą, wyśrodkowaną kolumnę treści, nie kartę na
   stronie. .step__body dostaje odstępy grid/flex zamiast marginesów per
   element (fundament: layout robi spacing przez gap). */
.step { max-width: 640px; margin: 0 auto 16px; padding: 4px 0; }
/* KROK Z FILMEM — szerszy niż kroki tekstowe. 640 px to miara długości WIERSZA
   (tyle, ile da się czytać bez gubienia linii); film nie ma z nią nic wspólnego,
   a dostawał ją tak samo, więc nagranie 1280×720 pokazywało się jako 640×360 px
   pośrodku obszaru szerokiego na 1185 px i wyglądało na wciśnięte (Antek 23.08,
   po obejrzeniu lekcji jako kursant: „a co z tym wideo takie małe").

   Wysokości to nie zmienia: pionowe rolki 9:16 nadal ogranicza .lesson-video
   (max-height 70vh), bo to one, a nie szerokość, decydują o ich rozmiarze —
   szersza kolumna działa więc na kadr poziomy i na starsze nagrania.

   Pasek postępu idzie za krokiem (klasę .stepper--wide przełącza show()
   w learning-lesson.html): pasek węższy od filmu wyglądałby jak resztka
   poprzedniego ekranu. */
.step--video,
.stepper--wide .stepper-progress { max-width: min(100%, 960px); }
.step__body { display: flex; flex-direction: column; gap: 14px; }

/* ==========================================================================
   TRYB HISTORII — krok z filmem jako CAŁY EKRAN
   Figma „Cluo — Core" → Excercises → Video / Story / Desktop (1512×990):
   film 9:16 zajmuje tam ~754 px wysokości (76% ekranu), stoi na środku, nie ma
   przy nim ani paska bocznego, ani karty z tytułem lekcji; w rogach zostają
   „×" i chorągiewka zgłoszenia, pasek postępu jest cienką linią u samej góry,
   a pod filmem jeden przycisk dalej. Komentarz Stana w pliku: „bez sidebara".

   Klasę `story` na <body> przełącza show() (learning-lesson.html) przy każdej
   zmianie kroku, bo tryb należy do KROKU, nie do strony — czytanka w tej samej
   lekcji wraca do zwykłego ekranu.

   Wszystko poniżej to WYŁĄCZANIE chrome'u aplikacji na czas filmu, więc
   selektory są zakotwiczone w `body.story` i nie dotykają reszty aplikacji. */
/* Dwie twarze belki lekcji: normalnie strzałka z tytułem kursu i napis
   „Zgłoś problem", w trybie historii same znaki „×" i chorągiewka. Znaki są
   więc domyślnie schowane, a etykiety znikają dopiero pod body.story. */
.lesson-topbar__x,
.lesson-topbar__flag { display: none; }
body.story .sidebar,
body.story .topbar,
body.story .lesson-topbar__label,
body.story .stepper-progress__label { display: none; }
/* Karta z tytułem i opisem lekcji: w tym trybie tytuł niesie sam film. */
body.story .stack > .card { display: none; }
body.story .content { max-width: none; padding: var(--space-5) var(--space-6); }
/* Pasek postępu przykleja się do górnej krawędzi ekranu jako cienka linia —
   w projekcie nie jest elementem kolumny treści, tylko obrysem okna. */
body.story .stepper-progress {
  position: fixed; inset: 0 0 auto; z-index: 30;
  max-width: none; margin: 0; gap: 0;
}
body.story .stepper-progress .progress { height: 4px; border-radius: 0; }
/* Belka lekcji zostaje, ale kurczy się do dwóch znaków w rogach. */
body.story .lesson-topbar { position: relative; z-index: 20; margin: 0; }
body.story .lesson-topbar__x,
body.story .lesson-topbar__flag { display: inline; font-size: 22px; line-height: 1; }
body.story .lesson-topbar__back,
body.story .lesson-topbar__report { padding: 6px 12px; }
/* Chorągiewka bez ramki i tła — w tym trybie to sam znak w rogu; panel
   zgłoszenia otwiera się nad ekranem (<dialog> w layout.html), więc pod
   przyciskiem nie ma już nic do obramowania. */
body.story .lesson-topbar__report { border: 0; background: none; }
/* Film: wysokość ekranu decyduje, szerokość wychodzi z proporcji 9:16.
   76dvh, nie 76vh — na telefonie pasek adresu zjada vh i film chowałby
   przycisk pod krawędzią. */
body.story .step--video { max-width: none; margin-bottom: 0; }
/* min(), a nie samo 76dvh: przy NISKIM oknie film musi ustąpić chrome'owi.
   Duży telefon w poziomie (iPhone Pro Max 896×414, Android 915×412) jest dla
   reguł szerokościowych „komputerem" — dostaje kadr poziomy (przełącznik
   w learning-lesson.html stoi na 768 px i ma tam zostać: 16:9 na szerokim
   ekranie jest właściwym kadrem) — ale ma 414 px wysokości. Film 76dvh
   (315 px) plus belka z „×" (47) plus nawigacja (44) plus odstępy to 446 px:
   strona zaczynała się przewijać, a belka wyjeżdżała NAD górną krawędź, więc
   zamknięcie lekcji i zgłoszenie problemu znikały z ekranu (zmierzone: belka
   na y=-60). To samo, słabiej, działo się na niskim laptopie (1024×600).
   175 px to zmierzony chrome nad filmem i pod nim. Zapytanie medialne byłoby
   tu gorsze od min(): musiałoby zgadnąć próg wysokości, a ta funkcja po prostu
   bierze mniejszą z dwóch liczb — powyżej ~730 px wysokości okna wygrywa 76dvh
   i NIC się nie zmienia względem poprzedniego układu. */
body.story .step--video .lesson-video {
  height: min(76dvh, calc(100dvh - 175px)); max-height: none; width: auto; max-width: 100%;
}
body.story .step--video .step__body { align-items: center; gap: var(--space-6); }

/* Nawigacja pod filmem: w projekcie jeden przycisk na środku. „Wstecz"
   zostaje (kursant musi móc wrócić do poprzedniego ćwiczenia), ale przestaje
   być rozpychane na przeciwne krawędzie szerokiego ekranu. */
body.story .step--video .step-nav {
  justify-content: center; gap: var(--space-4); width: min(100%, 420px); margin: 0;
}
body.story .step--video .step-nav__primary { margin-left: 0; }
/* Telefon: film wypełnia ekran, przycisk leży NA nim u dołu (Video / Mobile
   w tym samym pliku Figmy). */
/* 767 px — ten sam próg, od którego reszta aplikacji uznaje ekran za telefon
   (patrz reguły wyżej) i ten sam, na którym przełącza się kadr filmu
   (learning-lesson.html): dzięki temu nie ma szerokości, przy której kadr
   poziomy trafiałby w układ pełnoekranowy albo odwrotnie. */
@media (max-width: 767px) {
  body.story .content { padding: 0; }
  body.story .lesson-topbar { position: fixed; inset: 8px 8px auto; }
  body.story .step--video .lesson-video { height: 100dvh; width: 100%; object-fit: contain; }
  body.story .step--video .step-nav {
    /* Nad natywnymi kontrolkami odtwarzacza, nie na nich: pasek z czasem
       i głośnością siedzi u samego dołu elementu <video>, a przyciski
       postawione na 16 px wchodziły mu w klawisze. */
    position: fixed; left: 12px; right: 12px;
    bottom: calc(76px + env(safe-area-inset-bottom));
    width: auto; max-width: none; justify-content: space-between;
  }
  /* Wszystko, co leży NA filmie, dostaje własny kontrast — kadr bywa ciemny
     albo jasny, więc ciemna plama pod jasnym znakiem jest jedynym układem,
     który działa na obu i w obu motywach aplikacji. */
  body.story .lesson-topbar__back,
  body.story .lesson-topbar__report,
  body.story .step--video .step-nav .btn--ghost {
    background: rgb(0 0 0 / 62%); color: #f8fafc; border-radius: 999px;
  }
}
.step__hint { margin: 0; font-size: 13px; color: var(--color-fg-muted); }
/* OSTRZEŻENIE O JEDNEJ SZANSIE. Ten sam rozmiar co zwykła podpowiedź (to nadal
   podpowiedź, nie błąd) i ten sam kolor co plakietki ostrzegawcze — ma być
   widoczne bez krzyku, bo pada na KAŻDYM ćwiczeniu lekcji. */
.step__hint--warn { color: var(--color-warn-fg); font-weight: 600; }
.step__question { margin: 0; font-size: 19px; line-height: 1.4; }
.step__feedback { margin: 0; font-weight: 600; }
.step__feedback--ok { color: var(--color-ok-fg); }
.step__feedback--wrong { color: var(--color-error-fg); }
/* Druga linijka werdyktu: „Poprawna odpowiedź: B — Schodzisz od razu".
   Blokowa i w kolorze tekstu, nie czerwona — to jest treść do zapamiętania,
   a nie dalszy ciąg komunikatu o pomyłce. */
.step__feedback-key { display: block; margin-top: var(--space-2); font-weight: 500; color: var(--color-fg); }

/* Quiz — kwadrat z literą (A/B/C…) + treść opcji, wzorem humi QuizDisplay.
   Input radio zostaje w DOM-ie (natywna semantyka grupy radio dla czytników
   ekranu i klawiatury), tylko wizualnie schowany — kwadrat i tekst obok
   przejmują wygląd zaznaczenia przez :checked. */
.quiz-option { display: flex; align-items: stretch; cursor: pointer; margin-bottom: 10px; }
.quiz-option__input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.quiz-option__letter {
  flex: none; width: 44px; display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--color-border); border-right: none; border-radius: var(--radius) 0 0 var(--radius);
  background: var(--color-surface-muted); font-weight: 700; color: var(--color-fg-muted);
}
.quiz-option__text {
  flex: 1; display: flex; align-items: center; padding: 10px 14px;
  border: 1px solid var(--color-border); border-radius: 0 var(--radius) var(--radius) 0;
}
.quiz-option:hover .quiz-option__letter,
.quiz-option:hover .quiz-option__text { border-color: var(--color-accent-strong); }
.quiz-option__input:focus-visible ~ .quiz-option__letter,
.quiz-option__input:focus-visible ~ .quiz-option__text { outline: 2px solid var(--color-accent-strong); outline-offset: 1px; }
.quiz-option--correct .quiz-option__letter,
.quiz-option--correct .quiz-option__text { border-color: var(--color-ok-fg); background: var(--color-ok-bg); color: var(--color-ok-fg); }
.quiz-option--wrong .quiz-option__letter,
.quiz-option--wrong .quiz-option__text { border-color: var(--color-error-fg); background: var(--color-error-bg); color: var(--color-error-fg); }

/* Luki — pole wpisu WEWNĄTRZ zdania z podkreśleniem (humi: border-b-2
   border-dashed), nie osobny wiersz formularza — luka ma czytać się jako
   brakujące słowo w zdaniu, nie jak metryka obok tekstu. */
.gap-text { font-size: 17px; line-height: 2.6; }
.gap-slot { display: inline-flex; align-items: center; gap: 4px; margin: 0 2px; }
.gap-slot__input {
  width: 12ch; padding: 2px 4px; border: none; border-bottom: 2px dashed var(--color-border);
  background: transparent; color: var(--color-fg); text-align: center; font: inherit;
}
.gap-slot__input:focus { outline: none; border-bottom-color: var(--color-accent-strong); }
.gap-slot__input--ok { border-bottom-style: solid; border-bottom-color: var(--color-ok-fg); color: var(--color-ok-fg); }
.gap-slot__input--wrong { border-bottom-style: solid; border-bottom-color: var(--color-error-fg); color: var(--color-error-fg); }
/* Luka AKTYWNA — ta jedna, którą wypełni następny kafelek rozsypki. Ramka,
   a nie samo podkreślenie: pozostałe luki (ćwiczenia wielolukowe sprzed
   27.08.2026) mają wyglądać na jeszcze nieotwarte, a nie na równorzędne pola
   do wyboru. Nowe ćwiczenia mają dokładnie jedną lukę i wtedy ta ramka jest
   po prostu tą jedną rubryką ze zgłoszenia. */
.gap-slot--aktywna .gap-slot__input {
  border: 1px solid var(--color-accent-strong); border-radius: var(--radius);
  background: var(--color-surface); padding: 4px 8px;
}

/* ROZSYPKA — kafelki pod zdaniem, z których jeden pasuje do luki
   (courses.RozsypkaLuki). Ten sam kształt co kafelki planszy dopasowania
   niżej, bo to ten sam gest: „kliknij słowo". */
.word-bank { display: flex; flex-wrap: wrap; gap: 8px; }
.word-tile,
.match-tile {
  padding: 10px 16px; border: 1px solid var(--color-border); border-radius: var(--radius);
  background: var(--color-surface); color: var(--color-fg); font: inherit; font-size: 15px;
  cursor: pointer; text-align: center;
  /* Długie hasło ma się ZŁAMAĆ, a nie wypchnąć kolumny: przy 360 px szerokości
     ekranu kolumna planszy ma ~165 px, a prawe strony par bywają dłuższe niż
     jedno słowo tej szerokości. Bez tego jeden kafelek rozpychałby stronę
     w bok — usterka, którą widać dopiero na telefonie i tylko przy konkretnej
     treści kursu. */
  overflow-wrap: anywhere;
  /* 44 px celu dotykowego — ta sama miara, co reszta aplikacji pod
     pointer: coarse. */
  min-height: 44px;
  transition: border-color 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease;
}
.word-tile:hover:not(:disabled),
.match-tile:hover:not(:disabled) { border-color: var(--color-accent-strong); }
.word-tile:disabled,
.match-tile:disabled { cursor: default; }
/* Kafelek zużyty: zostaje na stole (żeby rząd nie skakał), ale wygasa. */
.word-tile--uzyty { opacity: 0.35; }

/* PLANSZA DOPASOWANIA — dwie kolumny kafelków (zrzut nr 3 ze zgłoszenia
   z 27.08.2026; układ znany z Duolingo). Zastąpiła wiersze „tekst + lista
   rozwijana": lista rozwijana pokazywała WSZYSTKIE prawe strony przy KAŻDEJ
   lewej, więc dopasowanie sprowadzało się do przewijania tej samej listy tyle
   razy, ile było par. */
.match-board {
  display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; align-items: start;
}
.match-col { display: flex; flex-direction: column; gap: 10px; }
.match-col .match-tile { width: 100%; }
/* Wybrana lewa strona podświetlona, reszta TEJ kolumny przygaszona —
   „tylko nasz wybór się podświetla, a reszta w tej kolumnie robi się lekko
   szara". Przygaszenie omija kafelki już rozliczone (:disabled): te niosą
   werdykt i wyblakłe kłamałyby o nim. */
.match-tile--wybrany {
  border-color: var(--color-accent-strong); background: var(--color-surface-muted); font-weight: 600;
}
.match-board--wybrano .match-col:first-child .match-tile:not(.match-tile--wybrany):not(:disabled) { opacity: 0.45; }
/* Werdykt pary. Kolory idą przez zmienne, bo ten sam komplet czyta animacja
   mrygania niżej — jedne klatki na oba werdykty, zamiast dwóch bliźniaczych
   bloków @keyframes różniących się wyłącznie nazwą koloru. */
.match-tile--ok {
  --match-werdykt-fg: var(--color-ok-fg);
  --match-werdykt-bg: var(--color-ok-bg);
}
.match-tile--wrong {
  --match-werdykt-fg: var(--color-error-fg);
  --match-werdykt-bg: var(--color-error-bg);
}
.match-tile--ok,
.match-tile--wrong {
  border-color: var(--match-werdykt-fg); background: var(--match-werdykt-bg);
  color: var(--match-werdykt-fg); opacity: 1;
}
/* „Obie zaczną chwilę mrygać… i po animacji zostaną zielone": kolor werdyktu
   jest na kafelku OD RAZU (klasa wyżej), a te klatki gaszą go i zapalają —
   dwa mrugnięcia, koniec na zapalonym. Klasa schodzi po 900 ms
   (learning-lesson.html), czyli po ostatniej klatce.

   Poprzednia wersja mrugać NIE mrugała. Miała `steps(1, end)` na CAŁEJ
   animacji, a to znaczy jeden skok na przebieg: kafelek trzymał wartość klatki
   0% (tło neutralne) przez pełne 300 ms i dopiero na końcu przeskakiwał —
   klatka 50% nie miała kiedy zagrać. Do tego klatki ruszały tło i obramowanie,
   ale nie `color`, więc tekst barwił się od razu i już taki zostawał. Razem
   dawało to dokładnie to, co zgłoszono 27.08.2026: „tekst robi się na zielono
   lub czerwono i po chwili cały przycisk zmienia kolor". Ostre cięcie między
   stanami daje tu dopiero `steps(1, end)` na PARACH klatek (x%, y% z tą samą
   wartością) — jeden krok na cały przebieg nie ma czego ciąć. */
.match-tile--mryga { animation: match-mryganie 700ms steps(1, end) 1 both; }
@keyframes match-mryganie {
  0%, 19.9%   { background: var(--match-werdykt-bg); border-color: var(--match-werdykt-fg); color: var(--match-werdykt-fg); }
  20%, 39.9%  { background: var(--color-surface); border-color: var(--color-border); color: var(--color-fg); }
  40%, 59.9%  { background: var(--match-werdykt-bg); border-color: var(--match-werdykt-fg); color: var(--match-werdykt-fg); }
  60%, 79.9%  { background: var(--color-surface); border-color: var(--color-border); color: var(--color-fg); }
  80%, 100%   { background: var(--match-werdykt-bg); border-color: var(--match-werdykt-fg); color: var(--match-werdykt-fg); }
}
@media (prefers-reduced-motion: reduce) {
  .match-tile--mryga { animation: none; }
}
@media (max-width: 599px) {
  .match-board { gap: 8px 10px; }
  .word-tile, .match-tile { padding: 10px 12px; font-size: 14px; }
}

.field__control--ok { border-color: var(--color-ok-fg) !important; }
.field__control--wrong { border-color: var(--color-error-fg) !important; }

.step-nav { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; }
.step-nav__primary { display: flex; gap: 10px; margin-left: auto; }

/* ---------- kreator kursu, Step 4 wg Figmy ("Course program") ---------- */
/* Program kursu 1:1 z Humi „Course program" (outline): WYŚRODKOWANA wąska
   kolumna (jak max-w-2xl), BEZ sidebara — sam słup z formularzem. Kolor NASZ. */
.course-program { max-width: 680px; margin: 0 auto; }
.course-program__head { text-align: center; margin-bottom: var(--space-7); }
.course-program__head h1 { margin: 0 0 4px; font-size: 28px; font-weight: 800; letter-spacing: -0.02em; }
.course-program__head p { margin: 0; color: var(--color-fg-muted); font-size: 14px; }
/* Nagłówek sekcji — wyśrodkowany UPPERCASE (Humi „WSTĘP"). */
.course-program__section {
  text-align: center; text-transform: uppercase; letter-spacing: .14em;
  font-size: 12px; font-weight: 600; color: var(--color-fg-muted);
  margin: 22px 0 6px;
}
/* W programie „Usuń" siedzi nad CAPTION (Lekcja N • Sekcja), nie nad tytułem —
   więc tytuł nie potrzebuje rezerwy z prawej (inaczej niż w strukturze). */
.course-program .lesson-editor__title-field { padding-right: 0; }

/* Podpis „Lekcja N • Sekcja" to KONTEKST karty, nie etykieta pola — należy mu
   się własny odstęp od pierwszego pola, nie te same 4 px, co między etykietą
   a kontrolką. */
.lesson-editor__caption { margin: 0 0 var(--space-5); padding-right: 120px; font-size: 12px; color: var(--color-fg-muted); }

/* Okno „Sekcje kursu": wiersz przeciągalny za kropki, strzałki schowane do
   fokusu klawiatury — ta sama zasada co przy kartach sekcji w kreatorze
   (zgłoszenie Eryka 25.08.2026: „nie powinno być strzałek, tylko kropki
   i przeciąganie"). Strzałki ZOSTAJĄ w markupie, bo przeciąganie jest
   myszą-only, a bez nich okno przestałoby działać z klawiatury i bez JS-u. */
.sections-list__item { cursor: grab; }
.sections-list__item.ex-row--dragging { opacity: .4; cursor: grabbing; }
.sections-list__grip { flex: none; margin-right: var(--space-4); }
.sections-list__item:hover .sections-list__grip { opacity: .9; }
.sections-list__move .icon-btn {
  position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.sections-list__move .icon-btn:focus-visible {
  position: static; width: auto; height: auto; padding: 6px;
  overflow: visible; clip-path: none;
}

/* ---------- kreator kursu, KROK „struktura sekcji" (.section-card) ----------
   Przebudowa 24.08.2026 (zgłoszenie Eryka: „przyciski za blisko siebie, brak
   spacingu, wszystko zlane i nieczytelne"). Zasada: PASEK NA GÓRZE KARTY niesie
   wszystko, co dotyczy CAŁEJ sekcji (numer, uchwyt kolejności, usunięcie), a
   treść karty — wyłącznie pola do wypełnienia. Wcześniej te dwa światy stały
   w jednym rzędzie z polem nazwy i nie dawało się ich odróżnić. */
.section-card__bar {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  min-height: 48px; padding: 8px 16px;
  border-bottom: 1px solid var(--color-border);
}
/* Numer i strzałki po LEWEJ, poza przepływem — dzięki temu uchwyt zostaje
   wyśrodkowany względem CAŁEJ karty, a nie względem tego, co po nim zostało. */
.section-card__lead {
  position: absolute; left: 16px; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; gap: 8px;
}
.section-card__badge {
  font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
  color: var(--color-fg-muted);
}
/* Uchwyt-kropki: kursor „łapki" i przygaszenie w spoczynku, jak przy wierszach
   lekcji i ćwiczeń (.ex-row__grip) — ten sam gest ma w całej aplikacji ten sam
   wygląd. Podnosi się na hover CAŁEJ karty, nie samego uchwytu: cel myszy jest
   wtedy większy niż 20 px. */
.section-card__grip { display: flex; color: var(--color-fg-muted); opacity: .45; }
.section-card:hover .section-card__grip { opacity: .9; }
/* Kursor „łapki" TYLKO na pasku z uchwytem, choć przeciągalna jest cała karta:
   grab nad polem tekstowym kłamałby („to się przeciąga"), a człowiek chce tam
   zaznaczać tekst. Pasek mówi, gdzie złapać; reszta karty i tak zadziała. */
.section-card__bar { cursor: grab; }
.section-card.ex-row--dragging .section-card__bar { cursor: grabbing; }
/* Karta w trakcie przeciągania — ta sama klasa co przy lekcjach/ćwiczeniach. */
.section-card.ex-row--dragging { opacity: .4; }
/* Strzałki kolejności: DOSTĘPNA droga dla klawiatury i czytnika ekranu, ale
   niewidoczna dla myszy (od niej jest przeciąganie). Nie `display:none` ani
   `visibility:hidden` — te wyjmują przycisk z tabulacji, czyli odbierają to,
   po co tu stoi. Klasyczne wycięcie do 1 px, cofane przy fokusie. */
.section-card__move { display: inline-flex; gap: 6px; }
.section-card__move .btn {
  position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.section-card__move .btn:focus-visible {
  position: static; width: auto; height: auto; padding: 4px 10px;
  overflow: visible; clip-path: none;
}
/* Pytania kontrolne jako WŁASNY blok z podpisem — oddzielony od pól wyżej
   odstępem i cienką linią, bo to inna kategoria treści: nie opis sekcji dla
   człowieka, tylko notatki dla następnego etapu generacji. */
.section-card__questions { margin-top: var(--space-6); padding-top: var(--space-5); border-top: 1px solid var(--color-border); }
.section-card__questions .control-questions { margin-top: 10px; gap: 8px; }
/* SLOT „NOWA SEKCJA" — przerywana ramka i przygaszony pasek, żeby czytał się
   jak dodawanie, a nie jak sekcja bez nazwy. */
.section-card--new { border-style: dashed; background: transparent; box-shadow: none; }
.section-card__bar--new { justify-content: flex-start; gap: 8px; border-bottom-style: dashed; }
.section-card__badge--add { font-size: 16px; font-weight: 700; }
.section-card__addtitle { font-size: 13px; font-weight: 600; color: var(--color-fg-muted); }
/* W tym kroku „✕ Usuń sekcję" leży w PASKU, nie nad polem nazwy — pole nazwy
   nie potrzebuje więc rezerwy z prawej (reguła .lesson-editor .lesson-editor__title-field
   wyżej dotyczy układu sprzed przebudowy i tu nie ma już czego chronić). */
.section-card .lesson-editor__delete { top: 8px; right: 8px; }

/* ---------- gotowy kurs: karty przeglądu i tabela lekcji (referencja „Course") ---------- */
/* NAGŁÓWEK ekranu kursu — kwadracik z inicjałem obok tytułu i opisu
   (partial „course-heading", wspólny dla przeglądu, członków i zgłoszeń).
   align-items: flex-start, nie center: przy dwuliniowym tytule wyśrodkowany
   kwadracik odjeżdżał na wysokość drugiej linii, a ma stać przy PIERWSZEJ,
   tak jak w wierszu listy kursów. */
/* flex: 1 1 22rem + min-width: 0 — blok tytułu bierze resztę wiersza, ale
   ZOSTAJE w nim razem z akcjami po prawej (.page-header ma
   justify-content: space-between i flex-wrap). Bez tego opis kursu, który od
   tej zmiany jest podtytułem, rozpychał blok na całą szerokość i zbijał
   przyciski nagłówka („Dodaj do kursu", „Edytuj kurs") do NASTĘPNEJ linii —
   dokładnie odwrotnie niż mówi uwaga Antka „przyciski wyrównane do
   nagłówków". 22rem to podłoga: pod nią blok i tak zawinie się pod akcje,
   bo na telefonie jedna linia się nie mieści. */
.page-heading { display: flex; align-items: flex-start; gap: var(--space-5); flex: 1 1 22rem; min-width: 0; }
.page-heading__text { min-width: 0; }
/* Okładka kursu w nagłówku (Figma "Course / Lessons": ~56 px, mocniej
   zaokrąglona niż miniatury w tabeli) — rozmiar dziedziczy z .cover-thumb,
   tu tylko promień i delikatna kreska, żeby jasny placeholder nie zlewał się
   z tłem strony. */
.page-heading__cover { border-radius: var(--radius-lg, 10px); box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%); }
/* Miara opisu: 80 znaków to górna granica czytelnej długości wiersza. Bez
   niej opis na szerokim monitorze biegł przez cały ekran w jednej linii. */
.page-heading .page-header__subtitle { max-width: 80ch; }
/* Kwadracik nagłówka jest większy od tego z wiersza tabeli (40 px), bo stoi
   przy H1, nie przy nazwie w liście — ten sam skok, który humi robi między
   `small` i `medium` w ThumbnailPlaceholder. */
.avatar-sq--lg { width: 56px; height: 56px; font-size: 24px; }
/* …a przy nazwisku w karcie „Kursanci" mniejszy: pięć wierszy w karcie ma
   zostać kartą, nie tabelą. */
.avatar-sq--sm { width: 28px; height: 28px; font-size: 13px; border-radius: 6px; }

/* TRZY KARTY nad zawartością kursu (Kursanci / Ocena / Szczegóły kursu).
   auto-fit + minmax zamiast sztywnych trzech kolumn: na telefonie składają
   się w jedną, na tablecie w dwie, bez osobnego media query — ta sama
   technika co .session-summary__grid i .analytics-tiles.
   18rem, bo karta „Kursanci" musi zmieścić nazwisko z procentem w jednej
   linii; niżej zawijało się każde. */
.overview-cards {
  display: grid; gap: var(--space-6); margin-bottom: var(--space-7);
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
/* flex-column + margin-top: auto na stopce = przyciski trzech kart stoją
   w JEDNEJ linii, niezależnie od tego, że jedna ma pięć nazwisk, a druga
   siedem wierszy metryk. Bez tego stopka „Członkowie" wisiała w połowie
   wysokości rzędu i rząd wyglądał na rozjechany. */
.overview-card { display: flex; flex-direction: column; }
.overview-card > .card__body { flex: 1; }
.overview-card > .card__footer { margin-top: auto; }
/* Przypis karty (ilu kursantów nie weszło na listę, ilu widzi kurs przez
   dział) — ta sama stonowana typografia co .field__hint, bo to zdanie
   uzupełniające treść karty, nie druga treść. */
.overview-card__note { margin: var(--space-5) 0 0; font-size: 12px; line-height: 16px; color: var(--color-fg-muted); }
.overview-card__empty { margin: 0; color: var(--color-fg-muted); }
/* Pusty stan na środku kafla — patrz komentarz przy tej gałęzi w course.html. */
.overview-card__vcenter {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; gap: var(--space-4);
}
.overview-card__vcenter .overview-card__note { margin: 0; }

/* Lista osób w karcie: kwadracik, nazwisko, po prawej postęp. Nazwisko bierze
   resztę szerokości i przycina się wielokropkiem — długi e-mail (konto przed
   pierwszym logowaniem nie ma imienia) nie ma rozpychać karty. */
.people-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-4); }
.people-list__item { display: flex; align-items: center; gap: var(--space-4); min-width: 0; }
.people-list__name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.people-list__meta { flex: none; font-size: 12px; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }

/* OCENA KURSU — te same gwiazdki co w tabelach ({{template "rating-stars"}}),
   tylko powiększone: skala i próg zaokrąglenia liczą się w jednym miejscu
   (store.ratingStars), a karta ma pokazać wynik jako pierwszą rzecz na
   ekranie. Powiększenie przez kontekst, nie przez drugi partial z kopią
   ścieżki SVG — kopia rozjechałaby się przy pierwszej zmianie ikony. */
.overview-rating { margin-bottom: var(--space-6); }
.overview-rating .stars { gap: var(--space-4); }
.overview-rating .stars__star { width: 30px; height: 30px; }
.overview-rating .stars__value {
  font-size: 30px; font-weight: 800; letter-spacing: -.02em; color: var(--color-fg);
}

/* PARY NAZWA–WARTOŚĆ w karcie (metryki kursu, wiersze oceny). Zastąpiły
   kafle z liczbą 22 px: siedem faktów o kursie w kaflach zajmowało dwa razy
   tyle miejsca i każdy krzyczał tak samo głośno, chociaż „Ćwiczenia: 18"
   i „Poziom: Przewodnik" czyta się raz na wejście, a nie codziennie. */
.kv { margin: 0; display: flex; flex-direction: column; gap: var(--space-4); }
.kv__row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-5); }
.kv__row dt { color: var(--color-fg-muted); font-size: var(--fs-sm); line-height: var(--lh-sm); }
.kv__row dd {
  margin: 0; text-align: right; font-weight: 500; font-variant-numeric: tabular-nums;
  display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap;
}
/* Kreska nad wierszem dzieli listę na to, co się LICZY (sekcje, lekcje,
   ćwiczenia) i to, czym kurs JEST (język, poziom, daty) — w referencji ten
   sam podział robi pozioma linia nad „Created". */
.kv__row--sep { padding-top: var(--space-4); border-top: 1px solid var(--color-border); }
.kv__note { font-size: 12px; color: var(--color-fg-muted); font-weight: 400; }

/* NAGŁÓWEK OBSZARU „Zawartość kursu" — mniejszy i przygaszony, bo to etykieta
   obszaru („CONTENT" w referencji), a nie tytuł strony; tytuł jest jeden
   i stoi wyżej. */
.content-heading {
  font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
  color: var(--color-fg-muted); margin: 24px 0 12px;
}
/* …a przy nim akcje dodawania (sekcja, lekcja). Nagłówek obszaru ma wtedy
   margines tylko po jednej stronie — odstęp niesie już rząd. */
.content-heading-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-5); flex-wrap: wrap; margin: var(--space-8) 0 var(--space-5);
}
.content-heading-row .content-heading { margin: 0; }

/* TABELA LEKCJI dziedziczy .list-table (te same nagłówki, linie i gniazda
   kolumn co listy kursów i działów) — tu dochodzi tylko to, czego tamte nie
   mają: stałe szerokości kolumn wąskich. Wiersze grupujące sekcji zniknęły
   (Eryk 24.08.2026) — sekcję niesie kolumna „Rozdział", a <tbody> per sekcja
   zostało wyłącznie granicą przeciągania i celem kotwicy.
   scroll-margin-top: <tbody> sekcji jest celem kotwicy ze spisu treści
   (#section-N). Bez tego zapasu na telefonie pierwszy wiersz po skoku chowa
   się pod przyklejonym paskiem górnym (.topbar, position: sticky). */
.lesson-table tbody { scroll-margin-top: 72px; }
/* UWAGA: numeracja nth-child odpowiada kolumnom tabeli lekcji w course.html
   (uchwyt, miniatura, Lekcja, Rozdział, Śr. czas, Śr. ocena, Ćwiczenia,
   Ostatnia edycja, menu). Dodanie albo usunięcie kolumny w szablonie WYMAGA
   poprawki tutaj — inaczej szerokości wędrują na sąsiednie kolumny.
   Szerokości uchwytu (1) i miniatury (2) niesie .list-table__grip-cell/
   __thumb-cell, a menu (9) — .list-table__actions. */
/* min-width na kolumnie lekcji: bez niej wąskie okno ściska tytuł do trzech
   linii po jednym słowie, zamiast pozwolić tabeli przewinąć się w bok
   (kontener ma overflow-x: auto — przewijanie jest tu ZAMIERZONE, zgniatanie
   nie). */
.lesson-table :where(th, td):nth-child(3) { min-width: 220px; }
.lesson-table :where(th, td):nth-child(5) { width: 96px; }
.lesson-table :where(th, td):nth-child(6) { width: 128px; }
.lesson-table :where(th, td):nth-child(7) { width: 96px; }
/* Wiersz lekcji jest przeciągalny — kursor i podświetlenie uchwytu jak
   w liście ćwiczeń (.ex-row). Sam uchwyt reużywa .ex-row__grip. */
.lesson-table tbody tr[data-lesson-id] { cursor: grab; }
.lesson-table tbody tr[data-lesson-id]:hover .ex-row__grip { opacity: .9; }
/* Opis lekcji jako DRUGA LINIA pod tytułem (wzorzec .list-table__sub z listy
   działów), przycięty do jednej linii. Wcześniej stał w osobnej, szerokiej
   kolumnie „Temat" przycięty do dwóch — razem z dwoma rzędami tagów pod nim
   dawało to wiersze na kilkanaście linijek i tabela przestawała być tabelą
   (zgłoszenie Antka 23.08.2026). Pełna treść zostaje w edytorze lekcji. */
.lesson-row__desc {
  display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical;
  overflow: hidden; max-width: 46ch;
}
/* Kolumna „Rozdział" — nazwa sekcji per lekcja (Figma: kolumna „Topic").
   Przycięta wielokropkiem, żeby długa nazwa sekcji nie rozpychała tabeli:
   pełną nazwę niesie modal „Sekcje kursu" i spis treści w pasku bocznym. */
.lesson-row__section { max-width: 16ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Kwadracik z inicjałem (kurs, lekcja, dział) = ThumbnailPlaceholder z humi
   (src/lib/components/ui/thumbnail-placeholder.svelte):
     `flex aspect-square items-center justify-center rounded-lg font-bold
      text-white` + jeden z 22 odcieni `bg-*-400`, wybierany hashem tytułu,
     rozmiar `small` → `max-h-10 max-w-10 text-lg`, czyli 40 px i pismo 18 px.
   Było: 34 px, promień 9 px, pismo 15 px i SZEŚĆ własnych, ciemnych odcieni
   (#2f855a, #2b6cb0, …) spoza jakiejkolwiek palety — dobrane ręcznie, zanim
   referencją stało się humi.
   Odcienie 400 są jasne i biały napis ma na części z nich (lime, yellow,
   cyan) mniej niż 2:1. Tak jest w referencji i tak zostaje: kwadracik nie
   niesie informacji — inicjał jest ozdobą przy nazwie, która stoi tuż obok
   i jest czytelna. Gdyby kiedyś miał NIEŚĆ treść, trzeba zejść na odcień 500
   albo 600.
   Wybór odcienia robi avatarClass (internal/web/courses.go) — modulo po ID,
   nie hash tytułu jak w humi: u nas zmiana tytułu kursu nie ma przemalowywać
   kwadracika. Liczba odcieni MUSI się zgadzać ze stałą avatarColors tam. */
.avatar-sq {
  flex: none; width: 40px; height: 40px; border-radius: var(--radius);
  display: grid; place-items: center; color: #fff;
  font-weight: 700; font-size: var(--fs-lg); line-height: 1;
}
.avatar-sq--0  { background: #94a3b8; } /* slate-400 */
.avatar-sq--1  { background: #9ca3af; } /* gray-400 */
.avatar-sq--2  { background: #a1a1aa; } /* zinc-400 */
.avatar-sq--3  { background: #a3a3a3; } /* neutral-400 */
.avatar-sq--4  { background: #a8a29e; } /* stone-400 */
.avatar-sq--5  { background: #f87171; } /* red-400 */
.avatar-sq--6  { background: #fb923c; } /* orange-400 */
.avatar-sq--7  { background: #fbbf24; } /* amber-400 */
.avatar-sq--8  { background: #facc15; } /* yellow-400 */
.avatar-sq--9  { background: #a3e635; } /* lime-400 */
.avatar-sq--10 { background: #4ade80; } /* green-400 */
.avatar-sq--11 { background: #34d399; } /* emerald-400 */
.avatar-sq--12 { background: #2dd4bf; } /* teal-400 */
.avatar-sq--13 { background: #22d3ee; } /* cyan-400 */
.avatar-sq--14 { background: #38bdf8; } /* sky-400 */
.avatar-sq--15 { background: #60a5fa; } /* blue-400 */
.avatar-sq--16 { background: #818cf8; } /* indigo-400 */
.avatar-sq--17 { background: #a78bfa; } /* violet-400 */
.avatar-sq--18 { background: #c084fc; } /* purple-400 */
.avatar-sq--19 { background: #e879f9; } /* fuchsia-400 */
.avatar-sq--20 { background: #f472b6; } /* pink-400 */
.avatar-sq--21 { background: #fb7185; } /* rose-400 */

/* Ocena gwiazdkami (referencja: ocena jest gwiazdkami, nie gołą liczbą).
   Puste gwiazdki rysowane konturem tym samym kolorem z mniejszą siłą — bez
   drugiego tokenu, bo to ten sam element w dwóch stanach. */
/* Gwiazdki 1:1 z CourseListingTable w humi: `flex h-full items-center gap-1`,
   ikona `h-5 w-5`, wypełniona `fill-orange-300 stroke-none`, pusta
   `stroke-orange-300`, wartość `text-muted-foreground text-xs`.
   Było: własny odcień #d69e2e, 15 px ikona, odstęp 7 px i wartość 13 px. */
.stars { display: inline-flex; align-items: center; gap: var(--space-2); }
.stars__row { display: inline-flex; gap: var(--space-2); color: #fdba74; }
/* Gniazdo jednej gwiazdki: kontur w normalnym przepływie wyznacza rozmiar,
   a wypełnienie leży NA NIM warstwą absolutną — dzięki temu połówkę robi
   przycięcie wypełnienia, a kontur zostaje cały. */
.stars__slot { position: relative; display: inline-flex; width: 20px; height: 20px; }
.stars__star { width: 20px; height: 20px; opacity: .45; }
.stars__star--on { position: absolute; inset: 0; opacity: 1; }
/* Połowa oceny = połowa szerokości wypełnienia (4,5 → cztery pełne i ta).
   Przycinamy WYPEŁNIENIE, nie całą ikonę: prawa połowa gwiazdki ma zostać
   widoczna jako kontur, inaczej połówka wyglądałaby jak ułamana. */
.stars__star--half { clip-path: inset(0 50% 0 0); }
.stars__value { font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
/* Brak ocen: partial rating-stars nie rysuje wtedy żadnej gwiazdki, zostaje
   sam myślnik — w tym samym stopniu i kolorze co .stars__value, żeby kolumna
   ocen nie skakała wysokością między wierszami z oceną i bez. */
.stars--empty { font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted); min-height: 20px; }
/* Data w kaflu (--date): ta sama pozycja co liczba, ale 26 px na „2026-08-11"
   rozpycha kafel na dwie linie — mniejszy stopień mieści ją w jednej. */
.session-summary__value--date { font-size: 18px; }

/* ---------- edytory gotowego kursu ---------- */
/* Każdy edytor (kurs, sekcja, lekcja, ćwiczenie) to <details> — natywne
   zwijanie bez JS-u, ta sama technika co pasek robót w tle. Żadnej reguły
   box-shadow w tym bloku: <summary> jest fokusowalny, a box-shadow
   ZASTĄPIŁBY pierścień fokusu z sekcji typografii
   (TestNoBoxShadowClassOnFocusableElement). */
.editor { margin-top: 12px; }
.editor--standalone { margin-top: 16px; }
.editor__summary {
  display: inline-block; cursor: pointer;
  font-size: 13px; font-weight: 600; color: var(--color-fg-muted);
  padding: 4px 0;
}
.editor__summary:hover { color: var(--color-fg); }
.editor[open] .editor__summary { color: var(--color-fg); margin-bottom: 8px; }
.editor__form {
  border: 1px solid var(--color-border); border-radius: var(--radius);
  background: var(--color-surface-muted); padding: 14px;
}
/* Pola powtarzalne (pytania kontrolne) stoją jedno pod drugim bez własnej
   etykiety — etykieta grupy jest nad nimi, a numerowanie ich osobno tylko
   dodałoby szumu przy trzech polach. */
.editor__repeat + .editor__repeat { margin-top: 6px; }
.editor__tag { width: auto; min-width: 120px; flex: 0 1 160px; }
.editor__option, .editor__pair { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.editor__option input[type="radio"] { width: 20px; height: 20px; flex: none; }
.editor__actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
/* Kolor niebezpiecznej akcji dopiero na hover: przycisk „usuń" stojący
   ZAWSZE na czerwono w rozwiniętym formularzu ciągnie wzrok mocniej niż
   zapis, który jest tu akcją domyślną. Kontrast tekstu na hoverze:
   #fff na --color-danger (#dc2626) to 4,83:1 — ponad próg AA. */
.editor__danger:hover { background: var(--color-danger); border-color: var(--color-danger); color: var(--color-danger-fg); }
/* Reguły wiersza-edytora (.lesson-table__editor-row/-cell) i podejść
   w wierszu (.lesson-table__sessions) zniknęły razem z markupem, który
   opisywały: edytor lekcji jest dziś <dialog>-em otwieranym z menu „⋯"
   (course.html), a liczba podejść stoi w tym menu, nad przyciskiem usuwania.
   Ukryty <tr> z formularzem pod KAŻDĄ lekcją podwajał liczbę wierszy tabeli
   i był jednym z tych „za dużo wszystkiego" ze zgłoszenia 23.08.2026. */

/* ---------- zakładka „Członkowie": tabela i menu wiersza ---------- */
/* Tabela dziedziczy .usage-table (te same nagłówki i linie co tabela lekcji
   obok i tabele na /sessions) — tu dochodzi tylko to, czego tamte nie mają.
   min-width: sześć kolumn nie zmieści się na telefonie i MA przewijać się
   w bok (kontener ma overflow-x: auto), a nie zgniatać e-maila do trzech
   linii po jednym znaku. */
.member-table { min-width: 640px; }
/* Wyrównanie do ŚRODKA, nie do góry jak w .lesson-table: wiersz członka jest
   jednoliniowy (bez opisu i tagów), więc plakietka postępu i menu mają stać
   w jednej linii z nazwiskiem. */
.member-table td { vertical-align: middle; }
.member-table__name { font-weight: 600; }
.member-table__menu { width: 44px; text-align: right; }

/* Menu wiersza na <details> — bez ani linijki JS, ten sam mechanizm co menu
   „⋮" przy obrazie w file.html (tam zrobione stylami inline; przy drugim
   użyciu zasługuje na klasę). Panel jest pozycjonowany względem <details>,
   więc rodzic musi mieć position: relative — bez tego panel ucieka do
   krawędzi karty.
   ŻADNEGO box-shadow na <summary>: to element fokusowalny, a box-shadow
   ZASTĄPIŁBY (nie dołożył) pierścień fokusu z sekcji typografii — patrz
   TestNoBoxShadowClassOnFocusableElement. Cień ma tylko panel, który fokusu
   nie łapie. */
.row-menu { position: relative; display: inline-block; }
.row-menu__btn {
  list-style: none; cursor: pointer;
  padding: 2px 8px; border-radius: var(--radius-sm);
  color: var(--color-fg-muted); font-weight: 700;
}
/* Trójkącik ujawniania z arkusza UA — Safari/WebKit potrzebuje własnej
   reguły, `list-style: none` mu nie wystarcza. */
.row-menu__btn::-webkit-details-marker { display: none; }
.row-menu__btn:hover,
.row-menu[open] > .row-menu__btn { background: var(--color-surface-muted); color: var(--color-fg); }
.row-menu__panel {
  position: absolute; right: 0; top: 100%; z-index: 2; margin-top: 4px;
  min-width: 190px; padding: 6px;
  display: flex; flex-direction: column; gap: 2px; text-align: left;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); box-shadow: var(--shadow-md);
}
/* Pozycja menu wygląda jak wiersz, ale JEST przyciskiem submitu w osobnym
   formularzu (każda akcja zmienia stan, więc każda jest POST-em) — stąd reset
   wyglądu przycisku, a nie własny element. */
/* DropdownMenu.Item z humi: `relative flex cursor-default items-center gap-2
   rounded-sm px-2 py-1.5 text-sm` + `data-[highlighted]:bg-accent`. Było
   13 px i waga 600 — pozycja menu była grubsza od treści, którą opisuje.
   `rounded-sm` to w skali shadcn 4 px, czyli o dwa mniej niż nasze
   --radius-sm (6 px = rounded-md); tutaj wartość stoi wprost, bo to jedyne
   miejsce w arkuszu, które używa tego stopnia. */
.row-menu__item {
  display: flex; align-items: center; gap: var(--space-3); width: 100%; text-align: left;
  padding: 6px var(--space-3); border: 0; border-radius: 4px;
  background: transparent; color: var(--color-fg);
  font: inherit; font-size: var(--fs-md); line-height: var(--lh-md); cursor: pointer;
  text-decoration: none;
}
.row-menu__item:hover { background: var(--color-surface-muted); }
.row-menu__item--danger { color: var(--color-danger); }
/* Ikona pozycji menu: 16 px, kolor z tekstu (więc „Usuń" ma ikonę czerwoną
   razem z napisem). flex: none, żeby długa etykieta jej nie ścisnęła. */
.row-menu__item svg { flex: none; width: 16px; height: 16px; }
/* Kreska dzieląca — oddziela akcję nieodwracalną od tych, które da się cofnąć
   (wzór z menu w referencyjnym humi onboarderze). Pełna szerokość panelu:
   ujemne marginesy zjadają jego padding. */
.row-menu__sep { height: 1px; margin: 4px calc(-1 * var(--space-3)); background: var(--color-border); }
/* Menu „Ustawienia" w nagłówku kursu: ten sam mechanizm <details> co menu
   wiersza, ale spustem jest zwykły przycisk .btn (a nie „⋯"), więc trzeba mu
   osobno zgasić trójkącik ujawniania — .row-menu__btn robi to u siebie przez
   własną klasę, której ten przycisk nie nosi. Panel równany do prawej
   krawędzi przycisku dziedziczy z .row-menu__panel. */
.row-menu--header > summary { list-style: none; display: inline-flex; align-items: center; gap: var(--space-2); }
.row-menu--header > summary::-webkit-details-marker { display: none; }
/* Notka w menu wiersza — fakt, którego się nie klika, a który ma być widoczny
   PRZED kliknięciem sąsiedniej pozycji (dziś: ile podejść kursantów zniknie
   razem z lekcją, kaskada migracji 0015). Nie jest .row-menu__item, bo nic
   nie robi: bez tego rozróżnienia wyglądałaby na wyszarzoną, zepsutą akcję. */
.row-menu__note {
  margin: 0; padding: var(--space-2) var(--space-3);
  font-size: 12px; line-height: 16px; color: var(--color-fg-muted);
}

/* Tagi lekcji: pigułki statyczne (istniejący tag) + jedna zawsze-obecna
   pusta pigułka "+ Dodaj tag" na końcu (ten sam "pusty slot na końcu"
   wzorzec co nowa sekcja/lekcja/pytanie kontrolne). Checkbox usuwania
   pojedynczego tagu MUSI poprzedzać oba elementy, na które wpływa
   (.tag-chip__label i .tag-chip__delete), jako rodzeństwo — ten sam wzorzec
   checkbox-hack co .lesson-editor__delete-input/.control-question-row__delete-input. */
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag-chip {
  display: inline-flex; align-items: center; gap: 6px;
  /* Te same metryki co .badge, czyli Badge z humi: `rounded-full border
     px-2.5 py-0.5 text-xs font-semibold`. Było 5/10 px i pismo 13 px —
     trzeci rozmiar pigułki w aplikacji obok .badge i .courses-table .badge. */
  padding: 2px 10px; border-radius: 999px;
  background: var(--color-chip-bg); color: var(--color-chip-fg);
  /* Badge variant="secondary" ma w humi `border-transparent` — ramka jest
     przezroczysta, nie usunięta, żeby .tag-chip--new nic nie dokładało do
     wysokości i pigułki nie skakały względem siebie w rzędzie.
     ZMIERZONE, ŚWIADOMIE: tagi lekcji leżą u nas wprost na tle strony
     (#f4f4f5), gdzie wypełnienie #f1f5f9 ma wobec podłoża 1,02:1 — pigułkę
     identyfikuje wtedy sama etykieta, nie krawędź. W humi plakietki leżą na
     białej karcie i mają tam 1,10:1, czyli praktycznie to samo. */
  border: 1px solid transparent;
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 600;
}
.tag-chip__delete { cursor: pointer; color: inherit; opacity: .55; line-height: 1; }
.tag-chip__delete:hover { opacity: 1; color: var(--color-danger); }
.tag-chip__delete-input:focus-visible ~ .tag-chip__delete {
  outline: 2px solid transparent; outline-offset: 2px;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
.tag-chip__delete-input:checked ~ .tag-chip__label { text-decoration: line-through; opacity: .55; }
.tag-chip__delete-input:checked ~ .tag-chip__delete { color: var(--color-danger); }
/* „Dodaj" to w referencji OSOBNA, szara pigułka z plusem — nie kolejny tag
   i nie przerywana ramka. Plus rysujemy przez ::after, żeby nie dokładać
   elementu do markupu formularza: pole musi zostać zwykłym <input>, bo to
   ono niesie wartość nowego tagu. */
.tag-chip--new {
  background: var(--color-chip-add-bg); color: var(--color-fg);
  font-weight: 500;
}
.tag-chip--new::after { content: "+"; opacity: .55; font-weight: 700; }
.tag-chip__input {
  border: none; background: transparent; font: inherit; color: inherit;
  width: 100px; padding: 0;
}
.tag-chip__input:focus-visible {
  outline: 2px solid transparent; outline-offset: 2px;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}

/* ---------- kafel zależności (zadanie 8, stan systemu) ---------- */
/* Kafel to <div class="card health-tile"> — tło, obramowanie i promień bierze
   z .card, a ta sekcja dokłada tylko układ i wypełnienie. Nie ustawiamy tu
   box-shadow (kafel nie jest fokusowalny, ale .card już swój cień ma) ani
   bezwarunkowego min-height na .btn/.btn--sm — jedno skasowałoby pierścień
   fokusu, drugie cel dotykowy 44 px z media query wyżej.

   DWIE LINIE O STAŁEJ WYSOKOŚCI (30.08.2026). Kafel był flexem w poziomie
   z trójlinijkowym blokiem tekstu, który zawijał się na najdłuższej wartości
   („text-embedding-3-large") i przez `grid` podnosił cały rząd sąsiadów.
   Teraz każda linia jest przycinana wielokropkiem, więc wszystkie kafelki mają
   tę samą wysokość niezależnie od długości nazwy modelu i tłumaczenia —
   a pełna treść zostaje w atrybucie `title` (szablon). */
.health-tile { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-5) var(--space-6); }
/* Pierwsza linia: kropka, nazwa, czas. `align-items: center` zamiast baseline,
   bo kropka jest pustym elementem bez linii pisma — na baseline usiadłaby
   dołem na linii tekstu, czyli za nisko względem nazwy. */
.health-tile__head { display: flex; align-items: center; gap: var(--space-3); }
/* Kropka jest DEKORACJĄ (aria-hidden w szablonie): sam kolor nigdy nie jest
   jedynym nośnikiem stanu — pod spodem stoi ten sam stan słowem, więc nie
   stosuje się do niej próg kontrastu 3:1 dla wskaźników (WCAG 1.4.11),
   a wymóg „nie tylko kolorem" (1.4.1) spełnia tekst. Kolory statusów są
   celowo POZA paletą z logo — bursztyn nie może znaczyć „sprawne" ani
   „awaria", a plakietki (.badge--ok/--error) już wcześniej wprowadziły zieleń
   i czerwień semantyczną. Bierzemy istniejące tokeny, nie nowe wartości:
   --color-ok-fg i --color-danger przełączają się razem z motywem (w ciemnym
   #4ade80/#f87171), więc kropka zostaje widoczna na ciemnym tle karty. */
.health-tile__dot { width: 8px; height: 8px; border-radius: 999px; flex: none; }
.health-tile__dot--ok { background: var(--color-ok-fg); }
.health-tile__dot--skipped { background: var(--color-fg-muted); }
.health-tile__dot--error { background: var(--color-danger); }
/* Nazwa i opis przycinane wielokropkiem, nie zawijane — to one decydują
   o wysokości kafelka, a ta ma być jedna dla wszystkich. min-width: 0, bo
   element flex domyślnie nie zwęża się poniżej długości swojego najdłuższego
   słowa i bez tego `text-overflow` nigdy by się nie uruchomił. */
.health-tile__name {
  flex: 1 1 auto; min-width: 0;
  font-weight: 600; font-size: var(--fs-md); line-height: var(--lh-md);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Czas: tabularne cyfry, żeby „277 ms" i „1 ms" dały się porównać między
   kafelkami bez czytania. flex: none — liczba nigdy nie ustępuje miejsca
   nazwie, bo jest krótsza i to ona jest tu wartością pomiaru. */
.health-tile__time {
  flex: none;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
.health-tile__state {
  margin: 0;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* AWARIA CZYTA SIĘ Z DALEKA. Przygaszona szarość jest właściwa dla zdania
   „Działa · docling" — to metadana, której nikt nie czyta, dopóki wszystko gra.
   Ale ta sama szarość na „Awaria · połączenie odrzucone" chowa jedyną rzecz,
   po którą się na ten ekran przychodzi: stan zepsuty ma kolor tekstu błędu,
   ten sam token co .badge--error i .list-row__meta--error. Kropka obok robi
   się przez to zbędna jako informacja — i dobrze, bo była dekoracją. */
.health-tile__state--error { color: var(--color-error-fg); }
/* Siatka kafelków: szersza kolumna niż domyślne 200 px z .grid, bo w drugiej
   linii stoi cała nazwa modelu („Działa · text-embedding-3-large") i przy
   200 px kończyła się wielokropkiem u każdego. Odstęp też mniejszy niż
   w .grid (12 zamiast 16 px) — kafelki są teraz niskie, więc przy 16 px
   siatka rozjeżdżała się bardziej, niż grupowała. */
/* margin-top mniejszy od sekcyjnych 32 px z `.content > * + *`: nad siatką
   stoi zdanie „Sprawdzono 2 minuty temu", które JEST jej podpisem, a nie
   osobnym akapitem — pełny odstęp sekcyjny odrywał je od kafelków. */
.health-grid { gap: var(--space-5); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
/* Nagłówek sekcji z metryką w jednej linii: „Zależności · Sprawdzono 2 minuty
   temu". Godzina sprawdzenia jest podpisem tej siatki, a nie akapitem sekcji —
   pod nagłówkiem zjadała dwa pełne odstępy sekcyjne (32 px z góry i z dołu)
   na jedno krótkie zdanie.
   center, nie baseline (uwaga Eryka 30.08.2026): przy 24 px nagłówka i 12 px
   metryki wyrównanie do linii pisma sadzało metrykę wizualnie za nisko —
   ma być wyśrodkowana względem wysokości napisu „Zależności".
   Zerowy margines pod h2 (globalnie ma 16 px), bo odstęp do siatki niesie
   już reguła sekcyjna `.tab-page > * + *`. */
.health-heading { display: flex; align-items: center; gap: var(--space-5); flex-wrap: wrap; }
.health-heading h2 { margin: 0; }
.health-heading__checked { font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted); }

/* ---------- tabela zużycia AI (zadanie 8, stan systemu) ---------- */
/* Wydzielone z ośmiu identycznych inline style= na komórkach status.html —
   zmiana jednego paddingu wcześniej wymagała edycji ośmiu atrybutów.
   .usage-table__num niesie wyrównanie do prawej + tabular-nums; nakładamy ją
   też na nagłówki „Wywołania"/„Jednostki" (tam stoi słowo, nie liczba) — samo
   font-variant-numeric nie ma efektu na tekst bez cyfr, więc wynik wizualny
   zostaje identyczny z osobną regułą tylko dla <td>.
   Selektor kwalifikowany typem (th./td.) celowo: samo .usage-table__num ma
   specyficzność (0,1,0) i PRZEGRYWA z .usage-table th (0,1,1), więc nagłówki
   liczbowe po cichu wracałyby do wyrównania w lewo. */
/* Metryki 1:1 z ui/table/* w humi (tego prymitywu używają tam ekrany sesji
   i raportów — czyli dokładnie te, co u nas .usage-table i jej pochodne):
     table  `w-full caption-bottom text-sm`        → 14px/20px
     th     `text-foreground h-10 px-2 font-medium text-left align-middle`
                                                   → 40px, 8px boki, waga 500,
                                                     kolor TREŚCI, nie stonowany
     td     `p-2 align-middle`                     → 8px dookoła
     tr     `hover:bg-muted/50 border-b`           → podświetlenie wiersza
   Było 13px i 12px — dwa stopnie spoza skali — oraz przygaszone nagłówki.
   Linie zostają na `border-top` w <td>, a nie `border-bottom` na <tr> jak
   w humi — wizualnie wynik jest identyczny. (Powodem był kiedyś rozwijany
   wiersz edytora lekcji, który zdejmował sobie górną kreskę; tabela lekcji
   stoi dziś na .list-table, a edytor jest modalem — patrz course.html.) */
.usage-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); line-height: var(--lh-md); }
.usage-table th {
  height: 40px; text-align: left; padding: 0 var(--space-3);
  font-weight: 500; color: var(--color-fg); white-space: nowrap;
}
.usage-table td { padding: var(--space-3); border-top: 1px solid var(--color-border); }
.usage-table tbody tr { transition: background-color .15s; }
.usage-table tbody tr:hover { background: var(--color-surface-muted); }
/* IMIĘ JAKO LINK, ALE BEZ PODKREŚLENIA — decyzja Eryka (01.09.2026) dla
   „Wyników kursantów" na pulpicie: przejście na panel kursanta ma być
   możliwe, a kolumna imion ma nadal czytać się jak dane, nie jak spis
   odsyłaczy. Podkreślenie w każdym z kilkudziesięciu wierszy zamienia
   pierwszą kolumnę w gęstą kreskę i przeciąga wzrok na siebie, choć treścią
   tabeli są liczby po prawej. Klikalność sygnalizuje kursor, podświetlenie
   wiersza wyżej i pierścień fokusu (globalny :focus-visible) — czyli nic
   z afordancji nie ginie poza samą kreską.
   `color: inherit` zbędne przy globalnej regule `a`, ale zostaje jawnie:
   ta klasa ma trzymać wygląd komórki także tam, gdzie ktoś ją użyje
   w tabeli o innym kolorze tekstu. */
.usage-table__link { color: inherit; text-decoration: none; }
th.usage-table__num, td.usage-table__num { text-align: right; font-variant-numeric: tabular-nums; }

/* ---------- podsumowanie sesji (moduł analityki, zadanie 1) ---------- */
/* Pasek agregatów nad logiem sesji (/sessions) — .card niesie tło/obramowanie
   /cień/promień; blok nie ma własnej klasy .session-summary na <section>
   (usunięta przy recenzji — nie miała ani jednej reguły, więc niosła zero
   informacji), tylko elementy BEM niżej. auto-fit + minmax zamiast sztywnej
   liczby kolumn: osiem kafli (sesje, kursanci, średni wynik, zaliczone,
   niezaliczone, w toku, porzucone, średni czas) ma się złożyć do jednej
   kolumny na telefonie bez osobnego media query, tak samo jak .grid wyżej
   w tym pliku. */
.session-summary__grid {
  display: grid; gap: 16px;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
.session-summary__item { display: flex; flex-direction: column; gap: 2px; }
/* tabular-nums: osiem liczb obok siebie ma się wyrównać do tej samej
   szerokości cyfry, inaczej "2" i "100,0%" migoczą przy odświeżeniu filtra. */
.session-summary__value {
  font-size: 22px; font-weight: 800; letter-spacing: -.01em;
  font-variant-numeric: tabular-nums;
}
.session-summary__label { font-size: 12px; color: var(--color-fg-muted); }
/* Przypis pod kaflami (definicja kubełków + zniekształcenia danych
   historycznych — patrz komentarz w sesje.html) — ta sama stonowana typografia co .field__hint. Własny
   padding, nie tylko margin: przypis jest bezpośrednim dzieckiem .card, która
   NIE ma paddingu (o to dbają .card__header/__body/__footer) — bez tego tekst
   dotykał krawędzi karty i jej dolnego obramowania. Pozioma wartość (18px)
   i dolna (16px) to te same liczby, których .card__body/.card__footer używają
   dla swojej treści — przypis ma się wizualnie wyrównać do kafli nad nim. */
/* Widoczne zdanie przypisu. Padding-bottom zdjęty, bo pod nim stoi teraz
   rozwijane <details> (.session-summary__caveat) i to ono domyka kartę. */
.session-summary__note { padding: 4px 18px 6px; font-size: 12px; color: var(--color-fg-muted); }

/* ---------- selektor lekcji w filtrze logu sesji (moduł analityki, zadanie 4) ---------- */
/* Nagłówek grupy lekcji WEWNĄTRZ scrollowanego boksu selektora (sesje.html) —
   widoczny tylko, gdy zaznaczono więcej niż jeden kurs naraz (patrz komentarz
   w szablonie): rozdziela lekcje kolejnych kursów, żeby długa lista
   checkboxów nie wyglądała jak jedna nieopisana lekcja po drugiej. Ta sama
   przytłumiona typografia co .field__hint/.session-summary__label — to nie
   jest treść do czytania osobno, tylko etykieta porządkująca resztę listy. */
.lesson-picker__group-title {
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--color-fg-muted); margin: 10px 0 4px;
}
.lesson-picker__group-title:first-child { margin-top: 2px; }
/* ---------- pasek robót w tle (Task 2 planu paska postępu) ---------- */
/* BELKA W DOLNEJ KRAWĘDZI OBSZARU TREŚCI, pełnej szerokości — nie pływająca
   karta w rogu.
   HISTORIA I POWÓD ZMIANY (26.08.2026). Do tej wersji pasek był kartą
   `min(320px, 100vw - 32px)` szerokości i do ~470px wysokości, przyklejoną
   16px od prawego dolnego narożnika okna (wzorzec paska wgrywania w Google
   Drive, referencja z briefu), renderowaną DOMYŚLNIE ROZWINIĘTĄ. Komentarz
   w tym miejscu twierdził, że ten róg „nigdy nie koliduje z akcją główną
   strony ani z paskiem bocznym”. Pierwsza połowa była nieprawdą i dało się
   to zmierzyć: przegląd wszystkich widoków (26.08.2026) zastał kartę leżącą
   na treści na 17 z 17 sprawdzonych ekranów admina — na /dashboard na
   kolumnie „Zaliczone lekcje”, na /settings na dwóch ostatnich kolumnach
   tabeli zużycia AI razem z ich nagłówkami, na /knowledge na rozmiarze
   i statusie dwóch plików, na /courses na ostatnim wierszu razem z jego menu
   akcji. Na 390×844px karta zajmowała 390×470px, czyli 56% okna, i przykrywała
   cztery wiersze tabeli razem z ich polami wyboru; zwinąć ją można było tylko
   trójkątem 12×12px w narożniku, bo × obok zdejmuje JEDEN WPIS, nie panel.

   Dlaczego rezerwa miejsca pod treścią tego nie ratowała — patrz „CZEGO TA
   REZERWA NADAL NIE ROBI” przy .shell--with-dock .content niżej. W skrócie:
   rezerwa to wyściółka na KOŃCU przepływu, a element fixed zakrywa to, co
   wypadło na dole okna przy DOWOLNYM przewinięciu.

   Naprawa nie polegała więc na powiększeniu rezerwy, tylko na zmniejszeniu
   tego, co zakrywa, i na przesunięciu go tam, gdzie nie ma kontrolek:
   z bloku 320×470px parkującego nad wierszami tabeli do paska 45px przy samej
   krawędzi okna, na całą szerokość obszaru treści, renderowanego domyślnie
   ZWINIĘTEGO. Żeby zwinięcie nie było utratą informacji, belka niesie
   najpilniejszą robotę w jednej linijce (workHeadlineItem w workbar.go
   i {{with workHeadline}} w layout.html) — to jest warunek, na którym stoi
   cała ta zmiana.

   z-index: 20 — MNIEJ niż topbar mobilny (30), tło szuflady (40) i sama
   szuflada (50, wszystkie @media max-width:1023px wyżej): gdy ktoś na
   telefonie otworzy menu boczne, przyciemnione tło backdropu przykrywa belkę
   razem z resztą strony, zamiast pływać nad nim jak osobna warstwa. */
.work-bar {
  position: fixed; right: 16px; bottom: 16px; z-index: 20;
  width: min(320px, calc(100vw - 32px));
}
/* SCROLL-PADDING: trzecia gwarancja, obok rezerwy wyżej i samego zmniejszenia
   belki. Rezerwa chroni KONIEC DOKUMENTU (da się doscrollować do wszystkiego),
   ale nie mówi nic o tym, gdzie przeglądarka ZATRZYMA przewijanie, gdy sama je
   wywoła: przy przejściu Tabem do elementu poza kadrem, przy skoku do
   #kotwicy i przy scrollIntoView. Bez scroll-padding fokus potrafi wylądować
   dokładnie pod belką — a to najgorszy przypadek, bo człowiek nie wie, gdzie
   jest kursor klawiatury, i nie ma powodu podejrzewać, że trzeba doscrollować.
   Wartość to wysokość ZWINIĘTEGO paska w danym wariancie: 112px dla karty
   (94px karty + 16px odstępu od krawędzi okna, zaokrąglone w górę), 45px dla
   belki (44px nagłówka + 1px kreski, bez odstępu — belka stoi na bottom: 0).

   BEZ WARUNKU na obecność paska, i to jest kompromis, nie przeoczenie:
   kontenerem przewijania jest DOKUMENT, a informacja „pasek się renderuje"
   siedzi na klasie potomka (.shell--with-dock) — kaskada nie idzie w górę,
   a `:root:has(.shell--with-dock)` jest w tym arkuszu zabronione (próg
   wsparcia :has(), patrz komentarz przy .shell--with-dock). Koszt na stronach
   BEZ paska jest niewidoczny: 45px scroll-padding zmienia wyłącznie to, gdzie
   zatrzymuje się przewijanie wywołane przez przeglądarkę, i zostawia wtedy
   45px zapasu pod elementem, na który skoczył fokus — czego nikt nie odbierze
   jako usterki.

   Stan ROZWINIĘTY świadomie NIE jest tu obsłużony: rozwinięcie jest jawnym
   wyborem człowieka, który widzi panel i może go zwinąć jednym kliknięciem,
   a kilkaset pikseli scroll-paddingu na dokumencie zmieniałoby zachowanie
   przewijania na całej stronie. To jest gwarantowana PODŁOGA, nie sufit. */
html { scroll-padding-bottom: 112px; }
/* Na wąskim ekranie zwinięta belka zajmuje mniej niż zwinięta karta razem
   z odstępem, więc podłoga schodzi razem z nią — inaczej fokus zatrzymywałby
   się 33px wyżej, niż trzeba, na każdej stronie telefonu. */
@media (max-width: 1023px) { html { scroll-padding-bottom: 45px; } }
/* REZERWA MIEJSCA POD TREŚCIĄ — CZTERY LICZBY: dwa stany panelu × dwa
   warianty obudowy.
   Od decyzji z 26.08.2026 („bar tylko dla wersji mobilnej, a dla wersji
   webowej stary widok okienka") pasek ma dwie postacie o różnej wysokości,
   więc rezerwa ma dwie pary wartości. Rachunek jest w obu przypadkach ten
   sam: wysokość tego, co pasek zajmuje w danym stanie, plus odstęp od
   krawędzi okna, plus zapas równy normalnej wyściółce .content.

   KARTA (wariant bazowy, od 1024px w górę; .content ma tam 32px wyściółki):
     zwinięta:   1px obramowania panelu + 92px nagłówka + 1px obramowania
                 = 94px karty + 16px odstępu + 32px zapasu = 142px,
                 zaokrąglone W GÓRĘ do 144px.
                 Te 92px nagłówka to TRZY wiersze: 20px z markerem <details>,
                 24px z etykietą i licznikiem, 24px z nazwą najpilniejszej
                 roboty i jej plakietką (nazwa ma własny wiersz, bo w 292px
                 użytecznej szerokości karty ściskała się do trzech znaków —
                 patrz .work-bar__summary-inner), plus 2×10px wyściółki
                 i odstęp między zawiniętymi wierszami.
                 ZMIERZONE w przeglądarce, nie policzone z reguł: dwie
                 poprzednie wersje tego rachunku wychodziły 60px i 64px, bo
                 zakładały interlinię tam, gdzie wysokość wiersza wyznacza
                 plakietka.
     rozwinięta: karta ma własny sufit (max-height: min(60vh, 420px) na
                 .work-bar__panel), więc liczymy od TEGO sufitu, nie od sumy
                 części: 420px + 16px odstępu + 32px zapasu = 468px.

   BELKA (poniżej 1024px; .content ma tam 16px wyściółki, odstępu nie ma, bo
   belka stoi na bottom: 0) — wartości w @media niżej:
     zwinięta:   44px nagłówka (JEDEN wiersz: 24px treści + 2×10px
                 wyściółki, czyli dokładnie min-height) + 1px kreski = 45px
                 + 16px zapasu = 61px, zaokrąglone W GÓRĘ do 64px.
     rozwinięta: 45px + 280px sufitu listy + 16px zapasu = 341px,
                 zaokrąglone W GÓRĘ do 344px.

   DLACZEGO STAN ROZWINIĘTY LICZY SIĘ OD SUFITU, a nie od „realnego rozmiaru
   trzech pozycji", jak przed 26.08.2026: tamta metoda świadomie oddawała
   gwarancję dla panelu z czterema i więcej robotami — rezerwa była wtedy za
   płytka i panel dalej zasłaniał dół strony, czyli dokładnie to, po co ta
   rezerwa istnieje. Liczona od sufitu jest DOKŁADNA dla dowolnej liczby
   pozycji. Kosztem jest pustka na końcu strony przy rozwiniętym panelu — ale
   rozwinięcie jest JAWNYM wyborem człowieka (oba warianty renderują się
   domyślnie zwinięte, patrz workbarOpenCookie w workbar.go), więc ten koszt
   ponosi tylko ten, kto o panel poprosił, i tylko dopóki go nie zwinie.

   CZEGO TA REZERWA NIE ROBI, świadomie: nie chroni treści, która wypada na
   dole okna PRZY PRZEWIJANIU W ŚRODKU dokumentu. Żaden element
   `position: fixed` tego nie potrafi — rezerwa jest wyściółką na KOŃCU
   przepływu, więc gwarantuje wyłącznie, że da się doscrollować do każdej
   treści. To był realny powód, dla którego karta zasłaniała treść na 17 z 17
   sprawdzonych ekranów admina, mimo że rezerwa istniała. Dlatego naprawa nie
   polegała na powiększeniu rezerwy, tylko na ZMNIEJSZENIU tego, co zakrywa:
   na telefonie z bloku 390×470px (56% okna, z polami wyboru czterech wierszy
   tabeli pod nim) do paska 45px, a na desktopie — gdzie karta wraca decyzją
   zamawiającego — ze 470px do 62px, dopóki nikt jej nie rozwinie.

   Klasa .shell--with-dock: zwykły selektor potomka, scalony z ISTNIENIEM
   paska przez klasę na .shell (dopisywaną w layout.html przez {{if .Work}}) —
   strony BEZ paska (pracownik, admin bez robót, niezalogowany) nie płacą
   pustym gutterem u dołu. Druga klasa (.shell--dock-open) niesie stan
   zwinięcia i pochodzi z tego samego jednego źródła po stronie Go
   (workbarDockOpen, workbar.go), nie ze zgadywania w CSS-ie; workbar.js
   przełącza ją NA ŻYWO przy toggle, więc rezerwa spada bez przeładowania. */
.shell--with-dock .content { padding-bottom: 144px; }
.shell--with-dock.shell--dock-open .content { padding-bottom: 468px; }
/* TA SAMA REZERWA DLA EKRANÓW BEZ POWŁOKI (.Focused: gracz lekcji kursanta,
   kreator kursu). Tam nie ma .shell, więc obie reguły wyżej nie mają czego
   złapać — a pasek robót renderuje się także na tych stronach (gniazdo
   #workbar-slot jest dzieckiem <body>, poza gałęzią .shell w layout.html).
   Bez tej rezerwy pasek przykrywałby ostatnią odpowiedź w quizie i przycisk
   „Dalej" — dosłownie treść, w którą trzeba kliknąć. Te same liczby co wyżej,
   w obu wariantach: jedna zmiana wysokości paska poprawia oba układy naraz.
   Klasy dokłada layout.html z tego samego .Work, co .shell--with-dock: jedna
   decyzja po stronie Go, dwa miejsca, w których się ją nakłada. */
.content--with-dock { padding-bottom: 144px; }
.content--with-dock.content--dock-open { padding-bottom: 468px; }

/* POLE PISANIA W CZACIE PONAD ZWINIĘTYM PASKIEM — I TYLKO NAD ZWINIĘTYM.
   Wyściółka .content odsuwa od dołu TREŚĆ, ale pasek pisania jest
   `position: sticky; bottom: 0` — przykleja się do dolnej krawędzi okna,
   czyli dokładnie tam, gdzie stoi zwinięty pasek, i wyściółka go nie
   dotyczy. Bez tej reguły pasek leżałby na polu wpisywania wiadomości.
   Liczba to wysokość SAMEGO paska razem z jego odstępem od krawędzi okna,
   BEZ zapasu, który dokłada rezerwa wyżej: tam zapas oddziela treść od
   krawędzi paska, a tu pole pisania ma stać bezpośrednio nad nim.
   Karta: 94px + 16px odstępu = 110px. Belka (@media niżej): 45px.

   STANU ROZWINIĘTEGO TU NIE MA — ŚWIADOMIE, i to jest sedno zgłoszenia
   („okienko robót w tle musi być zawsze jako oddzielny kontener i nad
   wszystkim"). Do 27.08.2026 stała tu druga reguła, która przy rozwiniętym
   panelu podbijała `bottom` do 436px (belka: 325px) — i pole pisania
   WYSKAKIWAŁO na środek ekranu w chwili rozwinięcia panelu, a wracało przy
   zwinięciu. Rozwinięty panel jest WARSTWĄ nad stroną (position: fixed,
   z-index: 20), a nie kolejnym wierszem układu: nic pod nim nie ma prawa się
   przesunąć. Kosztem jest to, że rozwinięty panel przykrywa prawy koniec
   pola pisania — ponosi go tylko ten, kto panel rozwinął, i znika jednym
   kliknięciem w nagłówek. Przesuwanie pola było kosztem WIĘKSZYM i płacił go
   każdy: układ czatu skakał o ponad 300px. */
.shell--with-dock .chat-composer-bar { bottom: 110px; }
/* DRUGA POŁOWA TEJ SAMEJ NAPRAWY: ekran rozmowy trzyma rezerwę ZWINIĘTEGO
   paska także wtedy, gdy panel jest rozwinięty.
   Samo zdjęcie `bottom: 436px` z pola pisania wyżej NIE wystarczy i to nie
   jest nadmiarowa ostrożność — pole pisania przesuwały DWIE niezależne
   przyczyny, a widać było jedną. Druga to ta rezerwa: `.content--chat` jest
   kolumną flex o wysokości ekranu (patrz `.content--chat` niżej), więc
   padding-bottom NIE dokłada tam pustki na końcu dokumentu, jak wszędzie
   indziej — on ŚCIĄGA DOLNĄ KRAWĘDŹ `.chat-page` w górę, a razem z nią
   przyklejone do niej pole pisania (`position: sticky` nie wychodzi poza
   pudełko rodzica). Rozwinięcie panelu podnosiłoby więc pole o 468−144=324px
   nawet z jednym `bottom` na sztywno.
   Liczby są CELOWO te same, co w stanie zwiniętym o dwie sekcje wyżej: to nie
   jest osobny rachunek do pilnowania, tylko „na czacie stan panelu nie zmienia
   rezerwy". Reguła stoi PO tamtej i ma tę samą wagę (trzy klasy), więc wygrywa
   kolejnością — przeniesienie jej wyżej cicho ją wyłączy.
   Ekran rozmowy jest jedynym miejscem, gdzie rezerwa ma taki skutek, i dlatego
   wyjątek jest scoped do niego: na zwykłych stronach (listy, szczegóły)
   padding-bottom dalej robi to, po co powstał — daje doscrollować do treści,
   którą rozwinięty panel przykrywa. */
.shell--with-dock.shell--dock-open .content--chat { padding-bottom: 144px; }
/* TE SAME CZTERY LICZBY DLA WARIANTU BELKI — rachunek: patrz „BELKA" wyżej.
   Osobny @media, a nie dopisanie do bloku geometrii belki: tam mieszkają
   reguły SAMEGO paska, tu rezerwa w TREŚCI. Dwa różne obszary arkusza,
   które przy jednej wspólnej klamrze rozjeżdżałyby się przy każdym szukaniu
   „gdzie jest padding-bottom treści". */
@media (max-width: 1023px) {
  .shell--with-dock .content { padding-bottom: 64px; }
  .shell--with-dock.shell--dock-open .content { padding-bottom: 344px; }
  .content--with-dock { padding-bottom: 64px; }
  .content--with-dock.content--dock-open { padding-bottom: 344px; }
  /* Pole pisania w czacie ponad ZWINIĘTĄ BELKĄ: sama wysokość belki, BEZ
     zapasu — tam zapas oddziela treść od kreski, a tu dwa paski mają się do
     siebie kleić, jak dwa wiersze jednej obudowy. Stanu rozwiniętego nie ma
     tak samo, jak w wariancie karty wyżej — uzasadnienie tam. */
  .shell--with-dock .chat-composer-bar { bottom: 45px; }
  /* Ta sama „rezerwa czatu nie zależy od stanu panelu", w wariancie belki. */
  .shell--with-dock.shell--dock-open .content--chat { padding-bottom: 64px; }
}
/* <details> niesie wygląd karty (tło/obramowanie/cień jak .card, ale własna
   reguła — .card ma box-shadow, którego tu NIE wolno powtórzyć na elemencie
   fokusowalnym, patrz uwaga przy .work-bar__summary niżej: to inny selektor,
   więc nie ma konfliktu, ale gdyby ktoś kiedyś zamienił <details> na .card,
   złapałby dokładnie tę pułapkę).
   UWAGA (znalezisko z przeglądu): TEN element celowo NIE dostaje
   `overflow: hidden`, mimo że max-height by o to prosił. <summary> niżej to
   JEDYNA fokusowalna kontrolka widgetu, a globalny pierścień :focus-visible
   to box-shadow renderowany NA ZEWNĄTRZ border-boxa (2px+4px, sekcja
   typografii) — <summary> jest pierwszym dzieckiem tego elementu i jego
   border-box pokrywa się z jego padding-boxem, więc overflow: hidden na
   RODZICU przycinałby cień potomka do cienkiego paska na krawędzi zamiast go
   w ogóle pokazać. UWAGA (zgłoszenie Antka: „jak są 4, nie mogę scrollować
   w dół"): NIE polegamy już na kurczeniu dziecka we flexboksie. Rozumowanie
   „.work-bar__list ma overflow-y: auto, więc jako element flex dostaje
   automatyczny min-height 0 i to ono się kurczy" było prawdziwe tylko przy
   założeniu, że <ul> jest BEZPOŚREDNIM dzieckiem flexowym <details> — a nie
   jest: Chromium owija treść <details> w pseudoelement ::details-content
   (overflow: visible), i to ON jest elementem flexowym. Overflow: visible
   znaczy brak automatycznego min-height 0, więc wrapper NIE kurczył się pod
   max-height panelu, lista nigdy nie dostawała ograniczenia wysokości,
   overflow-y: auto nie miał kiedy zadziałać — i czwarta pozycja wylewała
   się poza ekran bez możliwości przewinięcia. Dlatego limit wysokości
   siedzi teraz WPROST na .work-bar__list (max-height niżej: min(40vh, 280px)),
   niezależnie od tego, co przeglądarka wstawia między <details> a listę.
   Od przejścia na belkę (26.08.2026) ten limit NIE jest już wyliczany
   z max-height panelu — panel go nie ma. Jest wartością PIERWOTNĄ, z której
   liczy się w drugą stronę rezerwa miejsca pod treścią (patrz rachunek przy
   .shell--with-dock .content wyżej); dawny kierunek („panel ma sufit, lista
   bierze z niego resztę") dawał trzy liczby zależne od siebie w pętli.
   Dawny koszt („pasek przewijania może wystawić kwadratowy róg poza promień
   zaokrąglenia panelu") ZNIKŁ razem z promieniem: belka jest prostokątna. */
.work-bar__panel {
  display: flex; flex-direction: column; max-height: min(60vh, 420px);
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); box-shadow: var(--shadow-md);
}
/* <summary> jest fokusowalny z klawiatury (patrz focus_ring_test.go —
   TestNoBoxShadowClassOnFocusableElement zbiera dokładnie ten znacznik) —
   żadna reguła w tej sekcji nie ustawia na nim box-shadow, żeby nie podmienić
   globalnego pierścienia :focus-visible z sekcji typografii. min-height 44px
   i padding na całą szerokość: cel dotykowy to cały nagłówek panelu, nie sama
   strzałka rozwijania, którą przeglądarka rysuje sama.
   UWAGA (znalezisko z przeglądu): ta reguła CELOWO NIE ustawia `display` —
   <summary> ma domyślnie `display: list-item`, i to WYŁĄCZNIE przy tej
   wartości Chromium i Firefox rysują natywną strzałkę rozwijania (::marker).
   Każda inna wartość (flex, block, grid...) tę strzałkę kasuje bez żadnego
   ostrzeżenia — a to jedyna afordancja informująca, że panel da się zwinąć.
   Wyrównanie etykiety i licznika w rządek idzie przez WEWNĘTRZNY
   .work-bar__summary-inner (display: flex tam, nie tutaj) — dokładnie ten
   sam podział, co w file.html: „markdown" (linia ~114) zostawia <summary>
   bez display i ma strzałkę, menu „⋮" (linia ~63) świadomie ustawia
   display:flex RAZEM z list-style:none i strzałki nie ma — to był
   świadomy wybór tamtej reguły, nie coś do skopiowania tutaj bez namysłu. */
.work-bar__summary {
  /* 44px to minimalny cel dotykowy i zarazem wysokość nagłówka W WARIANCIE
     BELKI (poniżej 1024px, gdzie treść stoi w jednym wierszu). Na karcie
     nagłówek ma DWA wiersze — marker w swoim, treść w swoim (patrz
     .work-bar__summary-inner niżej) — więc realnie ~60px i min-height jest
     tam tylko podłogą. Oba rachunki rezerwy miejsca pod treścią liczą się
     z tych wysokości, patrz komentarz przy .shell--with-dock .content wyżej. */
  min-height: 44px; padding: 10px 14px; cursor: pointer; font-size: 13px; font-weight: 700;
  color: var(--color-fg);
  /* line-height 20px, nie odziedziczone 24px: wysokość nagłówka musi być
     PRZEWIDYWALNA, bo z niej liczą się obie rezerwy
     (TestWorkBarReserveMatchesBarHeight sprawdza ten rachunek statycznie, bez
     przeglądarki, więc nie wolno mu zależeć od tego, jak wysoko wyjdzie
     treść). Na belce: 20px + 2×10px wyściółki = 40px, czyli min-height 44px
     wygrywa i belka ma dokładnie 44px. Na karcie: dwa wiersze po 20px
     + 20px wyściółki = 60px. */
  line-height: 20px;
}
/* flex (blokowy) na KARCIE, inline-flex na BELCE (@media wyżej) — różnica
   wynika z markera <details>, nie z gustu.
   <summary> ma display: list-item (celowo, patrz uzasadnienie wyżej: tylko
   przy tej wartości przeglądarka rysuje natywną strzałkę rozwijania), a marker
   listy trafia w PIERWSZY WIERSZ treści elementu. Element blokowy tworzy
   własny wiersz, więc marker dostaje wiersz OSOBNY, nad napisem:
     • na karcie 320px to jest w porządku i wręcz pożądane — nagłówek karty ma
       dwa wiersze („▾ Roboty w tle 2" i pod nim nazwa najpilniejszej roboty
       z plakietką), bo w 292px użytecznej szerokości wszystko w jednym rządku
       ścisnęłoby nazwę do kilku znaków;
     • na belce pełnej szerokości byłoby marnotrawstwem i zmierzoną usterką:
       wychodziła 75px zamiast 44px, ze strzałką w pustej linijce nad napisem.
       Dlatego tam inline-flex (patrz @media (max-width: 1023px) wyżej). */
/* flex-wrap: wrap + flex-basis: 100% na nagłówku roboty (niżej) = nazwa
   dostaje WŁASNY wiersz na karcie.
   Zmierzone, dlaczego to konieczne: karta ma 320px, z czego 292px użytecznej
   szerokości. Etykieta „Roboty w tle" zajmuje 80px, licznik 20px, plakietka
   statusu do 110px („Czeka w kolejce") — na nazwę zostawało ~60px, czyli
   „Wid…". Nagłówek roboty istnieje po to, żeby zwinięty pasek mówił, CO się
   dzieje; nazwa skrócona do trzech znaków tego nie robi.
   Na belce (pełna szerokość okna) wszystko mieści się w jednym rządku, więc
   tam zawijanie jest zdjęte — patrz @media niżej. */
.work-bar__summary-inner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* NAJPILNIEJSZA ROBOTA W NAGŁÓWKU (layout.html: workHeadline).
   flex: 1 + min-width: 0 na karcie: nazwa bierze resztkę szerokości drugiego
   wiersza i skraca się wielokropkiem, a plakietka statusu zostaje widoczna —
   ta sama pułapka dziecka flexa co przy .work-bar__name niżej. Na belce
   flex: 1 nie ma czego wypełniać (inline-flex liczy szerokość z treści), więc
   tam nazwa dostaje granicę względem okna — patrz @media wyżej.
   Waga 400: nagłówek ma pismo 700, ale nazwa pliku czy kursu nie jest
   nagłówkiem — pogrubiona konkurowałaby z etykietą „Roboty w tle". */
.work-bar__headline { display: flex; align-items: center; gap: 8px; flex: 1 0 100%; min-width: 0; font-weight: 400; }
.work-bar__headline-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-fg-muted); }
/* Plakietka w nagłówku dostaje interlinię pod jego wysokość. Ta sama .badge
   w wierszu rozwiniętej listy zostaje bez zmian — tam nic nie zależy od jej
   wysokości, a tu 24px interlinii dawało 30px plakietki i rozpychało wiersz
   nagłówka o 6px, unieważniając rachunek rezerwy w obu wariantach. */
.work-bar__headline .badge { line-height: 18px; }
/* NAGŁÓWEK ROBOTY ZNIKA PO ROZWINIĘCIU. Istnieje po to, żeby ZWINIĘTY pasek
   mówił, co się dzieje — a po rozwinięciu ta sama robota stoi w pierwszym
   wierszu listy pod nim, z pełną plakietką, przyciskiem „Wyświetl błąd" i ×.
   Dwa razy to samo w jednym panelu jest szumem, a przy karcie kosztuje też
   wysokość: nagłówek schodzi z 64px do 44px, czyli rozwinięta karta wygląda
   DOKŁADNIE jak przed zmianą (o to prosił zamawiający).
   Selektor atrybutowy [open] na <details>, bez pomocy JS-u: przeglądarka
   dodaje i zdejmuje ten atrybut sama przy zwijaniu, więc stan wizualny idzie
   za stanem widgetu bez żadnej dodatkowej ścieżki, która mogłaby się z nim
   rozjechać. Rezerwy miejsca to nie dotyczy — ta liczy się dla stanu
   rozwiniętego od SUFITU panelu, nie od wysokości nagłówka. */
.work-bar__panel[open] .work-bar__headline { display: none; }
.work-bar__count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
  background: var(--color-bg); color: var(--color-fg-muted); font-size: 12px;
  font-variant-numeric: tabular-nums;
}
.work-bar__list {
  list-style: none; margin: 0; padding: 4px 0 8px; overflow-y: auto;
  /* 280px, nie dawne 356px: rozwinięta belka rezerwuje pod sobą miejsce
     w treści (padding-bottom wyżej), a ta rezerwa jest tym większa, im wyższa
     lista. 356px dawało 433px pustki na końcu każdej strony przy rozwiniętym
     panelu. 280px to nadal cztery pozycje bez przewijania (ok. 60px każda,
     patrz rachunek przy rezerwie) — czyli granica, przy której zgłoszenie
     „jak są 4, nie mogę scrollować w dół" zostaje spełnione bez przewijania,
     a piąta i dalsze dostają pasek przewijania listy. */
  max-height: min(52vh, 356px);
  border-top: 1px solid var(--color-border);
}
/* Wyściółka pozioma wiersza zrównana z belką i z .content — patrz
   .work-bar__summary. Pion zostaje 8px: to on daje wierszowi ok. 60px, z których
   liczy się sufit listy i rezerwa miejsca. */
.work-bar__item { padding: 8px 14px; }
.work-bar__item + .work-bar__item { border-top: 1px solid var(--color-border); }
.work-bar__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* min-width: 0 na dziecku flex — ta sama pułapka opisana przy
   .lesson-states__title wyżej: bez niej długi tytuł kursu rozpychałby
   plakietkę statusu poza panel zamiast się złamać. */
.work-bar__name {
  min-width: 0; overflow-wrap: anywhere;
  font-size: 13px; font-weight: 600; color: var(--color-fg); text-decoration: none;
}
.work-bar__name:hover { text-decoration: underline; }
/* Tor postępu + liczba obok — nie sam tor: course.html i knowledge.html
   ZAWSZE pokazują procent jako tekst (nie tylko aria-valuenow), bo pusty tor
   na 0% jest wizualnie nieodróżnialny od „nie wiem" (ta sama zasada z briefu,
   którą to zadanie już raz zastosowało przy WYBORZE, kiedy w ogóle rysować
   tor — tu chodzi o to samo dla treści WEWNĄTRZ toru). */
.work-bar__progress-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.work-bar__progress { flex: 1; min-width: 0; }
.work-bar__percent { flex: none; font-size: 12px; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
/* Licznik kolejki renderów wideo („+13 w kolejce") — ten sam ton co procent
   obok, bo to ta sama klasa informacji: liczba mówiąca, ile jeszcze zostało.
   flex: none, żeby długi tytuł lekcji nie ścisnął go do wielokropka. */
.work-bar__queued { margin: 6px 0 0; font-size: 12px; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
.work-bar__error { margin: 6px 0 0; font-size: 12px; color: var(--color-danger); }
/* Zdjęcie błędu z paska (uwaga Antka, 9.08.2026 — patrz store.InFlightWork,
   layout.html): mały przycisk "X" obok treści błędu, zgodny wizualnie
   z resztą paska, ale NIE .btn — .btn ma min-height 36px i poziomy padding
   16px (sekcja "przyciski" wyżej), za duże na pojedynczy znak w wąskim
   wierszu paska (patrz .work-bar__item padding: 8px 14px). Domyślnie
   przygaszony (--color-fg-muted, ten sam token co .work-bar__percent), żeby
   nie konkurował wizualnie z samą treścią błędu; hover/focus przechodzi na
   --color-danger — ten sam ton co .work-bar__error, bo to jest akcja
   dotycząca TEGO błędu. Fokus: globalny :focus-visible (sekcja typografii)
   wystarcza, przycisk nie ustawia własnego box-shadow (patrz
   TestNoBoxShadowClassOnFocusableElement, focus_ring_test.go). */
.work-bar__error-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-top: 6px; }
/* Kolumna treści błędu: krótki komunikat + przełącznik „Zobacz szczegóły”
   + pełna treść (zgłoszenie Eryka z 26.08.2026, pełne uzasadnienie przy
   .work-bar__error-row w layout.html). `flex: 1` PRZENIESIONE tutaj
   z .work-bar__error — od tej zmiany dzieckiem flexa jest ta kolumna, a nie
   sam akapit.

   min-width: 0 nie jest ozdobą: domyślne `min-width: auto` dzieci flexa nie
   pozwala im zmaleć poniżej własnej treści, a komunikaty dostawców to często
   goły JSON bez spacji. Bez tego rozwinięty błąd rozepchnąłby panel w prawo
   poza krawędź ekranu — zamiast przewinąć się w środku. */
.work-bar__error-body { flex: 1; min-width: 0; }
.work-bar__error-row .work-bar__error { margin-top: 0; }
/* 6px, jak .work-bar__error i .work-bar__queued: od 26.08.2026 ten przełącznik
   jest w wierszu w błędzie PIERWSZĄ rzeczą pod nazwą roboty (skrót zniknął),
   więc odstęp musi być ten sam, jaki miała treść, którą zastąpił. */
.work-bar__error-more { margin-top: 6px; }
/* Przełącznik: BEZ deklaracji `display` — dokładnie z tego samego powodu, dla
   którego nie ma jej .work-bar__summary (patrz TestWorkBarSummaryKeepsNativeMarker,
   workbar_test.go). `display` na <summary> kasuje natywną strzałkę ::marker,
   czyli jedyną afordancję mówiącą, że to się rozwija i zwija.

   TON CZERWONY, nie przygaszony (zmiana z 26.08.2026 — poprzednia wersja tej
   reguły argumentowała odwrotnie i było to słuszne, dopóki obok stał czerwony
   skrót błędu). Teraz w wierszu w błędzie NIE MA innej treści niż ten przycisk:
   przy --color-fg-muted robota zakończona błędem czytałaby się jak każda inna,
   z jedną szarą linijką, a jedynym sygnałem zostałaby plakietka. Ton przejmuje
   więc rolę, którą miał skrót. Token --color-danger jest już używany jako kolor
   tekstu w tym samym pasku (.work-bar__error), więc kontrast jest ten sam, co
   przedtem — i jest zdefiniowany dla obu motywów. */
.work-bar__error-toggle { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.work-bar__error-toggle:hover, .work-bar__error-toggle:focus-visible { text-decoration: underline; }
/* Dwie etykiety jednego przełącznika, przełączane WYŁĄCZNIE stanem [open].
   Tak wychodzi „pokaż” i „schowaj” bez ani jednej linii JS-u i bez łamania
   reguły paska „skrypt nie niesie tekstów” — oba napisy przychodzą z {{T}},
   więc działa to również po zmianie języka. */
.work-bar__error-toggle-hide { display: none; }
.work-bar__error-more[open] .work-bar__error-toggle-show { display: none; }
.work-bar__error-more[open] .work-bar__error-toggle-hide { display: inline; }
/* Pełna treść błędu — ROZWINIĘTA NA ŻĄDANIE, nigdy sama z siebie.
   max-height ze przewijaniem, bo powód istnienia skrótu nie znika po
   rozwinięciu: workbarErrorMaxLen (workbar.go) tłumaczy się wprost tym, że
   ściana tekstu „rozpycha panel i wypycha z widoku pozycje realnie pracujące”.
   Rozwinięcie ma więc własny sufit — ~7 linijek, dalej scroll w środku
   akapitu, a nie rosnący bez końca panel przysłaniający resztę robót.

   white-space: pre-wrap, bo skrocBladNaPasek bierze PIERWSZĄ LINIĘ i zwija
   białe znaki — to rozwinięcie jest jedynym miejscem w interfejsie paska,
   gdzie wielolinijkowy ogon logu widać tak, jak go zapisał dostawca. Bez
   pre-wrap linie zlałyby się w jeden nieczytelny akapit, czyli rozwinięcie
   pokazywałoby to samo, co title, tylko większe.
   overflow-wrap: anywhere — dla tego samego gołego JSON-a bez spacji, co
   min-width wyżej: bez tego jedna długa linia wychodzi poza obszar
   przewijania w poziomie i część treści jest nieosiągalna. */
.work-bar__error-full {
  margin: 4px 0 0; max-height: 10.5em; overflow-y: auto;
  font-size: 12px; line-height: 1.5; color: var(--color-danger);
  white-space: pre-wrap; overflow-wrap: anywhere;
}
.work-bar__dismiss {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 20px; height: 20px; padding: 0; border: none; border-radius: var(--radius-sm);
  background: none; color: var(--color-fg-muted); font-size: 14px; line-height: 1;
  cursor: pointer;
}
.work-bar__dismiss:hover, .work-bar__dismiss:focus-visible { color: var(--color-danger); background: var(--color-surface-muted); }
/* Zielony wiersz „gotowe” (uwaga Antka z 10.08.2026 — patrz store.InFlightWork,
   layout.html: .work-bar__done-row). DOKŁADNIE ten sam układ co wiersz błędu
   wyżej (treść + przycisk × po prawej) — bo to ta sama rola w interfejsie:
   stan końcowy, który zostaje, dopóki właściciel go nie zamknie. Różni się
   wyłącznie ton: --color-ok-fg (ten sam token co plakietka badge--ok, którą ten
   wiersz i tak nosi obok, i co kropka health-tile__dot--ok) zamiast
   --color-danger. Token, nie nowa wartość — zieleń jest już zdefiniowana dla
   OBU motywów (patrz sekcja zmiennych: #166534 na jasnym, #4ade80 na ciemnym),
   więc wiersz czyta się w każdym z nich bez osobnej reguły w @media.
   Reguła `display: flex` niżej DZIAŁA RAZEM z globalnym
   `[hidden] { display: none !important }` (patrz uzasadnienie na górze pliku) —
   bez tamtego !important ten sam błąd co przy .work-bar__error-row: szkielet
   renderowany z atrybutem `hidden` w KAŻDYM wierszu paska byłby widoczny
   wszędzie, a `doneRow.hidden = true` w workbar.js nie robiłoby nic. */
.work-bar__done-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-top: 6px; }
.work-bar__done { margin: 0; flex: 1; font-size: 12px; font-weight: 600; color: var(--color-ok-fg); }
/* Task 3 planu paska postępu (aktualizacja na żywo) — znacznik zerwanego
   połączenia SSE, chowany/pokazywany przez atrybut `hidden` z workbar.js
   (zdejmowany na zdarzeniu "error" strumienia /events, przywracany na
   "open" — patrz komentarz w layout.html). Sam skrypt NIE wstawia tu
   żadnego tekstu, tylko przełącza `hidden` — treść niesie {{T}} w szablonie,
   więc podlega tym samym testom i18n co reszta strony. Kolor ostrzegawczy
   (ten sam token co badge--running), nie błędu: to nie awaria, tylko chwilowa
   przerwa, którą przeglądarka sama naprawia. */
.work-bar__stale { font-size: 11px; font-weight: 600; color: var(--color-warn-fg); }
/* Syntetyczny postęp czasowy (spec — pasek robót w tle, etapy 'pytania'/
   'plan') — "trwa dłużej niż zwykle", zastępuje .work-bar__percent zamiast
   stać obok niego (patrz layout.html: hidden na jednym z dwóch elementów
   jest zawsze odwrotny do hidden na drugim). Sam rozmiar/waga co
   .work-bar__stale wyżej — inny selektor, bo to inny element z innym
   powodem pojawienia się (zerwane połączenie SSE kontra etap trwający
   dłużej niż mediana): mylenie ich w jednym JS-owym querySelector
   pokazywałoby złą treść przy złym zdarzeniu. */
.work-bar__stage-stale { font-size: 11px; font-weight: 600; color: var(--color-warn-fg); }
/* Tykający licznik „~2 min 10 s pozostało" pod torem syntetycznym (zgłoszenie
   Antka z 23.08: „generowanie raportu nie ma timera"). WŁASNA LINIA, nie
   trzeci element w rządku z torem i procentem — dokładnie ten sam wniosek co
   przy .work-bar__queued wyżej („+13 w kolejce"): na wąskim ekranie belka ma
   szerokość okna, a wciśnięty w flex-row licznik ścisnąłby tor do
   kilkudziesięciu pikseli, tym mocniej w językach z dłuższym słowem
   („залишилося", „დარჩა"). flex-basis: 100%
   w tym samym .work-bar__progress-row (a nie element poza nim), bo pętla
   tykania szuka go przez row.querySelector — trzymanie go w środku wiersza
   jest kontraktem z workbar.js, a nie estetyką. Ton i rozmiar jak
   .work-bar__percent, bo to ta sama klasa informacji: liczba mówiąca, ile
   jeszcze zostało. */
.work-bar__progress-row { flex-wrap: wrap; }
.work-bar__stage-remaining {
  flex: 0 0 100%; font-size: 11px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}

/* ═══════ WARIANT WĄSKIEGO EKRANU: BELKA PRZY DOLNEJ KRAWĘDZI ═══════
   Decyzja Eryka (26.08.2026, po obejrzeniu belki na żywo): „roboty w tle »bar«
   na dole ekranu powinien być tylko dla wersji mobilnej, a dla wersji webowej
   ma być stary widok tego okienka". Karta wraca więc jako wariant BAZOWY
   (reguły wyżej), a belka zostaje tam, gdzie faktycznie rozwiązywała problem:
   na telefonie karta zajmowała 390×470 px, czyli 56% okna, i przykrywała
   cztery wiersze tabeli razem z ich polami wyboru.

   Próg 1023 px, nie 767 px: to ta sama szerokość, na której .topbar wchodzi,
   a .sidebar wychodzi z przepływu do szuflady (@media (max-width: 1023px)
   wyżej). „Wersja mobilna" znaczy w tym arkuszu dokładnie to — układ
   z górnym paskiem i szufladą — więc pasek robót zmienia postać razem z resztą
   obudowy, a nie na własnym, czwartym progu.

   Belka nie potrzebuje żadnego wyliczania lewej krawędzi: poniżej tego progu
   pasek boczny jest szufladą poza przepływem, więc zaczyna się od zera
   w OBU układach (z powłoką i na ekranie skupienia). Zmienna --workbar-left,
   która to liczyła, zniknęła razem z belką na desktopie. */
@media (max-width: 1023px) {
  .work-bar { left: 0; right: 0; bottom: 0; width: auto; }
  /* Sama kreska u góry, bez obramowania z czterech stron i bez promienia:
     belka dochodzi do lewej, prawej i dolnej krawędzi okna, więc obramowanie
     po tych stronach rysowałoby ramkę wokół niczego, a promień odsłaniałby
     tło strony w narożnikach. Cień skierowany DO GÓRY — belka oddziela się od
     treści nad sobą, nie od krawędzi okna pod sobą.
     max-height: none zdejmuje sufit panelu z wariantu kartowego: tutaj
     wysokość ogranicza sama lista (niżej), a sufit liczony pod kartę
     obcinałby belkę w połowie ostatniej pozycji. */
  .work-bar__panel {
    border: 0; border-top: 1px solid var(--color-border); border-radius: 0;
    box-shadow: 0 -4px 12px -6px rgb(0 0 0 / 14%);
    max-height: none;
  }
  /* Wyściółka pozioma zrównana z .content, która na tym progu ma 16px —
     belka jest pełnej szerokości okna, więc jej napis ma stać w tej samej
     kolumnie co nagłówek strony nad nim. */
  .work-bar__summary { padding-inline: 16px; }
  .work-bar__item { padding-inline: 16px; }
  /* 280px, nie 356px jak na karcie: rozwinięta belka rezerwuje pod sobą
     miejsce w treści (padding-bottom niżej), a ta rezerwa rośnie razem
     z sufitem listy. Na ekranie 844px wysokości 356px listy zjadałoby
     prawie połowę okna. 280px to nadal cztery pozycje bez przewijania. */
  .work-bar__list { max-height: min(40vh, 280px); }
  /* JEDNA LINIA, nie dwie jak na karcie. inline-flex, a nie flex — i to jest
     istotne, nie kosmetyka: <summary> ma display: list-item (celowo, patrz
     uzasadnienie wyżej), a marker listy trafia w PIERWSZY WIERSZ treści.
     Element blokowy (display: flex jest blokowy) tworzy własny wiersz, więc
     marker dostaje wiersz osobny, NAD napisem — zmierzone: belka wychodziła
     wtedy 75px zamiast 45px, ze strzałką w linijce nad „Roboty w tle".
     Na karcie te dwa wiersze są w porządku (nagłówek karty), na belce nie. */
  .work-bar__summary-inner { display: inline-flex; vertical-align: middle; flex-wrap: nowrap; }
  /* Bez flex: 1 — w inline-flexie o szerokości liczonej z treści nie ma
     „resztki miejsca", którą można wypełnić. Nazwa dostaje więc granicę
     względem okna: 40vw to 156px na 390px, czyli tyle, ile zostaje po
     strzałce, liczniku i plakietce. */
  .work-bar__headline { flex: 0 1 auto; }
  .work-bar__headline-name { max-width: 40vw; }
}
/* Poniżej 560px na belce nie ma już miejsca na etykietę RAZEM z nazwą roboty
   i plakietką. Wypada etykieta, bo jest najmniej informacyjna z trzech:
   powtarza aria-label z <aside> (czytnik ekranu jej nie traci), a licznik
   obok i tak mówi, o czym ta belka jest. */
@media (max-width: 559px) {
  .work-bar__label { display: none; }
}


/* ---------- czat z bazą wiedzy (dymki rozmowy) ---------- */
/* chat-conversation.html renderował wiadomości klasami .message {{.Role}},
   których w tym pliku nigdy nie było — pytanie usera i odpowiedź AI były
   gołymi divami na całą szerokość, po tej samej stronie (uwaga Antka,
   9.08.2026). Klasyczny układ dymków: user po prawej w kolorze akcentu
   (ta sama para tło/tekst co .btn--primary — żółty akcent jest identyczny
   w obu motywach, więc ciemny tekst zawsze czytelny), asystent po lewej
   na tle karty. Kto mówi, niosą dodatkowo etykiety .sr-only w szablonie —
   samo wyrównanie jest niewidoczne dla czytników ekranu. */
.chat-thread { display: flex; flex-direction: column; gap: 10px; }
.chat-message {
  max-width: min(640px, 80%);
  padding: 12px;
  border-radius: 12px;
  font-size: 14px;
  line-height: 1.5;
}
/* Dymki 1:1 z Humi „chat-with-assistant" (max-w-[80%] rounded-lg p-3): user
   CZARNY z białym tekstem, asystent BIAŁY z ramką. Zamiast żółtego akcentu user
   dostaje --color-fg / --color-surface — ta sama para co .btn--secondary, więc
   kontrast trzyma w JASNYM i CIEMNYM motywie. RÓWNO zaokrąglone (jak Humi
   rounded-lg) — bez ściętego rożka/ogonka, który był naszym dodatkiem. */
.chat-message--user {
  align-self: flex-end;
  background: var(--color-bubble-user-bg);
  color: var(--color-bubble-user-fg);
}
.chat-message--assistant {
  align-self: flex-start;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
}
/* Odpowiedź modelu to zwykły tekst z \n (szablon eskejpuje HTML, chat.js
   dopisuje przez textContent) — bez pre-wrap akapity zlewałyby się w jedną
   linię. overflow-wrap jak w .exercise__reading: długie tokeny bez spacji
   (URL-e, identyfikatory) nie mogą rozpychać dymka. */
.chat-message__content { white-space: pre-wrap; overflow-wrap: anywhere; }

/* Wariant z markdownem odpowiedzi asystenta (chat.go: renderMarkdown).
   white-space wraca do normal — na gotowym HTML-u pre-wrap dublowałby
   odstępy, bo puste linie źródła markdownu są już akapitami. Reguły niżej
   to ten sam zestaw co .exercise__reading (czytanka lekcji, ta sama droga
   treści z modelu), zawężony do dymka: w środku ma być gęściej niż na
   stronie, więc pierwszy i ostatni element tracą margines, a nagłówki
   schodzą do rozmiaru tekstu — nagłówek h1 z odpowiedzi modelu nie może
   przebijać tytułu rozmowy w nagłówku strony. */
.chat-message__content--rich { white-space: normal; }
.chat-message__content--rich > :first-child { margin-top: 0; }
.chat-message__content--rich > :last-child  { margin-bottom: 0; }
.chat-message__content--rich :where(p,ul,ol) { margin: 0 0 8px; }
.chat-message__content--rich :where(ul,ol) { padding-left: 20px; }
.chat-message__content--rich li { margin-bottom: 2px; }
.chat-message__content--rich :where(h1,h2,h3) { font-size: 15px; margin: 12px 0 6px; }
.chat-message__content--rich pre { white-space: pre-wrap; overflow-x: auto; }
/* display: block na <table> z tego samego powodu co przy .exercise__reading:
   bez niego overflow-x nie działa, a szeroka tabela rozpycha dymek. */
.chat-message__content--rich table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
.chat-message__content--rich :where(td,th) { border: 1px solid var(--color-border); padding: 6px 8px; text-align: left; }

/* Cytowania pod odpowiedzią asystenta (naprawa czatu §4, chat.go:
   dedupeChatSources) — bez własnej reguły dziedziczyły goły styl <ul>
   przeglądarki: pełna szerokość dymka, duży znacznik, żaden związek
   wizualny z treścią odpowiedzi nad nimi. Mniejszy, przygaszony tekst jak
   .chat-message__time — to metadane odpowiedzi, nie jej treść. */
.chat-message__sources { margin-top: 6px; font-size: 12px; opacity: .8; }
.chat-message__sources ul { margin: 4px 0 0; padding-left: 18px; }
/* Rozwijak źródeł (Humi): podsumowanie klikalne z własnym chevronem zamiast
   domyślnego trójkąta (spójnie w przeglądarkach). Zwinięte domyślnie. */
.chat-message__sources-summary {
  cursor: pointer; font-weight: 700; user-select: none;
  display: inline-flex; align-items: center; gap: 6px; list-style: none;
}
.chat-message__sources-summary::-webkit-details-marker { display: none; }
.chat-message__sources-summary::before {
  content: "\203A"; /* › */ display: inline-block; font-weight: 700;
  transition: transform .15s ease;
}
.chat-message__sources[open] > .chat-message__sources-summary::before { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) {
  .chat-message__sources-summary::before { transition: none; }
}

/* Godzina w dymku — opacity zamiast --color-fg-muted, bo dymek usera ma
   żółte tło z ciemnym tekstem i szary token byłby tam za blady; przygaszone
   dziedziczenie koloru działa na obu tłach. Sama godzina bez daty: data
   rozmowy stoi w nagłówku (.chat-head__meta) i na liście rozmów. */
.chat-message__time { font-size: 11px; opacity: .65; margin-top: 4px; text-align: right; }

/* Nagłówek rozmowy: wstecz + tytuł + usunięcie. Tytuł (pierwsze pytanie
   przycięte do 100 znaków — handleChatNew) w jednej linii z wielokropkiem:
   pełna treść i tak jest w pierwszym dymku tuż niżej. min-width: 0 na
   dziecku flex — ta sama pułapka co przy .lesson-states__title. */
.chat-head { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
.chat-head__title-box { flex: 1; min-width: 0; }
.chat-head__title { font-size: 17px; font-weight: 700; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-head__meta { font-size: 12px; color: var(--color-fg-muted); }

/* Pasek pytania: zwykłe .field__control + .btn--primary, te same komponenty
   co w każdym innym formularzu (bez pigułki/okrągłego przycisku — to była
   jedyna pigułka w całym appce, wyglądała jak z innego produktu). Ta sama
   para klas na liście rozmów (karta u góry) i w rozmowie, gdzie karta
   dostaje dodatkowo .chat-composer-bar: przyklejona NAD dolną krawędzią
   (bottom z odstępem, nie 0) i z mocniejszym cieniem — pływa nad
   przewijanym wątkiem zamiast dotykać krawędzi okna. box-shadow na
   <section>, nie na elemencie fokusowalnym (patrz
   TestNoBoxShadowClassOnFocusableElement). */
.chat-composer { display: flex; gap: 8px; align-items: center; }
/* Input i „wyślij" jak w Humi: zwykłe zaokrąglone pole (nie pigułka) + przycisk
   w tym samym promieniu (nie koło). Kolor przycisku zostaje nasz (amber). */
.chat-composer .field__control { flex: 1; border-radius: var(--radius); padding: 10px 14px; }
.chat-composer .btn { flex: none; width: 44px; min-height: 44px; padding: 0; border-radius: var(--radius); font-size: 18px; }
/* Stopka pytania (Humi): przyklejona do samego dołu z górną kreską, na całą
   szerokość kolumny treści, tło powierzchni. Pole (.chat-composer) wyśrodkowane
   do szerokości wątku. Zamiast dawnej pływającej karty (bottom:12px + cień). */
/* Stopka pytania (Humi „sticky bottom-0 border-t"): na CAŁĄ szerokość treści,
   tło JAK STRONA (nie białe — inaczej robił się biały box na szarym tle,
   „odklejony pasek", uwaga Antka), tylko górna kreska. Pole wyśrodkowane.
   Tło musi być NIEprzezroczyste, żeby zakrywać przewijaną treść pod spodem. */
.chat-composer-bar {
  position: sticky; bottom: 0; margin-top: 8px;
  background: var(--color-bg);
  border-top: 1px solid var(--color-border);
  padding: 12px 0 14px;
}
.chat-composer-bar .chat-composer { max-width: var(--chat-max-width); margin: 0 auto; }

/* Wskaźnik pisania: TRZY SKACZĄCE KROPKI (Humi) w dymku strumienia, zanim
   dojdzie pierwszy token. Osobny #typing-dots (.chat-typing) wewnątrz
   #streaming-response, PO #stream-text — gaśnie sam, gdy chat.js dopisze tekst
   (`#stream-text:not(:empty) ~`), bez żadnego JS. aria-hidden: kropek czytnik
   nie czyta, etykietę niesie .sr-only w szablonie. prefers-reduced-motion
   (globalna reguła) zeruje animację → kropki stoją, dalej czytelne jako „w toku". */
.chat-typing { display: inline-flex; gap: 5px; align-items: center; }
.chat-typing span {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--color-fg-muted);
  animation: chat-bounce 1s infinite ease-in-out;
}
.chat-typing span:nth-child(2) { animation-delay: .18s; }
.chat-typing span:nth-child(3) { animation-delay: .36s; }
#stream-text:not(:empty) ~ .chat-typing { display: none; }
@keyframes chat-bounce {
  0%, 80%, 100% { transform: translateY(0);    opacity: .5; }
  40%           { transform: translateY(-5px); opacity: 1;  }
}

/* ── Port ekranu czatu z Humi (asystenci), nasze tokeny ─────────────────
   BYŁ tu „margin: 0 auto" do centrowania wąskiej kolumny (Humi max-w-4xl) —
   zdjęte razem z limitem szerokości. PRAWDZIWA PRZYCZYNA tego, że czat
   dalej wyglądał wąsko mimo max-width: none (znalezisko przez realny
   render Playwrightem, nie zgadywanie): .chat-page jest ELEMENTEM FLEX
   wewnątrz .content--chat (display:flex; flex-direction:column) — auto
   marginesy na osi poprzecznej flex-itemu WYŁĄCZAJĄ domyślne rozciąganie
   (align-items: stretch) i każą mu dopasować szerokość do TREŚCI, zupełnie
   niezależnie od max-width. Efekt: 1204px dostępnej szerokości, a
   .chat-page i tak wychodził ~457px, wyśrodkowany. Usunięcie marginesów
   przywraca domyślne stretch — .chat-page wypełnia całą szerokość rodzica. */
.chat-page { max-width: var(--chat-max-width); display: flex; flex-direction: column; gap: 16px; }
/* Ekran rozmowy: <main class="content content--chat"> jako kolumna flex pełnej
   wysokości (.content ma flex:1 w .shell o min-height:100dvh — więc realną
   wysokość ekranu). Wątek rośnie (flex:1) i DOCISKA stopkę pytania do dołu,
   zamiast pozwalać jej pływać w połowie przy krótkiej rozmowie (uwaga Antka
   „ma być na dole, nie ruszające się"). Scoped przez content--chat: hub czatu
   (chat.html, ta sama klasa .chat-page) i pozostałe ekrany bez zmian.
   Nie zeruję padding-bottom, żeby przy aktywnym doku robót w tle
   (.shell--with-dock .content) stopka nie wpadła pod dok. */
/* max-width: none — .content (rodzic) ma WSPÓLNY z każdą inną stroną limit
   --content-max (1280px) i się centruje; na szerokim monitorze to samo w
   sobie zostawiało odstęp między paskiem bocznym a treścią czatu, nawet gdy
   .chat-page już nie miał WŁASNEGO węższego limitu. Antek: „normalna
   szerokość, która sięga do sidebara" — czat, w odróżnieniu od reszty
   aplikacji, ma wypełniać CAŁĄ resztę ekranu obok paska, bez górnego
   ograniczenia. Padding .content (24px) zostaje — to oddech od krawędzi,
   nie dodatkowy limit szerokości. */
.content--chat { display: flex; flex-direction: column; max-width: none; }
.content--chat .chat-page { flex: 1 1 auto; min-height: 0; }
.content--chat .chat-thread { flex: 1 1 auto; }
/* width:100% już wystarcza (rodzic .chat-page ma teraz realną, pełną
   szerokość) — bez marginesów auto, z tego samego powodu co przy .chat-page
   wyżej: to też są elementy flex (kolumna), auto marginesy na poprzecznej
   osi wygrywałyby ze stretch i kurczyłyby je do treści mimo width:100%. */
.chat-head, .chat-thread { width: 100%; max-width: var(--chat-max-width); }
.chat-composer-bar { width: 100%; max-width: var(--chat-max-width); }

.chat-topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.chat-topbar__title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.chat-topbar__title h1 { margin: 0; font-size: 20px; }
.chat-topbar__title svg { color: var(--color-fg); flex: none; }

/* Bogaty nagłówek ROZMOWY (Humi „chat-with-assistant"): wyśrodkowany do
   szerokości wątku, kreska pod spodem, ikona + tytuł po lewej, ikony-akcje
   (Nowa/Historia/Edytuj/Usuń) po prawej. */
.chat-topbar--conv {
  width: 100%; max-width: var(--chat-max-width); margin: 0 auto 18px;
  align-items: center; padding-bottom: 14px;
  border-bottom: 1px solid var(--color-border);
}
.chat-actions { display: flex; align-items: center; gap: 6px; }
.chat-actions form { display: inline-flex; margin: 0; }
.chat-iconbtn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 38px; height: 38px; border-radius: var(--radius-sm);
  border: 1px solid var(--color-border); background: var(--color-surface);
  color: var(--color-fg); cursor: pointer; text-decoration: none;
}
.chat-iconbtn:hover { background: var(--color-surface-muted); }
/* „Nowa rozmowa" w zieleni jak New Chat u Humi — zielony jako color: jest
   dozwolony (strażnik kontrastu blokuje tylko żółty akcent na jasnym tle). */
/* "Nowa rozmowa" byl jedynym ZIELONYM obrysem w calej aplikacji - zielen
   niesie u nas (i w humi) status "gotowe", nie akcje. Przycisk wyglada teraz
   jak pozostale dwa w tej belce; wyroznia go ikona, nie kolor. Klasa zostaje
   w szablonie jako nazwany hak, ale nic juz nie przestawia. */
.chat-iconbtn--danger:hover { color: var(--color-danger); border-color: var(--color-danger); }

/* Meta nagłówka rozmowy — dziś sama data; wybór asystenta stoi w rzędzie
   akcji po prawej (.assistant-menu niżej), jak w Humi. */
.chat-topbar--conv .chat-head__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.chat-head__date { color: var(--color-fg-muted); }

/* Wybór asystenta w nagłówku rozmowy (Humi „Default Assistant \25BE"): przycisk
   z nazwą + rozwijana lista. Na <details>, bez ani linijki JS — ten sam
   mechanizm co menu wiersza w tabeli członków (.row-menu). Panel wyrównany do
   PRAWEJ krawędzi przycisku, bo przycisk stoi przy prawym brzegu ekranu
   i lista otwarta w lewo wypadłaby poza kadr. */
.assistant-menu { position: relative; }
.assistant-menu__btn {
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; gap: 8px;
  max-width: 220px; height: 38px; padding: 0 12px;
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-surface); color: var(--color-fg);
  font-size: 14px; font-weight: 600;
}
.assistant-menu__btn::-webkit-details-marker { display: none; }
.assistant-menu__btn:hover { background: var(--color-surface-muted); }
.assistant-menu__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.assistant-menu__chevron { flex: none; color: var(--color-fg-muted); transition: transform .15s ease; }
.assistant-menu[open] .assistant-menu__chevron { transform: rotate(180deg); }
.assistant-menu__panel {
  position: absolute; z-index: 40; top: calc(100% + 4px); right: 0;
  min-width: 240px; max-height: 20rem; overflow-y: auto; padding: 6px;
  display: flex; flex-direction: column;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-md);
}
.assistant-menu__panel form { margin: 0; }
/* Pozycja listy wygląda jak wiersz, ale JEST przyciskiem submitu (przełączenie
   asystenta rozmowy) albo linkiem otwierającym dialog — stąd reset wyglądu. */
.assistant-menu__item {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 8px 10px; border: 0; border-radius: var(--radius-sm);
  background: none; color: var(--color-fg); text-align: left; text-decoration: none;
  font: 400 14px/1.3 var(--font-sans); cursor: pointer;
}
.assistant-menu__item:hover { background: var(--color-surface-muted); }
.assistant-menu__item--current { background: var(--color-bg); font-weight: 600; }
.assistant-menu__sep { height: 1px; margin: 6px 4px; background: var(--color-border); }

/* Dialog czatu „Historia" — ten sam wzorzec co .confirm (confirm-modal.js):
   przezroczysty <dialog>, przyciemniony backdrop, pudełko na tokenach. */
.chat-dialog { border: 0; background: transparent; padding: 0; }
.chat-dialog::backdrop { background: rgb(0 0 0 / 45%); }
.chat-dialog__box {
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); padding: 18px;
  width: 460px; max-width: 92vw; max-height: 80vh; overflow-y: auto;
  box-shadow: var(--shadow-md);
}
.chat-dialog__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.chat-dialog__head h2 { margin: 0; font-size: 18px; }
.chat-dialog__head .chat-iconbtn { width: 32px; height: 32px; border: 0; font-size: 20px; }
/* Nagłówek dialogu z podtytułem (Humi „Chat History / View and resume…"). */
.chat-dialog__heading { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.chat-dialog__sub { margin: 0; font-size: 13px; color: var(--color-fg-muted); }
.chat-dialog__list { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
.chat-dialog__empty { color: var(--color-fg-muted); margin: 8px 0 4px; }
/* Stopka dialogu z przyciskiem „Zamknij" wyrównanym do prawej (Humi „Close"). */
.chat-dialog__foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
/* Wariant z akcją po lewej („Nowy asystent") i zamknięciem po prawej. */
.chat-dialog__foot--split { justify-content: space-between; }
/* Szersze pudełko dla dialogu asystentów (formularze CRUD potrzebują miejsca). */
.chat-dialog__box--wide { width: 620px; }
/* Pudełko historii nieco szersze — karty rozmów mieszczą tytuł, „Asystent",
   datę i kosz w jednym rzędzie jak w Humi. */
.chat-dialog__box--history { width: 560px; }

/* Karta rozmowy w Historii (Humi „Chat History"): obwódka + zaokrąglenie,
   obszar tekstu (link) po lewej, zegar+data i czerwony kosz po prawej.
   .chat-histrow to rząd; link i form usuwania są RODZEŃSTWEM (form w <a>
   byłby nieprawidłowy). */
.chat-histrow { display: flex; align-items: center; gap: 4px; }
.chat-histrow__main {
  flex: 1 1 auto; min-width: 0;
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 12px 14px; text-decoration: none; color: inherit;
  border: 1px solid var(--color-border); border-radius: var(--radius);
  background: var(--color-surface);
}
.chat-histrow__main:hover { border-color: var(--color-fg-muted); }
.chat-histrow__text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.chat-histrow__title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-histrow__assistant { font-size: 12px; color: var(--color-fg-muted); }
.chat-histrow__date { display: inline-flex; align-items: center; gap: 6px; flex: none; font-size: 12px; color: var(--color-fg-muted); white-space: nowrap; }
/* Kosz: goła czerwona ikona bez ramki, wyśrodkowana (jak Humi). --color-danger
   jako color: jest dozwolony przez strażnika kontrastu (blokuje tylko bursztyn). */
.chat-histrow__del {
  flex: none; width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center;
  border: 0; border-radius: var(--radius);
  background: transparent; color: var(--color-danger); cursor: pointer;
}
.chat-histrow__del:hover { background: var(--color-surface-muted); }

/* Pusty stan czatu (Humi „New Chat"): wyśrodkowana ikona + zaproszenie do
   pytania, gdy rozmowa nie ma jeszcze wiadomości. */
.chat-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; color: var(--color-fg-muted); padding: 48px 16px; }
.chat-empty svg { color: var(--color-border); }
/* Selektor asystenta w stopce NOWEJ rozmowy — obok pola pytania. */
.chat-composer__pick { flex: none; max-width: 180px; font: inherit; }

.chat-newform { display: flex; flex-direction: column; gap: 10px; }
.chat-newform__pick { margin: 0; }
.chat-newform__pick .field__control { max-width: 340px; }

/* Wiersz z checkboxem (asystent: tryb „tylko z bazy", domyślny, pliki). */
.check-row { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; padding: 3px 0; }
.check-row input[type="checkbox"] { width: 18px; height: 18px; flex: none; }

/* Formularz asystenta (Humi „Edit Assistant") — jeden partial dla dialogu
   tworzenia, dialogu edycji i huba bez JS, więc i jeden zestaw reguł. */
.assistant-form { margin-top: 12px; }
.assistant-form .field { margin-bottom: 16px; }
.assistant-form__foot { display: flex; justify-content: flex-end; gap: 8px; }
/* Gwiazdka pola wymaganego (Humi): czerwona, ale to sam znacznik przy
   etykiecie — treść niesie atrybut `required`, nie kolor. */
.field__req { color: var(--color-danger); }
/* Pliki bazy wiedzy: ramka z listą checkboxów. Pusta baza pokazuje komunikat
   W ŚRODKU tej samej ramki (Humi) — inaczej pole wygląda jak brakujące. */
.assistant-files__list { max-height: 240px; overflow-y: auto; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 6px; }
.assistant-files__empty { font-size: 13px; color: var(--color-fg-muted); margin: 0; padding: 8px; }
.check-row--file { align-items: flex-start; gap: 10px; padding: 8px; border-radius: var(--radius-sm); }
.check-row--file:hover { background: var(--color-surface-muted); }
.check-row__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.check-row__title { font-weight: 500; overflow-wrap: anywhere; }
.check-row__meta { font-size: 12px; color: var(--color-fg-muted); }

/* Wiersz asystenta na liście „Asystenci" (Humi „Manage Assistants"): nazwa
   z plakietkami, opis i licznik plików po lewej, akcje po prawej. */
.assistant-item {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  padding: 14px; border: 1px solid var(--color-border); border-radius: var(--radius);
}
.assistant-item:hover { border-color: var(--color-fg-muted); }
.assistant-item__text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.assistant-item__name { font-weight: 600; }
.assistant-item__desc { font-size: 13px; color: var(--color-fg-muted); }
.assistant-item__chip {
  align-self: flex-start; font-size: 12px; color: var(--color-fg-muted);
  background: var(--color-bg); border-radius: 999px; padding: 2px 10px;
}
.assistant-item__actions { display: flex; align-items: center; gap: 8px; flex: none; }
.assistant-item__actions form { margin: 0; }
.assistant-item__del { color: var(--color-danger); }
.assistant-item__del:hover { border-color: var(--color-danger); }
/* Hub bez JS: te same formularze w rozwijanym <details class="editor">. */
.assistant-editor .assistant-form { max-width: 560px; }
.assistant-row .list-row__actions { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; }
/* -- Wydruk i "Zapisz jako PDF" --------------------------------------------
   Eksport raportu do PDF-a robi PRZEGLADARKA: przycisk na stronie raportu
   wola window.print(), a uzytkownik w oknie druku wybiera "Zapisz jako PDF"
   (Chrome, Safari i Firefox maja to wbudowane na kazdej platformie).

   DLACZEGO NIE PO STRONIE SERWERA: w bibliotece standardowej Go nie ma
   niczego do PDF-ow, a repo trzyma zasade zero nowych zaleznosci i jednego
   binarnego pliku (patrz docs/plan-wdrozenia.md:
   szablony i JS sa WKOMPILOWANE w binarke). Kazde generowanie serwerowe to
   albo nowa biblioteka, albo headless Chrome w infrastrukturze.

   KOSZT TEJ DECYZJI, nazwany wprost, zeby nikt sie nie zdziwil: serwer NIE
   POTRAFI wyprodukowac tego pliku sam. Nie da sie wiec raportu zalaczyc do
   maila, wygenerowac w zadaniu w tle ani wystawic pod adresem /reports/N.pdf —
   to wymagaloby decyzji o zaleznosci, ktorej nikt jeszcze nie podjal.

   Chowamy CALA obudowe aplikacji (gorny pasek, menu, pasek robot w tle,
   przyciski akcji) — na papierze zostaje sama tresc raportu. Kolory z tokenow
   :root, zero literalow. */
@media print {
  /* PAPIER JEST ZAWSZE JASNY — niezależnie od motywu na ekranie. Bez tego
     bloku raport wydrukowany przy włączonym motywie ciemnym wychodził
     z czarnymi kartami i jasnym napisem: drukarka wypluwała stronę czarnego
     tonera, a „Zapisz jako PDF" dawał plik, którego nie da się wysłać
     klientowi. Reguła `body { background: #fff; color: #000 }` niżej sama
     tego nie załatwiała, bo karty, obrysy i plakietki biorą kolor z tokenów,
     a te w motywie ciemnym są ciemne.
     Nadpisujemy TOKENY, nie reguły — jedno miejsce zamiast pięćdziesięciu,
     i nowa karta dodana kiedyś niżej dostaje poprawny wydruk za darmo.
     Wartości są neutralne (biel/czerń/szarości), nie kopią jasnego motywu:
     na papierze liczy się kontrast i zużycie tonera, nie zgodność
     z referencją. */
  :root[data-theme="dark"] {
    color-scheme: light;
    --color-bg: #fff;
    --color-surface: #fff;
    --color-surface-muted: #f2f2f2;
    --color-surface-subtle: #f8f8f8;
    --color-surface-muted-hover: #f2f2f2;
    --color-fg: #000;
    --color-fg-soft: #1a1a1a;
    --color-fg-muted: #444;
    --color-fg-faint: #666;
    --color-meta-fg: #666;
    --color-border: #ccc;
    --color-border-strong: #999;
    --color-accent: #000;
    --color-accent-strong: #000;
    --color-accent-deep: #000;
    --color-accent-fg: #fff;
    --color-chip-bg: #f2f2f2;
    --color-chip-fg: #000;
    --color-ok-fg: #15803d;
    --color-warn-fg: #8a5406;
    --color-error-fg: #b91c1c;
    --color-scroll-shadow: rgb(0 0 0 / 12%);
    --color-surface-fade: rgb(255 255 255 / 0%);
  }

  /* Obudowa aplikacji nie ma sensu na papierze. .no-print to jawna furtka
     dla akcji, ktore na wydruku sa martwe (przyciski, linki powrotu). */
  .topbar,
  .sidebar,
  .work-bar,
  .no-print { display: none !important; }

  /* Powloka jest siatka z miejscem na menu i pasek robot — na wydruku obie
     kolumny znikaja, wiec siatka musi ustapic zwyklemu przeplywowi. Klasy
     shell--with-dock/shell--dock-open rezerwuja miejsce na pasek (patrz
     komentarz w layout.html) i tez trzeba je zdjac. */
  .shell,
  .shell--with-dock,
  .shell--dock-open { display: block !important; }

  .content,
  .content--centered {
    max-width: none !important;
    margin: 0 !important;
    padding: 0 !important;
  }

  body {
    background: #fff;
    color: #000;
    font-size: 10.5pt;
    line-height: 1.45;
  }

  /* Cienie nie drukuja sie sensownie; obramowanie niesie te sama informacje
     (gdzie konczy sie karta) i kosztuje mniej tonera. */
  .card {
    box-shadow: none !important;
    border: 1px solid #ccc !important;
  }
  .badge { border: 1px solid #ccc !important; }

  /* Zaden wiersz tabeli nie ma sie lamac w polowie miedzy stronami, a
     naglowek tabeli ma sie powtarzac na kazdej stronie — inaczej druga
     strona rozkladu per typ cwiczenia to kolumny liczb bez opisu. */
  tr, td, th {
    page-break-inside: avoid;
    break-inside: avoid;
  }
  thead { display: table-header-group; }
  h1, h2, h3 {
    page-break-after: avoid;
    break-after: avoid;
  }

  /* Zwiniete <details> MUSZA sie wydrukowac. Bez tego wydruk strony pliku
     gubi fragmenty i podglad Markdown (sa domyslnie zwiniete od uwag Antka
     z 09.08), a wydruk, ktory po cichu pomija tresc, jest gorszy niz brak
     wydruku. */
  details > *:not(summary) { display: block !important; }
  details > summary { list-style: none; }

  /* Adresy wewnetrzne na papierze sa martwe — zostaje sam tekst. */
  a[href^="/"] {
    color: inherit;
    text-decoration: none;
  }

  .print-brand { display: block !important; }

  @page { margin: 14mm 12mm; }
}

/* Nazwa firmy na wydruku: pasek boczny z logo jest schowany, wiec bez tego
   PDF wyslany klientowi nie mowi, z czego pochodzi. Na ekranie niewidoczne. */
.print-brand {
  display: none;
  margin: 0 0 .6rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: #555;
}

/* ---------- analityka (wygląd odtworzony z humionboardera) ---------- */
/* Cel: ekran ma wyglądać jak analityka humi (shadcn/ui), nie jak reszta
   naszego panelu — kolorowe kafle z ikoną, plakietki trendu, wykres
   z gradientem, gwiazdki. Odtworzone ręcznie, bo nie mamy Tailwinda ani
   layerchart (patrz komentarz w analytics.html). */
.analytics-tiles {
  display: grid; gap: 16px; margin-bottom: 16px;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
/* stat-tile — kafel KPI humi: liczba i etykieta po lewej, kolorowa ikona
   w zaokrąglonym kwadracie po prawej, plakietka trendu pod spodem. */
.stat-tile .card__body { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
.stat-tile__top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.stat-tile__label { display: block; font-size: 13px; color: var(--color-fg-muted); margin-bottom: 6px; }
.stat-tile__value { display: block; font-size: 30px; font-weight: 800; line-height: 1; letter-spacing: -.02em; }
/* Ikona w kolorowym kwadracie — humi: bg-<kolor>/10 + text-<kolor>. Tło to
   ten sam kolor co ikona przy ~14% krycia (color-mix), więc każdy akcent
   działa w obu motywach bez osobnych tokenów tła. */
.stat-tile__icon {
  flex: none; width: 44px; height: 44px; border-radius: 12px;
  display: flex; align-items: center; justify-content: center;
}
.stat-tile__icon svg { width: 22px; height: 22px; }
/* „primary" bierze KOLOR PIERWSZOPLANOWY (grafit w jasnym, biel w ciemnym) na
   swoim własnym przygaszeniu — dokładnie jak humi (text-primary na bg-primary/10).
   NIE bursztyn: bursztyn jako `color` łamie kontrast (contrast_test.go), a i tak
   nie tak wygląda tamten kafel. */
.stat-tile__icon--primary { color: var(--color-fg); background: color-mix(in srgb, var(--color-fg) 12%, transparent); }
.stat-tile__icon--blue    { color: #3b82f6; background: color-mix(in srgb, #3b82f6 15%, transparent); }
.stat-tile__icon--gold    { color: #eab308; background: color-mix(in srgb, #eab308 15%, transparent); }
.stat-tile__icon--emerald { color: #10b981; background: color-mix(in srgb, #10b981 15%, transparent); }
.stat-tile__trend { display: flex; align-items: center; gap: 8px; }
.stat-tile__note { font-size: 12px; color: var(--color-fg-muted); }

/* trend-badge — humi: obwódka + zielony tekst + strzałka. Zielony na
   powierzchni karty: --color-ok-fg (#166534) ma na białej karcie ~7:1.
   Wariant --down przełącza na kolor błędu, ta sama rola co strzałka w dół. */
.trend-badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700;
  color: var(--color-ok-fg); border: 1px solid color-mix(in srgb, var(--color-ok-fg) 40%, transparent);
}
.trend-badge svg { width: 13px; height: 13px; }
.trend-badge--down { color: var(--color-danger); border-color: color-mix(in srgb, var(--color-danger) 40%, transparent); }

/* Wykres: SVG rysowane przez serwer. preserveAspectRatio "none" rozciąga
   linię na pełną szerokość karty — oś X to czas, proporcje nie niosą
   znaczenia. Kropki (Highlight points w humi) w węzłach, linia grubsza,
   pod nią gradientowe wypełnienie. */
/* Stos sekcji analityki: jeden odstęp między KAŻDYMI dwiema sekcjami
   (kafle, wykres, tabele pulpitu, podsumowanie, rozkład, punkty zapalne).
   Ta sama liczba co `.content > * + *` na poziomie strony, bo to ten sam
   rodzaj podziału — tylko o poziom głębiej, wewnątrz zwijanej karty. */
.analytics-stack > * + *:not(dialog) { margin-top: var(--space-8); }

/* ---------- wykres przebiegu (linia + kropki, interaktywny) ----------
   aspect-ratio zamiast stałej wysokości i zamiast preserveAspectRatio="none":
   proporcje muszą odpowiadać viewBoxowi (720×160 → 9/2), bo tylko wtedy
   skalowanie jest JEDNORODNE i kropka zostaje kropką. To jest cała naprawa
   „kropek-elips" — patrz web.analyticsChart. Zmieniasz chartWidth/chartHeight
   w Go → zmień tę proporcję, inaczej `meet` wyśrodkuje wykres i po bokach
   pojawią się puste pasy.

   border-bottom = linia bazowa. W SVG wypadałaby na krawędzi viewBoxa
   i połowa obrysu byłaby obcięta; jako border rysuje ją przeglądarka
   w pikselach ekranu, zawsze ostro. */
/* Zapas z prawej na OŚ WARTOŚCI: podpisy siatki stoją poza rysunkiem (jak
   w projekcie dashboardu), więc muszą mieć gdzie stanąć — inaczej wychodziłyby
   na krawędź karty. */
.analytics-chart__wrap { position: relative; padding-right: 44px; }
/* Układ odniesienia dla podpisów osi wartości — wysokość DOKŁADNIE taka jak
   rysunku, patrz komentarz przy tym elemencie w analytics.html. */
.analytics-chart__plot { position: relative; }
.analytics-chart {
  display: block; width: 100%; aspect-ratio: 9 / 2;
  /* BEZ border-bottom. Linię bazową rysuje dziś siatka: zero jest jedną
     z wartości osi i leży o chartPadY nad krawędzią viewBoxa, więc nie ma
     czego przycinać (to był jedyny powód, dla którego linia bazowa była
     krawędzią elementu, a nie <line> w SVG). Border ZOSTAWIONY obok siatki
     dawałby dwie kreski kilka pikseli od siebie. */
  overflow: visible;
}
/* Wąski ekran: te same dane na 430 px dałyby wykres 96 px wysokości, czyli
   pasek, na którym nie da się odczytać kształtu. Wyższe proporcje. */
@media (max-width: 48rem) {
  .analytics-chart { aspect-ratio: 3 / 2; }
}
/* NIEBIESKA, nie w kolorze akcentu — patrz --color-chart-line w tokenach
   (jeden token na linię, wypełnienie i kropki, żeby wykres nie rozjechał się
   na trzy odcienie przy pierwszej korekcie). */
/* KOLOR SERII PRZEZ ZMIENNĄ LOKALNĄ --seria, nie przez trzy komplety reguł.
   Modyfikator (--a/--b/--c) ustawia ją RAZ na elemencie, a linia, wypełnienie,
   kropka i klucz legendy tylko ją czytają. Dzięki temu dołożenie czwartej serii
   to jedna linijka z kolorem, a nie cztery reguły do skopiowania — i nie da się
   dodać koloru linii, zapominając o kropce.
   Wartość zapasowa --color-chart-line jest dla wykresu BEZ modyfikatora
   (gdyby ktoś wstawił partial ze starą strukturą danych): wtedy wszystko
   zachowuje się jak przed wykresem wieloseryjnym. */
.analytics-chart__line--a, .analytics-chart__area--a, .analytics-chart__dot--a, .analytics-chart__key--a { --seria: var(--color-chart-a); }
.analytics-chart__line--b, .analytics-chart__area--b, .analytics-chart__dot--b, .analytics-chart__key--b { --seria: var(--color-chart-b); }
.analytics-chart__line--c, .analytics-chart__area--c, .analytics-chart__dot--c, .analytics-chart__key--c { --seria: var(--color-chart-c); }
/* WSZYSTKIE LINIE SĄ CIĄGŁE. Kreskowanie i kropkowanie, które stało tu do
   30.08.2026, nie było decyzją estetyczną, tylko obowiązkiem: przy poprzedniej
   palecie para bursztyn↔zieleń schodziła przy protanopii do ΔE 7,9, a kolor
   w tym paśmie wolno stosować tylko z drugim nośnikiem tożsamości. Nowa paleta
   (patrz --color-chart-*) rozdziela wszystkie pary powyżej progu 8, więc
   nośnikiem jest sam kolor i linie mogą być tym, czym powinny być — liniami.
   Rozróżnienie na wydruku czarno-białym niesie legenda, ta sama, którą czyta
   każdy inny czytelnik.
   HIERARCHIA GRUBOŚCI zostaje: seria pierwsza jest głównym przebiegiem karty
   (niesie wypełnienie i to o niej mówi podpis pod wykresem), więc ma 2,5 px,
   a towarzyszące jej serie o pół piksela mniej. Trzy jednakowo ciężkie linie
   sprawiają, że wykres nie ma na czym oka zaczepić. */
.analytics-chart__line--b, .analytics-chart__line--c { stroke-width: 2; }
.analytics-chart__line {
  /* 2,5 px, nie 2: w projekcie dashboardu krzywa jest wyraźnie cięższa od
     siatki i od obrysu kropek, i to ona ma być pierwszą rzeczą, jaką widać
     na tej karcie. */
  fill: none; stroke: var(--seria, var(--color-chart-line)); stroke-width: 2.5;
  stroke-linejoin: round; stroke-linecap: round;
  /* Skalowanie jest jednorodne, więc obrys rośnie razem z wykresem — na
     szerokim ekranie linia byłaby grubsza niż wszystko inne w interfejsie.
     non-scaling-stroke trzyma ją na 2 px niezależnie od szerokości karty. */
  vector-effect: non-scaling-stroke;
}
/* Wypełnienie TYLKO pod serią pierwszą (szablon rysuje <polygon> jedynie dla
   niej): trzy pola jedno na drugim dają szlam, w którym nie widać żadnej
   z linii.
   PRZEJŚCIE ROBI MASKA, nie gradient w <defs>: gradient trzeba nazwać
   atrybutem `id`, a identyfikatory w szablonach SVG są u nas zakazane
   (patrz komentarz przy tej samej pętli w analytics.html). Maska działa na
   kształtach SVG tak samo jak na elementach HTML i nie potrzebuje nazwy.
   Wygaszenie kończy się na 92%, nie na 100%: ostatnie procenty i tak są
   niewidoczne, a wcześniejsze wygaszenie zostawia więcej powietrza nad osią.
   -webkit- z przodu dla starszych Safari, które nie znają jeszcze wersji
   bez przedrostka. */
.analytics-chart__area {
  fill: var(--seria, var(--color-chart-line)); opacity: .22; stroke: none;
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 92%);
          mask-image: linear-gradient(to bottom, #000 0%, transparent 92%);
}
/* Siatka CIĄGŁA, nie kreskowana. Kreskowanie miało sens przy JEDNEJ linii
   pomocniczej w połowie skali („to nie jest oś, tylko podpowiedź") — odkąd
   linii jest tyle, ile wartości na osi, i każda niesie podpis, kreskowanie
   robiło z nich szum. W projekcie dashboardu siatka też jest ciągła. */
.analytics-chart__grid {
  stroke: var(--color-border); stroke-width: 1;
  vector-effect: non-scaling-stroke;
  /* Linie POŚREDNIE są ledwie widoczne, bo mają tylko pomagać odczytać
     wysokość — pełną krechę widać na rysunku mocniej niż krzywą, która przez
     nią przechodzi. */
  opacity: .55;
}
/* Podstawa wykresu (wartość 0) zostaje pełna: na niej stoi wypełnienie i to
   ona jest linią odniesienia dla wszystkich serii. Modyfikator z szablonu,
   nie `:last-of-type` — powód przy tej samej pętli w analytics.html. */
.analytics-chart__grid--base { opacity: 1; }
/* OŚ WARTOŚCI — kolumna liczb tuż za prawą krawędzią rysunku, każda na
   wysokości swojej linii siatki (`top` w procentach przychodzi z Go).
   translateY(-50%) środkuje podpis NA linii, a nie pod nią. */
.analytics-chart__yaxis {
  position: absolute; top: 0; bottom: 0; right: -44px; width: 44px;
  pointer-events: none;
}
.analytics-chart__yaxis span {
  position: absolute; left: 10px; transform: translateY(-50%);
  font-size: 12px; line-height: 16px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
/* OŚ CZASU — pas pod rysunkiem; każdy podpis stoi procentem szerokości
   dokładnie pod swoim punktem. Skrajne dwa są PRZYPIĘTE do krawędzi zamiast
   środkowane: wyśrodkowany podpis pierwszego punktu wystawałby połową poza
   lewą krawędź karty, a ostatniego poza prawą. */
.analytics-chart__xaxis { position: relative; height: 16px; margin-top: var(--space-3); }
.analytics-chart__xaxis span {
  position: absolute; transform: translateX(-50%); white-space: nowrap;
  font-size: 12px; line-height: 16px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
.analytics-chart__xaxis span:first-child { transform: none; }
.analytics-chart__xaxis span:last-child { transform: translateX(-100%); }
/* Kropka: białe wnętrze i obrys w akcencie — czytelna także wtedy, gdy siedzi
   na wypełnieniu pod linią. */
.analytics-chart__dot {
  /* Obrys 1,75 px zamiast 2 przy promieniu 3 px zamiast 3,5: przy trzech
     seriach na jednym rysunku kropki zaczęły ważyć więcej niż linie, które
     opisują — a to linia niesie kształt. */
  fill: var(--color-surface); stroke: var(--seria, var(--color-chart-line)); stroke-width: 1.75;
  vector-effect: non-scaling-stroke; transition: r .12s ease-out;
}
/* Dzień bez ukończeń — mniejszy i wyblakły. To on rozwiązuje „grzebień":
   trzydzieści równych kropek na płaskim odcinku krzyczało głośniej niż dwa
   dni, w których coś się faktycznie stało. */
.analytics-chart__dot--zero { stroke: var(--color-border); }
/* Kropka pod kursorem: pełny akcent i większy promień. Atrybut `r` nadpisany
   z CSS (jest animowalny jako właściwość geometryczna), więc skrypt nie musi
   dotykać geometrii — przestawia wyłącznie klasę. */
.analytics-chart__dot.is-active { r: 5.5; fill: var(--seria, var(--color-chart-line)); stroke: var(--color-surface); }
/* Pola trafienia: przezroczyste, na całą wysokość, szerokie na cały dzień —
   patrz web.analyticsDay. pointer-events: all jest KONIECZNE: element z
   fill="none"/przezroczystym wypełnieniem domyślnie nie łapie zdarzeń. */
.analytics-chart__hit { fill: transparent; pointer-events: all; cursor: crosshair; }
/* Pionowa nitka pod kursorem (wstawiana skryptem). */
.analytics-chart__cursor {
  stroke: var(--color-fg-muted); stroke-width: 1; stroke-dasharray: 2 3;
  vector-effect: non-scaling-stroke; pointer-events: none;
}
/* Podpowiedź: HTML, nie <text> w SVG (skalowałby się razem z wykresem).
   translate(0,-100%) z odstępem stawia ją NAD kropką, a pointer-events: none
   pilnuje, żeby sama nie przechwytywała kursora i nie migała. */
.analytics-chart__tip {
  position: absolute; z-index: 1; pointer-events: none;
  transform: translateY(calc(-100% - 12px));
  display: flex; flex-direction: column; gap: 2px;
  padding: 6px 10px; border-radius: var(--radius-sm);
  background: var(--color-fg); color: var(--color-bg);
  font-size: 12px; line-height: 16px; white-space: nowrap;
  box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
}
.analytics-chart__tip-day { opacity: .7; font-variant-numeric: tabular-nums; }
.analytics-chart__tip-value { font-weight: 600; }
/* Wiersze wartości w podpowiedzi (jeden na serię, wstawia je skrypt).
   Kropka koloru przed nazwą wiąże wiersz z linią na rysunku; NAZWA I LICZBA
   są zwykłym tekstem podpowiedzi, nie tekstem w kolorze serii — kolorowy napis
   na ciemnym tle podpowiedzi albo gaśnie, albo krzyczy, a tożsamość niesie
   już kropka obok. */
.analytics-chart__tip-values { display: flex; flex-direction: column; gap: 2px; }
.analytics-chart__tip-row { display: flex; align-items: center; gap: var(--space-3); }
.analytics-chart__tip-row::before {
  content: ""; width: 8px; height: 8px; border-radius: 999px; flex: none;
  background: var(--seria, var(--color-chart-line));
}
.analytics-chart__tip-row--a { --seria: var(--color-chart-a); }
.analytics-chart__tip-row--b { --seria: var(--color-chart-b); }
.analytics-chart__tip-row--c { --seria: var(--color-chart-c); }
.analytics-chart__tip-name { opacity: .7; }
.analytics-chart__tip-num { font-weight: 600; font-variant-numeric: tabular-nums; margin-left: auto; }

/* Rozwijany przypis pod paskiem podsumowania: jedno zdanie widoczne, punkty
   pod <details>. Drobne i wyblakłe, bo to zastrzeżenie do liczb, nie treść
   ekranu — patrz komentarz w sesje.html po powód, dla którego długi akapit
   został pocięty, a nie usunięty. */
.session-summary__caveat { padding: 0 18px 16px; }
.session-summary__caveat > summary {
  cursor: pointer; color: var(--color-fg-muted);
  font-size: var(--fs-sm); line-height: var(--lh-sm);
}
.session-summary__caveat > summary:hover { color: var(--color-fg); }
.session-summary__caveat ul {
  margin: var(--space-5) 0 0; padding-left: 18px;
  color: var(--color-fg-muted); font-size: var(--fs-sm); line-height: var(--lh-md);
}
.session-summary__caveat li + li { margin-top: var(--space-2); }

/* LEGENDA pod wykresem — zastąpiła pas „pierwsza data | szczyt | ostatnia
   data". Dwie ze starych trzech informacji przeniosła na siebie oś czasu
   (daty stoją dziś pod swoimi punktami, a nie w rogach), więc zostawał
   powtórzony rząd tych samych dat. Zostaje szczyt okna, a na jego miejscu
   po lewej staje klucz koloru — tak jak w projekcie dashboardu, gdzie pod
   wykresem jest „— Last 7 days". */
.analytics-chart__axis {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  /* flex-wrap dołożone razem z legendą wieloseryjną: trzy klucze plus zdanie
     o szczycie okna nie mieszczą się w jednym rzędzie na wąskiej karcie,
     a bez zawijania ostatni z nich byłby ściśnięty do kilku liter. */
  flex-wrap: wrap;
  margin-top: var(--space-5); font-size: 12px; color: var(--color-fg-muted);
}
/* Szczyt okna trzyma się PRAWEJ krawędzi także wtedy, gdy legenda zajęła całą
   linię i zepchnęła go do własnej: `justify-content: space-between` przy
   zawinięciu wyrównałoby go do lewej i wyglądałby na urwane zdanie pod
   legendą, a nie na podpis rysunku. */
.analytics-chart__axis > :last-child { margin-left: auto; }
.analytics-chart__key { display: inline-flex; align-items: center; gap: var(--space-3); }
/* Legenda wieloseryjna: klucze w rzędzie, łamane na wąskim ekranie. */
.analytics-chart__legend { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-6); min-width: 0; }
/* Kreska w kolorze linii — ten sam token, więc klucz nie może się rozjechać
   z rysunkiem przy zmianie koloru wykresu. */
.analytics-chart__key::before {
  content: ""; width: 18px; height: 2px; border-radius: 1px; flex: none;
  background: var(--seria, var(--color-chart-line));
}


/* Pasek postępu w tabeli: .progress niesie tło i promień, tu tylko szerokość
   i wyrównanie w rzędzie z procentem i ułamkiem. */
.analytics-progress { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
.analytics-progress__pct { min-width: 38px; font-variant-numeric: tabular-nums; }
.analytics-progress__track { width: 96px; flex: none; }

/* Gwiazdki wyniku — złote wypełnione, puste przygaszone. width:auto zdejmuje
   wyrównanie liczbowe (kolumna gwiazdek nie jest .usage-table__num). */
/* Ten sam odcien co .stars__row (orange-300 z CourseListingTable w humi) -
   dwie rozne zolcie na gwiazdki w jednej aplikacji byly przypadkiem. */
.analytics-stars { white-space: nowrap; color: #fdba74; }
.analytics-stars .star { width: 16px; height: 16px; display: inline-block; vertical-align: -3px; }
.analytics-stars .star--off { color: var(--color-border); }
/* Połowa wypełnienia gwiazdki „4,5". Przycięcie, a nie gradient z identyfikatorem
   (patrz komentarz przy star-half w analytics.html): partial powiela się raz na
   wiersz, więc każde <defs id="..."> byłoby powielonym identyfikatorem — 41 kopii
   na /analytics. Przycinamy WYPEŁNIENIE, nie cały <svg>: prawa połowa ma zostać
   widoczna jako kontur, inaczej połówka wyglądałaby na ułamaną. Ta sama zasada,
   co w .stars__star--half wyżej. */
.star__fill-half { clip-path: inset(0 50% 0 0); }
.analytics-stars--muted { color: var(--color-fg-muted); }
/* Liczbowa wartość oceny obok gwiazdek („★★★ 3,5") — w kolorze TREŚCI, nie
   w bursztynie gwiazdek: gwiazdki są miarą przybliżoną i mają swój kolor,
   a liczba jest daną tabeli i ma się czytać jak każda inna liczba w wierszu.
   Uzasadnienie, po co ona w ogóle jest — przy tej komórce w dashboard.html. */
.analytics-stars__value {
  margin-left: var(--space-3); color: var(--color-fg);
  font-variant-numeric: tabular-nums;
}

/* ---------- dashboard admina (/dashboard) ---------- */
/* Pasmo sekcji („GLOBAL", „STUDENTS" u humi) — drobny, przygaszony nagłówek
   NAD grupą kart, nie w karcie. Bez niego wykres i lista wdrożeń czytają się
   jak dwie niepowiązane karty, a to jest jedno pasmo „co się dzieje w firmie".
   Margines górny tylko przy sąsiedztwie, bo pierwsze pasmo stoi zaraz pod
   nagłówkiem strony, który ma własny odstęp. */
.dashboard-band {
  margin: 0 0 var(--space-4);
  font-size: 12px; font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; color: var(--color-fg-muted);
}
* + .dashboard-band { margin-top: var(--space-8); }

/* Pasmo GLOBAL: wykres szeroki, lista wdrożeń wąska — proporcja z humi
   (2fr/1fr). minmax(0, …) w pierwszej kolumnie jest KONIECZNE: bez niego
   `1fr` to `minmax(auto, 1fr)`, a wykres SVG z aspect-ratio ma własny rozmiar
   wewnętrzny, przez który kolumna odmawiałaby zwężenia się poniżej niego
   i cała siatka wystawałaby poza treść na wąskich ekranach.
   Poniżej 900 px jedna kolumna — dwie karty obok siebie na telefonie dałyby
   wykres szerokości kciuka. */
/* Karty ZOSTAJĄ wyrównane do wspólnej wysokości (domyślne `stretch`) — dwie
   karty obok siebie kończące się na różnej wysokości wyglądają jak niedokończony
   układ. Problemem nigdy nie było samo wyrównanie, tylko to, że karta wykresu
   nie miała CZYM wypełnić dociągniętej wysokości: wykres ma stałe proporcje,
   więc pod nim zostawało pół ekranu pustego tła. Odpowiedzią jest wyższy wykres
   (.dashboard-chartcard niżej) i limit wierszy listy (dashboardWdrozenieMax),
   czyli sprowadzenie obu kart do podobnej wysokości NATURALNEJ. */
.dashboard-grid { display: grid; gap: var(--space-6); grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr); }
@media (max-width: 900px) { .dashboard-grid { grid-template-columns: minmax(0, 1fr); } }

/* Karta wdrożeń jest WĄSKA (prawa kolumna), a niesie trzy kolumny: nazwisko,
   postęp i zmianę tygodniową. Pasek postępu w domyślnej szerokości (96 px,
   .analytics-progress__track) wypychał trzecią kolumnę poza kartę — zostawał
   poziomy pasek przewijania i ucięty nagłówek „W tym tygodniu". Węższy pasek
   i ciaśniejsze odstępy TYLKO tutaj: w pulpicie na /sessions ta sama tabela
   ma całą szerokość strony i nie ma czego ściskać. */
.dashboard-onboarding .analytics-progress { gap: 8px; }
.dashboard-onboarding .analytics-progress__track { width: 60px; }
.dashboard-onboarding .analytics-progress__pct { min-width: 34px; }

/* ---------- zakładka Reports kursu (zgłoszenia problemów z lekcją) ---------- */
/* Odznaka liczby otwartych zgłoszeń przy zakładce (Figma: licznik przy Reports).
   Kolor błędu, bo to rzeczy DO ZROBIENIA — nie neutralny licznik. */
.tabs__badge {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
  font-size: 11px; font-weight: 700; vertical-align: 1px;
  background: var(--color-error-bg); color: var(--color-error-fg);
}
/* Tabela zgłoszeń dziedziczy .usage-table; tu tylko to, czego tamta nie ma:
   komórka statusu z inline formularzem zmiany i komórka treści z podpisem
   zgłaszającego. */
.report-table td { vertical-align: top; }
.report-table__when { white-space: nowrap; color: var(--color-fg-muted); }
.report-table__status { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.report-table__status .field__control { width: auto; min-width: 130px; }
.report-table__message { min-width: 240px; overflow-wrap: anywhere; }
.report-table__reporter { display: block; margin-top: 4px; }
/* PANEL ZGŁOSZENIA PROBLEMU — okno nad kursem.
   ==========================================================================
   Stał tu przełącznik `.report-toggle`: <details> z formularzem w belce lekcji,
   któremu ta reguła ograniczała szerokość, „żeby textarea nie rozpychała
   nagłówka lekcji". To zdanie samo opisuje usterkę, którą naprawia ten blok —
   rozwijany panel był CZĘŚCIĄ nagłówka, więc rozpychał go w obie strony:
   wszerz i (co gorsza) wzwyż, spychając w dół okładkę, film i treść lekcji.

   Formularz siedzi teraz w <dialog class="modal zgloszenie"> (layout.html),
   czyli poza przepływem dokumentu i w top layer — cały wygląd okna niesie
   wspólne `.modal` wyżej w tym pliku. Zostaje więc dokładnie to, czego .modal
   nie ma: odstępy dwóch rzeczy swoistych dla tego okna. */
.zgloszenie__blad { margin: 0 0 var(--space-3); }
.zgloszenie__dziekujemy .alert { margin: 0; }
/* Wejście na ekranie po lekcji — osobny rząd pod przyciskami akcji, żeby nie
   stanęło w jednym szeregu z „Spróbuj ponownie" i „Wróć do kursu": to nie jest
   czynność, do której ten ekran namawia. */
.done-screen__report { margin-top: var(--space-5); }

/* POTWIERDZENIE ZGŁOSZENIA BEZ JS-u.
   Zwykły formularz nie umie nie przeładować strony, więc bez skryptu
   zgłoszenie wraca przekierowaniem na ?zgloszono=1. Ten pasek jest wtedy
   jedynym potwierdzeniem — i też jest PRZYPIĘTY DO OKNA, nie wstawiony
   w treść: alert w przepływie spychałby lekcję w dół dokładnie tak, jak robił
   to rozwijany panel, czyli naprawiałby jedną usterkę drugą.

   z-index 60: nad wszystkim, co ten arkusz stawia na warstwach (pasek robót
   20, belka lekcji i pasek postępu 30, menu wierszy 40, szuflada nawigacji 50)
   — ale POD samym oknem zgłoszenia, bo to jest top layer i warstw w ogóle nie
   liczy. Te same 60 ma baner podglądu kursu, i to jest bezpieczne: baner
   wyklucza panel (renderStatus nie daje .Zgloszenie w podglądzie), więc te dwa
   elementy nigdy nie są na ekranie razem — a gdyby kiedyś były, baner jest
   przyklejony do GÓRY, a pasek stoi u dołu.

   Znika sam po ~6 s animacją, bo bez JS-u nie ma go czym zamknąć. `visibility`
   obok `opacity`, nie sama przezroczystość: niewidzialny pasek nadal
   przechwytywałby kliknięcia w to miejsce ekranu. Bez `transform` w animacji —
   to ma zgasnąć, a nie odjechać, więc nie ma tu ruchu, którego ktoś czuły na
   animacje musiałby unikać. */
.zgloszenie-pasek {
  position: fixed; z-index: 60;
  left: 50%; transform: translateX(-50%);
  bottom: calc(var(--space-5) + env(safe-area-inset-bottom));
  width: max-content; max-width: calc(100vw - 32px);
  margin: 0; box-shadow: var(--shadow-lg);
  animation: zgloszenie-pasek-gasnie .4s ease 6s forwards;
}
@keyframes zgloszenie-pasek-gasnie { to { opacity: 0; visibility: hidden; } }

/* Uchwyt przeciągania kolejności kursów (migracja 0039) — te same reguły co
   przy wierszach lekcji, tylko na tabeli kursów. */
td.courses-table__grip-cell, th.courses-table__grip-cell { width: 28px; padding-right: 0; }
.courses-table tbody tr[data-course-id] { cursor: grab; }
.courses-table tbody tr[data-course-id]:hover .ex-row__grip { opacity: .9; }

/* ---------- lista wg humionboardera (port komponentu z referencji) ----------
   Antek: „weź kod HTML i CSS z humionboarder". Wartości niżej są przepisane
   1:1 z klas Tailwinda w repo referencyjnym
   (repos/main/humi-sales-onboarder: src/lib/components/courses/CourseListingTable.svelte
   plus prymitywy src/lib/components/ui/table-custom/*), żeby dało się je
   sprawdzić linijka po linijce:

     div.relative.w-full.overflow-auto        → .list-table-wrap
     table.w-full.caption-bottom.text-sm      → 14px/20px
     thead tr.border-b-8.border-black         → 8px kreska pod nagłówkiem
     th.h-12.px-4.align-middle.font-medium    → 48px wysokości, 16px boki, 500
     th.text-xs (nadpisanie w liście kursów)  → 12px
     tbody tr.h-16.border-b                   → wiersz 64px, kreska 1px
     tr.hover:bg-muted/50                     → podświetlenie wiersza
     td.p-4.align-middle                      → 16px dookoła
     miniatura h-14.w-14.rounded-sm           → 56px, promień 4px
                                                (--radius: .5rem, sm = radius-4px)
     komórka menu w-8.pr-2                    → 32px szerokości, 8px z prawej

   CO ŚWIADOMIE NIE JEST 1:1: kolory. W humi arkusz jest jasny-first
   (`border-black`, `--muted: 210 40% 96.1%`), a ciemny motyw dokłada osobne
   wartości pod klasą `.dark`. U nas ciemny jest równoprawny i to w nim
   pracuje Marathon, więc twarda czerń pod nagłówkiem po prostu zniknęłaby
   z ekranu. Kreski i tła idą więc przez NASZE tokeny — układ, rytm i
   wysokości zostają humi, kolor zostaje nasz. */
/* Selektory łączone: .courses-table (kursy, pliki bazy wiedzy, katalog nauki)
   to ta sama tabela co .list-table (działy) — jedna implementacja, jeden
   rytm. Wcześniej miały osobne, rozjeżdżające się zestawy reguł. */
.list-table,
.courses-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); line-height: var(--lh-md); }
/* Kreska 1 px w kolorze krawędzi — NIE `border-b-8 border-black`.
   Klasa `border-b-8 border-black` faktycznie stoi w CourseListingTable.svelte
   i dlatego przez chwilę był tu 8-pikselowy czarny pas. Zdejmuję go na
   podstawie DWÓCH niezależnych obserwacji działającej aplikacji: Eryk
   (22.08, ze zrzutem Bazy wiedzy: „widzisz że jest taki granatowy ciemny
   pasek, a w humi takiego nie ma") i wcześniej Antek („po co ten pasek").
   Ze źródeł nie da się tego rozstrzygnąć: paczka referencyjna nie zawiera
   `$lib/utils`, więc nie widać, czy `cn()` to twMerge (wtedy `border-b-8`
   wygrywa z bazowym `border-b`), czy zwykła konkatenacja (wtedy o wyniku
   decyduje kolejność klas w wygenerowanym arkuszu Tailwinda). Obserwacja
   z żywej aplikacji bije wnioskowanie z niekompletnego źródła. */
.list-table thead tr,
.courses-table thead tr { border-bottom: 1px solid var(--color-border); }
.list-table th,
.courses-table th {
  height: 48px; padding: 0 16px; text-align: left; vertical-align: middle;
  font-weight: 500; font-size: 12px; line-height: 16px; color: var(--color-fg);
  white-space: nowrap;
}
.list-table tbody tr,
.courses-table tbody tr {
  height: 64px; border-bottom: 1px solid var(--color-border);
  transition: background-color .15s;
}
.list-table tbody tr:last-child,
.courses-table tbody tr:last-child { border-bottom: 0; }
.list-table tbody tr:hover,
.courses-table tbody tr:hover { background: var(--color-surface-muted); }
.list-table td,
.courses-table td { padding: var(--space-6); vertical-align: middle; }
/* max-w-32 z humi: kolumny tekstowe nie rozjeżdżają się na długiej treści. */
/* Kwalifikacja typem (td.) jest KONIECZNA, nie kosmetyczna: `.list-table td`
   ma swoistość (0,1,1), a sama klasa (0,1,0) — bez `td.` padding poniżej
   nigdy nie wchodził i miniatura siedziała w komórce z pełnymi 16 px dookoła.
   Ten sam błąd miały wszystkie modyfikatory kolumn w tabelach aplikacji. */
/* Kolumna UCHWYTU przeciągania — ta sama szerokość i ten sam brak prawego
   paddingu co w liście kursów (.courses-table__grip-cell): uchwyt ma stać tuż
   przy gnieździe ikony, nie w osobnym, pustym pasie. Kwalifikacja typem (td./
   th.) jest konieczna z tego samego powodu, co przy miniaturze niżej. */
td.list-table__grip-cell, th.list-table__grip-cell { width: 28px; padding: 0 0 0 var(--space-5); }
td.list-table__thumb-cell, th.list-table__thumb-cell { width: 56px; padding: 0 0 0 var(--space-6); }
/* ROZMIAR KWADRATU — sprawdzone w kodzie referencji, nie na oko (pytanie
   Antka: „ile takie ikonki mają w kursie?"). Humi ma DWA różne rozmiary
   w tym samym gnieździe listy:
     • kurs ZE zdjęciem  → <SmartImage class="max-h-14 max-w-14 rounded-sm">
       czyli 56 px, promień 4 px;
     • kurs BEZ zdjęcia → ten sam SmartImage renderuje ThumbnailPlaceholder
       z size="small" (ui/thumbnail-placeholder.svelte), a to jest
       `max-h-10 max-w-10 text-lg` + `rounded-lg`, czyli 40 px, promień 8 px
       (--radius: .5rem) i pismo 18 px, na tle z palety *-400 z białą literą.
   Dział NIGDY nie ma zdjęcia — jest dokładnie tym drugim przypadkiem, więc
   bierzemy 40 px, a nie 56 px. Wcześniejsze 56 px brało wymiar ze slotu na
   FOTOGRAFIĘ i dlatego litera przebijała nazwę działu. */
/* Nadpisanie zniknęło: .avatar-sq ma dziś dokładnie te wymiary w bazie
   (40 px, rounded-lg, text-lg) — bo to jest rozmiar `small` z humi, a lista
   działów jest właśnie tym przypadkiem. Klasa zostaje w szablonie jako
   nazwane gniazdo kolumny, ale nie musi już niczego przestawiać. */
.list-table__main { font-weight: 500; color: var(--color-fg); }
.list-table__sub { color: var(--color-fg-muted); font-size: 12px; line-height: 16px; margin-top: 2px; }
.list-table__num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.list-table__muted { color: var(--color-fg-muted); white-space: nowrap; }
/* w-8 pr-2 — kolumna menu „⋯" trzyma się prawej krawędzi tabeli. */
td.list-table__actions, th.list-table__actions { width: 32px; padding-right: var(--space-3); text-align: right; }

/* Kolumna „Postęp wdrożenia" na /team. Pasek to ten sam .progress co
   w analityce i bazie wiedzy — tutaj tylko układ obok liczby, żeby procent
   nie skakał od wiersza do wiersza (tabular-nums + stała szerokość).
   Reguła stoi TUTAJ, a nie inline w szablonie, bo to metryka listy, tak samo
   jak .list-table__num wyżej. */
.list-table__progress { display: flex; align-items: center; gap: 8px; min-width: 140px; }
.list-table__progress .progress { flex: 1; }
.list-table__progress-pct { font-variant-numeric: tabular-nums; min-width: 38px; text-align: right; }

/* WYNIK SESJI — dwudzielna plakietka „punkty | procent". Zgłoszenie
   (Eryk, 23.08): procent pod plakietką „wygląda bardzo słabo, nie jest
   wyśrodkowane ani nic". Był wtedy zwykłym .list-table__sub, czyli małym
   tekstem przyklejonym do LEWEJ krawędzi komórki, podczas gdy plakietka nad
   nim miała własną szerokość zależną od treści — dwa pola, dwie osie, nic się
   nie zgadzało ani w wierszu, ani między wierszami. Tu oba pola są jednym
   obiektem ułożonym w poziomie, tak jak cała ta tabela.

   Stałe szerokości segmentów są tu ISTOTNE, nie kosmetyczne: tabular-nums
   równa szerokość CYFR, ale nie ich liczbę, więc „6/9" i „11/11" różnią się
   o dwa znaki i bez min-width kreska działowa skakałaby wiersz po wierszu.
   Punkty wyśrodkowane w swoim segmencie (są tekstem „ile z ilu"), procent do
   prawej (jest liczbą i ma się równać w kolumnie ze znakiem % w jednej linii
   pionowej). Segment procentu ma ciemniejsze tło — to on decyduje o progu
   zaliczenia, więc czyta się pierwszy. */
.score-chip {
  display: inline-flex; align-items: stretch;
  border: 1px solid var(--color-border); border-radius: 999px;
  background: var(--color-surface); overflow: hidden;
  font-size: 12px; line-height: 20px; font-weight: 600;
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.score-chip__pts, .score-chip__pct { padding: 1px var(--space-3); }
.score-chip__pts { min-width: 48px; text-align: center; color: var(--color-fg); }
.score-chip__pct {
  min-width: 44px; text-align: right; color: var(--color-fg-muted);
  background: var(--color-bg); border-left: 1px solid var(--color-border);
}

/* Działy w wierszu zespołu: człowiek może być w kilku, więc plakietki muszą
   się zawijać zamiast rozpychać kolumnę. */
.list-table__chips { display: flex; flex-wrap: wrap; gap: 4px; }

/* Avatar OSOBY jest okrągły, avatar rzeczy (dział, kurs) kwadratowy — ta sama
   kostka i ta sama paleta z avatarClass, inny promień. Rozróżnienie jest
   zwyczajowe i niesie informację: po kształcie widać, czy wiersz to człowiek,
   zanim przeczyta się treść. */
.avatar-sq--round { border-radius: 999px; }

/* Stan konta w wierszu: kropka + słowo, nie plakietka. Plakietka z tłem to
   u nas sygnał „coś się dzieje / coś wymaga uwagi" (status pliku, kursu),
   a stan konta jest po prostu jedną z kolumn danych — pełne tło przy każdym
   wierszu przeciągałoby na siebie wzrok mocniej niż postęp obok. */
.list-table__status { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; white-space: nowrap; }
.list-table__status::before { content: ""; width: 6px; height: 6px; border-radius: 999px; background: currentColor; flex: none; }
.list-table__status--ok { color: var(--color-ok-fg); }
.list-table__status--off { color: var(--color-warn-fg); }

/* Kolumna zaznaczenia (multi-select) — pierwsza kolumna listy sesji. Wąska
   i z mniejszym paddingiem od prawej, bo kwadracik 20x20 nie potrzebuje
   pełnych 16 px z obu stron; kwalifikacja typem (td./th.) KONIECZNA, bo
   `.list-table td` ma swoistość (0,1,1) i sama klasa by przegrała — ta sama
   pułapka, którą opisuje komentarz przy .list-table__thumb-cell wyżej. */
td.list-table__check, th.list-table__check { width: 36px; padding-right: var(--space-3); }
.list-table__check input[type="checkbox"] { width: 18px; height: 18px; display: block; cursor: pointer; }

/* Sortowalny nagłówek kolumny. Link, nie przycisk — sortowanie siedzi
   w adresie (?sort=), więc da się je zalinkować i wrócić do niego „wstecz".
   Kolor dziedziczony z <th>, żeby nagłówek nie wyglądał jak odnośnik w treści;
   podkreślenie dopiero na hover, tak jak w tabelach humi. */
.list-table__sortable a { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 4px; }
.list-table__sortable a:hover { text-decoration: underline; }
.list-table__sort-arrow { color: var(--color-fg-muted); font-size: 10px; line-height: 1; }

/* ---------- Sesje lekcji (/sessions) ----------
   Układ z humi „Lesson Sessions": tytuł i trzy przyciski w nagłówku karty,
   panel filtrów rozwijany spod nagłówka, tabela, przypis i stronicowanie
   w stopce. Reguły stoją tutaj, bo wszystkie dotyczą JEDNEJ karty tego
   jednego ekranu — .card__header--actions jest jedynym wyjątkiem, bo to
   modyfikator wspólnego komponentu i przyda się każdemu następnemu ekranowi
   z akcjami przy tytule karty. */

/* Nagłówek karty z akcjami przy tytule. Bazowy .card__header to sam padding,
   więc tytuł i przyciski leżałyby jeden pod drugim. flex-wrap, bo trzy
   przyciski obok długiego tytułu nie mieszczą się na telefonie. */
.card__header--actions {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-6); flex-wrap: wrap;
}

/* Przełącznik panelu filtrów — checkbox-hack, ten sam wzorzec co
   .lesson-editor__delete-input w edytorze kursu: sam checkbox zdjęty
   z ekranu, ale DALEJ fokusowalny, a jego stan CSS przekłada na rodzeństwo.
   position: absolute + opacity zamiast display: none / visibility: hidden —
   te dwa wyjmują element z kolejności tabulacji i przełącznik przestałby
   być osiągalny z klawiatury.

   Pointer-events: none, bo kwadracik siedzi w lewym górnym rogu karty
   i bez tego przechwytywałby kliknięcia w tytuł. */
.sesje-filters__toggle {
  position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
/* Panel schowany domyślnie; :checked go odsłania. Selektor rodzeństwa (~)
   wymaga, żeby oba elementy były dziećmi TEJ SAMEJ karty — patrz komentarz
   przy przełączniku w sesje.html. */
.sesje-filters { display: none; }
.sesje-filters__toggle:checked ~ .sesje-filters { display: block; }
/* Pierścień fokusu przenosimy z niewidocznego checkboxa na jego etykietę —
   inaczej tabulacja przez nagłówek karty nie zostawiałaby żadnego śladu.
   Ta sama liczba i ten sam kolor co .btn:focus-visible, bo etykieta wygląda
   dokładnie jak przycisk obok niej. */
.sesje-filters__toggle:focus-visible ~ .card__header .sesje-filters__btn {
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
/* Etykieta-przycisk pokazuje stan: rozwinięty panel = wciśnięty guzik.
   Bez tego „Filtry" wygląda identycznie przy otwartym i zamkniętym panelu. */
.sesje-filters__toggle:checked ~ .card__header .sesje-filters__btn {
  background: var(--color-surface-muted);
}
/* Presety siedzą w tym samym panelu co pola, tylko oddzielone kreską —
   to inny rodzaj akcji (gotowy raport, nie zawężenie listy). */
.sesje-presets { border-top: 1px solid var(--color-border); padding-top: var(--space-6); }
/* …a od 24.08.2026 stoją TUŻ POD rzędem „Raporty" w formularzu, więc kreska
   byłaby drugą kreską pod rząd. */
.sesje-presets--tight { border-top: 0; padding-top: 0; }

/* ---------- panel filtrów logu sesji: siatka i akcje ----------
   Do 24.08.2026 obie siatki panelu były wpisane INLINE w szablon (grid
   1fr 1fr oraz auto-fit/12rem), poza systemem designu i bez żadnego media
   query — na telefonie dwie kolumny nie zwijały się do jednej. */
.sesje-filters__grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-6); margin-top: var(--space-5);
}
.sesje-filters__grid--narrow { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--space-5); margin-top: var(--space-6); }
@media (max-width: 767px) {
  .sesje-filters__grid { grid-template-columns: 1fr; }
}
/* Adres pod nazwą kursanta — druga, przygaszona linijka wiersza wyboru. */
.sesje-filters__sub { display: block; font-size: 12px; color: var(--color-fg-muted); }
/* Licznik aktywnych zawężeń w etykiecie „Filtry" i chevron mówiący, że to
   się rozwija. Chevron obraca się przy otwartym panelu — ten sam gest co
   .disclosure__chevron, tylko na etykiecie-przycisku. */
.sesje-filters__count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
  background: var(--color-fg); color: var(--color-surface);
  font-size: 11px; font-weight: 700;
}
.sesje-filters__chevron { transition: transform .15s; }
.sesje-filters__toggle:checked ~ .card__header .sesje-filters__chevron { transform: rotate(180deg); }
/* Dolny rząd panelu: podpowiedź po lewej, „Zastosuj" po prawej. */
.sesje-filters__actions {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-5);
  flex-wrap: wrap; margin-top: var(--space-6);
}
/* Raporty — inny rodzaj akcji niż zawężanie, więc pod linią i z podpisem. */
.sesje-filters__reports {
  display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap;
  margin-top: var(--space-6); padding-top: var(--space-5);
  border-top: 1px solid var(--color-border);
}

/* ---------- plakietki aktywnych zawężeń (nad tabelą) ----------
   Widoczne ZAWSZE, także przy zwiniętym panelu i na zakładce „Sesje" kursu,
   która panelu nie ma wcale — to jedyne miejsce, w którym widać, że lista
   jest zawężona, i jedyna droga do zdjęcia jednej osi bez rozkładania
   wszystkiego. */
.sesje-chips {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  padding-top: var(--space-5); padding-bottom: 0;
}
.sesje-chips__label { font-size: 13px; color: var(--color-fg-muted); }
.sesje-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px; border-radius: 999px;
  border: 1px solid var(--color-border); background: var(--color-surface);
  color: var(--color-fg); font-size: 13px; font-weight: 600; text-decoration: none;
}
.sesje-chip:hover { background: var(--color-surface-muted); }
.sesje-chip__x { color: var(--color-fg-muted); font-size: 15px; line-height: 1; }
.sesje-chip:hover .sesje-chip__x { color: var(--color-danger); }
.sesje-chips__clear { font-size: 13px; color: var(--color-fg-muted); }

/* Pasek akcji zbiorczej nad tabelą: pojawia się (js/bulk-select.js zdejmuje
   `hidden`), gdy zaznaczony jest choć jeden wiersz. Tło wyróżnione, bo to
   stan przejściowy — coś jest zaznaczone i czeka na decyzję. */
.bulk-bar {
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
  /* Górny margines JEST potrzebny, mimo że nagłówek karty jest wyżej:
     .card__header ma `padding: 24px 24px 0`, czyli ZERO od dołu (żeby zwykła
     treść karty przyklejała się do tytułu). Bez tego pasek dotykał rzędu
     przycisków „Filtry / Reset / Zastosuj" i czytał się jak ich część. */
  margin: var(--space-7) var(--space-7) var(--space-6);
  padding: var(--space-5) var(--space-6);
  background: var(--color-surface-muted);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
}
.bulk-bar__count { font-weight: 600; margin-right: auto; }

/* Szerokości kolumn listy sesji. Jedenaście kolumn to więcej, niż mieści się
   na typowym ekranie, więc dwie skrajne potrzeby dostają jawne liczby zamiast
   tego, co wyliczy automat:

     • kolumna „Logi" niesie ikonę 16 px, a automat dawał jej 64 px, bo tyle
       zajmuje sam NAGŁÓWEK — tu wystarczy tyle, co na ikonę z oddechem;
     • tytuł lekcji jest najważniejszym tekstem wiersza i bez dolnej granicy
       łamał się na trzy linijki, podczas gdy obok zostawało kilkadziesiąt
       pikseli wolnego miejsca (automat rozdaje szerokość po najdłuższym
       nierozrywalnym słowie, a nie po ważności kolumny).

   Przy węższym oknie tabela normalnie przewija się w poziomie — kontener ma
   u-scroll-x razem z tabindex i role="region", jak każda szeroka tabela w tej
   aplikacji. */
td.sesje-table__logs, th.sesje-table__logs { width: 24px; padding-left: var(--space-3); padding-right: var(--space-3); }
/* WSZYSTKO W JEDNEJ LINII. Zgłoszenie (Eryk, 22.08): „dane w kolumnie kursant
   strasznie się rozjeżdżają i skalują do dołu, a wszystko powinno iść w bok".
   Poprzednia wersja pozwalała łamać adres w dowolnym miejscu
   (`overflow-wrap: anywhere`), żeby kolumna była węższa — i owszem była, ale
   kosztem czterowierszowej komórki („test.kur / sant@ / marath / on.local"),
   która rozpychała CAŁY wiersz w pionie. Tabela z jedenastoma kolumnami po
   prostu nie zmieści się w oknie i nie ma udawać, że mieści: nie łamiemy
   niczego w dół. Przewijanie w bok zostaje jako ostatnia deska ratunku dla
   naprawdę wąskich okien (patrz .sesje-table-scroll niżej), ale na typowym
   ekranie ma nie być potrzebne — stąd mniejsze pismo, ciaśniejsze komórki
   i łamane nagłówki poniżej.

   Kontener niesie już u-scroll-x + tabindex + role="region" (sesje.html), więc
   przewijanie jest osiągalne z klawiatury i opisane dla czytnika ekranu. */
.sesje-table td { white-space: nowrap; }
/* …ale NAGŁÓWKI wolno łamać, i to jest cała sztuczka, którą tabela mieści się
   w oknie (zgłoszenie Eryka, 23.08: „nie chcę, żeby ten widok trzeba było
   przesuwać w bok, żeby zobaczyć resztę informacji"). Automatyczny układ
   tabeli ściska kolumny w stronę ich min-content, kiedy suma max-content nie
   mieści się w dostępnej szerokości. Komórki danych mają nowrap, więc ich
   min-content = pełna treść i nie da się z nich nic urwać — jedyny zapas
   siedzi w nagłówkach. „Czas trwania" złamane na dwie linijki oddaje ~40 px,
   a robi to TYLKO wtedy, gdy trzeba: przy szerokim oknie nagłówek zostaje
   w jednej linii. Wysokość wiersza nagłówka rośnie raz, wysokość wierszy
   danych — nigdy, więc zakaz „skalowania w dół" z poprzedniego zgłoszenia
   obowiązuje dalej tam, gdzie o niego szło (w danych). */
.sesje-table th { white-space: normal; }
/* Ciaśniejsze marginesy komórek niż w bazowej .list-table (8 px zamiast 16).
   Jedenaście kolumn razy 16 px zaoszczędzonych to 176 px — bez tego ostatnia
   kolumna („Zakończono") była na typowym ekranie na stałe przycięta i wyglądało
   to na niedokończony ekran, choć obszar przewijania działał poprawnie.

   To najtańszy z rozważanych sposobów odzyskania szerokości: alternatywy to
   skracanie nagłówków („Czas" zamiast „Czas trwania") albo zdjęcie adresu
   spod nazwy kursanta — jedno pogarsza opis kolumny, drugie zabiera
   informację. Cztery piksele oddechu w komórce nie zabierają nic. */
.sesje-table th, .sesje-table td { padding-left: var(--space-3); padding-right: var(--space-3); }
/* Pismo o stopień mniejsze niż w pozostałych listach — świadoma zamiana
   („możesz pomniejszyć lekko informacje czy coś w tym stylu", 23.08).
   Jedenaście kolumn na ekranie 1470 px daje ~1120 px na tabelę; przy 14 px
   sama treść potrzebowała ~1290 px. 13 px w danych i 11 px w drugiej linijce
   (adres, godzina) oddaje resztę potrzebnych pikseli i nie zjada czytelności:
   to wciąż większe pismo niż plakietki, które w tej samej tabeli stoją. */
.sesje-table { font-size: 13px; }
.sesje-table .list-table__sub { font-size: 11px; line-height: 15px; }
/* Nagłówek nie musi być większy od danych, które opisuje — a w połowie kolumn
   to właśnie NAGŁÓWEK wyznacza szerokość, nie treść („Zakończono" jest
   szersze od „2026-08-22", „Ukończona" od „tak"). Jednowyrazowych nagłówków
   nie da się złamać, więc jedyne, co je ścieśnia, to stopień pisma. */
.sesje-table th { font-size: 11px; }

/* …ale przewijać ma się KONTENER, nie strona. `contain: inline-size` odcina
   szerokość tego bloku od szerokości jego zawartości — bez tego tabela
   rozpychała całą kolumnę treści na ~56 px poziomego przewijania CAŁEJ strony.
   Mechanizm, wart zapisania, bo nieoczywisty: .content to `flex: 1`
   z domyślnym `min-width: auto`, więc jego minimalna szerokość to min-content
   zawartości; `overflow-x: auto` tworzy owszem obszar przewijania, ale NIE
   zmniejsza intrinsic size bloku, więc szerokość tabeli propagowała się w górę
   aż do elementu flex. Containment w osi inline zatrzymuje tę propagację
   dokładnie tutaj — bez ruszania globalnego .content, czyli bez wpływu na
   pozostałe ekrany. Wysokość dalej idzie za treścią (to containment TYLKO
   w osi poziomej). */
.sesje-table-scroll { contain: inline-size; }

/* PEŁNA SZEROKOŚĆ KARTY. .card__body daje 24 px z każdej strony — na tabelę
   z jedenastoma kolumnami to 48 px, których nie ma skąd wziąć, a rząd i tak
   ma własne marginesy w komórkach. Wiersze idą więc od krawędzi do krawędzi
   karty (przy okazji podświetlenie wiersza obejmuje całą szerokość, tak jak
   w tabelach humi), a pierwsza komórka dostaje pełne 24 px, żeby kolumna
   „Kursant" stała w jednej linii z nagłówkiem karty i paskiem zaznaczenia
   nad nią. */
.sesje-table-scroll { padding-left: 0; padding-right: 0; }
.sesje-table th:first-child, .sesje-table td:first-child { padding-left: var(--space-7); }
.sesje-table th:last-child, .sesje-table td:last-child { padding-right: var(--space-6); }

/* GRANICE SZEROKOŚCI KOLUMN TEKSTOWYCH. Bez nich szerokość tabeli zależy od
   najdłuższego adresu i najdłuższego tytułu lekcji w bazie — czyli od danych,
   a nie od projektu: jedno konto z długim e-mailem wypychało całą tabelę za
   krawędź okna. Z granicami suma kolumn jest z góry znana i mieści się na
   ekranie, a nadmiar jest ucinany W POZIOMIE (wielokropkiem), nie zawijany
   w dół — pełna treść zostaje w atrybucie title i w widoku sesji.
   Ucinane są tylko dwie kolumny opisowe; żadna liczba, data ani plakietka
   nigdy nie jest skracana. */
.sesje-table__user a, .sesje-table__user .list-table__sub {
  display: block; max-width: 112px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Ucinanie MUSI siedzieć na elemencie w środku komórki, nie na samej komórce:
   max-width na <td> jest w automatycznym układzie tabeli ignorowane (tak mówi
   specyfikacja), a wymuszenie display:block na komórce wyjmuje ją z układu
   tabeli i rozwala wiersz. Stąd <span class="sesje-table__clip"> w szablonie
   wokół tytułu kursu — tytuł lekcji i nazwa kursanta mają już swoje własne
   elementy (.list-table__main, <a>, .list-table__sub). */
.sesje-table__clip { display: block; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
.sesje-table .list-table__main { display: block; max-width: 205px; overflow: hidden; text-overflow: ellipsis; }

/* Plakietki w tej tabeli o stopień mniejsze — ta sama decyzja co pismo
   komórek wyżej, ta sama przyczyna (kolumna „Status" niosła 12 px pisma
   w 20 px wysokiej plakietce i zabierała 110 px na słowo „niezaliczone"). */
.sesje-table .badge { font-size: 11px; padding: 2px var(--space-3); }
.sesje-table .score-chip { font-size: 11px; }
.sesje-table .score-chip__pts, .sesje-table .score-chip__pct { padding: 1px var(--space-3); }
.sesje-table .score-chip__pts { min-width: 40px; }
.sesje-table .score-chip__pct { min-width: 36px; }

/* Daty o stopień mniejsze od reszty komórek: „2026-08-22" to dziesięć znaków
   w kolumnie, w której nie ma nic innego, a dwie takie kolumny stoją obok
   siebie na końcu wiersza. 12 px zamiast 13 px oddaje ~16 px na tabelę i nic
   nie kosztuje — data i tak stoi w drugiej linijce z godziną, czyli w bloku,
   który czyta się jako jedna wartość. */
.sesje-table__date { font-size: 12px; }

/* Klikalny wiersz (js/bulk-select.js dokłada nasłuch na całej tabeli).
   Kursor tylko na wierszach danych — nagłówek nie prowadzi nigdzie. */
.sesje-table tbody tr { cursor: pointer; }
/* Komórka zaznaczenia i zagnieżdżone odnośniki mają swoje własne akcje —
   kursor musi to zapowiadać, inaczej cały wiersz „obiecuje" przejście
   do sesji także tam, gdzie klik zrobi co innego. */
.sesje-table tbody tr .list-table__check { cursor: default; }

/* Zapasowy przycisk raportu pod tabelą — jedyna droga do raportu z
   zaznaczonych wierszy przy wyłączonym JS. Skrypt go ukrywa, gdy działa
   (wtedy tę rolę pełni pasek nad tabelą). */
.sesje-bulk-fallback { padding-top: 0; }

/* Stopka karty: przypis po lewej, licznik i stronicowanie po prawej.
   Nadpisuje align-items z .card__footer, bo przy zawinięciu na telefonie
   obie części mają trzymać się lewej krawędzi, nie środka. */
.sesje-footer {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-6); flex-wrap: wrap;
  border-top: 1px solid var(--color-border); padding-top: var(--space-6);
}
.sesje-footer__note { margin: 0; color: var(--color-fg-muted); font-size: var(--fs-sm); line-height: var(--lh-sm); }
.sesje-footer__pager { display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }

/* ---------- edytor osi czasu wideo (editor.html) ----------
   Te reguły były NAPISANE, ale nieużywane: siedziały w internal/web/static/
   style.css — starym, klasowo-bezklasowym arkuszu marki, którego od czasu
   przejścia na app.css NIE ŁADUJE żadny szablon (jedyne wystąpienie nazwy
   w repo to komentarz w teście). Efekt: oś czasu renderowała się bez
   jakiegokolwiek stylu, a miniatury klipów szły w pełnym rozmiarze obrazu —
   pasek zamieniał się w słup zdjęć na kilkaset pikseli wysokości i cały
   ekran wyglądał na zepsuty (zgłoszone 16.08.2026).

   Przeniesione tutaj z jedną istotną zmianą: stare reguły wymuszały własny
   ciemny motyw (`body:has(#editor-data) { background: #0a1120 }`), bo
   powstały, gdy reszta SSR była jasna. app.css jest dziś motywowany
   tokenami w obu wariantach, więc nadpisywanie tła łamałoby jasny motyw —
   zamiast tego wszystko poniżej stoi na tokenach i działa w obu. */

/* ---------- blat montażowy (editor.html) ----------
   Podgląd, pasek transportu i oś czasu jako JEDNA ciemna powierzchnia, jak
   w Remotion Studio i każdym innym programie do montażu. Wcześniej były to
   trzy osobne kafelki z nagłówkami i akapitami pomocy — czytało się to jak
   formularz, a podgląd był daleko od osi, którą się przesuwa.

   Blat trzyma własną, ciemną paletę w OBU motywach. To celowe i to jedyne
   miejsce w aplikacji, gdzie tak robimy: dookoła kadru filmu jasne tło
   zaburza ocenę jasności i kolorów, dlatego montażówki są ciemne. Kolory
   siedzą w lokalnych zmiennych, żeby nie udawały tokenów motywu. */
.bay {
  /* Odcienie NEUTRALNE (31.08.2026), wcześniej granatowe (#11151c / #171c25 /
     #2a3240 — slate z zafarbem). Decyzja „blat jest ciemny w obu motywach"
     zostaje nietknięta; zmienia się tylko barwa, tym samym ruchem, co paleta
     motywu ciemnego. Na blacie do oceny kadru zafarb tła jest zresztą gorszą
     wadą niż gdziekolwiek indziej: przekłamuje kolory filmu, który się na nim
     ocenia. */
  --bay-bg: #131315;
  --bay-panel: #1c1c1f;
  --bay-line: #33333a;
  --bay-fg: #ebebee;
  --bay-fg-muted: #9b9ba4;
  --bay-panel-2: #171719;
  /* Numer klatki na chłodno, jak w Studiu — i przy okazji bez bursztynu
     jako koloru tekstu, którego pilnuje TestAccentContrast. Na blacie
     (#1c1c1f) daje 6,75:1, czyli powyżej progu AA. */
  --bay-frame: #7aa2f7;

  border: 1px solid var(--bay-line); border-radius: var(--radius);
  background: var(--bay-bg); overflow: hidden; margin-bottom: .75rem;
}

/* Kadr dopasowany do WYSOKOŚCI blatu, nie do szerokości — jak „Fit" w Studiu.
   Przy dopasowaniu do szerokości podgląd zjadał cały ekran i oś czasu, którą
   się przesuwa, lądowała pod krawędzią: trzeba było przewijać tam i z powrotem
   między klipem a jego skutkiem, czyli dokładnie to, co ten ekran ma znosić.

   KADR JEST PIONOWY (9:16), bo pionowy jest film: HeyGen dostaje
   aspect_ratio 9:16, pipeline renderuje 720×1280, przebitki przychodzą
   portrait_16_9. Blat stał tu w 16/9 jeszcze z czasów poziomego toru i to on
   przycinał lektora (zgłoszenie Eryka 25.08: „awatar jest w pionie i edytor go
   obcina"). Sama proporcja pudełka by nie wystarczyła — kadr składał
   zbundlowany Player, który miał zaszyte 1280×720; poprawka jest w obu
   miejscach naraz (wideo/src/player/embed.tsx).

   Blat jest przez to WYŻSZY niż przy kadrze poziomym: przy 400 px wysokości
   pion ma raptem 225 px szerokości, czyli za mało, żeby ocenić, co widać
   w kadrze. Wyżej niż 52vh świadomie NIE idziemy — pion dopasowany do
   wysokości rośnie tylko w tym jednym wymiarze, więc każde dodatkowe 100 px
   blatu kupuje 56 px szerokości kadru, a zabiera 100 px osi czasu. Sufit
   600 px trzyma pasek transportu i oś w tym samym widoku na typowym laptopie,
   czyli utrzymuje warunek, dla którego blat w ogóle powstał: klip i jego
   skutek widać naraz. */
.bay__stage {
  display: flex; align-items: center; justify-content: center;
  background: #000; padding: 14px;
  height: clamp(300px, 52vh, 600px);
}
.bay__preview {
  height: 100%; aspect-ratio: 9 / 16; max-width: 100%;
  display: flex; align-items: center; justify-content: center;
  background: #000;
}
.bay__empty {
  margin: 0; text-align: center; padding: 1rem;
  color: var(--bay-fg-muted); font-size: .92rem;
}

.bay__transport {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  padding: 8px 14px;
  background: var(--bay-panel);
  border-top: 1px solid var(--bay-line); border-bottom: 1px solid var(--bay-line);
}
.bay__readout { display: flex; align-items: baseline; gap: 8px; }
.bay__tc {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 1.05rem; font-weight: 600; color: var(--bay-fg);
  font-variant-numeric: tabular-nums; letter-spacing: .02em;
}
.bay__frame {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .8rem; color: var(--bay-frame);
  font-variant-numeric: tabular-nums;
}
.bay__buttons { display: flex; align-items: center; gap: 2px; }
.bay__btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 30px; padding: 0;
  border: 0; border-radius: var(--radius-sm);
  background: transparent; color: var(--bay-fg);
  font-size: 1rem; line-height: 1; cursor: pointer;
}
.bay__btn:hover { background: #2a2a2f; }
.bay__btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.bay__btn--play { font-size: 1.15rem; }
.bay__actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.bay__status { color: var(--bay-fg-muted); font-size: .88rem; }

.bay__timeline { background: var(--bay-bg); }

.bay__help {
  color: var(--color-fg-muted); font-size: .92rem;
  margin: 0 0 1.25rem;
}
.bay__help p { margin: 0 0 .35rem; }
.bay__help p:last-child { margin-bottom: 0; }

/* Gotowy plik z ostatniego montażu schodzi do zwijanego bloku — jest ważny
   po zmontowaniu, ale na co dzień tylko odciągał wzrok od blatu. */
details.card > summary.card__header { cursor: pointer; list-style: revert; }
details.card > summary.card__header h2 { display: inline; }
/* ZWINIĘTY <details> to cała karta: tytuł musi mieć odstęp z DOŁU, nie tylko
   z góry. .card__header ma `padding-bottom: 0`, bo normalnie pod nim stoi
   .card__body — a przy zwiniętej karcie nie stoi nic, więc napis siedział na
   dolnej krawędzi (zgłoszenie Eryka ze zrzutem „Analityka"). Po rozwinięciu
   odstęp wraca do zera, żeby nagłówek nie odrywał się od treści. */
details.card > summary.card__header { padding-bottom: var(--space-7); }
details.card[open] > summary.card__header { padding-bottom: 0; }

/* Oś czasu siedzi WEWNĄTRZ blatu, więc jedzie na jego palecie, nie na
   tokenach motywu — te w jasnym motywie dałyby jasne linie i jasny tekst
   na ciemnym tle, czyli nieczytelną plątaninę. */
#timeline { background: var(--bay-bg); overflow: hidden; }
#timeline .vis-timeline { border: 0; }
#timeline .vis-panel.vis-center,
#timeline .vis-panel.vis-left,
#timeline .vis-panel.vis-top { border-color: var(--bay-line); }
#timeline .vis-time-axis .vis-text { color: var(--bay-fg-muted); font-size: 11px; }
#timeline .vis-grid.vis-minor,
#timeline .vis-grid.vis-major { border-color: var(--bay-line); }
#timeline .vis-panel.vis-left { background: var(--bay-panel); }
#timeline .vis-labelset .vis-label { color: var(--bay-fg); }
#timeline .vis-foreground .vis-group { border-bottom: 1px solid var(--bay-line); }

/* Etykieta ścieżki (V1/V2 + nazwa) w lewej kolumnie osi. */
.track { display: flex; align-items: center; gap: 7px; padding: 0 12px; font-size: 12.5px; font-weight: 600; }
.track-num { color: var(--bay-fg-muted); font-size: 10.5px; font-weight: 700; }
.track .icon svg { width: 14px; height: 14px; }

/* Klip: miniatura + nazwa + czas trwania. Bez `.clip img` poniżej obraz
   wchodzi w naturalnym rozmiarze (1280 px) i rozpycha całą ścieżkę — to
   był właśnie objaw martwego arkusza. */
#timeline .vis-item {
  border: 1px solid var(--bay-line); border-radius: var(--radius-sm);
  background: var(--bay-panel); color: var(--bay-fg);
  box-shadow: 0 2px 8px rgb(0 0 0 / 25%);
  transition: box-shadow .15s, border-color .15s;
}
#timeline .vis-item .vis-item-content { padding: 0; width: 100%; }
#timeline .vis-item.vis-selected {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 1px var(--color-accent), 0 6px 16px rgb(0 0 0 / 35%);
}
/* Ścieżka lektora leży pod przebitkami przez cały film i jest nieedytowalna —
   stąd inny odcień i kursor domyślny zamiast łapki. */
#timeline .vis-item.clip-item-avatar { background: var(--bay-panel-2); cursor: default; }
#timeline .vis-item.clip-item-avatar.vis-selected {
  border-color: var(--bay-line); box-shadow: 0 2px 8px rgb(0 0 0 / 25%);
}
.clip { display: flex; align-items: center; gap: 8px; padding: 5px 9px 5px 6px; }
.clip img { width: 46px; height: 30px; object-fit: cover; border-radius: 5px; flex-shrink: 0; }
.clip img.round { width: 30px; height: 30px; border-radius: 50%; }
.clip .icon svg { width: 11px; height: 11px; }
.clip-text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.clip-name {
  display: flex; align-items: center; gap: 5px; font-weight: 700; font-size: 11.5px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.clip-meta { font-size: 10.5px; color: var(--bay-fg-muted); white-space: nowrap; }

/* Klip lektora: etykieta, pod nią pasek klatek i fala dźwięku — jak ścieżka
   wideo w Studiu. Bez nich ten klip był jednolitym prostokątem przez całą
   długość filmu i nie niósł żadnej informacji o tym, co się w nim dzieje.
   Paski rozciągają się na CAŁĄ szerokość klipu, bo serwer generuje je równo
   na całym nagraniu — dzięki temu pokrywają się w czasie z osią. */
.clip-avatar-content { display: flex; flex-direction: column; align-items: stretch; gap: 3px; padding: 4px 5px; }
.clip-avatar-head { padding: 0; }
.clip-band {
  background-repeat: no-repeat; background-size: 100% 100%;
  border-radius: 3px; flex: none;
}
/* Kolejność z `order`, nie z kolejności dodania do DOM: paski dochodzą
   asynchronicznie, a fala liczy się 0,3 s wobec ~3 s paska klatek — bez tego
   wskakiwała NAD klatki, zależnie od tego, co pierwsze wróci z serwera. */
.clip-band--frames { height: 26px; order: 1; }
.clip-band--wave { height: 16px; order: 2; background-color: rgb(0 0 0 / 25%); }

/* Uchwyty zmiany długości — widoczne dopiero pod kursorem, żeby klip
   w spoczynku nie wyglądał na obwieszony kontrolkami. */
#timeline .vis-item .vis-drag-left::after,
#timeline .vis-item .vis-drag-right::after {
  content: ""; position: absolute; top: 50%; transform: translateY(-50%);
  width: 3px; height: 55%; border-radius: 2px;
  background: var(--bay-fg); opacity: 0; transition: opacity .15s;
}
#timeline .vis-item .vis-drag-left::after { left: 4px; }
#timeline .vis-item .vis-drag-right::after { right: 4px; }
#timeline .vis-item:hover .vis-drag-left::after,
#timeline .vis-item:hover .vis-drag-right::after { opacity: .65; }
#timeline .clip-item-avatar .vis-drag-left::after,
#timeline .clip-item-avatar .vis-drag-right::after { display: none; }

/* Głowica: bursztyn jako TŁO paska i trójkąta (nie kolor tekstu — patrz
   TestAccentContrast), zsynchronizowana z podglądem w editor.js. */
#timeline .vis-custom-time { background: var(--color-accent); width: 2px; cursor: ew-resize; }
#timeline .vis-custom-time::before {
  content: ""; position: absolute; top: 0; left: -5px;
  border: 6px solid transparent; border-top-color: var(--color-accent);
}

/* Karty przebitek pod osią — podmiana pojedynczego obrazu bez renderu. */
.cutaway-card {
  display: flex; gap: 16px; align-items: flex-start;
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); padding: 14px 16px; margin: 11px 0;
}
.cutaway-card img { width: 180px; border-radius: var(--radius-sm); flex-shrink: 0; }

/* Przebitka dodana ręcznie nie ma jeszcze obrazu (dostanie go z wgrania albo
   z generowania). Bez tego <img> z martwym adresem rysuje się jako ikona
   zepsutego zdjęcia w losowej wysokości — czyta się to jak awaria i rozjeżdża
   kartę. Kreskowana ramka w miejscu obrazu mówi „tu wejdzie zdjęcie". */
.cutaway-card--pusta img {
  height: 101px; /* 180 × 9/16 — kadr filmu, żeby karta nie skakała po wgraniu */
  display: flex; align-items: center; justify-content: center;
  box-sizing: border-box; padding: 8px; text-align: center;
  border: 1px dashed var(--color-border); background: var(--color-surface-muted, transparent);
  color: var(--color-fg-muted); font-size: 0.8rem; line-height: 1.3;
}
.cutaway-desc { flex: 1; min-width: 0; }
.cutaway-desc p { margin: 0 0 6px; }
.cutaway-desc textarea { width: 100%; min-height: 64px; margin-bottom: 8px; }

/* Dwie drogi do zmiany obrazu: wgranie własnego i wygenerowanie. Ręczna stoi
   PIERWSZA i jest darmowa, więc nie ma powodu, żeby prowadzić do płatnej.

   OBA są .btn z systemu — tą samą klasą co „Dodaj przebitkę" w nagłówku TEJ
   SAMEJ karty (editor.html). Wcześniej żaden nią nie był i było to widać
   (zgłoszenie Eryka 25.08.2026 ze zrzutu kart przebitek: „są po prostu
   brzydkie, nie wpasowują się w nasz design, jakby w ogóle nie miały żadnego
   projektu"): wgrywanie miało tu ręcznie policzoną IMITACJĘ przycisku
   (wyściółka 6/12 zamiast 0/12 i min-height, stopień .92rem zamiast 14 px,
   własna krawędź, hover malujący akcentem po krawędzi — czego nie robi żaden
   inny przycisk w aplikacji), a generowanie nie miało ŻADNEJ klasy, czyli
   rysowało się domyślnym przyciskiem przeglądarki: systemowy szary gradient
   z własnym krojem i własnym promieniem, w środku naszej karty.

   OBA dostają --outline --sm, czyli DOKŁADNIE to samo co przycisk dodawania
   w nagłówku tej karty — to jest w tej aplikacji idiom małego przycisku
   (37 wystąpień w szablonach, --secondary ma jedno). Wariant nie niesie tu
   hierarchii z akapitu wyżej i nie ma nią niczego nieść: darmowe wgranie stoi
   PIERWSZE (kolejność w .cutaway-actions), a płatne generowanie samo mówi, ile
   kosztuje (.cutaway-cost niżej). Dwa różne kształty przycisku obok siebie
   powiedziałyby, że to dwie różne KLASY akcji — a to dwie równorzędne drogi do
   tego samego obrazu. */
.cutaway-actions { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
/* Wygląd niesie .btn z szablonu — zostaje tu wyłącznie to, czego .btn nie ma,
   bo wgrywanie jest <label> z polem pliku w środku, a nie <button>. */
.cutaway-upload { position: relative; /* kotwica dla schowanego pola pliku poniżej */ }
/* Pole pliku chowamy wizualnie, ale ZOSTAJE fokusowalne — `hidden` albo
   `display:none` wyjęłoby je z kolejności tabulacji i wgrywanie zniknęłoby
   dla klawiatury. Obrys fokusa pokazuje otaczający <label>, bo sam <label>
   focusa nie dostaje. */
.cutaway-upload__input {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
/* Pierścień 1:1 z .btn:focus-visible (3 px, ten sam kolor, bez odstępu) —
   inaczej dwa sąsiadujące przyciski o TYM SAMYM wyglądzie dawałyby przy
   tabulacji dwa różne wskaźniki fokusu: bursztynowy obrys z odstępem na
   wgrywaniu i szary pierścień na generowaniu. `outline`, nie `box-shadow`:
   box-shadow na tej klasie podmieniłby pierścień z globalnego :focus-visible
   i wskaźnik zniknąłby bez śladu (patrz TestNoBoxShadowClassOnFocusableElement).
   :focus-within, nie :focus-visible — fokus łapie schowane pole w środku,
   a pokazać go musi widoczna etykieta wokół niego. */
.cutaway-upload:focus-within {
  outline: 3px solid var(--color-ring-shadow); outline-offset: 0;
}
/* Cena W PRZYCISKU jest drugorzędna: nazwa akcji ma się czytać pierwsza, cena
   być doczytana. Wcześniej siedziała w tym samym napisie („Wygeneruj ponownie
   (~$0,03)") — jeden ciąg, jeden stopień, jedna waga, więc nawias z ceną ważył
   tyle samo co polecenie i przycisk kończył się ogonem, który wyglądał na
   doklejony. Osobny <span> pozwala ją wyciszyć i zmniejszyć, nie ruszając
   etykiety; treść nadal idzie z i18n (editor.regenerate_cost). */
.cutaway-cost { color: var(--color-fg-muted); font-size: var(--fs-sm); font-weight: 400; }

/* Import gotowego wideo — druga, darmowa droga obok renderu. Oddzielona
   linią, bo to osobna decyzja niż wybór persony i kliknięcie „Renderuj”. */
.video-import { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--color-border); }
.video-import__help { margin: 0 0 8px; color: var(--color-fg-muted); font-size: .92rem; max-width: 62ch; }
.video-import__row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.bay__buffer {
  font-size: .8rem; color: var(--bay-fg-muted);
  font-variant-numeric: tabular-nums;
}
.bay__btn[disabled] { opacity: .45; cursor: default; }

/* Fragment materiału pod pytaniem szczegółowym — uzasadnienie DO WGLĄDU, więc
   zwinięte i wyciszone: admin ma tu odpowiadać na pytanie, a nie czytać cytat.
   Rozwija się, gdy chce sprawdzić, skąd pytanie się wzięło. */
.question-source { margin: -2px 0 8px; font-size: 0.85rem; }
.question-source summary { cursor: pointer; color: var(--color-fg-muted); }
.question-source blockquote {
  margin: 6px 0 0; padding: 8px 12px;
  border-left: 3px solid var(--color-border);
  color: var(--color-fg-muted); background: var(--color-surface);
}

/* WYBÓR LEKTORA — RZĄD PORTRETÓW JAK WYBÓR PROFILU (Antek 22.08: „muszą być
   obok siebie jak w netflixie wybór profilu"). Wcześniej był to zawijany rząd
   kafelków w ramkach: przy czwartej personie kafelki schodziły do drugiego
   rzędu, a wybór twarzy zamieniał się w siatkę do przeszukiwania.

   Teraz rząd się NIE zawija — gdy person przybywa, przewija się w bok. Karta
   kreatora ma 646 px, po 40 px paddingu zostaje 564 px światła; kafelek 96 px
   plus 14 px odstępu daje PIĘĆ portretów naraz (5×96 + 4×14 = 536). Dwa
   miejsca są stałe („automatycznie" na początku, „dodaj" na końcu), więc trzy
   persony widać bez jednego ruchu.

   Kafelek nie ma już ramki ani tła: patrzy się na twarz, nie na pudełko.
   Zaznaczenie niesie WYŁĄCZNIE pierścień wokół portretu i rozjaśnione imię —
   wzorzec zaznaczania jak .level-pill: radio schowane wizualnie (nie
   display:none — zostaje w tabulacji). Używane w trzech miejscach: krok 4
   kreatora (persona całego kursu), sekcja wideo lekcji (nadpisanie per lekcja)
   i powrót z nadpisania. */
.avatars {
  display: flex; gap: 18px;
  overflow-x: auto;
  /* Boki muszą mieć NIE MNIEJ niż pierścień zaznaczenia wystaje poza kafelek
     (outline 3 px + outline-offset 3 px = 6 px). Kontener jest przewijalny,
     a overflow tnie wszystko, co wyjdzie poza jego pudełko — przy 2 px zapasu
     skrajny zaznaczony portret dostawał ścięty obrys po tej stronie, w którą
     dotykał krawędzi (zgłoszenie Antka 22.08: „obramówka się ucina"). */
  padding: var(--space-3) var(--space-3) var(--space-4);
  scrollbar-width: thin;
  /* BEZ CIENIA PRZEWIJANIA. Stał tu trik z background-attachment: local —
     dwa gradienty w kolorze POWIERZCHNI przyklejone do treści plus dwa ciemne
     przyklejone do ramki, razem dające miękką krawędź po tej stronie, po
     której jest jeszcze ktoś dalej. Zamawiający odrzucił go 23.08 („te białe
     gradienty po bokach wyróżniają się na tle") i miał rację co do przyczyny:
     gradient był w kolorze --color-surface, czyli BIAŁY, a rząd person stoi
     w arkuszu wideo na tle strony (szare --color-bg z .exedit__main). Biel na
     szarym nie wygasza rzędu, tylko świeci dwoma pasami.
     Znika CAŁA czwórka warstw, nie same białe: białe przyklejone do treści
     ZAKRYWAŁY ciemne przyklejone do ramki, gdy nie było czego przewijać.
     Zostawienie samych ciemnych dałoby stałą smugę przy obu krawędziach —
     czyli drugą ozdobę zamiast żadnej. Informację „jest dalej" niesie teraz
     sam pasek przewijania (scrollbar-width: thin wyżej). */
}
.avatar-tile {
  flex: 0 0 96px;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  cursor: pointer; font-size: 13px; font-weight: 600; text-align: center;
  /* Kafelek „dodaj" jest <button> (otwiera modal kreatora), reszta to <label>.
     Bez tego resetu przycisk wnosiłby własne tło, ramkę i font przeglądarki. */
  background: none; border: 0; padding: 0; color: inherit; font-family: inherit;
}
/* Radio schowane wizualnie, ale ZOSTAJE w tabulacji (nie display: none) —
   wzorzec zaznaczania jak .level-pill. Rozmiaru NIE ma sensu tu ruszać:
   przeglądarka i tak przewija do widoku pudełko <label>, a nie to pole
   (zmierzone 28.08.2026), więc kafelek na całą wysokość dawał tylko WIĘKSZY
   skok. Skok gasi avatar-picker.js — patrz tam. */
.avatar-tile input[type="radio"] { position: absolute; opacity: 0; width: 1px; height: 1px; }
.avatar-tile img {
  width: 80px; height: 80px; border-radius: 999px; object-fit: cover;
  /* KADR NA TWARZ, NIE NA TORS. Zdjęcia person przychodzą z HeyGena jako
     PIONOWE portrety (osoba od pasa w górę, twarz w górnej jednej trzeciej
     kadru). `cover` w kwadratowym krążku wycina wtedy ŚRODEK klatki, czyli
     klatkę piersiową, a głowę ścina przy górnej krawędzi — na 80 px nie da
     się po tym rozpoznać człowieka, a po to ten kafelek istnieje.
     50% w poziomie zostaje, więc zdjęcia poziome (przycinane po bokach)
     i kwadratowe (nieprzycinane wcale) zachowują się jak dotąd. */
  object-position: 50% 25%;
  background: var(--color-bg);
}
/* Persona bez miniatury (dodana samym talking_photo_id) — krążek z inicjałem
   zamiast dziury; inicjał wstawia szablon. */
.avatar-tile__initial {
  width: 80px; height: 80px; border-radius: 999px;
  display: flex; align-items: center; justify-content: center;
  background: var(--color-bg); color: var(--color-fg-muted);
  font-size: 27px; font-weight: 700;
}
/* Fokus Z KLAWIATURY — ten sam pierścień na portrecie, w szarości globalnego
   :focus-visible. Reguła stoi PRZED zaznaczeniem, więc kafelek wybrany i
   jednocześnie skupiony pokazuje bursztyn: w grupie radiowej strzałki
   przesuwają fokus RAZEM z wyborem, a wtedy ważniejsze jest widzieć, co jest
   wybrane, niż że coś jest skupione.

   Do 28.08.2026 stała tu zamiast tego reguła `.avatars label:focus-within`
   z `box-shadow: 0 0 0 3px`. Cień obrysowywał CAŁY <label>, czyli kolumnę
   portret + imię + płeć — a że kafelek nie ma ani ramki, ani zaokrąglenia,
   wychodził z tego szary PROSTOKĄT wokół okrągłego zdjęcia (zgłoszenie Eryka
   27.08.2026 ze zrzutem: „mamy dziwny kwadrat zaznaczenia"). Do tego
   :focus-within łapie każdy sposób oddania fokusu, więc kwadrat zostawał na
   ekranie po zwykłym kliknięciu myszą — tam, gdzie native radio nie rysuje
   nic. :focus-visible tego nie robi: kliknięte radio go nie dopasowuje. */
.avatar-tile input:focus-visible ~ img,
.avatar-tile input:focus-visible ~ .avatar-tile__initial {
  outline: 3px solid var(--color-ring-shadow);
  outline-offset: 3px;
}
/* Zaznaczenie przez `:checked ~`, NIE :has() — repo świadomie nie używa
   :has() (próg wsparcia przeglądarek, komentarz przy .shell--with-dock).
   Radio stoi PIERWSZE w kafelku, więc pierścień na portrecie i kolor nazwy
   sięgają go selektorem rodzeństwa.

   Pierścień na OUTLINE, nie box-shadow: kafelkiem „dodaj lektora" jest
   <button>, a każda reguła box-shadow dotykająca jego klasy podmieniłaby mu
   pierścień fokusu z :focus-visible (pułapka 2 z README, pilnuje jej
   TestNoBoxShadowClassOnFocusableElement). outline-offset daje przy okazji tę
   samą przerwę między zdjęciem a obrysem, którą wcześniej robiła druga
   warstwa cienia w kolorze powierzchni. */
.avatar-tile input:checked ~ img,
.avatar-tile input:checked ~ .avatar-tile__initial {
  outline: 3px solid var(--color-accent);
  outline-offset: 3px;
}
.avatar-tile input:checked ~ .avatar-tile__name { color: var(--color-fg); }
.avatar-tile__name { color: var(--color-fg-muted); line-height: 1.25; }
/* Kafelek „automatycznie"/„jak w kursie": pod nazwą druga linia z personą,
   którą weźmie render. Mniejsza i przygaszona, bo to skutek wyboru, nie jego
   nazwa. */
.avatar-tile__sub { font-size: 11px; color: var(--color-fg-muted); }
/* „Dodaj lektora" stoi w tym samym rzędzie, na końcu. Przerywany krążek mówi,
   że to miejsce na kogoś, kogo jeszcze nie ma — a nie kolejny wybór. */
.avatar-tile--add .avatar-tile__initial {
  background: none; border: 2px dashed var(--color-border);
  font-size: 30px; font-weight: 400;
}
/* Gwiazdka zostaje w kolorze przygaszonego tekstu, jak inicjały pozostałych
   person. Bursztyn jako kolor GLIFU dawał 2,27:1 na bieli — złapał to
   TestAccentContrast; kafelek i tak odróżnia się gwiazdką i podpisem, więc
   akcentu tu nie potrzebuje. */

/* KROK 4 KREATORA — ekran wyboru twarzy, nie formularz. Pytanie na środku,
   pod nim rząd portretów; karta nie ma nagłówka, bo tytułem jest samo pytanie. */
.narrator-step { display: flex; flex-direction: column; align-items: center; gap: var(--space-7); }
.narrator-step__title { margin: 0; font-size: var(--fs-h4); line-height: var(--lh-h4); font-weight: 700; }
.narrator-step__hint { margin: 0; text-align: center; max-width: 46ch; }
/* Rząd wyśrodkowany, dopóki mieści się w karcie — tak samo na kroku 4 kreatora
   i w karcie „Lektor tej lekcji" (Antek 22.08: „wyśrodkuj"). Wybór twarzy jest
   w obu miejscach tym samym gestem, więc nie ma powodu, żeby raz stał na
   środku, a raz przy lewej krawędzi.

   Trzeci selektor to rząd przy ZERZE person: stoi wtedy wprost w sekcji
   arkusza, poza <details class="narrator">, więc dwie pierwsze reguły go nie
   łapały i sam kafelek „+" lądował przy lewej krawędzi (Antek 23.08:
   „wyśrodkuj kółko").

   `safe center` nie chowa pierwszego portretu poza lewą krawędzią, gdy rząd
   jest szerszy niż karta — starsza przeglądarka odrzuci całą deklarację
   i zostanie przy wyrównaniu do lewej, czyli też użytecznie. */
.narrator-step .avatars,
.narrator .avatars,
.video-sheet__section > .avatars { align-self: stretch; justify-content: center; justify-content: safe center; }

/* Linijka „filmy poprowadzi" pod kafelkami szczegółów gotowego kursu — jedno
   zdanie, nie karta: to przypomnienie decyzji z kroku 4, a nie miejsce jej
   podejmowania (zmienia się ją tam, gdzie się ją podjęło). Górna krawędź
   oddziela ją od siatki liczb w tej samej karcie. */
.course-narrator {
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
  border-top: 1px solid var(--color-border);
  font-size: var(--fs-md); line-height: var(--lh-md);
}
.course-narrator__label { color: var(--color-fg-muted); }
.course-narrator__name { font-weight: 600; }
.course-narrator__note { color: var(--color-fg-muted); font-size: var(--fs-sm); }

/* Formularz renderu zaczyna się etykietą „Lektor" tuż pod przyciskiem „Zapisz
   skrypt" z formularza wyżej — dwa osobne <form> stykały się bez oddechu, więc
   etykieta czytała się jak podpis tamtego przycisku. Odstęp jest na formularzu,
   nie na etykiecie: dotyczy sąsiedztwa dwóch bloków, a nie samego pola. */
.video-render { margin-top: var(--space-7); }

/* Karta „Lektor tej lekcji" oddycha jak karty kreatora, nie jak gęsty
   formularz edytora (Antek 22.08: „mało miejsca jakoś"). To ten sam powód, dla
   którego .wizard-shell .card__body dostało 40 px zamiast 16: ekran jednej
   decyzji ma mieć światło, a nie upchane 8 px między każdym elementem.
   Odstępy między blokami niesie gap kolumny — dzięki temu panel person pod
   spodem trzyma ten sam rytm co reszta, bez własnego marginesu. */
.narrator-card .card__body {
  padding: var(--space-8) var(--space-9) var(--space-9);
  display: flex; flex-direction: column; gap: var(--space-7);
}

/* LEKTOR LEKCJI JAKO WIERSZ STANU (Antek 22.08). Sekcja wideo pokazywała rząd
   person bez zaznaczenia, choć lektor JEST ustawiony — tyle że na poziomie
   kursu. Teraz jeden wiersz mówi, kto prowadzi tę lekcję i skąd się wziął,
   a rząd portretów chowa się pod <details>. */
.narrator { margin-bottom: var(--space-3); }
.narrator-row {
  display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap;
  padding: var(--space-5) var(--space-6);
  border: 1px solid var(--color-border); border-radius: var(--radius);
  background: var(--color-surface); cursor: pointer;
}
.narrator-row::-webkit-details-marker { display: none; }
.narrator-row::marker { content: ""; }
.narrator-row__face {
  width: 40px; height: 40px; border-radius: 999px; object-fit: cover;
  object-position: 50% 25%; /* twarz, nie tors — patrz .avatar-tile img */
  background: var(--color-bg); flex: none;
}
.narrator-row__face--initial {
  display: flex; align-items: center; justify-content: center;
  color: var(--color-fg-muted); font-weight: 700; font-size: 15px;
}
.narrator-row__main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 140px; }
.narrator-row__name { font-weight: 600; display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.narrator-row__src { font-size: var(--fs-sm); color: var(--color-fg-muted); }
.narrator-row__act { font-size: var(--fs-sm); font-weight: 600; color: var(--color-accent-strong); white-space: nowrap; }
.narrator[open] .narrator-row { margin-bottom: var(--space-7); }
.narrator__hint { margin: var(--space-5) 0 0; text-align: center; }
/* Zapis siedzi w rozwiniętym <details>: przy zwiniętym wierszu nie ma czego
   zapisywać, więc guzika też nie ma. */
.narrator__save { margin: var(--space-7) auto 0; display: block; }

/* METRYKA PODEJŚCIA nad logiem sesji (sesja.html) — pary etykieta-wartość
   z metadanymi, które do tej pory niósł wyłącznie wiersz listy: wynik, status,
   ukończenie, czas trwania i oba znaczniki czasu.

   Auto-fit, nie stała liczba kolumn: sześć pozycji mieści się w jednym rzędzie
   na szerokim ekranie i zawija po dwie–trzy na wąskim, bez punktu przełamania
   pisanego z ręki. minmax(8.5rem, …) to szerokość najdłuższej etykiety
   („Czas trwania") — poniżej tej wartości etykieta łamałaby się na dwie linie
   i rząd przestawałby się czytać jako rząd.

   Kreska u góry zamiast karty: to jest część nagłówka strony, a nie osobna
   sekcja treści — karta z ramką i cieniem konkurowałaby z kartą „Przebieg
   lekcji" tuż pod spodem, chociaż niesie o rząd wielkości mniej. */
.session-meta {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
  gap: var(--space-5) var(--space-7);
  margin: var(--space-6) 0 var(--space-7);
  padding-top: var(--space-6); border-top: 1px solid var(--color-border);
}
.session-meta__item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.session-meta dt {
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
/* Reset marginesu <dd> (przeglądarka daje 40px od lewej) — wartość ma stać
   dokładnie pod swoją etykietą, inaczej siatka rozjeżdża się o wcięcie. */
.session-meta dd { margin: 0; font-weight: 600; display: flex; align-items: center; gap: 8px; }
/* Procent obok punktów — drugorzędny, jak podlinijka w kolumnie wyniku na
   liście: mówi to samo co punkty, tylko w skali progu zaliczenia. */
.session-meta__sub { font-size: var(--fs-sm); font-weight: 400; color: var(--color-fg-muted); }

/* ARKUSZ Z LOGIEM SESJI (sesja.html) — panel wysuwany z PRAWEJ krawędzi nad
   listą sesji, wzorem „Lesson progress" z humionboardera. Zgłoszenie Antka
   23.08.2026: „nie widzę sidebaru, tylko oddzielną zakładkę, co jest złe" —
   log był u nas osobną stroną, więc wejście w wiersz zabierało z ekranu to,
   czego log dotyczy.

   1100 px to ICH miara, zmierzona na żywo (`.logs-sheet`, max-width: 1100px
   przy oknie 1440 px); poniżej 640 px arkusz zajmuje całe okno, jak `w-full`
   w prymitywie. Osobny komponent, nie wariant okna edycji ćwiczenia (.exedit):
   tamto jest wyśrodkowanym oknem na 90% ekranu do pisania akapitów, to jest
   szuflada z tabelą, która ma zostawić listę widoczną pod spodem.

   Natywny <dialog> + showModal() (ten sam wzorzec co .modal i .exedit) daje
   pułapkę fokusu, Escape i ::backdrop. Nadpisania inset/margin/max-* są
   KONIECZNE: arkusz przeglądarki centruje <dialog> przez `margin: auto`
   i ogranicza go `max-width: calc(100% - 6px - 2em)`, więc bez nich szuflada
   nie dojedzie do krawędzi okna. */
.logs-sheet {
  display: none; border: none; padding: 0; background: transparent; color: inherit;
  position: fixed; inset: 0 0 0 auto; margin: 0;
  width: 100%; max-width: none; height: 100dvh; max-height: none;
}
@media (min-width: 640px) { .logs-sheet { width: min(1100px, 100vw); } }
.logs-sheet[open] { display: block; }
/* SheetOverlay w humi to `bg-black/50` — o stopień jaśniej niż nakładka okna
   edycji (bg-black/80), bo arkusz ZOSTAWIA widoczny kontekst pod spodem: to
   jest jego sens. */
.logs-sheet::backdrop { background: rgb(0 0 0 / 50%); }
.logs-sheet__panel {
  display: flex; flex-direction: column; height: 100%; overflow-y: auto;
  background: var(--color-surface);
  /* `border-l` i zero promienia — szuflada przylega do krawędzi okna, więc
     zaokrąglone rogi nie mają się o co oprzeć. Padding 16 px: SheetHeader
     i SheetFooter mają w humi `p-4`. */
  border: 0; border-left: 1px solid var(--color-border); border-radius: 0;
  box-shadow: var(--shadow-lg); padding: var(--space-6);
}
/* WJAZD ARKUSZA jedzie na SAMYM <dialog>, nie na panelu w środku, i odpala go
   JS (data-modal-slide z modal.js), nie samo wyrenderowanie elementu. Oba te
   warunki są konieczne, żeby cokolwiek było widać:

   • NA DIALOGU, bo `<dialog>` ma w arkuszu przeglądarki `overflow: auto`.
     Panel jest dokładnie tak duży jak dialog, więc odsunięcie PANELU o 100%
     robiło z dialogu pojemnik z przewijaniem w poziomie (scrollWidth 2200 przy
     1100 szerokości) — a `autofocus` na krzyżyku każe przeglądarce dowieźć
     zafokusowany element do widoku, czyli przewinąć dialog do samego końca.
     Przewinięcie kasowało transform co do piksela (zmierzone: scrollLeft
     szedł 1100 → 8 równo z transformem, a panel stał w miejscu), więc wjazdu
     NIE BYŁO WCALE — na ekranie zostawało tylko drganie o kilkadziesiąt
     pikseli w lewo z jednoklatkowego rozjazdu między transformem a
     przewinięciem. Dialog jest `position: fixed`, jego przesunięcie nie
     powiększa więc obszaru przewijania niczego i nie ma czego dowozić.

   • Z JS-u, bo animacja postawiona wprost na elemencie startuje w chwili
     PARSOWANIA HTML-a, a nie pierwszego rysowania. Serwer renderuje arkusz
     otwarty (patrz sesja.html), więc przy szybkim ładowaniu połowa ruchu
     schodziła przed pierwszą klatką, a przy wolniejszym cała — animacja
     „raz jest, raz jej nie ma". `data-modal-slide` pada po showModal(),
     czyli w tym samym zadaniu, w którym arkusz staje się modalem: pełne
     300 ms zawsze ląduje na ekranie. Bez JS-u arkusz po prostu jest — tak
     jak i tak wygląda tam cała reszta jego obsługi.

   Sam transform, bez opacity — animowanie przezroczystości elementu o pełnej
   wysokości okna szarpie. `ease-out` zamiast `ease-in-out`: rzecz WJEŻDŻA,
   więc ma wystartować od razu i wyhamować na końcu, a nie rozpędzać się
   z miejsca. */
@keyframes logs-sheet-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
.logs-sheet[data-modal-slide] { animation: logs-sheet-in .3s ease-out; }
@media (prefers-reduced-motion: reduce) { .logs-sheet[data-modal-slide] { animation: none; } }
.logs-sheet__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.logs-sheet__heading { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.logs-sheet__title {
  margin: 0; font-size: var(--fs-h4); line-height: 1; font-weight: 600;
  letter-spacing: -.025em; padding-right: 32px;
}
.logs-sheet__sub { margin: 0; font-size: var(--fs-md); line-height: var(--lh-md); color: var(--color-fg-muted); }
/* Krzyżyk jest ODNOŚNIKIEM (wraca na listę), więc potrzebuje wyśrodkowania
   treści i zdjęcia podkreślenia — inaczej niż przycisk .modal__close. */
.logs-sheet__close {
  flex: none; width: 32px; height: 32px; display: grid; place-items: center;
  border-radius: var(--radius-sm); border: 0; background: none; cursor: pointer;
  color: var(--color-fg-muted); font-size: 20px; line-height: 1; text-decoration: none;
}
.logs-sheet__close:hover { background: var(--color-surface-muted); color: var(--color-fg); }
/* Sekcja W ŚRODKU arkusza: nagłówek i treść bez ramki — panel jest już
   powierzchnią, więc karta w karcie rysowałaby drugą ramę wokół tego samego.
   U humi pod nagłówkiem panelu stoi wprost tabela. */
.logs-sheet__section { margin-top: var(--space-7); }
.logs-sheet__section-head { margin-bottom: var(--space-6); }
.logs-sheet__section-title {
  margin: 0; font-size: var(--fs-h4); line-height: var(--lh-h4);
  font-weight: 600; letter-spacing: -.02em;
}

/* LOG SESJI JAKO TABELA POSTĘPU — układ skopiowany z modala „Lesson progress"
   w Humi (Antek 18.08: „skopiuj ten design do nas"). Był stos kart z treścią
   rozwiniętą od razu; teraz wiersz na ćwiczenie, treść pod rozwinięciem.

   Grid, nie <table>: rozwijanie ma działać BEZ JS (u nich robi to skrypt),
   a <details> nie może objąć wierszy tabeli — HTML na to nie pozwala.
   Ten sam efekt wizualny, natywny mechanizm. */
.progress-table { display: grid; gap: 0; }
.progress-table__head,
.progress-row__summary {
  display: grid;
  /* Kolumny: #, ćwiczenie, typ, wynik, zaliczone, próby, „⌄".
     Kolumna prób (4.5rem) doszła z mechaniką powtórek (MARAT-52) — mieści
     jedną cyfrę albo kreskę, więc jest najwęższa z opisowych. */
  grid-template-columns: 2.5rem minmax(0, 1fr) 9rem 6rem 8rem 4.5rem 1.5rem;
  gap: 12px; align-items: center;
}
/* Zdaniowe, nie WERSALIKI — spójnie z .courses-table th. Wersaliki
   z rozstrzelonymi literami krzyczą mocniej niż treść wiersza pod nimi. */
.progress-table__head {
  padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--color-border);
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 600;
  color: var(--color-fg-muted);
}
.progress-row { border-bottom: 1px solid var(--color-border); }
.progress-row:last-child { border-bottom: 0; }
.progress-row__summary {
  padding: 12px 14px; cursor: pointer; font-size: 14px; list-style: none;
}
/* Domyślny trójkącik przeglądarki znika — kolumna z „⌄" jest własna, żeby
   trzymała się siatki tak jak reszta. */
.progress-row__summary::-webkit-details-marker { display: none; }
.progress-row__summary:hover { background: var(--color-surface-muted); }
.progress-row__summary:focus-visible {
  outline: 2px solid transparent;
  box-shadow: 0 0 0 3px var(--color-ring-shadow);
}
.progress-row__num { font-variant-numeric: tabular-nums; color: var(--color-fg-muted); }
/* Komórka ćwiczenia niesie DWIE linie jak u humi: nazwę, a pod nią Start /
   Koniec / Czas. min-width: 0 na kolumnie gridu, inaczej długie pytanie
   rozpycha siatkę zamiast się uciąć. */
.progress-row__main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.progress-row__name {
  font-weight: 600;
  /* Pytanie bywa długie — ucinamy do jednej linii, całość jest po rozwinięciu. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Trójka czasów w układzie humi: `grid grid-cols-2 gap-x-3 text-xs` — Start
   i Koniec obok siebie, Czas w osobnym wierszu na całą szerokość
   (u nich `col-span-2`). Kolumny są równe i stałe, więc godziny stoją jedna
   pod drugą przez wszystkie wiersze tabeli; cyfry o równej szerokości robią
   resztę. minmax(0, …), żeby długa etykieta nie rozpychała kolumny. */
.progress-row__span {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 2px var(--space-5); max-width: 22rem;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
.progress-row__span > :last-child { grid-column: 1 / -1; }
.progress-row__type,
.progress-row__score,
.progress-row__attempts,
.progress-row__passed { color: var(--color-fg-muted); font-size: 13px; }
/* Liczba prób ma czytać się jak liczba, nie jak etykieta — cyfry o równej
   szerokości, żeby kolumna nie skakała między „1" a „2". */
.progress-row__attempts { font-variant-numeric: tabular-nums; }
/* Lista prób w rozwinięciu (MARAT-52) — wzorem sekcji „User attempts"
   z humionboardera: jedna próba w jednym wierszu, oddzielone cienką linią,
   bez ramek i tła, żeby nie konkurowały z samą odpowiedzią nad nimi. */
.answer__attempts { margin-top: var(--space-4); }
.answer__attempt { padding: 6px 0; border-top: 1px solid var(--color-border); }
/* Etykieta typu też się ucina, a nie wychodzi na kolumnę wyniku. Bez tego
   „Uzupełnij luki" czy dłuższe tłumaczenie (ru „Заполните пропуски") łamie
   siatkę — komórka gridu domyślnie rozpycha się treścią ponad zadaną szerokość. */
.progress-row__type { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-row__score { font-variant-numeric: tabular-nums; }
.progress-row__chev { color: var(--color-fg-muted); transition: transform .15s; }
.progress-row[open] .progress-row__chev { transform: rotate(180deg); }
.progress-row__details {
  padding: var(--space-6) 14px var(--space-6) calc(2.5rem + 26px);
  background: var(--color-bg);
}
.progress-row__details p:last-child { margin-bottom: 0; }

/* ---------- rozwinięcie logu ćwiczenia (.answer) ----------
   Zgłoszenie Eryka (22.08): „wygląda zlane, nie oddzielone, słabo i tanio".
   Diagnoza: wszystko było ciągiem <p> o tym samym rozmiarze i wadze,
   z plakietką werdyktu wklejoną w środek zdania — nie dawało się jednym
   spojrzeniem odróżnić pytania od odpowiedzi ani odpowiedzi od oceny.

   Lekarstwo to HIERARCHIA i KRESKA, nie więcej koloru: pytanie mocniejsze od
   opcji, opcje jako wiersze rozdzielone linią, werdykty w jednej kolumnie przy
   prawej krawędzi (dają się czytać w pionie), punkty odcięte kreską na dole.
   Tła kolorowe TYLKO na wierszach, które coś znaczą — wybrana i poprawna —
   żeby wyróżnienie brało znaczenie z kontrastu, a nie z tego, że wszystko
   jest pokolorowane. */
.answer__question {
  margin: 0 0 var(--space-5); font-weight: 600; font-size: 14px;
  line-height: var(--lh-md); color: var(--color-fg);
}
.answer__note { margin: 0; color: var(--color-fg-muted); font-size: 13px; }

/* Zdanie ćwiczenia „luki" z wpisanymi odpowiedziami. Waga normalna (nie 600
   jak zwykły .answer__question), bo tu treścią są WPISY, a nie samo zdanie —
   pogrubione zdanie przekrzykiwałoby to, co ma się z niego wyłowić. */
.answer__sentence { font-weight: 400; line-height: 2; }
/* Wpis w luce jako pigułka wewnątrz zdania: podkreślenie zamiast pełnego tła,
   żeby zdanie dało się dalej czytać jako zdanie. Kolor niesie werdykt. */
.answer__gap {
  display: inline-block; padding: 0 6px; border-radius: var(--radius-sm);
  font-weight: 600; border-bottom: 2px solid currentColor;
}
.answer__gap--ok  { color: var(--color-ok-fg); background: color-mix(in srgb, var(--color-ok-fg) 10%, transparent); }
.answer__gap--bad { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 10%, transparent); }

/* Lista opcji/luk/par: wiersz na element, kreska między wierszami (nie pod
   ostatnim), treść po lewej, plakietka po prawej. */
.answer__options { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--color-border); }
.answer__option {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: var(--space-6); padding: var(--space-5) var(--space-5);
  border-bottom: 1px solid var(--color-border); font-size: 14px;
  line-height: var(--lh-md);
}
/* Kreska po lewej zamiast pełnego tła: mówi „ten wiersz coś znaczy" bez
   zamalowywania treści, którą trzeba przeczytać. Tło dopiero jako drugi,
   słabszy sygnał (8% krycia), żeby nie tłumiło tekstu. */
.answer__option--ok   { border-left: 3px solid var(--color-ok-fg); background: color-mix(in srgb, var(--color-ok-fg) 8%, transparent); }
.answer__option--bad  { border-left: 3px solid var(--color-danger); background: color-mix(in srgb, var(--color-danger) 8%, transparent); }
/* --key = „poprawna odpowiedź, ale kursant jej NIE wybrał". Neutralnie:
   to nie jego wynik, tylko klucz — zielone tło sugerowałoby, że trafił. */
.answer__option--key  { border-left: 3px solid var(--color-border); }
.answer__option .badge { flex: none; }
.answer__text { min-width: 0; overflow-wrap: anywhere; }
/* Etykieta elementu (numer luki, lewa strona pary) — mniejsza i wyblakła,
   bo to nagłówek wiersza, nie odpowiedź kursanta. Blok, żeby odpowiedź
   zaczynała się od nowej linii i nie zlewała się z etykietą. */
.answer__label { display: block; color: var(--color-fg-muted); font-size: 12px; line-height: 16px; }
.answer__empty { color: var(--color-fg-muted); }

/* Odpowiedź opisowa: cytat, nie akapit — od razu widać, że to słowa kursanta,
   a nie tekst aplikacji. white-space: pre-wrap zachowuje jego akapity. */
.answer__essay {
  margin: 0 0 var(--space-6); padding: var(--space-5) var(--space-6);
  border-left: 3px solid var(--color-border); background: var(--color-surface);
  white-space: pre-wrap; font-size: 14px; line-height: var(--lh-md);
}
/* Ocena odpowiedzi opisowej: plakietka i „kto ocenił" w jednym rzędzie,
   uzasadnienie pod nimi. Wcześniej wszystko trzy leciało jednym ciągiem
   tekstu w .alert i zlewało się w akapit. */
.answer__verdict {
  padding: var(--space-5) var(--space-6); border-radius: var(--radius-sm);
  border: 1px solid var(--color-border); background: var(--color-surface);
}
.answer__verdict--ok  { border-left: 3px solid var(--color-ok-fg); }
.answer__verdict--bad { border-left: 3px solid var(--color-danger); }
.answer__verdict-head { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.answer__by { color: var(--color-fg-muted); font-size: 12px; }
.answer__reason { margin: var(--space-5) 0 0; font-size: 14px; line-height: var(--lh-md); }

/* Punkty jako stopka rozwinięcia — odcięte kreską od treści nad nimi. */
.answer__foot {
  margin: var(--space-6) 0 0; padding-top: var(--space-5);
  border-top: 1px solid var(--color-border);
  color: var(--color-fg-muted); font-size: 13px;
}
.answer__foot strong { color: var(--color-fg); font-variant-numeric: tabular-nums; }

/* Ostrzeżenie o rozjeździe zapisanego wyniku z przeliczeniem na żywo —
   patrz web.sesjaEkspozycja.Rozjazd. Wygląda jak ostrzeżenie, bo nim jest:
   bez tego czytelnik widzi dwa sprzeczne werdykty i żadnej wskazówki. */
.answer__warn {
  margin: var(--space-6) 0 0; padding: var(--space-5) var(--space-6);
  border-radius: var(--radius-sm);
  border: 1px solid var(--color-warn-fg);
  background: color-mix(in srgb, var(--color-warn-fg) 10%, transparent);
  color: var(--color-fg); font-size: 13px; line-height: var(--lh-md);
}

/* Kolumna „Zaliczone" w wierszu nagłówkowym nie kurczy się poniżej treści
   (komórki gridu mają min-width: auto), więc długa plakietka wychodziła poza
   kartę. min-width: 0 + ucinanie to strażnik na wypadek, gdyby któreś
   tłumaczenie znów okazało się dłuższe niż kolumna — sama treść jest już
   skrócona (sesja.pending_short). */
.progress-row__passed { min-width: 0; overflow: hidden; }
.progress-row__passed .badge { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Wąski ekran: kolumny techniczne (typ, próby) schodzą, zostaje numer, nazwa,
   wynik, werdykt i strzałka.
   PIĘĆ KOLUMN, NIE CZTERY — poprawka usterki, nie kosmetyka. Wiersz ma
   SIEDMIORO dzieci; po ukryciu typu i prób zostaje PIĘCIORO, a siatka miała
   tu cztery kolumny. Piąte dziecko — strzałka „⌄" — spadało więc do drugiego
   rzędu siatki i wisiało samo pod numerem, a plakietka werdyktu wychodziła
   poza prawą krawędź arkusza sesji (widoczne na telefonie w każdym wierszu
   przebiegu lekcji). Liczba kolumn MUSI się zgadzać z liczbą widocznych
   dzieci; zmieniając widoczność którejkolwiek, popraw i tę listę.
   Odstęp 8 px zamiast 12: przy pięciu kolumnach trzy przerwy więcej zjadały
   nazwę ćwiczenia, a to ona jest treścią wiersza. */
@media (max-width: 52rem) {
  .progress-table__head { display: none; }
  .progress-row__summary {
    grid-template-columns: 1.75rem minmax(0, 1fr) auto auto 1.25rem;
    gap: 8px;
  }
  .progress-row__type,
  /* Próby schodzą razem z typem: liczba bez nagłówka (ten jest ukryty na
     wąskim ekranie) nie mówi nic, a w rozwinięciu stoi opisana sekcja. */
  .progress-row__attempts { display: none; }
  .progress-row__details { padding-left: 14px; }
}

/* ---------- kreator persony lektora (modal na stronie lekcji) ----------
   Układ przeniesiony z Figmy „Cluo/Core — Virtual Clients" (Antek 22.08:
   „ma być 1:1"). Przejęty jest UKŁAD i komponenty, nie pola: tamten ekran
   konfiguruje rozmówcę do symulacji sprzedażowej (etapy rozmowy, słowa
   kluczowe, „Call AI Agent"), a u nas persona tylko czyta skrypt lekcji,
   więc większość tamtych pól nie ma tu odpowiednika.

   Co jest 1:1: pogrupowanie pól w białe karty na szarym tle (stąd
   __panel z --color-bg zamiast --color-surface) i CIEMNY panel podglądu
   z portretem oraz zielonym przyciskiem akcji — w Figmie „Call AI Agent",
   u nas odsłuch głosu. Paleta zgadza się bez tłumaczenia: redesign z PR
   #134/135 przestawił onboarder na to samo indygo i Inter. */
.modal--persona { max-width: 560px; }
.modal--persona .modal__panel { background: var(--color-bg); }

.persona-card {
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); padding: 18px; margin-bottom: 14px;
}
.persona-card__title { font-size: 15px; font-weight: 700; margin: 0 0 14px; }

/* Panel podglądu — w Figmie jest ciemny, ale tam cała aplikacja ma jeden,
   jasny motyw. U nas motyw przełącza użytkownik, a ciemny prostokąt wbity
   na stałe w jasny ekran wyglądał jak dziura (uwaga Antka 22.08: „kolor nie
   może być ciemny w jasnym trybie"). Panel idzie więc za motywem: w jasnym
   bierze bladą poświatę indygo, w ciemnym granat z Figmy. Kolory przez
   TOKENY, nie wprost, żeby żaden nie został zdefiniowany wyłącznie w bloku
   [data-theme] — inaczej stan „system" (bez stempla na <html>) renderowałby
   tekst jednego motywu na tle drugiego.
   Zielony przycisk zostaje zielony w obu: to jedyna zielona akcja w tym
   widoku i czytelnie odróżnia „posłuchaj" od „zapisz". */
:root {
  --persona-preview-bg: var(--slate-100);
  --persona-preview-fg: var(--slate-950);
  --persona-preview-photo-bg: rgb(15 23 42 / 8%);
  --persona-preview-border: var(--slate-200);
}
:root[data-theme="dark"] {
  --persona-preview-bg: var(--slate-800);
  --persona-preview-fg: #fff;
  --persona-preview-photo-bg: rgb(255 255 255 / 8%);
  --persona-preview-border: transparent;
}
.persona-preview {
  background: var(--persona-preview-bg); border-radius: var(--radius);
  border: 1px solid var(--persona-preview-border);
  padding: 22px; text-align: center; color: var(--persona-preview-fg);
}
.persona-preview__title { font-size: 14px; font-weight: 700; margin: 0 0 18px; color: inherit; }
.persona-preview__photo {
  width: 116px; height: 116px; border-radius: 50%; object-fit: cover;
  object-position: 50% 25%; /* twarz, nie tors — patrz .avatar-tile img */
  margin: 0 auto 18px; display: block; background: var(--persona-preview-photo-bg);
}
.persona-preview__name { font-weight: 700; margin: 0 0 14px; color: inherit; }
/* #15803d, nie jaśniejsze #16a34a: to drugie dawało z białym tekstem 3,30:1,
   a 14 px bold NIE jest „dużym tekstem" wg WCAG (próg to 18,66 px bold albo
   24 px zwykłego), więc obowiązuje 4,5:1. #15803d daje 5,02:1. */
.persona-preview__listen {
  display: inline-flex; align-items: center; gap: 8px;
  background: #15803d; color: #fff; border: none; cursor: pointer;
  border-radius: var(--radius-sm); padding: 10px 18px;
  font: 700 14px/1.4 var(--font-sans);
}
.persona-preview__listen:hover { background: #166534; }
.persona-preview audio { width: 100%; margin-top: 16px; }
.persona-preview__hint { font-size: 12px; color: var(--color-fg-muted); margin: 10px 0 0; text-align: center; }

/* ═══════════════════════════════════════════════════════════════════════════
   WARSTWA UŻYTKOWA (utilities)
   ═══════════════════════════════════════════════════════════════════════════
   Powód istnienia: szablony niosły 224 statyczne atrybuty `style=` — dokładnie
   to, co kryterium odbioru nazywa „jednorazowymi, lokalnymi stylami". Były
   trzy osobne problemy naraz:
     • wartości SPOZA skali (1.5rem, .75rem, .35rem, 6px, 18px, 20px) — każdy
       szablon dobierał odstęp sam, więc rytm strony nie miał jak się zgadzać;
     • `opacity: .75` zamiast koloru stonowanego — przezroczystość przygasza
       CAŁE poddrzewo razem z linkami i plakietkami w środku, a humi używa
       w tych miejscach `text-muted-foreground`, czyli koloru;
     • ta sama deklaracja powtórzona kilkanaście razy, więc zmiana jednego
       odstępu wymagała edycji kilkunastu atrybutów.
   Klasy niżej to ten sam idiom, którym napisany jest cały front humi
   (Tailwind: `mt-6`, `text-sm`, `text-muted-foreground`, `flex items-center
   gap-4`), tylko z prefiksem `u-` i wartościami wziętymi z NASZYCH tokenów.
   Nie dopisuj tu niczego, co ma odpowiednik w komponencie — utility służą
   układowi strony, nie budowaniu nowych komponentów z klocków. */
.u-row        { display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }
.u-row--tight { gap: var(--space-3); }
.u-row--between { justify-content: space-between; }
/* Dosunięcie JEDNEGO dziecka wiersza do prawej krawędzi — „margin-left: auto"
   w wersji nazwanej. Powtórzone dotąd czterokrotnie stylem inline
   (style="margin:0 0 0 auto") i pięciokrotnie w regułach komponentów. */
.u-push-right { margin-left: auto; }
.u-stack-xs   { display: flex; flex-direction: column; gap: var(--space-2); }
/* position: relative NIE jest ozdobą — bez niego suwak nie trzyma tego, co
   w nim siedzi. Element pozycjonowany absolutnie (u nas: .sr-only w ostatniej
   komórce nagłówka szerokiej tabeli) szuka najbliższego POZYCJONOWANEGO
   przodka; gdy go nie ma, blokiem obejmującym zostaje okno, więc taki spanik
   wychodzi POZA kontener z overflow i ciągnie za sobą szerokość całego
   dokumentu. Objaw jest nieoczywisty: na /courses/{id}/members przy oknie
   390 px strona ma 630 px i przeglądarka POMNIEJSZA CAŁOŚĆ, żeby ją zmieścić
   (zmierzone 30.08.2026: innerWidth=630 przy viewport 390) — dla człowieka nie
   „przewija się w bok", tylko „wszystko jest małe". Bliźniaczy .list-table-wrap
   (wyżej) ma position: relative od początku i dlatego /team oraz /sessions
   z tą samą tabelą i tym samym .sr-only są zdrowe. */
.u-scroll-x   { overflow-x: auto; position: relative; }
.u-muted      { color: var(--color-fg-muted); }
.u-sm         { font-size: var(--fs-md); line-height: var(--lh-md); }
.u-center     { text-align: center; }
.u-wrap       { flex-wrap: wrap; }
.u-wrap-any   { overflow-wrap: anywhere; }
.u-pre        { white-space: pre-wrap; }
.u-inline     { display: inline; }
.u-block      { display: block; }
.u-full       { width: 100%; }
.u-grow       { flex: 1; min-width: 200px; }
.u-m-0        { margin: 0; }
.u-p-0        { padding: 0; }
.u-mb-0       { margin-bottom: 0; }
.u-mt-3       { margin-top: var(--space-3); }
.u-mt-8       { margin-top: var(--space-8); }
.u-mt-5       { margin-top: var(--space-5); }
.u-mt-6       { margin-top: var(--space-6); }
.u-mt-7       { margin-top: var(--space-7); }
.u-mb-2       { margin-bottom: var(--space-2); }
.u-mb-3       { margin-bottom: var(--space-3); }
.u-mb-5       { margin-bottom: var(--space-5); }
.u-mb-6       { margin-bottom: var(--space-6); }
.u-mb-7       { margin-bottom: var(--space-7); }

/* Nagłówek karty, która się rozwija (<details><summary>) — użyty na stronie
   pliku i w zespole. Ta sama typografia co .card__title, ten sam padding co
   .card__header; strzałka zostaje natywna. Wcześniej cztery kopie
   `style="padding:16px 18px;cursor:pointer;font-weight:700"`, czyli własny
   padding (18 px z boków) i własna waga (700) obok komponentu karty. */
.card__summary {
  padding: var(--space-7); cursor: pointer;
  font-size: var(--fs-h4); line-height: 1; font-weight: 600; letter-spacing: -.025em;
}
.u-my-2       { margin: var(--space-2) 0; }
.u-mt-2       { margin-top: var(--space-2); }
/* Wiersz listy w gęstym kontekście (wybór kursów/kursantów w filtrach sesji):
   sam checkbox z etykietą, bez 16/24 px oddechu, którego potrzebuje wiersz
   z treścią. Zastąpiło trzy kopie `style="padding:.25rem 0"`. */
.list-row--compact { padding: var(--space-2) 0; }
/* Pudełko z listą, która może być długa (wybór kursów i kursantów w filtrach)
   — przewija się w środku zamiast rozpychać stronę. Zastąpiło dwie kopie
   sześcioczłonowego `style="max-height:12rem;overflow-y:auto;border:…"`,
   różniące się między sobą tylko wysokością. */
.u-scrollbox {
  max-height: 200px; overflow-y: auto;
  border: 1px solid var(--color-border); border-radius: var(--radius);
  padding: var(--space-3); margin-top: var(--space-3);
  /* CIEŃ PRZEWIJANIA (24.08.2026): pudełko urywało listę w połowie wiersza
     i nic nie mówiło, że jest tam coś jeszcze — przy siedmiu kursach panel
     filtrów wyglądał, jakby znał tylko pięć. Klasyczna technika dwóch teł:
     nieruchoma biel przy krawędziach ORAZ cień, który przewija się razem
     z treścią; przy górze/dole listy biel go zakrywa, w środku — nie.
     `background-attachment: local` na drugiej parze robi całą robotę,
     bez ani jednej linijki JS i bez `:has()`. */
  background:
    linear-gradient(var(--color-surface) 30%, var(--color-surface-fade)) top / 100% 22px no-repeat,
    linear-gradient(var(--color-surface-fade), var(--color-surface) 70%) bottom / 100% 22px no-repeat,
    radial-gradient(farthest-side at 50% 0, var(--color-scroll-shadow), var(--color-surface-fade)) top / 100% 8px no-repeat,
    radial-gradient(farthest-side at 50% 100%, var(--color-scroll-shadow), var(--color-surface-fade)) bottom / 100% 8px no-repeat;
  background-attachment: local, local, scroll, scroll;
}
.u-ml-5       { margin-left: var(--space-5); }
.u-row--bottom   { align-items: flex-end; }
.u-row--noshrink { flex: none; flex-wrap: nowrap; }

/* ---------- ekran po zakończeniu lekcji (learning-done.html) ----------
   Odpowiednik „You completed the lesson!" z humionboardera: pełna plansza
   w kolorze, gwiazdki, jeden duży komunikat na środku. Sens jest taki, że
   moment ukończenia dostaje własny ekran zamiast paska nad listą ćwiczeń —
   kursant najpierw widzi werdykt, a rozliczenie ogląda dopiero, jeśli chce.

   min-height 100dvh, nie 100vh: na telefonach pasek adresu zjada vh i plansza
   wychodziła poza ekran (ten sam próg przeglądarkowy, co reszta arkusza). */
.done-screen {
  /* fixed inset 0, nie zwykły blok: plansza ma iść OD KRAWĘDZI DO KRAWĘDZI
     okna, a .content (layout.html) otacza swoje dzieci paddingiem — bez tego
     zielone tło kończyło się kilkadziesiąt pikseli przed brzegiem ekranu
     i wyglądało jak wielki kafelek, a nie jak ekran. */
  position: fixed; inset: 0; z-index: 30;
  display: flex; align-items: center; justify-content: center;
  padding: var(--space-8) var(--space-6); text-align: center;
  /* Gradient pionowy, nie płaski kolor — zmierzone u humi: green-400 →
     emerald-400 (linear-gradient w dół). Płaska zieleń wychodziła płaska
     dosłownie: ekran wyglądał jak duży prostokąt farby, a nie jak tło. */
  background: linear-gradient(180deg, var(--color-done-from), var(--color-done-to));
  color: var(--color-done-fg);
  overflow-y: auto;
}
/* Wariant „do poprawy" — ten sam układ, inny kolor: ekran ma nie udawać
   gratulacji, gdy kursant nie zaliczył. */
.done-screen--retry {
  background: linear-gradient(180deg, var(--color-done-retry-from), var(--color-done-retry-to));
  color: var(--color-done-retry-fg);
}
.done-screen__inner { display: flex; flex-direction: column; align-items: center; gap: var(--space-5); max-width: 34rem; }
.done-stars { display: flex; align-items: center; gap: var(--space-3); }
/* Gwiazdki: 96 px na środkowej (wymiar zmierzony na ich ekranie), kontur
   w kolorze tekstu. WYPEŁNIENIE (.is-full) zależy od wyniku — w Figmie
   gwiazdki są wypełnione żółto-kremowe z cieniem, u humi zostały same
   kontury; zgłoszenie Antka („gwiazdy się powinny wypełniać") wraca do
   projektu i dokłada mu znaczenie: pusta gwiazdka to brakujący kawałek
   wyniku, a nie ozdoba, która zawsze wygląda tak samo. */
.done-stars__star { fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linejoin: round; }
.done-stars__star--side { width: 3.75rem; height: 3.75rem; }
.done-stars__star--main { width: 6rem; height: 6rem; }
/* Pusta gwiazdka przygaszona, żeby wypełnione niosły uwagę. */
.done-stars__star:not(.is-full) { opacity: .45; }
.done-stars__star.is-full {
  fill: var(--color-star-fill); stroke: var(--color-star-stroke);
  /* Miękki blask pod gwiazdką — w projekcie wypełnione gwiazdki mają cień,
     który odkleja je od tła; filter zamiast box-shadow, bo cień ma iść za
     KSZTAŁTEM, nie za prostokątem elementu. */
  filter: drop-shadow(0 2px 6px rgb(0 0 0 / 18%));
}
/* 48 px jak u nich; clamp trzyma to w ryzach na wąskim ekranie. */
/* PISMO: nasz zwykły krój, NIE odręczny jak u humi — i to nie jest
   przeoczenie. Ich napis idzie krojem Knewave, który (sprawdzone pomiarem
   glifów 23.08.2026) NIE MA ANI JEDNEGO polskiego znaku diakrytycznego poza
   „ó": ą, ć, ę, ł, ń, ś, ź, ż spadają na font zastępczy, więc „Ukończyłeś
   lekcję!" renderuje się dwoma krojami naraz, w środku wyrazów. Dla rosyjskiego
   i ukraińskiego (mamy oba locale) Knewave nie ma nawet cyrylicy. Font
   odręczny z pełnym pokryciem istnieje (Caveat, Pacifico) — ale to już nie
   byłoby „ich" pismo, tylko nasz wybór, więc czeka na decyzję. */
.done-screen__title {
  margin: 0; font-weight: 700;
  font-size: clamp(1.75rem, 5vw, 3rem); line-height: 1.2;
}
.done-screen__score { margin: 0; font-size: var(--fs-lg); }
.done-screen__hint { margin: 0; opacity: .85; }
/* Plakietka stanu na KOLOROWEJ planszy: własne tło, bo badge--ok/-needs-retry
   są pomyślane na białą kartę (zielony napis na bladozielonym) i na pełnej
   zieleni ginęły — sprawdzone na renderze. Półprzezroczysta biel działa na
   obu wariantach planszy, a napis dziedziczy jej kolor. */
.done-screen__state { margin: 0; }
.done-screen__state .badge {
  background: rgb(0 0 0 / 18%); color: inherit;
  border: 1px solid rgb(255 255 255 / 45%);
  font-size: var(--fs-sm); padding: 4px 12px;
}
.done-screen__actions { display: flex; flex-wrap: wrap; gap: var(--space-3); justify-content: center; margin-top: var(--space-3); }
/* PRZYCISKI NA KOLOROWEJ PLANSZY (Eryk 24.08.2026: „te dwa przyciski, powrót
   do kursu i zobacz swoje wyniki, są niewidoczne, bo są w kolorze tła").
   Warianty z arkusza są policzone na BIAŁĄ kartę: .btn bez wariantu jest
   przezroczysty, a .btn--ghost dostaje jasne tło na hover — na żółtej albo
   zielonej planszy biały napis na jasnym tle dawał kontrast rzędu 2:1,
   czyli napis, którego po prostu nie widać.
   Tu wszystkie akcje poza główną są BIAŁE Z CIEMNYM NAPISEM: kontrast nie
   zależy wtedy od koloru planszy, a hierarchię niesie wypełnienie (pełna biel
   dla „zobacz odpowiedzi", sama obwódka dla „wróć do kursu"). */
.done-screen__actions .btn:not(.btn--primary) {
  background: #fff; color: #1f2937; border-color: #fff;
}
.done-screen__actions .btn:not(.btn--primary):hover { background: #f1f5f9; border-color: #f1f5f9; }
.done-screen__actions .btn--ghost {
  background: rgb(255 255 255 / 12%); color: #fff; border-color: rgb(255 255 255 / 70%);
}
.done-screen__actions .btn--ghost:hover { background: rgb(255 255 255 / 24%); border-color: #fff; }

/* ---------- confetti na ekranie ukończenia lekcji ----------
   Zgłoszenie Antka 23.08: „jakieś confetti". Sypie się WYŁĄCZNIE przy
   zaliczonej lekcji — warunek siedzi w szablonie (learning-done.html), więc
   na ekranie „Jeszcze nie tym razem" tych elementów nie ma w dokumencie
   w ogóle, a nie tylko są ukryte.

   Ruch robi CSS, parametry rozdaje JS przez zmienne (--x, --czas, --zwloka,
   --obrot, --dryf, --barwa) — każdy kawałek leci własnym torem, bo przy
   jednakowych spadałyby równym rzędem jak kurtyna. */
.confetti {
  position: absolute; inset: 0; overflow: hidden;
  pointer-events: none; /* plansza pod spodem ma zostać klikalna */
}
.confetti__piece {
  position: absolute; top: -8vh; left: var(--x);
  width: 11px; height: 11px; background: var(--barwa);
  border-radius: 2px; opacity: 0;
  animation: confetti-lot var(--czas) linear var(--zwloka) forwards;
}
/* Część kawałków wydłużona — jednakowe kwadraty czytałyby się jak piksele,
   nie jak papier. */
.confetti__piece--long { height: 18px; width: 7px; }
@keyframes confetti-lot {
  0%   { opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg); }
  100% { opacity: 1; transform: translate3d(var(--dryf), 110vh, 0) rotate(var(--obrot)); }
}
/* Kto prosi o mniej ruchu, dostaje ekran bez confetti. Warunek jest też w JS
   (nie tworzy wtedy elementów) — tu dla pewności, gdyby ustawienie zmieniło
   się już po wygenerowaniu kawałków. */
@media (prefers-reduced-motion: reduce) {
  .confetti { display: none; }
}

/* ── RAPORT: WERDYKT I WYKRESY ──────────────────────────────────────────────

   Zgłoszenie (Eryk, 23.08.2026): „raport jest za długi i to sama ściana
   tekstu — patrząc na niego, nie da się nic zrozumieć, brak wykresów".
   Kryterium, które rozstrzyga o KOLORACH w tej sekcji: „osoba nietechniczna
   po 10 sekundach patrzenia potrafi powiedzieć, czy dany user radzi sobie
   dobrze czy źle".

   Kolor jest tu więc treścią, nie ozdobą — i dlatego cały ten blok stoi na
   TYCH SAMYCH czterech parach tokenów, którymi cała aplikacja koloruje stany
   (--color-ok-*, --color-warn-*, --color-error-*, plus wyblakłe
   --color-fg-muted na „porzucone"). Żadnej własnej palety raportu: zielony
   na wykresie i zielona plakietka „zaliczone" na liście sesji muszą znaczyć
   dokładnie to samo, bo patrzący przechodzi między tymi ekranami. Tokeny mają
   już wariant dla ciemnego motywu (patrz definicje przy :root), więc ta sekcja
   dostaje go bez ani jednej reguły @media.

   Kolor NIGDY nie jest jedynym nośnikiem: każdy segment paska, każdy słupek
   i każda kropka mają obok siebie liczbę albo podpis (legenda, wartość na
   końcu słupka, title= w SVG). Wydruk mono i widzenie barw nie zabierają
   z tych wykresów informacji, tylko szybkość jej odczytu. */

/* Karta werdyktu — lewa krawędź w kolorze tonu. Cienki pasek, nie tło całej
   karty: pełne tło w --color-error-bg pod tekstem opisu od modelu robiło
   z niego alert („coś się zepsuło"), a to jest wynik pomiaru, nie awaria. */
.report-verdict { border-left: 4px solid var(--color-border); }
.report-verdict--ok      { border-left-color: var(--color-ok-fg); }
.report-verdict--warn    { border-left-color: var(--color-warn-fg); }
.report-verdict--error   { border-left-color: var(--color-error-fg); }
.report-verdict--pending { border-left-color: var(--color-fg-muted); }

/* Górna część: werdykt po lewej, trzy liczby po prawej. Na wąskim ekranie
   liczby schodzą pod werdykt (wrap), a nie ściskają się do jednej cyfry
   w kolumnie. */
.report-verdict__top {
  display: flex; flex-wrap: wrap; gap: var(--space-7);
  align-items: flex-start; justify-content: space-between;
}
.report-verdict__main { flex: 1 1 22rem; min-width: 0; }
.report-verdict__eyebrow {
  margin: 0; text-transform: uppercase; letter-spacing: .06em;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
/* Werdykt: NAJWIĘKSZY tekst na stronie, większy niż <h1> z numerem raportu.
   Hierarchia jest tu odpowiedzią na kryterium dziesięciu sekund — „Raport #4"
   jest identyfikatorem, a to jest treścią. */
.report-verdict__value {
  margin: var(--space-2) 0 0;
  font-size: 1.75rem; line-height: 1.2; font-weight: 700;
}
.report-verdict--ok      .report-verdict__value { color: var(--color-ok-fg); }
.report-verdict--warn    .report-verdict__value { color: var(--color-warn-fg); }
.report-verdict--error   .report-verdict__value { color: var(--color-error-fg); }
.report-verdict--pending .report-verdict__value { color: var(--color-fg-muted); }
.report-verdict__note {
  margin: var(--space-5) 0 0; max-width: 46ch;
  color: var(--color-fg-muted); font-size: var(--fs-sm); line-height: var(--lh-md);
}

.report-verdict__stats {
  display: flex; flex-wrap: wrap; gap: var(--space-8);
  margin: 0; flex: 0 1 auto;
}
.report-verdict__stat dt {
  color: var(--color-fg-muted); font-size: var(--fs-sm); line-height: var(--lh-sm);
}
.report-verdict__stat dd {
  margin: var(--space-2) 0 0;
  font-size: 1.375rem; line-height: 1.2; font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* Druga linia pod liczbą („próg zaliczenia 60%", „z 4 domkniętych") — to
   MIANOWNIK albo SKALA, bez której sama liczba nie mówi nic. Wyblakła
   i mała, bo jest przypisem do liczby nad nią, nie drugą daną. */
.report-verdict__stat-sub {
  font-size: var(--fs-sm) !important; line-height: var(--lh-sm) !important;
  font-weight: 400 !important; color: var(--color-fg-muted);
}
/* Opis od modelu w tej samej karcie, odcięty kreską: to jest UZASADNIENIE
   werdyktu, więc ma być pod nim, a nie w osobnej karcie na trzecim
   przewinięciu (tak było przed zgłoszeniem). */
.report-verdict__text { border-top: 1px solid var(--color-border); }
.report-verdict__text > :first-child { margin-top: 0; }
.report-verdict__text > :last-child { margin-bottom: 0; }
/* Odstępy WEWNĄTRZ opisu od modelu doszczelnione względem domyślnych reguł
   treści: akapit, nagłówek sekcji i lista mają się zmieścić RAZEM z kaflem
   werdyktu na jednym ekranie („podsumowanie na maks. 1 ekran" ze zgłoszenia).
   Domyślne marginesy akapitu i listy dawały ~50 px więcej wysokości, czyli
   dokładnie tyle, ile brakowało na laptopie 1440×900. Mniejsze padding-y
   samej sekcji z tego samego powodu — nie z upodobania do ciasnoty. */
.report-verdict__text { padding-top: var(--space-6); padding-bottom: var(--space-7); }
.report-verdict__text p { margin: 0 0 var(--space-5); }
.report-verdict__text p:last-child { margin-bottom: 0; }
.report-verdict__text ul { margin: var(--space-3) 0 var(--space-6); padding-left: 20px; }
.report-verdict__text ul:last-child { margin-bottom: 0; }
.report-verdict__text li + li { margin-top: var(--space-2); }

/* ── Wykres przebiegu (SVG) ──
   aspect-ratio ODTWARZA proporcje viewBoxa liczonego w Go
   (web.przebiegWidth 720 × web.przebiegHeight 244) i robi to DOSŁOWNIE, jako
   `720 / 244` zamiast skróconego ułamka — te dwie liczby mają być te same, co
   stałe w Go, żeby dało się je porównać wzrokiem.

   ZMIANA KTÓREJKOLWIEK Z TYCH LICZB WYMAGA ZMIANY DRUGIEJ STRONY. Inaczej
   preserveAspectRatio="meet" wyśrodkuje rysunek i doda puste pasy — a od
   chwili, w której podpisy skali stoją w HTML-u i pozycjonują się PROCENTOWO
   względem wysokości ELEMENTU (.rchart__tick, `top` z web.reportSiatka.PctY),
   taki pas przestawia każdy podpis względem jego kreski. Wykres pokazuje wtedy
   „70%" kilkadziesiąt pikseli nad linią 70%.

   DLATEGO NIE MA TU NADPISANIA DLA WĄSKICH EKRANÓW, w odróżnieniu od
   .analytics-chart wyżej (tamten nie ma podpisów w HTML-u, więc może sobie
   pozwolić na inne proporcje na telefonie). Wysokość na wąskim ekranie bierze
   się z WYŻSZEGO viewBoxa (720×244, patrz komentarz przy web.przebiegPlotH),
   nie z rozciągania elementu. Kto tu dopisze `@media (max-width: …) {
   aspect-ratio: … }`, po cichu zepsuje wszystkie podpisy skali.

   UKŁAD (przebudowa po drugiej rundzie zgłoszenia — „jest po prostu kreska…
   nie wiadomo, o co chodzi"):

     .rchart            — całość: rynienka skali + pole + oś X + daty
       .rchart__plot    — position:relative, JEDYNE dziecko w przepływie to SVG,
                          więc wysokość tego pudła == wysokość wykresu. To jest
                          warunek działania podpisów skali: `top` procentowy
                          liczy się względem TEJ wysokości, a Go podaje procent
                          względem viewBoxa. Wstawienie tu czegokolwiek jeszcze
                          w przepływie rozjedzie podpisy z kreskami.
       .rchart__xaxis   — numery podejść, pozycjonowane procentowo (PctX z Go)
       .rchart__dates   — daty skrajne i nazwa osi

   margin-left robi miejsce na rynienkę: podpisy skali są wysunięte POZA pole
   ujemnym `left`, żeby nie zabierały szerokości samemu wykresowi (przy 720
   jednostkach viewBoxa każde 40 px rynienki to realna strata rozdzielczości
   poziomej). */
/* Szerokość rynienki podpisów skali. 4rem, nie mniej: najdłuższy podpis to
   nie liczba, a nazwa kategorii pod osią („bez wyniku") — przy 2,75rem łamała
   się na dwie linie i wchodziła na skrajny znacznik wykresu. Szerzej też nie:
   rynienka zabiera szerokość samemu wykresowi, a przy 720 jednostkach viewBoxa
   każde 16 px to realna strata rozdzielczości poziomej. */
.rchart { --rchart-gutter: 4rem; }
.rchart__plot { position: relative; margin-left: var(--rchart-gutter); }
.rchart-line { display: block; width: 100%; aspect-ratio: 720 / 244; overflow: visible; }
/* Siatka skali Y (100/50/0%) — cienka, ciągła i wyblakła: to TŁO odczytu, nie
   dana. Kreskowana biłaby się z linią progu, która kreskowana być musi (patrz
   niżej), i wykres miałby cztery przerywane linie, z których żadna nie
   wygrywa. */
.rchart-line__grid {
  stroke: var(--color-border); stroke-width: 1;
  /* .55, nie .8 — od chwili, w której linii jest pięć (100/75/50/25/0,
     patrz web.buildReportPrzebieg), poprzednia moc robiła z siatki drabinę
     ciężejszą od samego przebiegu. Ta sama wartość co w .analytics-chart__grid,
     i z tego samego powodu: siatka ma pomagać odczytać wysokość, a nie
     konkurować z danymi. */
  opacity: .55;
  vector-effect: non-scaling-stroke;
}
/* Linia progu zaliczenia — kreskowana, bo to GRANICA, nie dane. Ciągła
   mieszałaby się z przebiegiem. */
.rchart-line__prog {
  stroke: var(--color-warn-fg); stroke-width: 1; stroke-dasharray: 5 4;
  opacity: .7; vector-effect: non-scaling-stroke;
}
/* Oś 0% — dolna krawędź POLA wykresu (pod nią jest jeszcze pasek sesji bez
   wyniku, patrz web.przebiegStripH). */
.rchart-line__axis {
  stroke: var(--color-border); stroke-width: 1; vector-effect: non-scaling-stroke;
}
/* Przebieg: neutralny akcent, nie kolor stanu. Kolory stanów niosą KROPKI —
   linia w kolorze ostatniej sesji sugerowałaby, że cały odcinek ma ten stan. */
.rchart-line__path {
  /* PRZYGASZONA, nie w mocnym akcencie. Powód jest w podtytule tego wykresu:
     „kolor mówi, jak podejście się skończyło" — treść niosą KROPKI, linia jest
     tylko nicią, która pokazuje kolejność. Dopóki biegła w --color-accent-strong
     (prawie czarna w jasnym motywie, prawie biała w ciemnym) i miała 2 px, była
     najcięższą kreską na całej stronie: przy pięćdziesięciu kilku podejściach
     zygzak zjadał zielone i czerwone kropki, czyli dokładnie tę informację,
     po którą ten wykres powstał. --color-fg-muted przy 1,5 px odwraca tę
     hierarchię, nie zmieniając zasady „linia jest neutralna, nie w kolorze
     stanu" (kolor ostatniej sesji sugerowałby, że cały odcinek ma ten stan). */
  fill: none; stroke: var(--color-fg-muted); stroke-width: 1.5;
  stroke-linejoin: round; stroke-linecap: round;
  /* Skalowanie jest jednorodne, więc bez tego obrys rósłby razem z szerokością
     karty i na szerokim ekranie linia byłaby grubsza niż wszystko inne. */
  vector-effect: non-scaling-stroke;
}
/* Kropka: białe wnętrze i obrys w kolorze stanu — czytelna także tam, gdzie
   siedzi na linii przebiegu. Cztery stany = cztery kolory, te same, którymi
   plakietki opisują sesję na liście. */
.rchart-line__dot {
  /* Obrys 2 px, nie 2,5: promień kropki spada do 3 px na gęstym wykresie
     (web.przebiegRGesty), a przy 2,5 px obrysu z takiej kropki zostawał
     kleks bez środka. PROMIEŃ IDZIE Z GO, nie stąd — patrz
     web.reportPrzebiegChart.R. */
  fill: var(--color-surface); stroke-width: 2;
  vector-effect: non-scaling-stroke;
  transition: r .12s ease-out;
}
.rchart-line__dot--ok    { stroke: var(--color-ok-fg); }
.rchart-line__dot--error { stroke: var(--color-error-fg); }
.rchart-line__dot--run   { stroke: var(--color-warn-fg); }
.rchart-line__dot--muted { stroke: var(--color-fg-muted); }
/* Kropka pod kursorem: pełny kolor stanu i większy promień. Atrybut `r`
   nadpisany z CSS (jest animowalny jako właściwość geometryczna), więc skrypt
   nie musi dotykać geometrii — przestawia wyłącznie klasę. */
.rchart-line__dot.is-active { r: 6.5; }
.rchart-line__dot--ok.is-active    { fill: var(--color-ok-fg); }
.rchart-line__dot--error.is-active { fill: var(--color-error-fg); }
.rchart-line__dot--run.is-active   { fill: var(--color-warn-fg); }
.rchart-line__dot--muted.is-active { fill: var(--color-fg-muted); }
/* Sesja BEZ WYNIKU — znacznik na pasku POD osią, wypełniony i wyblakły.
   Wypełniony, a nie obwiedziony jak kropki wyżej, właśnie po to, żeby NIE
   czytał się jako punkt przebiegu: leży poza polem wykresu i nie ma wartości
   (patrz store.SesjaPrzebieg.Wynik). */
.rchart-line__miss { fill: var(--color-fg-muted); opacity: .55; stroke: none; }
.rchart-line__miss.is-active { opacity: 1; }

/* POLA TRAFIENIA: przezroczyste, na całą wysokość, szerokie na cały slot
   podejścia — patrz web.reportPrzebiegPunkt.HitX. pointer-events: all jest
   KONIECZNE: element z przezroczystym wypełnieniem domyślnie nie łapie
   zdarzeń. */
.rchart-line__hit { fill: transparent; pointer-events: all; cursor: crosshair; }
/* Pionowa nitka pod kursorem (wstawiana skryptem). */
.rchart-line__cursor {
  stroke: var(--color-fg-muted); stroke-width: 1; stroke-dasharray: 2 3;
  vector-effect: non-scaling-stroke; pointer-events: none;
}

/* PODPISY SKALI w rynience. Wysunięte POZA pole wykresu ujemnym `left`
   (szerokość rynienki robi margin-left na .rchart__plot), wyrównane do prawej,
   żeby liczby stały równo pod sobą niezależnie od liczby znaków („100%" vs
   „0%"). translateY(-50%) stawia je NA kresce, nie pod nią. `top` przychodzi
   z szablonu jako procent policzony w Go — patrz web.reportSiatka.PctY. */
.rchart__tick {
  position: absolute; left: calc(-1 * var(--rchart-gutter));
  width: calc(var(--rchart-gutter) - 8px); text-align: right;
  transform: translateY(-50%);
  font-size: 11px; line-height: 1; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums; pointer-events: none;
}
/* Podpis progu w kolorze linii progu — to jedyny podpis skali, który opisuje
   DANĄ (granicę zaliczenia), nie tło odczytu, więc ma się wyróżniać. */
.rchart__tick--prog { color: var(--color-warn-fg); font-weight: 600; }
/* „bez wyniku" pod osią: krótsze i mniejsze, bo to nazwa KATEGORII, nie
   wartość skali. Bez tego podpisu dwie szare kropki pod osią wyglądały jak
   śmieć na wykresie. */
.rchart__tick--miss { font-size: 10px; white-space: nowrap; }

/* Oś X: numery podejść, pozycjonowane procentowo (PctX z Go) i wyśrodkowane
   pod kropką. Pudło ma stałą wysokość, bo elementy w środku są wyjęte
   z przepływu. */
.rchart__xaxis {
  position: relative; height: 16px; margin-left: var(--rchart-gutter);
  margin-top: 4px;
}
.rchart__xnum {
  position: absolute; top: 0; transform: translateX(-50%);
  font-size: 11px; line-height: 16px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
/* Daty skrajne i NAZWA OSI pośrodku — nazwa jest tu treścią, nie ozdobą:
   bez niej nie było widać, co odmierza oś pozioma. */
.rchart__dates {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--space-5); margin-left: var(--rchart-gutter); margin-top: 2px;
  font-size: 12px; color: var(--color-fg-muted);
}
.rchart__xlabel { text-transform: lowercase; letter-spacing: .02em; }

/* Podpowiedź: HTML, nie <text> w SVG (skalowałby się razem z wykresem).
   translate(-50%, -100%) z odstępem stawia ją NAD kropką i wyśrodkowaną na
   niej, a pointer-events: none pilnuje, żeby sama nie przechwytywała kursora
   i nie migała. */
.rchart__tip {
  position: absolute; z-index: 1; pointer-events: none;
  transform: translate(-50%, calc(-100% - 12px));
  display: flex; flex-direction: column; gap: 3px;
  padding: 8px 10px; border-radius: var(--radius);
  background: var(--color-fg); color: var(--color-bg);
  font-size: 12px; line-height: 16px;
  box-shadow: 0 6px 16px rgb(0 0 0 / 22%);
  /* GRANICA SZEROKOŚCI, i to ona jest tu naprawą. Wcześniej plakietka miała
     `white-space: nowrap` i całą treść w jednej linii („lekcja · data · stan"),
     więc rozciągała się na ~330 px — przy tym wykresie zasłaniała kilkanaście
     punktów, czyli to, po co ktoś na nią najechał. 16rem mieści dwie linie
     nazwy lekcji i nie przykrywa więcej niż kilka sąsiednich podejść. */
  max-width: 16rem;
}
/* Odbicie pod kropkę — klasa przestawiana skryptem, gdy nad punktem nie ma
   miejsca (punkt na 100% przy górnej krawędzi pola). Sam transform, bez
   ruszania `top`: skrypt podaje tę samą współrzędną kropki w obu wariantach. */
.rchart__tip.is-below { transform: translate(-50%, 12px); }
/* DZIOBEK — plakietka ma WSKAZYWAĆ swój punkt, nie unosić się nad wykresem.
   Bez niego przy pięćdziesięciu kilku podejściach nie dało się powiedzieć,
   o której kropce mówi (nitka pomagała, ale biegnie przez całą wysokość
   i sama jest kreskowana, czyli cicha).
   `left` przychodzi ze skryptu jako --caret, bo plakietka bywa DOSUNIĘTA do
   krawędzi wykresu i wtedy jej środek nie jest już nad kropką. clamp trzyma
   dziobek w prostej części krawędzi, żeby nie wyrastał z zaokrąglonego rogu. */
.rchart__tip::after {
  content: ""; position: absolute;
  left: clamp(10px, var(--caret, 50%), calc(100% - 10px));
  margin-left: -6px; width: 0; height: 0;
  border: 6px solid transparent;
  top: 100%; border-top-color: var(--color-fg); border-bottom-width: 0;
}
.rchart__tip.is-below::after {
  top: auto; bottom: 100%;
  border-top-color: transparent; border-top-width: 0;
  border-bottom-color: var(--color-fg); border-bottom-width: 6px;
}
/* Wąski ekran: jeszcze ciaśniej, bo tu 16rem to już jedna trzecia szerokości
   ekranu. */
@media (max-width: 48rem) {
  .rchart__tip { max-width: 13rem; }
}
/* Nagłówek: numer podejścia i data w jednej, NIEŁAMANEJ linii — to najkrótsza
   treść plakietki i jej złamanie wyglądałoby na usterkę. */
.rchart__tip-head {
  display: flex; gap: var(--space-4); align-items: baseline;
  opacity: .7; white-space: nowrap;
}
.rchart__tip-date { font-variant-numeric: tabular-nums; margin-left: auto; }
/* Wiersz główny: wynik i stan. baseline, żeby 15-punktowa liczba i 12-punktowa
   nazwa stanu stały na jednej linii pisma, a nie środkiem do środka. */
.rchart__tip-main {
  display: flex; gap: var(--space-5); align-items: baseline;
  white-space: nowrap;
}
.rchart__tip-score {
  font-weight: 600; font-size: 15px; line-height: 20px;
  font-variant-numeric: tabular-nums;
}
/* Stan z kropką w KOLORZE STANU — ten sam token, którym pokolorowana jest
   kropka na wykresie, więc plakietka jest oczywiście tą samą rzeczą co punkt
   pod nią. Sama nazwa stanu zostaje w kolorze tekstu plakietki: kolorowy napis
   na ciemnym tle albo gaśnie, albo krzyczy, a tożsamość niesie już kropka
   (dokładnie ta sama zasada co w .analytics-chart__tip-row). */
.rchart__tip-state {
  display: inline-flex; align-items: center; gap: 6px;
  margin-left: auto; opacity: .85;
}
.rchart__tip-state::before {
  content: ""; width: 8px; height: 8px; border-radius: 999px; flex: none;
  background: currentColor;
}
.rchart__tip[data-ton="ok"]    .rchart__tip-state::before { background: var(--color-ok-fg); }
.rchart__tip[data-ton="error"] .rchart__tip-state::before { background: var(--color-error-fg); }
.rchart__tip[data-ton="run"]   .rchart__tip-state::before { background: var(--color-warn-fg); }
.rchart__tip[data-ton="muted"] .rchart__tip-state::before { background: var(--color-fg-muted); }
/* Nazwa lekcji — jedyna część, której WOLNO się złamać, do dwóch linii.
   Nazwy lekcji bywają zdaniami; jedna linia z nowrap była powodem, dla którego
   cała plakietka rozpychała się na pół wykresu. */
.rchart__tip-lesson {
  opacity: .7;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; overflow-wrap: anywhere;
}

/* Legenda — wspólna dla wykresu przebiegu i paska rozkładu, więc kwadracik
   ma JEDNĄ definicję i jeden zestaw wariantów. Nazwy wariantów są
   końcówkami kodów z Go (web.reportSegment.Kod, web.reportPrzebiegPunkt.Ton),
   dlatego jest ich pięć: cztery stany plus „bez wyniku". */
.rlegend, .rstack__legend {
  display: flex; flex-wrap: wrap; gap: var(--space-5) var(--space-7);
  margin: var(--space-6) 0 0; padding: 0; list-style: none;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
.rlegend li, .rstack__legend li { display: flex; align-items: center; gap: 6px; }
.rstack__legend strong { color: var(--color-fg); font-variant-numeric: tabular-nums; }
.rlegend__key {
  width: 10px; height: 10px; flex: none; border-radius: 3px;
  background: var(--color-border);
}
.rlegend__key--ok,      .rlegend__key--passed      { background: var(--color-ok-fg); }
.rlegend__key--error,   .rlegend__key--not_passed  { background: var(--color-error-fg); }
.rlegend__key--run,     .rlegend__key--in_progress { background: var(--color-warn-fg); }
.rlegend__key--muted,   .rlegend__key--abandoned   { background: var(--color-fg-muted); }
.rlegend__key--miss { background: var(--color-fg-muted); opacity: .55; }

/* ── Pasek rozkładu stanów (100%) ──
   overflow:hidden + promień na kontenerze, żeby segmenty nie wystawały poza
   zaokrąglone końce. Wysokość 14 px (nie 8 jak .progress): ten pasek jest
   CZYTANY (cztery kawałki do rozróżnienia), a nie tylko wskazuje postęp. */
.rstack {
  display: flex; height: 14px; border-radius: 999px; overflow: hidden;
  background: var(--color-surface-muted);
}
.rstack__seg { display: block; height: 100%; }
/* Segment o zerowym udziale w ogóle się nie renderuje (szablon go pomija) —
   tu nie ma więc reguły „szerokość 0", bo taki element nie istnieje. */
.rstack__seg--passed      { background: var(--color-ok-fg); }
.rstack__seg--not_passed  { background: var(--color-error-fg); }
.rstack__seg--in_progress { background: var(--color-warn-fg); }
.rstack__seg--abandoned   { background: var(--color-fg-muted); }

/* ── Słupki poziome (trzy wykresy, jeden komponent) ──
   Siatka trzykolumnowa: podpis o STAŁEJ szerokości, tor, wartość. Stała
   szerokość podpisu jest warunkiem czytelności — przy `auto` każdy wykres
   miałby tor zaczynający się w innym miejscu, a trzy wykresy pod rząd
   wyglądałyby na rozjechane. */
.rbars { display: grid; gap: var(--space-5); margin: 0; padding: 0; list-style: none; }
.rbars__row {
  display: grid; grid-template-columns: minmax(0, 16rem) 1fr 3.25rem;
  gap: var(--space-6); align-items: center;
  /* min-height na wysokość dwóch linii podpisu: bez tego wiersze z jedną
     linią i z dwiema miałyby różną wysokość i tory rozjeżdżałyby się w pionie
     względem skali pod nimi. */
  min-height: calc(2 * var(--lh-sm));
}
@media (max-width: 48rem) {
  /* Wąski ekran: podpis nad torem, bo 14rem na etykietę zostawiałoby torowi
     kilkadziesiąt pikseli i słupki przestałyby się różnić długością. */
  .rbars__row { grid-template-columns: 1fr 3.25rem; }
  .rbars__label { grid-column: 1 / -1; }
}
.rbars__label { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
/* Podpis w DWÓCH liniach, nie w jednej uciętej — zgłoszenie „słabo opisane"
   trafiało też tutaj: etykiety mylonych zagadnień to całe pytania z ćwiczeń
   („Które stwierdzenie najlepiej oddaje…"), a jedna linia z wielokropkiem
   ucinała je w miejscu, w którym jeszcze nic nie znaczyły. Dwie linie mieszczą
   pierwsze kilka słów każdego realnego pytania z tego kursu.
   -webkit-line-clamp jest dziś wspierany we wszystkich przeglądarkach, ale
   fallback (overflow:hidden + max-height) zostaje, żeby brak obsługi dał
   ucięcie, a nie rozjechany wiersz. Pełna treść dalej w title=. */
.rbars__name {
  font-size: var(--fs-sm); line-height: var(--lh-sm);
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; max-height: calc(2 * var(--lh-sm));
}
/* Druga linia (kurs, punkty) zostaje JEDNOLINIOWA: to kontekst, nie treść —
   dwie linie kontekstu pod dwiema liniami podpisu rozmyłyby rytm wiersza. */
.rbars__sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rbars__sub { font-size: 12px; line-height: 16px; color: var(--color-fg-muted); }
/* Tor: position:relative pod kreskę progu, overflow:visible żeby ta kreska
   mogła wystawać nad i pod tor (na wysokości samego toru byłaby niewidoczna
   pod wypełnieniem). Zaokrąglenie przenosi się na samo wypełnienie. */
.rbars__track {
  position: relative; height: 10px; border-radius: 999px;
  background: var(--color-surface-muted);
}
.rbars__fill { height: 100%; border-radius: 999px; background: var(--color-border); }
/* KRESKA PROGU ZALICZENIA na torze — odpowiedź GEOMETRIĄ na pytanie „czy 67%
   to dobrze": słupek kończy się przed nią albo za nią. Do tej pory jedyną
   wskazówką był kolor, czyli zagadka bez legendy — i nic z tego nie zostawało
   w wydruku mono ani przy zaburzonym widzeniu barw.

   Wystaje 3 px nad i pod tor, żeby była widoczna także tam, gdzie wypełnienie
   ją przecina. Nad wypełnieniem (z-index), bo ma być widać DOKŁADNIE, po
   której stronie kończy się słupek. */
.rbars__prog {
  position: absolute; top: -3px; bottom: -3px; width: 2px; z-index: 1;
  margin-left: -1px; border-radius: 1px;
  background: var(--color-warn-fg);
  /* OTOCZKA W KOLORZE KARTY, i to nie jest ozdoba. Kreska progu ma
     --color-warn-fg, czyli DOKŁADNIE ten sam kolor co słupek „tuż nad progiem"
     (.rbars__fill--warn) — a takich słupków na tym wykresie jest zwykle
     najwięcej. Kreska znikała więc w słupku właśnie tam, gdzie odpowiedź „po
     której stronie progu" jest najtrudniejsza do odczytania z samej długości.
     Cienki prześwit w kolorze tła karty odcina ją od każdego wypełnienia.
     opacity zdjęte: przy otoczce przygaszenie robiło z kreski zamgloną
     smugę zamiast granicy. */
  box-shadow: 0 0 0 1.5px var(--color-surface);
}
.rbars__fill--ok    { background: var(--color-ok-fg); }
.rbars__fill--warn  { background: var(--color-warn-fg); }
.rbars__fill--error { background: var(--color-error-fg); }
.rbars__value {
  text-align: right; font-variant-numeric: tabular-nums;
  font-size: var(--fs-sm); line-height: var(--lh-sm); font-weight: 600;
}
/* Podświetlenie wiersza pod kursorem — bez JS-u, samym :hover. Daje sygnał
   „to jest interaktywne" i ułatwia prowadzenie wzroku od podpisu do liczby
   przez cały tor, co przy ośmiu wierszach naprawdę bywa potrzebne. */
.rbars__row:hover .rbars__track { background: var(--color-border); }
.rbars__row:hover .rbars__name { color: var(--color-fg); }

/* SKALA pod słupkami — ta sama trzykolumnowa siatka co wiersze, więc „0%"
   i „100%" stoją nad krańcami TORU, a nie nad podpisami. Bez tej skali
   długość słupka nie miała jednostki: „w połowie toru" nie znaczyło nic
   konkretnego. */
.rbars__scale {
  display: grid; grid-template-columns: minmax(0, 16rem) 1fr 3.25rem;
  gap: var(--space-6); margin-top: var(--space-5);
  font-size: 11px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
@media (max-width: 48rem) {
  /* Ta sama zmiana siatki co w .rbars__row na wąskim ekranie — inaczej skala
     przestałaby stać pod torem, który opisuje. */
  .rbars__scale { grid-template-columns: 1fr 3.25rem; }
  .rbars__scale > :first-child { display: none; }
}
.rbars__scale-track { position: relative; display: block; height: 14px; }
.rbars__scale-min { position: absolute; left: 0; }
.rbars__scale-max { position: absolute; right: 0; }
/* Etykieta progu pozycjonowana TYM SAMYM procentem, którym idzie kreska na
   torze (ProgPct z Go) — jedna wartość, dwa użycia, więc nie mogą się
   rozjechać. */
.rbars__scale-prog {
  position: absolute; transform: translateX(-50%);
  color: var(--color-warn-fg); white-space: nowrap;
}
/* Przypis pod wykresem: legenda kolorów jednym zdaniem i (gdy trzeba)
   wyjaśnienie kreski „—". Kolor NIGDY nie jest jedynym nośnikiem znaczenia —
   to zdanie jest drugim nośnikiem, obok kreski progu i liczby na końcu
   słupka. */
.rbars__note {
  margin: var(--space-5) 0 0;
  font-size: var(--fs-sm); line-height: var(--lh-md); color: var(--color-fg-muted);
}

/* Wydruk: wykresy MUSZĄ wyjść na papier — to jest główna droga, którą ten
   raport opuszcza aplikację („Zapisz jako PDF" w nagłówku). Domyślnie
   przeglądarki nie drukują tła elementów, więc słupki i segmenty paska
   wyszłyby BIAŁE — czyli wykres bez ani jednej danej.
   print-color-adjust: exact to jedyny sposób, żeby to wymusić. */
@media print {
  .rstack__seg, .rbars__fill, .rbars__prog, .rlegend__key {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  /* Podpowiedź pod kursorem nie ma czego robić na papierze — a mogłaby zostać
     otwarta w chwili wywołania druku. */
  .rchart__tip { display: none !important; }
  /* Karta werdyktu nie ma się łamać między stronami: werdykt na dole jednej,
     a uzasadniające go liczby na górze drugiej to najgorszy możliwy podział
     tej strony. */
  .report-verdict { page-break-inside: avoid; break-inside: avoid; }
}

/* CIĄG DALSZY TEJ SAMEJ GRANICY — kontrolki, które przy sterowaniu palcem były
   za małe. Lista nie jest zgadnięta: powstała z pomiaru KAŻDEGO widoku
   aplikacji na dziewięciu rozmiarach okna (320/375/414/768/1024 px w obu
   orientacjach) z włączoną emulacją `pointer: coarse`. Zebrane tu, a nie przy
   swoich komponentach, bo to jedna decyzja („palec potrzebuje 44 px"),
   powtórzona dla kilkunastu selektorów — rozsypana po pliku rozjeżdżałaby się
   przy pierwszej zmianie progu.
   MIEJSCE NA SAMYM KOŃCU PLIKU JEST WARUNKIEM DZIAŁANIA, nie porządkiem.
   Połowa tych selektorów ma swój odpowiednik dalej w arkuszu z DOKŁADNIE tą
   samą swoistością (.row-menu__btn ma padding w sekcji menu wiersza,
   .list-table__check input rozmiar w sekcji tabel, .sesje-table th rozmiar
   pisma w sekcji tabeli sesji) — postawione wyżej, przegrywałyby kaskadą
   po cichu, bez żadnego objawu poza „nic się nie zmieniło".
   Żadna reguła tutaj nie dotyka .btn/.btn--sm ani box-shadow na elemencie
   fokusowalnym, więc cel dotykowy z sekcji komponentów i pierścień fokusu
   z sekcji typografii zostają nienaruszone (ostrzeżenie w sekcji powłoki). */
@media (max-width: 767px), (pointer: coarse) {
  /* „⋯" w wierszu tabeli — 30×24 px, najmniejszy cel w całej aplikacji,
     a otwiera menu z „Usuń kurs". Także <a> o tej klasie (kolumna „Logi"
     w tabeli sesji). */
  .row-menu__btn {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 44px; min-height: 44px; padding: 2px var(--space-3);
  }
  /* Pozycje w rozwiniętym menu: 32 px wysokości to dwa palce na trzech
     pozycjach. Panel liczy sobie szerokość sam (row-menu.js), więc rośnie
     tylko w pionie. */
  .row-menu__item { min-height: 44px; }
  /* Ścieżka „Kursy / Kurs / Lekcja" — 12-pikselowy tekst dawał link wysoki
     na 24 px. inline-flex, nie sam padding: linki są krótkie, nic się tu nie
     przycina wielokropkiem, a wiersz ścieżki ma i tak własne zawijanie. */
  .breadcrumbs a { display: inline-flex; align-items: center; min-height: 44px; }
  /* LINKI W KOMÓRKACH TABEL (nazwa kursu, pliku, lekcji, kursanta) — główny
     cel wiersza, a wysoki na 17 px, czyli na tyle, ile ma sam tekst.
     Powiększamy WYŚCIÓŁKĄ, nie `min-height`: pionowa wyściółka elementu
     liniowego powiększa pole trafienia, nie zmieniając układu wiersza — a
     `display: inline-flex` zrobiłby z takiego linku atom, na którym przestaje
     działać wielokropek (.sesje-table__clip, .sesje-table .list-table__main
     przycinają właśnie długie nazwy). Wiersze mają ≥48 px, więc pola trafienia
     sąsiadów nie zachodzą na siebie. */
  .list-table td a:not(.btn):not(.row-menu__btn),
  .courses-table td a:not(.btn):not(.row-menu__btn) { padding-block: 12px; }
  /* Sortowanie klika się w nagłówek — ten sam zabieg. */
  .list-table__sortable a { padding-block: 14px; }
  /* Zaznaczanie wierszy. 24 px, nie 44: kwadrat 44×44 w wierszu tabeli
     wygląda jak pomyłka, a 24 px to minimum WCAG 2.5.8 dla celu, który ma
     wokół siebie wolną przestrzeń — kolumna rośnie do 48 px właśnie po to,
     żeby ją miał. */
  .list-table__check input[type="checkbox"] { width: 24px; height: 24px; }
  td.list-table__check, th.list-table__check { width: 48px; }
  /* Rozwijane „dlaczego?" pod podsumowaniem sesji (16 px wysokości). 14 px
     wyściółki, nie 8: przy 8 wychodziły 32 px, czyli dalej za mało. */
  .session-summary__caveat > summary { padding-block: 14px; }
  /* Rozwijane „Zgłoś problem" — i w nagłówku odtwarzacza lekcji, i pod
     ćwiczeniem. inline-flex zamiast inline-block z sekcji edytora zgłoszeń:
     to jeden krótki napis, nic się w nim nie przycina. */
  .editor__summary { display: inline-flex; align-items: center; min-height: 44px; }
  /* Krzyżyk zamykający arkusz z logami sesji — 32×32. */
  .logs-sheet__close { width: 44px; height: 44px; }
  /* Transport blatu montażowego (edytor przebitek): „◀◀ ◀ ▶ ▶ ▶▶", cofnij,
     ponów, pełny ekran — 32×30 px, czyli najciaśniejszy rząd przycisków
     w aplikacji, a stoją obok siebie z odstępem 2 px. Na dotyku rząd rośnie
     do 44 px i dostaje 6 px przerwy, żeby kciuk nie wciskał sąsiada.
     Sam blat zostaje ciemny i w tym samym układzie — to nadal montażówka,
     tylko klikalna palcem. */
  .bay__btn { width: 44px; height: 44px; flex: none; }
  .bay__buttons { gap: 6px; }
  /* flex: none wyżej jest połową roboty — bez tego „cofnij" i „ponów" (stoją
     w .bay__actions razem z dwoma szerokimi przyciskami zapisu) kurczyły się
     do 17 px szerokości przy 320 px okna, mimo 44 px wysokości. Druga połowa
     to zawijanie rzędu akcji: przy trzech elementach na 288 px nie ma innego
     wyjścia niż druga linia. */
  .bay__actions { flex-wrap: wrap; margin-left: 0; }
  /* Belka kreatora kursu: „Usuń" i „Zapisz szkic" miały 28 px. */
  .wizard-action { min-height: 44px; }
  /* Przełącznik „użyj wszystkich plików": sam suwak zostaje suwakiem (40×22 —
     to jego kształt, nie cel), ale sprzężona etykieta staje się pełnym celem
     dotykowym, a klik w nią przełącza dokładnie to samo. */
  .files-toggle__label { display: inline-flex; align-items: center; min-height: 44px; }
  /* Linki akcji w wierszach kart (spis lekcji kursanta). */
  .list-row a:not(.btn) { padding-block: 10px; }
  /* UCHWYT PRZECIĄGANIA ZNIKA NA EKRANIE DOTYKOWYM — i to nie odbiera żadnej
     funkcji. Kolejność zmienia się przez natywne drag&drop (`draggable` na
     <tr>, skrypt w course.html), a to zdarzenie na dotyku w ogóle nie
     powstaje: uchwyt jest tam martwym rysunkiem. Sam szablon zresztą pisze go
     jako `aria-hidden`, bo dostępną drogą do tej samej operacji są pozycje
     „Przenieś w górę/w dół" w menu wiersza — i one zostają.
     Zysk: 28 px szerokości w tabeli, w której na ekranie 320 px liczy się
     każda kolumna. */
  td.list-table__grip-cell, th.list-table__grip-cell,
  td.courses-table__grip-cell, th.courses-table__grip-cell { display: none; }
}

/* CZYTELNOŚĆ TEKSTU NA EKRANIE DOTYKOWYM — ta sama granica, osobny blok, bo to
   inne kryterium niż wielkość celu. Aplikacja ma kilka miejsc z tekstem 11 px
   (zwarte tabele sesji, skala pod wykresem raportu, przypis w pasku robót).
   Na monitorze to świadoma gęstość i ZOSTAJE bez zmian; w ręce, na ekranie
   trzymanym dalej od oczu niż monitor, 11 px jest poniżej granicy czytelności.
   12 px, a nie 13: chodzi o próg czytelności, nie o przeprojektowanie tabeli —
   przy 13 px zwarta tabela sesji przestaje mieścić kolumny bez przewijania. */
@media (max-width: 767px), (pointer: coarse) {
  .sesje-table th,
  .sesje-table .list-table__sub,
  .sesje-table .badge,
  .sesje-table .score-chip,
  .work-bar__stale,
  .rbars__scale { font-size: 12px; }
}

/* BŁĄD W WIERSZU LISTY PLIKÓW — zwinięty do przycisku (punkt E3, 26.08.2026).
   Te same reguły co .work-bar__error-* (app.css wyżej), osobne nazwy: wiersz
   listy i pasek robót to dwa różne elementy i sklejanie ich selektorów
   znaczyłoby, że zmiana wyglądu jednego rusza drugi bez pytania. */
.list-row__error-more { margin-top: 4px; }
.list-row__error-toggle { font-size: 12px; color: var(--color-danger); cursor: pointer; }
.list-row__error-toggle:hover, .list-row__error-toggle:focus-visible { text-decoration: underline; }
.list-row__error-toggle-hide { display: none; }
.list-row__error-more[open] .list-row__error-toggle-show { display: none; }
.list-row__error-more[open] .list-row__error-toggle-hide { display: inline; }
/* Pełna treść ma SUFIT WYSOKOŚCI, tak jak na pasku: powód istnienia zwinięcia
   nie znika po rozwinięciu — komunikat dostawcy potrafi mieć kilkaset znaków,
   a wiersz tabeli nie jest miejscem na akapit bez końca. */
.list-row__error-full {
  margin: 4px 0 0; font-size: 12px; line-height: 1.45; color: var(--color-danger);
  max-height: 8.7em; overflow-y: auto;
}

/* Dwie akcje w nagłówku strony (punkt E5, 26.08.2026): drugorzędna obok
   głównej. Dotąd każdy nagłówek miał dokładnie jedną akcję, więc kontener nie
   był potrzebny — na stanie systemu są dwie („Sprawdź teraz" i „Preferencje"),
   bo pierwsza przestała być główną, ale nie przestała być potrzebna. */
.page-header__actions { display: flex; gap: 8px; align-items: center; }

/* LISTA „NAUKA” — kolumna postępu (punkt D1 przeglądu widoków, 27.08.2026).

   Pasek i podpis stoją jeden pod drugim w komórce tabeli, o stałej szerokości:
   bez niej kolumna zwężałaby się do najdłuższego podpisu i pasek przestawałby
   być porównywalny między wierszami — a jego jedyny sens to porównywanie
   wzrokiem, w pionie, bez czytania liczb. */
.learning-progress-cell { width: 220px; min-width: 180px; }
.learning-progress-cell .progress { margin-bottom: 6px; }
.learning-progress__label {
  font-size: 0.8125rem;
  color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
/* „do poprawy" na tokenach ostrzeżenia, tych samych, co plakietka o tej samej
   nazwie na stronie kursu — jedna rzecz nie ma prawa mieć dwóch kolorów. */
.learning-progress__retry { color: var(--color-warn-fg); font-weight: 600; }

/* Następna lekcja pod nazwą kursu. Mniejsza i stonowana, bo nazwa kursu jest
   nadrzędna — ale to ONA jest odnośnikiem, który kursant klika najczęściej,
   więc dostaje własny wiersz zamiast dopisku w nawiasie. */
.courses-table__name .learning-next {
  display: block;
  margin-top: 2px;
  font-size: 0.8125rem;
  font-weight: 500;
  /* accent-strong, nie accent: jasne indygo jako kolor TEKSTU daje 4,47:1 na
     bieli i nie przechodzi WCAG AA (pilnuje tego TestAccentContrast). */
  color: var(--color-accent-strong);
}
.courses-table__name .learning-next:hover { text-decoration: underline; }

/* Pasek postępu kursu na stronie kursu u kursanta (punkt D2, 27.08.2026).
   Odsunięty od opisu, bo to osobna odpowiedź — na pytanie „ile mi zostało”,
   a nie „o czym to jest”. */
.course-progress { margin-top: 16px; max-width: 420px; }
.course-progress__label {
  margin: 6px 0 0;
  font-size: 0.8125rem;
  color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}

/* CZAT: zaproszenie w pustej rozmowie (punkt D3 przeglądu widoków, 27.08.2026). */

/* Zdanie o zasięgu — pod powitaniem, w tym samym dymku, ale stonowane:
   to przypis do powitania, nie druga wiadomość. */
.chat-scope {
  margin: 6px 0 0;
  font-size: 0.8125rem;
  color: var(--color-fg-muted);
}

.chat-starters { margin: 12px 0 0; max-width: 620px; }
.chat-starters__heading {
  margin: 0 0 8px;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-fg-muted);
}
.chat-starters__list { display: flex; flex-wrap: wrap; gap: 8px; }
/* Przycisk wygląda jak pytanie do kliknięcia, nie jak akcja formularza:
   pełne zdanie, lewa krawędź, obrys zamiast wypełnienia — żeby cztery obok
   siebie nie krzyczały mocniej niż powitanie nad nimi. */
.chat-starter {
  text-align: left;
  padding: 8px 12px;
  border: 1px solid var(--color-border);
  border-radius: 10px;
  background: var(--color-surface);
  color: var(--color-fg);
  font: inherit;
  font-size: 0.875rem;
  cursor: pointer;
}
/* Kolor tekstu z accent-strong — jasne indygo na bieli nie przechodzi
   WCAG AA (TestAccentContrast). Obrys może zostać jasny: to nie tekst. */
.chat-starter:hover { border-color: var(--color-accent); color: var(--color-accent-strong); }

/* Pole pytania rosnące z treścią. Sufit na pięciu wierszach: wyżej stopka
   zjadałaby rozmowę, po którą się tu przyszło. */
.chat-composer__input {
  resize: none;
  overflow-y: auto;
  min-height: 44px;
  max-height: 8.5rem;
  line-height: 1.4;
  padding-top: 11px;
  padding-bottom: 11px;
}

/* PODPISY PRZY PRZYCISKACH-IKONACH w nagłówku czatu (punkt D3, 27.08.2026).

   Ikony miały aria-label i title, więc czytnik ekranu i najechanie myszą
   radziły sobie od początku — ale człowiek patrzący na ekran musiał zgadnąć,
   co robi plus, a co zegar ze strzałką. Podpis usuwa zgadywanie.

   Na wąskim ekranie podpis znika, a przycisk wraca do samej ikony: cztery
   podpisane przyciski nie zmieszczą się obok tytułu rozmowy, a title i
   aria-label zostają na miejscu, więc nic nie ginie bezpowrotnie. */
/* MODYFIKATOR, NIE :has(). Pierwsza wersja celowała w :has(.chat-iconbtn__label)
   — czytelniej, ale ten arkusz ma :has() ZAKAZANE (TestContentReservesSpaceForWorkBar:
   wymaga Firefoksa 121, czyli progu wyższego niż reszta arkusza). Klasę dokłada
   szablon obok podpisu, więc jedno i drugie powstaje w tym samym miejscu.

   Przyciski-ikony bez podpisu (× zamykające dialogi, kosz w wierszu historii)
   MUSZĄ zostać kwadratowe — 38 px jest tam całym ich kształtem. */
.chat-iconbtn--labeled {
  width: auto; min-width: 38px; padding: 0 12px; gap: 6px;
}
.chat-iconbtn__label { font-size: 0.8125rem; font-weight: 500; white-space: nowrap; }
@media (max-width: 900px) {
  .chat-iconbtn__label { display: none; }
  .chat-iconbtn--labeled { width: 38px; padding: 0; }
}

/* PASEK FILTRÓW, NAGŁÓWKI SORTUJĄCE I STRONY (punkt E2, 27.08.2026). */

.listbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: 0 0 16px;
}
/* .field__control ma width:100% (jest projektowane pod pionowe formularze),
   więc w pasku poziomym KAŻDE pole zajmowałoby własny wiersz. Zdejmujemy tę
   szerokość tutaj, zakresowo — zmiana w samej .field__control ruszyłaby
   wszystkie formularze aplikacji. */
.listbar .field__control { width: auto; }
/* select.js opakowuje <select> w .select (własny rozwijak), a ten wrapper ma
   display:block i width:100% — czyli w pasku poziomym zajmowałby cały wiersz,
   spychając resztę filtrów niżej. Zdejmujemy to zakresowo, razem z szerokością
   pola pod spodem. */
.listbar .select { display: inline-block; width: auto; flex: 0 0 auto; min-width: 170px; }
.listbar .select .select__button { width: 100%; }
/* Pole szukania dostaje całą wolną szerokość, ale nie mniej niż 200 px —
   pole węższe niż fraza, którą się w nie wpisuje, jest gorsze niż brak. */
.listbar__search { flex: 1 1 240px; min-width: 200px; }
.listbar__pick { flex: 0 0 auto; min-width: 170px; }

/* ---------- WYSZUKIWARKA KONTEKSTOWA TABELI ----------
   Komponent: templates/tabela.html, zachowanie: static/js/tabela-szukaj.js.
   Reguły stoją TU, przy pasku filtrów, bo to jest ich sąsiad na ekranie —
   a nie w osobnej sekcji na końcu arkusza, do której nikt nie zajrzy przy
   zmianie wysokości pól filtra.

   DWA STANY, JEDNA WYSOKOŚĆ 36 px — ta sama co .btn i .field__control obok,
   więc rozwinięcie pola nie przesuwa ani nie podnosi wiersza akcji, w którym
   lupka stoi. Rozróżnia je natywny atrybut [open] elementu <details>, nie
   klasa dokładana skryptem: stan rozwinięcia MA jedno źródło prawdy i jest
   nim <details>. Klasa opisuje tylko to, czego <details> nie wie — czy
   w polu stoi fraza (--aktywna). */
.tabela-szukaj { display: inline-flex; align-items: center; }

/* ZWINIĘTA: kwadratowy przycisk-ikona. */
.tabela-szukaj__lupa {
  /* list-style + ::-webkit-details-marker: gaszenie trójkącika ujawniania,
     ta sama para co przy .row-menu__btn (WebKit nie słucha samego list-style). */
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  flex: none; width: 36px; height: 36px;
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-surface); color: var(--color-fg-muted);
  box-shadow: var(--shadow-sm);
}
.tabela-szukaj__lupa::-webkit-details-marker { display: none; }
.tabela-szukaj__lupa:hover { background: var(--color-surface-muted); color: var(--color-fg); }
/* Pierścień fokusu ten sam co u pola i przycisku obok — <summary> jest
   fokusowalne z natury, więc bez tej reguły tabulacja przez wiersz akcji
   zostawiałaby systemowy obrys niepasujący do reszty formularza. */
.tabela-szukaj__lupa:focus-visible {
  outline: none; border-color: var(--color-ring);
  box-shadow: 0 0 0 3px var(--color-ring-shadow), var(--shadow-sm);
}
/* ZAWĘŻENIE WIDAĆ TAKŻE PO ZWINIĘCIU. Escape zwija pole, ale frazy nie kasuje
   (patrz zwin() w module), więc lupka bez tego wyróżnienia stałaby nad listą,
   z której zniknęła połowa wierszy, nie mówiąc o tym ani słowem. Tylko
   w stanie zwiniętym: przy rozwiniętym polu fraza jest po prostu widoczna. */
.tabela-szukaj:not([open]).tabela-szukaj--aktywna > .tabela-szukaj__lupa {
  border-color: var(--color-ring); color: var(--color-fg);
  background: var(--color-surface-muted);
}

/* ROZWINIĘTA: JEDNA KONTROLKA, nie przycisk sklejony z polem. Ramka, tło
   i cień przechodzą na <details>, a lupka i pole tracą swoje — inaczej obok
   siebie stoją dwa pudełka z dwiema ramkami i dwoma cieniami, co po
   rozwinięciu wygląda jak dwie kontrolki zamiast jednej szukajki.
   Ikona zostaje wewnątrz, przy lewej krawędzi pola — tak jak w polu szukania
   w Stripe, z którego wzięty jest cały ten wzorzec. */
.tabela-szukaj[open] {
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-surface); box-shadow: var(--shadow-sm);
}
/* Pierścień na CAŁEJ kontrolce, bo fokus może być na ikonie albo w polu —
   a widać ma być jedno pudełko, nie jego połowa. */
.tabela-szukaj[open]:focus-within {
  border-color: var(--color-ring);
  box-shadow: 0 0 0 3px var(--color-ring-shadow), var(--shadow-sm);
}
.tabela-szukaj[open] > .tabela-szukaj__lupa {
  width: 32px; height: 34px;
  border-color: transparent; background: transparent; box-shadow: none;
}
/* Ikona wewnątrz kontrolki NIE rysuje własnego pierścienia — rysuje go
   przodek (:focus-within wyżej), więc drugi byłby pierścieniem w pierścieniu. */
.tabela-szukaj[open] > .tabela-szukaj__lupa:focus-visible {
  border-color: transparent; box-shadow: none; color: var(--color-fg);
}
.tabela-szukaj__panel { display: inline-flex; align-items: center; gap: var(--space-3); padding-right: var(--space-5); }
/* Pole traci własną ramkę (niesie ją przodek) i lewą wyściółkę (odstęp od
   tekstu robi ikona obok). Szerokość wprost, a nie flex: 1 — komponent stoi
   raz w pasku filtrów, gdzie flex miałby sens, i raz w wierszu akcji obok
   przycisku, gdzie rozepchnąłby go na całą szerokość.
   Swoistość 0,2,0 celowo równa `.listbar .field__control { width: auto }`
   wyżej — rozstrzyga kolejność w pliku, więc ta reguła MUSI zostać pod tamtą. */
.tabela-szukaj .field__control {
  width: 200px; max-width: 40vw; min-height: 34px; padding-left: 0;
  border-color: transparent; background: transparent; box-shadow: none;
}
.tabela-szukaj .field__control:focus-visible { border-color: transparent; box-shadow: none; }
.tabela-szukaj__licznik {
  font-size: var(--fs-sm); line-height: var(--lh-sm);
  color: var(--color-fg-muted); white-space: nowrap;
}
/* „Nic nie pasuje" jako WIERSZ tabeli, nie zdanie pod nią: tabela z samym
   nagłówkiem czyta się jak nieudane wczytanie, a nie jak odpowiedź. */
.tabela-szukaj__brak {
  padding: var(--space-7) var(--space-6); text-align: center;
  color: var(--color-fg-muted); font-size: var(--fs-md); line-height: var(--lh-md);
}
/* Na telefonie rozwinięte pole nie zmieści się obok tytułu ani obok akcji —
   cały komponent bierze wtedy własny wiersz, a pole całą jego szerokość. */
@media (max-width: 640px) {
  .tabela-szukaj[open] { flex: 1 1 100%; }
  .tabela-szukaj[open] .tabela-szukaj__panel { flex: 1 1 auto; }
  .tabela-szukaj[open] .field__control { width: 100%; max-width: none; }
}

/* ---------- SZUKANIE W GRUPIE CHECKBOXÓW ----------
   Komponent: templates/tabela.html ({{define "grupa-szukaj"}}), zachowanie:
   static/js/grupa-szukaj.js. Stoi w oknie „Edytuj członków", nad listą całego
   zespołu.

   ZAWSZE ROZWINIĘTE, w odróżnieniu od lupki tabeli wyżej. Tamta stoi w wierszu
   akcji, obok filtrów i przycisków, i zwinięta do ikony nie zabiera nikomu
   miejsca. Ta jest pierwszą rzeczą w oknie, którego jedyną treścią jest długa
   lista do przejrzenia — chowanie jej za kliknięciem dokładałoby krok do
   czynności, dla której to okno się otwiera. */
.grupa-szukaj {
  display: flex; align-items: center; gap: var(--space-3);
  margin-bottom: var(--space-4); padding-right: var(--space-4); padding-left: var(--space-4);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-surface); box-shadow: var(--shadow-sm);
}
/* Pierścień na CAŁEJ kontrolce (ikona + pole + licznik są jednym pudełkiem) —
   ten sam zabieg co przy rozwiniętej szukajce tabeli. */
.grupa-szukaj:focus-within {
  border-color: var(--color-ring);
  box-shadow: 0 0 0 3px var(--color-ring-shadow), var(--shadow-sm);
}
.grupa-szukaj__ikona { flex: none; color: var(--color-fg-muted); }
/* Pole oddaje ramkę, tło i cień przodkowi, a bierze całą wolną szerokość:
   okno ma stałą szerokość, więc nie ma tu czego rozpychać. min-width: 0 —
   bez tego element formularza w kontenerze flex nie schodzi poniżej swojej
   domyślnej szerokości i wypycha licznik poza ramkę. */
.grupa-szukaj .field__control {
  flex: 1 1 auto; width: auto; min-width: 0; min-height: 34px; padding-left: 0;
  border-color: transparent; background: transparent; box-shadow: none;
}
.grupa-szukaj .field__control:focus-visible { border-color: transparent; box-shadow: none; }
.grupa-szukaj__licznik {
  flex: none; font-size: var(--fs-sm); line-height: var(--lh-sm);
  color: var(--color-fg-muted); white-space: nowrap;
}
/* LISTA MA WŁASNE PRZEWIJANIE, żeby pole szukania nie odjechało w górę razem
   z nią. Bez tego całe okno (.modal__panel, max-height: 84vh) przewija się
   jako jedno: przy stu kontach wpisanie drugiej frazy wymagałoby najpierw
   przewinięcia z powrotem na górę, do pola, którego nie widać. */
.grupa-szukaj__lista { max-height: 40vh; overflow-y: auto; }
/* „Nic nie pasuje" WEWNĄTRZ listy, nie pod nią — pusta ramka po liście czyta
   się jak zepsute okno, nie jak odpowiedź na wpisaną frazę. */
.grupa-szukaj__brak {
  margin: 0; padding: var(--space-6) var(--space-4); text-align: center;
  color: var(--color-fg-muted); font-size: var(--fs-md); line-height: var(--lh-md);
}

/* GDZIE TA LUPKA STOI — i dlaczego nie ma dla niej osobnego paska.
   Pierwsza wersja dokładała nad tabelą własny wiersz (.tabela-pasek). Wyglądał
   źle i słusznie: jedna 36-pikselowa ikona w 24-pikselowej ramce pustki, bez
   niczego po lewej, czyli pas martwej przestrzeni między nagłówkiem a pierwszą
   kolumną. Lupka wchodzi więc WYŁĄCZNIE do wiersza, który na danym ekranie
   już istnieje, i nie tworzy nowego:
     • nagłówek karty z tytułem  → .card__header--actions (Zespół, Lekcje,
       Członkowie, zakładka „Sesje");
     • wiersz akcji nagłówka strony → .page-header__actions (Działy, Duplikaty,
       Ćwiczenia lekcji);
     • sam nagłówek strony, gdy akcji nie ma → .page-header jest już
       space-between, więc lupka staje się jego prawą stroną (Nauka);
     • pasek filtrów → .listbar (Kursy, Baza wiedzy, Sesje lekcji).
   Wszystkie cztery to miejsca, w których i tak stoją kontrolki tej listy. */

/* Nagłówek sortujący wygląda jak nagłówek, nie jak odnośnik: kolor tekstu
   tabeli, bez podkreślenia. Podkreślenie na hover i strzałka przy aktywnej
   kolumnie mówią, że da się kliknąć — pięć niebieskich nagłówków w rzędzie
   wyglądałoby jak menu. */
.sortlink {
  color: inherit;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  white-space: nowrap;
}
.sortlink:hover { text-decoration: underline; }
.sortlink--on { font-weight: 700; color: var(--color-fg); }

.pager {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-top: 1px solid var(--color-border);
}
.pager__count, .pager__page {
  font-size: 0.8125rem;
  color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
.pager__buttons { display: flex; gap: 8px; align-items: center; }
/* Krawędź strony pokazujemy WYSZAROWANYM przyciskiem, nie jego zniknięciem:
   znikający przycisk przesuwa pozostałe pod kursorem, więc drugie kliknięcie
   „dalej” trafia w „wstecz”. */
.btn--disabled {
  opacity: 0.45;
  pointer-events: none;
}

/* ---------- WYSZUKIWARKA GLOBALNA ----------
   Komponent: partial „wyszukiwarka-globalna" w layout.html, wołany WYŁĄCZNIE
   z dashboard.html; zachowanie: static/js/global-search.js, dane:
   GET /api/search (internal/web/search.go).

   Zgłoszenie Eryka (29.08.2026): „search bar w prawym górnym rogu, który
   przeszukuje wszystko w całym systemie; po kliknięciu przenosi na widok",
   a w drugiej turze: „tylko na dashboardzie, a nie na każdym widoku".
   Dlatego to jest jedna reguła dla jednego ekranu, a nie element powłoki
   liczony do wysokości każdej strony w aplikacji.

   PASEK STOI W .content, NIE W .topbar — powód w komentarzu przy markupie:
   .topbar tej aplikacji istnieje wyłącznie poniżej 1024 px, a na desktopie
   stałym chrome jest pasek boczny, więc prawego górnego rogu po prostu nie
   było. Wiersz jest pierwszym dzieckiem kolumny treści i korzysta z jej
   rytmu pionowego (.content > * + *), zamiast dokładać własny margines. */
.content-topbar { display: flex; justify-content: flex-end; }

/* position: relative — panel trafień wisi na tym kontenerze (absolute),
   więc nie rozpycha wiersza i nie przesuwa nagłówka strony pod spodem. */
.gsearch { position: relative; width: 100%; max-width: 420px; }

.gsearch__icon {
  position: absolute; left: var(--space-5); top: 50%; transform: translateY(-50%);
  display: flex; color: var(--color-fg-muted); pointer-events: none;
}
/* Wcięcie z lewej robi miejsce na lupkę SIEDZĄCĄ W POLU, nie obok niego —
   pole i ikona są jednym prostokątem, jak w projekcie. */
.gsearch__input {
  width: 100%; height: 36px; padding: 0 var(--space-5) 0 34px;
  font: inherit; font-size: var(--fs-md); color: var(--color-fg);
  background: var(--color-surface);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
}
.gsearch__input::placeholder { color: var(--color-fg-muted); }
.gsearch__input:focus-visible {
  outline: 2px solid var(--color-ring); outline-offset: 1px; border-color: transparent;
}

/* Panel trafień. z-index nad treścią strony, ale POD paskiem robót i modalami —
   to podpowiedź nawigacyjna, nie okno zadania. */
.gsearch__panel {
  position: absolute; top: calc(100% + var(--space-3)); right: 0; left: 0; z-index: 40;
  max-height: min(60vh, 420px); overflow-y: auto;
  padding: var(--space-3);
  background: var(--color-surface);
  border: 1px solid var(--color-border); border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
}
.gsearch__panel[hidden] { display: none; }

.gsearch__group + .gsearch__group { margin-top: var(--space-5); }
.gsearch__grouphead {
  margin: 0 0 var(--space-2); padding: 0 var(--space-4);
  font-size: var(--fs-sm); line-height: var(--lh-sm);
  font-weight: 600; color: var(--color-fg-muted); text-transform: uppercase; letter-spacing: .04em;
}

/* Trafienie: tytuł nad kontekstem. Kontekst (dział pliku, kurs lekcji, e-mail)
   jest tu po to, żeby pięć podobnie nazwanych „Wprowadzeń" dało się rozróżnić
   bez wchodzenia w każde. */
.gsearch__hit {
  display: flex; flex-direction: column; gap: var(--space-1);
  padding: var(--space-4); border-radius: var(--radius-sm);
  color: var(--color-fg); text-decoration: none;
}
.gsearch__hit:hover, .gsearch__hit--on { background: var(--color-surface-muted); }
/* Klawiatura i mysz mają WSPÓLNE podświetlenie (.gsearch__hit--on ustawia
   skrypt przy strzałkach) — dwa różne wyglądy tego samego stanu czytałyby się
   jak dwa różne stany. */
.gsearch__hit:focus-visible { outline: 2px solid var(--color-ring); outline-offset: -2px; }
.gsearch__hittitle { font-size: var(--fs-md); line-height: var(--lh-md); }
.gsearch__hitmeta {
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
  /* Opis kursu bywa akapitem — w podpowiedzi ma zająć jedną linię. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.gsearch__msg, .gsearch__more {
  margin: 0; padding: var(--space-4);
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}

/* Poniżej 1024 px pasek boczny chowa się w szufladę, a górną belkę przejmuje
   .topbar z hamburgerem — wyszukiwarka zostaje w kolumnie treści i bierze
   całą jej szerokość, bo na telefonie nie ma czego wyrównywać do prawej. */
@media (max-width: 1023px) { .gsearch { max-width: none; } }

/* ---------- KARTA WYKRESU NA PULPICIE: WYSOKOŚĆ DOCIĄGNIĘTA DO SĄSIADA ----------
   Zgłoszenie Eryka (29.08.2026): „puste miejsce pod wykresem jest
   nieakceptowalne — albo kontener nie rośnie razem z listą, albo to miejsce
   musi być czymś zapełnione".

   WYBRANE JEST DRUGIE. Pierwsze podejście (align-items: start) odbierało
   kartom wspólną wysokość i owszem, kasowało rozciąganie — ale zostawiało
   dwie karty kończące się na różnym poziomie, czyli wymieniało jedną wadę
   układu na drugą. Tutaj wykres po prostu ROBI SIĘ WYŻSZY i zajmuje miejsce,
   które i tak było jego.

   PROPORCJE MUSZĄ ODPOWIADAĆ viewBoXOWI, I TO JEST TWARDY WARUNEK, nie
   preferencja. Rysunek i podpisy osi to dwa elementy o wspólnej geometrii:
   siatka jest w SVG, a liczby przy osi są HTML-em pozycjonowanym PROCENTOWO
   względem .analytics-chart__plot (patrz komentarz przy tym elemencie
   w analytics.html). Gdy pudełko dostanie inne proporcje niż viewBox, domyślne
   preserveAspectRatio="xMidYMid meet" wpisuje rysunek w pudełko i CENTRUJE go,
   a podpisy — liczone od pudełka, nie od rysunku — zostają na swoich
   procentach. Skala przestaje się wtedy zgadzać z krzywą: sprawdzone na żywo
   przy proporcji 5/3, gdzie podpis „0" wylądował 100 px pod dnem wykresu.

   Dlatego wysokość wykresu zmienia się PO STRONIE Go (chartHeightPulpit
   w analytics.go = 360 przy chartWidth 720, czyli 2/1), a tutaj stoi tylko
   odbicie tamtej liczby. Zgodności pilnuje TestProporcjeWykresuZgodneZArkuszem
   — bez niego rozjazd tych dwóch miejsc jest niewidoczny do czasu, aż ktoś
   spojrzy na oś.

   Reszta różnicy wysokości (lista bywa o kilkadziesiąt pikseli wyższa) rozkłada
   się NA GÓRĘ I DÓŁ przez wyśrodkowanie treści karty: kilkanaście pikseli nad
   i pod wykresem czyta się jak odstęp, a nie jak dziura. */
.dashboard-chartcard { display: flex; flex-direction: column; }
.dashboard-chartcard .card__body {
  flex: 1; display: flex; flex-direction: column; justify-content: center;
}
/* 2 / 1 = chartWidth / chartHeightPulpit (720 / 360). Nie zmieniaj tu jednej
   liczby bez drugiej — patrz akapit wyżej. */
/* 12/7 (720×420 z Go), nie 2/1: karta wykresu ma wysokość sąsiadującej listy
   wdrożeń, a przy proporcji 2/1 pod rysunkiem zostawało ponad sto pikseli
   pustego tła. Wyższy rysunek jednocześnie rozsuwa serie — różnica między
   tygodniem z 44 a 51 aktywnymi kursantami przestaje być trzema pikselami.
   TA LICZBA MA ODPOWIEDNIK W GO (chartHeightPulpit) i pilnuje tego test
   TestProporcjeWykresuZgodneZArkuszem. */
.dashboard-chartcard .analytics-chart { aspect-ratio: 12 / 7; }

/* ---------- STOPKA DOŁADOWYWANIA LISTY ----------
   Komponent: partial „lista-wiecej" w templates/tabela.html, zachowanie:
   static/js/lista-fragment.js. Przycisk „Pokaż więcej" plus licznik „Pokazano
   25 ze 119" pod długą listą (zespół, wyniki kursantów).

   Wyśrodkowane, nie dosunięte do krawędzi: to zakończenie listy, a nie akcja
   wiersza — pod tabelą na całą szerokość karty przycisk przy lewej krawędzi
   gubi się, a przy prawej wygląda na „dalej" ze stronicowania, którym nie jest
   (patrz uzasadnienie „ile wierszy, nie numer strony" przy porcjaZURL). */
.lista-wiecej {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-3);
  padding: var(--space-6) var(--space-4) var(--space-4);
}
/* Licznik pod przyciskiem, nie obok: obok — przy wąskiej karcie zawijał się
   w drugą linię i rozpychał stopkę wyżej niż sam przycisk.

   Hak to data-wiecej-licznik, a NIE [role="status"]: ten licznik siedzi
   wewnątrz wymienianego obszaru, więc szablon celowo nie daje mu roli
   ogłaszającej (robi to trwała komunikatorka z js/lista-fragment.js) — reguła
   po [role="status"] nie łapała tu ani jednego elementu. */
.lista-wiecej [data-wiecej-licznik] { text-align: center; }

/* PIERWSZY NOWO DOŁADOWANY WIERSZ — cel skupienia i przewinięcia po kliknięciu
   „Pokaż więcej" (js/lista-fragment.js, atrybut ustawiany na chwilę).

   Zgłoszenie Eryka (29.08.2026, druga tura): „widok powinien być na nowym,
   rozwiniętym pierwszym użytkowniku, a nie powinien umożliwiać dalszego
   klikania". Wzorzec zgodny z tym, co robią dopracowane implementacje „load
   more": skupienie idzie na pierwszy NOWY element, nie na przycisk. */

/* scroll-margin-top — wiersz przewinięty na samą krawędź okna wygląda jak
   ucięty; te 24 px to oddech nad nim. Działa też pod belką na wąskim ekranie. */
[data-wiecej-cel] { scroll-margin-top: 24px; }

/* Programowe focus() na tabindex="-1" NIE zapala :focus-visible przy akcji
   myszą, więc obwódki nie widzi ten, kto klika — a widzi ten, kto przyszedł
   klawiaturą. Dokładnie ten podział jest tu potrzebny. */
[data-wiecej-cel]:focus { outline: none; }
[data-wiecej-cel]:focus-visible { outline: 2px solid var(--color-ring); outline-offset: -2px; }

/* Chwilowe podświetlenie: bez niego skok widoku nie mówi, GDZIE zaczyna się
   to, co doszło — a to jest cała treść zgłoszenia. Gaśnie samo, żeby po kilku
   doładowaniach pół listy nie było „nowe" (skrypt zdejmuje atrybut). */
@media (prefers-reduced-motion: no-preference) {
  [data-wiecej-cel] { animation: wiecej-blysk 1.6s ease-out; }
  @keyframes wiecej-blysk {
    from { background: var(--color-surface-muted); }
    to   { background: transparent; }
  }
}

/* WYBÓR STRONY (partial „wybor-strony" w tabela.html) — zgłoszenie Eryka
   (29.08.2026): „przewinięcie 10 stron na raz zajmuje dużo czasu". Plakietka
   z numerem bieżącej strony zamieniona na rozwijaną listę stron (albo pole
   liczbowe, gdy stron jest bardzo dużo — patrz maxOpcjiStron w listy.go).

   JEDEN KOMPLET KLAS NA TRZY EKRANY. Log sesji miał tu przez chwilę własną
   parę (.sesje-pager__*), z czasów zanim wybór strony stał się wspólnym
   partialem — po scaleniu nie pasowała już do żadnego elementu w aplikacji.

   Szerokości ograniczone, bo .field__control jest projektowane pod formularze
   pionowe i ma width:100% — w poziomym pasku stopki zajęłoby cały wiersz,
   spychając „Następną" do drugiej linii. Ta sama poprawka i to samo
   uzasadnienie co przy .listbar .field__control wyżej.

   .sesje-footer__pager w ostatniej regule: log sesji ma własną stopkę
   (sesje.html), ale ten sam wybór strony w środku — więc obie stopki zdejmują
   szerokość tym samym zestawem selektorów. */
.pager__wybor { width: auto; min-width: 5rem; }
.pager__numer { width: 5.5rem; }
.pager .select, .sesje-footer__pager .select { display: inline-block; width: auto; flex: 0 0 auto; min-width: 5rem; }

/* ══════════════════════════════════════════════════════════════════════════
   PANEL KURSANTA (/team/user/{id}) — 01.09.2026
   ══════════════════════════════════════════════════════════════════════════
   Komponenty tej strony, które nie istniały nigdzie wcześniej: pasek
   metadanych konta pod nagłówkiem, odniesienie do grupy pod kaflem
   podsumowania, plakietki ostrzeżeń w karcie werdyktu i szuflady. Reszta
   korzysta z klas, które już były (.card, .session-summary__*, .list-row,
   .usage-table, .rchart, .rbars, .report-verdict, .analytics-progress).

   BYŁ TU TEŻ KALENDARZ AKTYWNOŚCI (.kheat*, 01.09.2026) — usunięty razem
   z sekcją 02.09.2026 („usuń kalendarz, bo dużo z nim problemów"). Nie ma po
   nim ani jednej reguły: martwe klasy w arkuszu tej wielkości to koszt, który
   płaci każdy następny, kto czegoś tu szuka. */

/* ---------- pasek metadanych konta (.entity-meta) ---------- */
/* Rola, działy, „w systemie od", ostatnia aktywność — cztery fakty, których
   panel nie mówił wcale. NIE tabela i nie lista definicji: to jest jeden
   wiersz drobnego druku pod nagłówkiem, który ma się ZAWIJAĆ na wąskim
   ekranie, a nie łamać na cztery pełne wiersze.

   Podpis i wartość jedno pod drugim (kolumna), bo wartości bywają długie
   (dwa działy z nazwami), a układ „podpis: wartość" w jednej linii przy
   zawijaniu rozjeżdża się tak, że nie widać, co należy do czego.

   Dwukropka NIE MA w treści — jest tutaj, przy podpisie: gdyby stał
   w katalogu i18n, każdy język musiałby go powtarzać i połowa i tak by go
   zgubiła (tak było z kursant.last_activity, patrz kursant.html). */
.entity-meta {
  display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-8);
  margin: 0 0 var(--space-6);
}
.entity-meta__item { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.entity-meta__label {
  font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
  color: var(--color-fg-muted);
}
.entity-meta__value { font-size: 13px; color: var(--color-fg); overflow-wrap: anywhere; }
.entity-meta__value a { color: inherit; text-decoration: none; }
.entity-meta__value a:hover { text-decoration: underline; }

/* ---------- odniesienie pod kaflem (.summary-ref) ---------- */
/* Trzecia linia w kaflu podsumowania: wartość grupy i różnica („dział 79.0%
   +5.5 pkt”). Trzecia, czyli PO wartości i podpisie — kolejność czytania to
   „ile ma on / czego to miara / ile ma reszta", a nie odwrotnie.

   11 px i --color-fg-muted, czyli o stopień ciszej niż podpis kafla: to jest
   informacja pomocnicza i nie ma konkurować z liczbą, do której należy.
   Wysokość wiersza dopięta do 14 px, żeby kafle z odniesieniem i bez niego
   („Sesje", „W toku") nie różniły się wysokością w siatce — inaczej rząd
   kafli robi się schodkowy.

   KOLOR RÓŻNICY: zielony/czerwony to TE SAME tokeny, którymi cała aplikacja
   maluje „zaliczone/niezaliczone" (--color-ok-fg / --color-error-fg), a nie
   nowa para. Neutralnej różnicy (czas podejścia) nie kolorujemy wcale —
   krótszy czas nie jest ani lepszy, ani gorszy, więc kolor sugerowałby
   wniosek, którego dane nie niosą (patrz buildKursantOdniesienie). */
.summary-ref {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px;
  margin-top: 2px; min-height: 14px;
  font-size: 11px; color: var(--color-fg-muted);
  font-variant-numeric: tabular-nums;
}
.summary-ref__group { text-transform: lowercase; }
.summary-ref__value { color: var(--color-fg-soft); }
.summary-ref__delta { font-weight: 700; }
.summary-ref__delta--ok { color: var(--color-ok-fg); }
.summary-ref__delta--error { color: var(--color-error-fg); }

/* ---------- plakietki ostrzeżeń w karcie werdyktu ---------- */
/* „Bez postępu od 34 dni" — wiadomość, której werdykt nie niesie, bo ocenia
   jakość odpowiedzi, a nie ruch. Osobny wiersz pod zdaniem werdyktu, nie
   wewnątrz niego: to jest inny fakt, a nie doprecyzowanie tamtego. */
.kursant-alerts { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-4) 0 0; }

/* ---------- szuflada z pozostałymi przekrojami (.kursant-more) ---------- */
/* Trzy wykresy, które odpowiadają na pytania zadawane DOPIERO po tym, jak
   wiadomo, że jest problem. Zwinięte w <details> wewnątrz karty „Gdzie się
   potyka", nie w osobne trzy karty (tak było w pierwszej wersji).
   Obramowanie górne zamiast własnej karty: szuflada należy do tej karty i ma
   się czytać jako jej dalsza część, a nie jako czwarty element na stronie. */
.kursant-more { border-top: 1px solid var(--color-border); }
.kursant-more > summary {
  padding: var(--space-4) 18px; cursor: pointer;
  font-size: 13px; font-weight: 600; color: var(--color-fg-soft);
}
.kursant-more > summary:hover { color: var(--color-fg); }
.kursant-more__body { padding: 0 18px var(--space-6); }
.kursant-more__title { font-size: 14px; font-weight: 700; margin: var(--space-6) 0 var(--space-1); }
.kursant-more__body > .kursant-more__title:first-child { margin-top: 0; }
.kursant-more__hint { font-size: 12px; color: var(--color-fg-muted); margin: 0 0 var(--space-4); }

/* ---------- karta zwinięta w <details> (szuflady „Wszystkie liczby", „Konto") ---------- */
/* <details class="card"> z nagłówkiem karty w <summary>: ten sam wzorzec, co
   „Dane szczegółowe" w raporcie (report.html). Trójkącik rozwijania dostaje
   kursor i podświetlenie, żeby było widać, że to jest do kliknięcia — sam
   nagłówek karty tego nie sugeruje. */
details.card > summary { cursor: pointer; }
details.card > summary:hover .card__title { color: var(--color-fg-soft); }
/* Data w kaflu werdyktu jest DŁUŻSZA od liczby (29.09.2026 kontra 85%), więc
   w tej jednej pozycji schodzi o stopień — inaczej rozpycha siatkę czterech
   kafli i łamie się na dwie linie na węższych ekranach.
   Selektor kwalifikowany przez rodzica, bo `.report-verdict__stat dd` ma
   specyficzność (0,2,1) i sama klasa modyfikatora by z nim przegrała. */
.report-verdict__stat dd.report-verdict__stat--date { font-size: 1.125rem; }

/* ---------- dwa drobne stany tekstowe ---------- */
/* „do poprawy" i „kurs zrobiony w całości" w wierszu kursu. Osobne klasy, nie
   .list-row__meta--error: to nie jest błąd (jak nieudany render wideo), tylko
   stan nauki, i ma nieść ten sam kolor, co plakietki stanów w całej
   aplikacji. */
.u-warn { color: var(--color-warn-fg); font-weight: 600; }
.u-ok { color: var(--color-ok-fg); }

/* ---------- okno „Nowy klucz API" (api_keys.html) ----------
   Wydanie klucza to cztery pytania w jednym oknie: dla kogo, co otwiera, co
   wolno, do kiedy. Przy `max-height: 84vh` panelu ta treść nie mieści się na
   ekranie laptopa, a `.modal__panel` przewija się CAŁY — więc „Wydaj klucz"
   wypadał pod krawędź i okno wyglądało, jakby nie miało akcji. Panel dostaje
   tu układ kolumnowy: nagłówek i stopka stoją, przewija się sama treść. Ten
   sam zabieg co przy .grupa-szukaj__lista wyżej („lista ma własne
   przewijanie, żeby pole szukania nie odjechało w górę razem z nią").

   Padding schodzi z panelu na jego trzy części — inaczej przewijana treść
   podjeżdżałaby pod górny padding okna i tekst ucinałby się w połowie
   wiersza, zamiast znikać za kreską stopki. */
.modal--klucz { max-width: 600px; }
.modal--klucz .modal__panel { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
.klucz-form { display: flex; flex-direction: column; min-height: 0; }
.klucz-form__head { flex: none; padding: var(--space-7) var(--space-7) 0; }
/* min-height: 0 na przewijanej części jest OBOWIĄZKOWE: domyślnie element
   flex nie zmaleje poniżej wysokości swojej treści, więc bez tego okno
   rozepchnęłoby się do 84vh i przewijał się znów cały panel. */
.klucz-form__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 var(--space-7); }
/* Odstęp nad stopką niesie margines ostatniego pola (.field), a nie padding
   przewijanej części — padding-bottom zostawiłby pod treścią pustą taśmę,
   którą widać przy przewinięciu do końca. */
.klucz-form__footer { flex: none; margin: 0; padding: var(--space-5) var(--space-7); border-top: 1px solid var(--color-border); }
/* Siatka pary „Otwiera API / Właściciel" stoi w oknie, nie w panelu filtrów
   logu sesji: tam margin-top jest odstępem od nagłówka panelu, a tutaj
   dokładałby drugi odstęp do marginesu pola stojącego nad nią. Pola w środku
   siatki nie noszą własnego marginesu — odstęp między kolumnami i wierszami
   liczy `gap`, a podwójna wartość rozjeżdżała parę przy zwinięciu do jednej
   kolumny na telefonie. */
.klucz-form .sesje-filters__grid { margin-top: 0; }
.klucz-form__pole { margin-bottom: 0; }
/* Podpowiedź jako AKAPIT (nie <span>): margin-top z .field__hint działa
   tylko na elemencie blokowym, a inline'owy span kleił się do pola. Akapit
   niesie własny margines dolny, więc pod ostatnim w polu trzeba go zdjąć —
   inaczej sumuje się z marginesem pola i para selektów odsuwa się od
   uprawnień dwa razy dalej niż resztka formularza. */
.klucz-form .field > .field__hint:last-child { margin-bottom: 0; }
/* Dwie granice wysokości, obie policzone dla ekranu laptopa (715 px treści
   przeglądarki): panel dostaje 90vh zamiast wspólnych 84vh, a lista uprawnień
   własne 178 px, czyli dwa pełne wiersze i skrawek trzeciego. Skrawek jest
   informacją („jest tego więcej"), a nie niedoróbką — bez niego trzeci
   wiersz nie istnieje na ekranie i nikt nie zgadnie, że lista się przewija.
   Sens podziału jest ten sam co przy .grupa-szukaj__lista: rośnie lista, nie
   formularz, więc pola nad nią i pod nią zostają na swoich miejscach. */
.modal--klucz .modal__panel { max-height: 90vh; }
.klucz-form .assistant-files__list { max-height: 178px; }
/* Stopka niesie DWIE rzeczy: po lewej termin, który wyliczył skrypt, po prawej
   guziki. Termin jest skutkiem tego, co robi guzik obok, więc stoi przy nim,
   a nie w przewijanej treści, z której wypadał pod krawędź. Zawija się w
   dwie linijki i wtedy stopka rośnie — świadomie, bo wysokość stopki
   nie jest tu niczym, co trzeba trzymać na milimetr. */
.klucz-form__footer { justify-content: space-between; align-items: center; flex-wrap: wrap; }
.klucz-form__termin {
  margin: 0; flex: 1 1 12rem; min-width: 0;
  font-size: var(--fs-sm); line-height: var(--lh-sm); color: var(--color-fg-muted);
}
.klucz-form__akcje { flex: none; display: flex; gap: var(--space-3); }
