Що ти зробиш сьогодні
Ти зробиш так, щоб сторінка зрозуміло поводилася у трьох випадках: коли записів у базі немає, коли вони є і коли сервер не відповів на запит. Кожне поле форми отримає свій підпис, кнопка видалення — питання «точно видалити?», а сама сторінка — розміри й кольори, з якими вона читається на телефоні шириною 360 пікселів.
Наприкінці ти впишеш назви своїх елементів у розділ pages файлу project.yaml. Саме звідти автоперевірка дізнається, де на твоїй сторінці шукати список, окремий запис, форму й текст для порожнього стану — сама вона їх не вгадує.
Теорія
1. Інтерфейс і три стани списку
Інтерфейс — усе, що людина бачить на сторінці й чим користується: заголовки, тексти, поля, кнопки, повідомлення. Код бекенду вона не бачить ніколи. Якщо запис не додався, а на екрані нічого не змінилося, для неї зламався весь сервіс, і причина її не цікавить.
Стан — вигляд сторінки в конкретній ситуації. Список записів має три стани, і кожен виникає в реального користувача:
- є дані — у базі є записи, сторінка показує їх;
- порожньо — запит виконався успішно, але записів поки що жодного. Так сторінка виглядає для кожного, хто відкрив сервіс уперше;
- помилка — запит не виконався. Сервер перезапускається, або на телефоні зник інтернет, або помилка в коді ендпоінта. Ендпоінт — окрема адреса твого сервера, за якою він віддає дані:
api/habitsу тебе саме такий.
Є ще четвертий, короткий стан — завантаження: запит уже пішов, відповідь ще не прийшла. На швидкому інтернеті він триває частку секунди, у метро — кілька секунд.
Приклад із життя. Ти вперше відкриваєш новий месенджер. Списку розмов ще немає. Якщо на екрані просто біле поле — ти вирішиш, що застосунок не завантажився, і закриєш його. Якщо там написано «Розмов ще немає. Натисни „плюс“, щоб почати першу» — ти знаєш і що сталося, і що робити далі.
Звідси правило для тексту будь-якого стану: одне речення про те, що сталося, і одне про те, що робити. «Записів ще немає. Додай перший через форму вище» — цього достатньо. «Помилка» без пояснень не годиться: людина не знає, чи винна вона, чи сервіс.
Порожній стан і помилка мають різні тексти, бо вимагають різних дій. При порожньому стані треба щось додати. При помилці додавати марно — треба оновити сторінку за хвилину. Якщо обидві ситуації показують один напис, людина робитиме не те.
Ти зробив два стани списку: «є дані» і «порожньо». Стану «помилка» немає — при невдалому запиті сторінка показує той самий текст, що й при порожній базі. Людина, у якої вже є 20 записів, відкриває сторінку в момент, коли сервіс перезапускається. Що вона побачить і що зробить?
2. Підписи до полів і ті, хто читає сторінку не очима
label — тег-підпис, прив’язаний до конкретного поля вводу. Прив’язка робиться двома атрибутами: у поля є id (власна назва елемента на сторінці), а в підпису — for з точно таким самим значенням.
<label for="f-title">Назва звички</label>
<input id="f-title" name="title" type="text">for="f-title" і id="f-title" — те саме слово, символ у символ. Якщо в одному місці написати f-title, а в іншому ftitle, прив’язки не буде, хоча на екрані все виглядатиме однаково.
Часта заміна підпису — placeholder: сірий текст усередині порожнього поля. Він виглядає акуратно й економить місце, але для підпису не годиться з трьох причин:
- він зникає, щойно людина починає писати. Заповнюючи третє поле, вона вже не бачить, що мало бути в першому;
- він сірий і часто не проходить за контрастом — про це наступний розділ;
- частина програм, що читають сторінку вголос, його не читає.
Екранний диктор — програма, яка читає вміст сторінки вголос людині, що не бачить екрана. Вона є в кожній операційній системі: у Windows це NVDA і «Екранний диктор», в Android — TalkBack, в iPhone — VoiceOver. Така програма читає не картинку, а розмітку. Дійшовши до поля вводу, вона шукає його label і промовляє: «Назва звички, поле для тексту». Якщо label немає — вона скаже «поле для тексту» й нічого більше, і людина не дізнається, що туди писати.
Підпис допомагає й тим, хто бачить екран. Натискання на label ставить курсор у прив’язане поле. На телефоні це означає, що ціль для пальця стає вдвічі більшою: промахнутися по підпису й полю разом важче, ніж по самому полю.
Приклад із життя. Форма реєстрації з трьома однаковими порожніми прямокутниками, у яких сірим написано «Введіть», «Введіть», «Введіть». Ти набрав перше значення, і всі три підказки стали неактуальними: перша зникла, а дві інші однакові. Це та сама помилка, яку робить більшість авторів перших сервісів.
У твоїй формі поля label не мають, зате в кожному є сірий placeholder із назвою поля. На екрані все зрозуміло. Твій сервіс відкриває незряча людина через екранний диктор. Що станеться?
3. Семантична розмітка
Семантична розмітка — це коли тег обирають за змістом того, що всередині, а не за тим, як воно має виглядати. Заголовок сторінки — h1, шапка — header, основний вміст — main, розділ — section, перелік — ul зі вкладеними li, кнопка — button.
Протилежність — <div>. Це тег без власного значення: усередину нього кладуть будь-який вміст, і для браузера це не означає нічого конкретного. Виглядати він може як завгодно, бо вигляд задає CSS. Тому будь-яку кнопку технічно можна зробити з div, і на екрані різниці не буде.
Різниця з’являється поза екраном, і вона в трьох речах:
- клавіатура. Клавіша Tab переходить між елементами, з якими можна щось зробити: полями, посиланнями, кнопками.
buttonу цей перелік потрапляє сам,div— ні. Людина, яка не користується мишею (зламаний тачпад, травма руки), до твоєї кнопки зdivпросто не дістанеться; - екранний диктор. Дійшовши до
button, він каже «кнопка Видалити». Дійшовши доdivз тим самим текстом — просто «Видалити», і людина не знає, що з цим можна щось зробити; - поведінка за замовчуванням.
buttonспрацьовує не тільки від кліка, а й від Enter і Пробіл. Наdivусе це довелося б дописувати вручну.
Приклад із життя. На сайті школи розклад зроблено картинками замість таблиці. Ти не можеш ані скопіювати з нього рядок, ані знайти пошуком своє прізвище, ані нормально відкрити його з телефона. Виглядає воно при цьому нормально. Це та сама історія: вигляд правильний, розмітка ні.
Практичне правило на сьогодні: перш ніж написати div, перевір, чи немає тега з потрібним значенням. Для сьогоднішньої сторінки вистачить шести: header, main, section, h1–h2, ul/li, button, form.
Замість <button>Видалити</button> ти зробив <div class="btn">Видалити</div> і повісив на нього обробник кліка. Мишею все працює, виглядає однаково. Що саме зламалося?
4. Сторінка на екрані шириною 360
CSS-піксель — одиниця, у якій задають розміри в розмітці. Це не точка матриці. Телефон із матрицею на 1080 точок у ширину показує сторінку так, ніби її ширина 360 CSS-пікселів, бо кожен CSS-піксель у нього намальовано трьома точками. Тому 360 — це не «поганий старий телефон», а звичайний сучасний. Саме під це число перевіряють сторінки.
Щоб браузер телефона рахував ширину саме так, у <head> потрібен один рядок:
<meta name="viewport" content="width=device-width, initial-scale=1">Viewport — видима область сторінки у вікні браузера. width=device-width означає «вважай ширину сторінки рівною ширині екрана». Без цього рядка браузер телефона робить вигляд, що екран широкий (близько 980 пікселів), і показує всю сторінку зменшеною — текст стає нечитабельним. Цей рядок уже є у твоєму ~/www/index.html з першого заняття; сьогодні просто перевір, що ти його не видалив.
Горизонтальна прокрутка — коли сторінку доводиться тягати вбік, щоб побачити її праву частину. Вертикальна прокрутка нормальна, горизонтальна — ні: людина не бачить, що там праворуч, і не здогадується туди тягнути. Виникає вона тоді, коли хоч один елемент ширший за екран. Причин зазвичай п’ять:
- ширина задана числом:
width: 600pxна екрані в 360; - до ширини
100%додано ще й поля. За звичайних налаштувань браузер додає внутрішні поля понад задану ширину, тожwidth: 100%плюсpadding: 16pxдає 100% + 32 пікселі; - довге слово чи посилання без пробілів — адреса, назва файлу, ідентифікатор. Розірвати його браузеру нема де, тож він розсовує сторінку;
- таблиця, картинка або блок коду, який ширший за екран і нічим не обмежений;
white-space: nowrapна довгому тексті — прямий наказ не переносити рядок.
Друге, що стосується телефона, — розмір цілі для натискання. Один CSS-піксель — це приблизно 0.26 мм екрана, тож 44 CSS-пікселі дають ціль близько 7 міліметрів. Саме 44 рекомендує Apple, Google називає близьке число — 48 (приблизно 9 мм). Обидва числа взяті з того, скільки місця накриває палець дорослої людини. Кнопка заввишки 20 пікселів вимагає прицілювання, а кнопка «видалити» поруч із кнопкою «відкрити» на такій висоті дає помилкові натискання регулярно.
Третє — розмір шрифту в полях вводу. Якщо в поля шрифт менший за 16 пікселів, браузер на iPhone при натисканні на поле збільшує всю сторінку, і після введення вона лишається збільшеною. Ставити 16 пікселів у полях простіше, ніж потім пояснювати це користувачам.
Приклад із життя. Ти відкриваєш з телефона сайт із розкладом секції. Заголовок видно, а праву частину таблиці — ні, і сторінка їздить убік від кожного дотику. Ти закриваєш її й пишеш тренерові запитання, відповідь на яке була на цій сторінці.
На ноутбуці сторінка виглядає добре, а на телефоні їздить убік. Ти виміряв: сторінка ширша за екран рівно на 32 пікселі. У формі є поле з width: 100% і padding: 16px. Що відбувається?
5. Контраст тексту
Коефіцієнт контрасту — число, яке показує, наскільки колір тексту відрізняється за яскравістю від кольору фону. Рахують його за формулою: кожен колір спершу перетворюють на одне число яскравості від 0 до 1, потім до обох чисел додають 0.05 і ділять більше на менше. Додаток 0.05 потрібен, щоб чорний колір із яскравістю рівно 0 не давав ділення на нуль. Результат лежить між 1:1 і 21:1:
| Текст | Фон | Контраст | Висновок |
|---|---|---|---|
| #000000 | #FFFFFF | 21:1 | максимум, який узагалі буває |
| #1A1A1A | #FFFFFF | ≈17:1 | звичайний основний текст |
| #767676 | #FFFFFF | 4.54:1 | найсвітліший сірий, який ще проходить |
| #999999 | #FFFFFF | 2.85:1 | не проходить |
| #CCCCCC | #FFFFFF | 1.61:1 | практично не видно |
Мінімум для звичайного тексту — 4.5:1. Для великого тексту (від 24 пікселів, або від 18.66 пікселя напівжирного) допускають 3:1, але простіше тримати 4.5 скрізь і не рахувати винятки.
Чому це не формальність. Твій екран новий, яскравість повна, ти сидиш у кімнаті. Той самий текст читатимуть на трирічному телефоні з подряпаною плівкою, на сонці на зупинці, при нічній яскравості 10%, і читатиме людина, у якої зір мінус чотири й окуляри залишилися вдома. При контрасті 2.85:1 у половині цих випадків текст просто зникає.
Звідки береться низький контраст у першому сервісі, майже завжди:
- сірий колір для «другорядного» тексту — дат, підписів, приміток. Саме там опиняється
#999; - властивість
opacity: 0.6на блоці. Вона робить напівпрозорим увесь блок разом із текстом, і контраст падає непомітно для автора; - кольоровий текст на кольоровому фоні: помаранчевий на бежевому, світло-зелений на білому;
- сірий
placeholderусередині поля, який автор використав замість підпису.
Перевіряти контраст на око не можна: людина погано оцінює різницю яскравості двох кольорів, а до кольорів власної сторінки ще й звикає за кілька годин роботи. На кроці 9 практики ти напишеш невеликий скрипт на Python, який рахує це число точно.
Дати записів ти зробив кольором #999999 на білому — виглядає спокійно й акуратно. Скрипт каже: 2.85:1 при потрібних 4.5:1. Що це означає на практиці?
6. Дії, які не можна скасувати
Видалення запису з бази незворотне: рядок зникає, і повернути його можна тільки з резервної копії. Кнопка, яка робить це від одного натискання, рано чи пізно спрацює випадково. На телефоні, де сусідні кнопки стоять за пів сантиметра одна від одної, перше таке натискання зазвичай трапляється в перший же тиждень.
Найпростіша перевірка — питання перед дією. У браузері для нього є вбудована команда confirm: вона показує невелике вікно з текстом і двома кнопками, «ОК» і «Скасувати», і повертає true або false залежно від того, що людина натиснула. Вигляд цього вікна задає сам браузер: у Chrome, Firefox і Safari воно різне — і за розташуванням, і за порядком кнопок. Зате в межах одного браузера воно однакове на всіх сайтах, тож людина його впізнає. Ані розмітки, ані стилів воно не потребує.
Текст питання має називати конкретний запис, а не дію взагалі. «Ви впевнені?» не дає жодної інформації: людина не бачить, що саме зникне, і натискає «ОК» за звичкою. «Видалити запис „Пробіжка, 3 вересня“? Скасувати не вийде» — тут видно і що зникає, і що це назавжди.
Приклад із життя. У галереї телефона ти виділив 20 фотографій, натиснув «видалити» і побачив питання «Видалити 20 фото? Їх можна буде відновити протягом 30 днів». Саме через це питання ти встиг помітити, що виділив зайве. У твоєму сервісі кошика немає: після запиту на видалення рядок зникає з бази остаточно. Тому питання перед дією — єдина перевірка, яка в нього закладена.
Мама відкрила твій сервіс із телефона, хотіла подивитися запис і натиснула кнопку «Видалити» — вона стояла поруч і зникла разом із записом. Що правильно зробити в коді?
Слова цього уроку
| Слово | Що означає |
|---|---|
| інтерфейс | Усе, що людина бачить на сторінці й чим користується |
| стан | Вигляд сторінки в конкретній ситуації: порожньо, є дані, помилка, завантаження |
| семантична розмітка | Тег обирають за змістом (header, button, ul), а не за виглядом |
label | Тег-підпис, прив’язаний до конкретного поля через for та id |
id | Власна назва елемента на сторінці; на одній сторінці не повторюється |
placeholder | Сірий текст усередині порожнього поля; зникає під час введення, підпис не замінює |
| екранний диктор | Програма, що читає розмітку сторінки вголос незрячій людині |
| CSS-піксель | Одиниця розмірів у розмітці; на телефоні їй відповідає кілька точок матриці |
| viewport | Видима область сторінки; рядок meta viewport прирівнює її до ширини екрана |
| горизонтальна прокрутка | Сторінку доводиться тягати вбік, бо якийсь елемент ширший за екран |
box-sizing: border-box | Правило, за яким поля й рамка входять усередину заданої ширини |
| коефіцієнт контрасту | Число від 1:1 до 21:1: різниця яскравості тексту й фону. Мінімум 4.5:1 |
| селектор CSS | Короткий запис, яким вказують на елемент: #list — з id="list", .item — з class="item" |
hidden | Атрибут, який приховує елемент; з JavaScript керується через елемент.hidden |
confirm | Вбудоване вікно браузера з питанням; повертає true або false |
| браузер без вікна | Справжній браузер, запущений без екрана — ним автоперевірка відкриває твою сторінку |
| API | Набір адрес, за якими дані забирає програма, а не людина; у тебе вони починаються з api/ |
| ендпоінт | Окрема адреса всередині API, за якою сервер віддає щось конкретне: api/habits |
| nginx | Програма на сервері, яка приймає всі запити з інтернету й вирішує, кому їх передати |
| служба | Твоя програма на Python, яку система запускає сама й тримає запущеною; зветься club-api |
| код відповіді | Тризначне число, яким сервер каже, чим скінчився запит: 200 — успіх, 404 — адреси немає, 422 — дані не пройшли перевірку, 500 — помилка в коді сервера |
| коміт | Збережений у git знімок стану файлів разом із підписом про те, що змінено |
| репозиторій | Тека, за змінами в якій стежить git; у тебе це ~/app |
async / await | Позначки для функцій, які чекають на відповідь ззовні: await — «зачекай на відповідь і тільки потім іди далі», async — «усередині цієї функції є await» |
Практика
Приклади написані для щоденника звичок: таблиця habit, поля title, day, minutes, ендпоінт списку api/habits. Скрізь підставляй свої назви — ті, що в тебе в project.yaml і в main.py.
1 Розібрати список зауважень
Дістань записи, які ти зробив удома, коли давав сервіс іншій людині. Розділи їх на три групи прямо на аркуші:
- не зрозуміла, що робити — не знайшла форму, не зрозуміла, що вписувати в поле, не побачила, що запис додався;
- не влучила чи не побачила — промахнулася по кнопці, не помітила текст, довелося збільшувати сторінку;
- справді зламано — помилка, порожній екран, запис не зберігся.
Третю групу лагодь останньою: там зазвичай один-два пункти. Перші дві — це саме те, що ти сьогодні виправляєш, і майже все в них стосується речей, які тобі здавалися очевидними.
Проти кожного пункту постав, який крок цього заняття його закриває. Пункти, які не закриває жоден, підуть у домашнє завдання.
2 Зайти на сервер і зберегти копію сторінки
ssh ЛОГІН@91.219.61.4Замість ЛОГІН — свій, наприклад bohdan.g9. Пароль під час введення не показується: ні крапок, ні зірочок.
Сьогодні ти переписуватимеш сторінку великими шматками. Перед цим збережи те, що працює:
cp ~/www/index.html ~/backups/index-$(date +%F).html
ls -l ~/backups/$(date +%F) підставляє сьогоднішню дату у вигляді 2026-09-06, тож копії різних днів не затирають одна одну. Якщо сторінка перестане відкриватися, повернути попередній варіант можна командою cp у зворотному напрямку.
3 Зробити каркас сторінки
nano ~/www/index.htmlСпершу перевір <head>: там мають бути рядок meta charset, рядок meta viewport і <title> з назвою твого продукту. Заголовок вкладки бачить людина в переліку вкладок і в закладках; Document або Скоро тут буде сервіс там бути не повинно.
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Щоденник звичок — Богдан</title>Тепер тіло сторінки. Заміни те, що є всередині <body>, на таку структуру:
<header>
<h1>Щоденник звичок</h1>
<p>Записую, скільки хвилин на день витрачаю на кожну звичку. Автор: Богдан.</p>
</header>
<main>
<section>
<h2>Додати запис</h2>
<form id="add-form">
<!-- поля будуть на кроці 6 -->
</form>
<p id="form-msg"></p>
</section>
<section>
<h2>Мої записи</h2>
<!-- три стани будуть на кроці 4 -->
<ul id="list"></ul>
</section>
</main>
<!-- блок script із заняття 05 лишається нижче, його заміниш на кроці 5 -->Самі теги <body> і </body> у блоці не наведені навмисно: вони у файлі вже є, і копіювати їх удруге не треба. Заміняєш саме те, що між ними — але блок <script> наприкінці не чіпай: він лишається у файлі до кроку 5.
Що тут важливо:
header— шапка сторінки,main— основний вміст,section— окремий змістовий розділ. Диктор і клавіатура використовують ці теги, щоб переходити між частинами сторінки;h1на сторінці один — це назва самого сервісу.h2— заголовки розділів. Пропускати рівні (зh1одразу наh3) не треба;- речення під
h1пояснює, що це за сервіс і для кого. Людина, яка потрапила сюди вперше, має зрозуміти це за п’ять секунд; id="add-form"іid="list"— назви, за якими JavaScript і автоперевірка знаходять форму й список. На кроці 12 ти впишеш їх уproject.yaml;<p id="form-msg">— порожній абзац під формою. У нього код напише, чому сервер не прийняв запис. Такий самий абзац був у тебе на занятті 05; залиш його, бо з ним працює код із кроку 5;<ul>порожній: записи в нього додає JavaScript після відповіді сервера.
Зберегти — Ctrl+O, потім Enter. Вийти — Ctrl+X.
4 Додати три стани списку
Усередину розділу «Мої записи», перед рядком <ul id="list"></ul>, який уже стоїть у файлі з кроку 3, додай три абзаци:
<p id="loading">Завантажую записи…</p>
<p id="empty" hidden>Записів ще немає. Додай перший через форму вище.</p>
<p id="error" hidden>Не вдалося завантажити записи. Онови сторінку за хвилину.</p>Сам <ul id="list"> другий раз додавати не треба: двох елементів з однаковим id на сторінці бути не може, і JavaScript тоді знайде не той.
hidden — атрибут, який приховує елемент: браузер його не показує, поки атрибут стоїть. Тексти при цьому лежать у файлі сторінки, а JavaScript тільки вирішує, який із них показати. Це важливо ще з однієї причини: автоперевірка читає HTML-файл і шукає в ньому текст порожнього стану. Якщо цей текст існує лише всередині JavaScript і збирається під час роботи, скрипт його не знайде.
loading видно одразу, бо запит по дані ще не завершився. Далі спрацює один із трьох варіантів: показати список, показати «порожньо» або показати «помилка».
Тексти пиши свої й до свого сервісу. «Записів ще немає» для щоденника звичок, «Ти ще не додав жодної гри» для списку ігор. Слово «Порожньо» саме по собі не пояснює нічого — воно не каже, що робити далі.
5 Навчити JavaScript перемикати стани
Знайди в кінці файлу блок <script>, який ти додав на занятті 05, і заміни його цілком — від рядка <script> до рядка </script> — на цей. Замінюємо весь блок, а не одну функцію: старий код звертається до елементів, яких після кроку 3 на сторінці вже немає, а якщо лишити старі рядки поруч із новими, у файлі буде двічі оголошено const list, і браузер не виконає жодного рядка скрипту.
<script>
const API = 'api/habits'; // ← адреса твого ендпоінта
const form = document.querySelector('#add-form');
const list = document.querySelector('#list');
const loading = document.querySelector('#loading');
const empty = document.querySelector('#empty');
const errBox = document.querySelector('#error');
const formMsg = document.querySelector('#form-msg');
async function loadItems() {
loading.hidden = false;
empty.hidden = true;
errBox.hidden = true;
list.innerHTML = '';
try {
const response = await fetch(API);
if (!response.ok) throw new Error(response.status);
const items = await response.json();
loading.hidden = true;
if (items.length === 0) {
empty.hidden = false;
return;
}
for (const item of items) {
const li = document.createElement('li');
li.className = 'item';
li.textContent = item.day + ' — ' + item.title + ', ' + item.minutes + ' хв';
list.append(li);
}
} catch (e) {
loading.hidden = true;
errBox.hidden = false;
}
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
formMsg.textContent = '';
const response = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ // ← твої поля, назви з кроку 6
title: document.querySelector('#f-title').value,
day: document.querySelector('#f-day').value,
minutes: Number(document.querySelector('#f-minutes').value)
})
});
if (response.ok) {
form.reset();
await loadItems();
} else if (response.status === 422) {
formMsg.textContent = 'Перевір поля: назва не може бути порожня.';
} else {
formMsg.textContent = 'Сервер відповів помилкою ' + response.status;
}
});
loadItems();
</script>Поля #f-title, #f-day і #f-minutes з’являться на кроці 6 — зараз форма ще порожня, і відправляти в ній нічого. Код звертається до них усередині обробника, тобто в момент натискання кнопки, тому до кроку 6 він просто не виконується й помилки не дає.
Розбір по частинах:
document.querySelector('#list')знаходить елемент за селектором. Селектор CSS — короткий запис, яким вказують на елемент:#listозначає «елемент зid="list"»,.item— «усі елементи зclass="item"»;asyncперед словомfunctionозначає «усередині цієї функції є очікування відповіді», аawaitперед запитом — «зачекай на відповідь і тільки потім іди далі». Безawaitкод помчав би далі, поки сервер ще думає, і в наступному рядку відповіді ще не було б. Обидва слова ти вже писав на занятті 05;- перші чотири рядки функції повертають сторінку в початковий вигляд: показати «завантажую», сховати решту, очистити список. Без цього при повторному виклику старі записи лишилися б на екрані поруч із новими;
list.innerHTML = ''— стерти весь вміст списку.innerHTML— це вся розмітка всередині елемента одним рядком; присвоїти їй порожній рядок означає прибрати звідти всі<li>;fetch(API)— запит по дані. Адреса відносна: браузер добудує її від адреси сторінки й отримає/u/ЛОГІН/api/habits. Тому та сама сторінка працюватиме й тоді, коли на занятті 10 у сервісу з’явиться власне доменне ім’я;- код відповіді — тризначне число, яким сервер повідомляє, чим скінчився запит. 200 і сусідні означають «зроблено», 404 — «такої адреси немає», 422 — «дані не пройшли перевірку», 500 — «помилка в коді на сервері».
response.ok— цеtrueсаме для кодів від 200 до 299. Якщо код інший,throwперериває виконання й передає керування вcatch; tryіcatch— те саме, щоtryіexceptу Python: якщо всерединіtryщось пішло не так, виконується другий блок. Слово інше, зміст той самий. Сюди потрапляють і обрив мережі, і код 500 від сервера;items.length === 0— список порожній. Тоді показуємо «порожньо» і виходимо з функції черезreturn;textContentвставляє текст саме як текст. Якщо людина напише в назві символи<і>, вони лишаться символами й не стануть частиною розмітки. Якби той самий рядок вставили черезinnerHTML, назва<script>…</script>стала б розміткою й виконалася б у браузері кожного, хто відкриє твою сторінку;li.className = 'item'— назва класу для кожного рядка списку. За нею його знаходить і CSS, і автоперевірка;- обробник
submitспрацьовує при натисканні кнопки в формі.event.preventDefault()зупиняє стандартне перезавантаження сторінки, далі значення полів пакуються в JSON і йдуть на сервер методом POST. Якщо сервер відповів 422, у#form-msgз’являється текст про перевірку полів.
6 Дати кожному полю підпис
Заповни форму add-form полями свого сервісу. Приклад для трьох полів:
<form id="add-form">
<p>
<label for="f-title">Назва звички</label>
<input id="f-title" name="title" type="text" required maxlength="200">
</p>
<p>
<label for="f-day">Дата</label>
<input id="f-day" name="day" type="date" required>
</p>
<p>
<label for="f-minutes">Скільки хвилин</label>
<input id="f-minutes" name="minutes" type="number" min="0" max="600" value="0">
</p>
<button type="submit">Додати запис</button>
</form>Що робить кожен атрибут:
forу підпису іidу поля — однакове значення, символ у символ. Це і є прив’язка;name— назва поля, під якою значення піде на сервер. Вона має збігатися з назвою в моделі із заняття 05 — це клас уmain.py, у якому перелічено, які поля сервер очікує в запиті й якого вони типу. Такі класи описують бібліотекою Pydantic, звідси й назва «модель Pydantic». Якщо назви не збігаються, сервер відповість кодом 422 — «дані не пройшли перевірку»;required— браузер не дасть відправити форму з порожнім полем і сам покаже підказку. Це зручність для людини, а не захист: перевірка на сервері все одно потрібна, бо запит можна надіслати й повз форму;type="date"відкриває календар,type="number"на телефоні показує цифрову клавіатуру замість літерної;maxlength,min,max— межі значень. Вони теж лише для зручності: справжні межі стоять у моделі на сервері;type="submit"у кнопки означає «відправити форму». Текст кнопки — дія, яку вона виконує: «Додати запис», а не «ОК».
Підпис має бути в кожного поля вводу без винятку. Якщо якесь поле, на твою думку, і так зрозуміле — підпис усе одно потрібен: автоперевірка рахує їх усі, і людина з диктором теж.
Звір назви полів із кодом. На занятті 05 поля звалися інакше — f-title і f-days, і на сервер ішло days. Якщо в тебе тепер f-day і f-minutes, ті самі три назви мають стояти в трьох місцях: у id поля, у JSON.stringify з кроку 5 і в моделі в main.py. Розбіжність в одному місці дає або 422, або порожнє значення в базі.
7 Запитувати підтвердження перед видаленням
У циклі, який будує рядки списку (крок 5), додай кнопку видалення після рядка li.textContent = …:
const del = document.createElement('button');
del.type = 'button';
del.textContent = 'Видалити';
del.addEventListener('click', async function () {
const yes = confirm('Видалити запис «' + item.title + '»? Скасувати не вийде.');
if (!yes) return;
await fetch(API + '/' + item.id, { method: 'DELETE' });
loadItems();
});
li.append(del);Розбір:
del.type = 'button'— тегbuttonбез явного типу вважається кнопкою відправлення форми. Ця кнопка лежить у<li>поза формою, тож зараз це ні на що не впливає; але щойно така кнопка опиниться всередині<form>, вона відправить форму й перезавантажить сторінку. Тому тип пишуть явно завжди;confirmпоказує вікно з питанням і повертаєtrue, якщо натиснули «ОК», іfalse, якщо «Скасувати». У тексті питання стоїть назва конкретного запису — людина бачить, що саме зникне;if (!yes) return;— натиснули «Скасувати», виходимо з функції й нічого не робимо;method: 'DELETE'— той самий запит на видалення, який ти зробив на занятті 05.API— константа з першого рядка скрипту, тож адреса виходитьapi/habits/17;loadItems()наприкінці перечитує список із сервера. Якщо в базі не лишилося записів, сторінка сама перемкнеться на стан «порожньо» — окремо це програмувати не треба.
8 Задати розміри й кольори
Стилів у твоєму ~/www/index.html ще немає: з першого заняття в <head> стоять тільки meta charset, meta viewport і <title>. Тому створи блок <style> — постав його в <head> одразу після </title> — і встав усередину це. Кольори підбирай свої, але контраст перевір на кроці 9.
<style>
/* сюди піде код, наведений нижче */
</style>* { box-sizing: border-box; }
body {
margin: 0;
padding: 16px;
background: #FFFFFF;
color: #1A1A1A;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header, main { max-width: 640px; margin: 0 auto; }
h1 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; }
h2 { font-size: 19px; margin: 28px 0 10px; }
label {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
input {
width: 100%;
max-width: 340px;
padding: 10px 12px;
font-size: 16px;
color: #1A1A1A;
background: #FFFFFF;
border: 1px solid #767676;
border-radius: 6px;
}
button {
min-height: 44px;
padding: 10px 18px;
font-size: 16px;
color: #FFFFFF;
background: #8A2C0D;
border: 0;
border-radius: 6px;
cursor: pointer;
}
#list { list-style: none; margin: 0; padding: 0; }
.item {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #D6DDE3;
overflow-wrap: anywhere;
}
img, table { max-width: 100%; }Навіщо кожен блок:
* { box-sizing: border-box; }— зірочка означає «кожен елемент сторінки». Правило змінює спосіб рахувати ширину: поля й рамка тепер входять усередину заданої ширини, а не додаються до неї. Це прибирає найчастішу причину горизонтальної прокрутки;#1A1A1Aна білому дає контраст близько 17:1, білий текст на#8A2C0D— близько 8.6:1. Обидва проходять із запасом;max-width: 640pxобмежує ширину тексту на великому екрані. Рядок на всю ширину монітора читати важче: після кінця такого рядка довше шукати початок наступного;label { display: block }ставить підпис окремим рядком над полем. Так підпис не стоїть поруч із полем і не забирає в нього ширину на телефоні;font-size: 16pxу полях — щоб браузер на iPhone не збільшував сторінку при натисканні на поле;min-height: 44pxу кнопок — розмір цілі під палець;overflow-wrap: anywhereдозволяє розірвати довге слово, якщо воно не вміщується. Без цього одна довга адреса в назві запису розсуває всю сторінку. Значення самеanywhere, а не звичнішеbreak-word: у.itemстоїтьdisplay: flex, а всерединіflexбраузер рахує мінімальну ширину по найдовшому нерозривному слову, іbreak-wordцього числа не зменшує;img, table { max-width: 100% }— картинка чи таблиця не буде ширшою за свій контейнер;display: flexу.itemставить текст запису й кнопку в один рядок, аflex-wrap: wrapдозволяє кнопці перейти на наступний рядок, коли місця мало.
9 Порахувати контраст
Створи невеликий скрипт, який рахує коефіцієнт контрасту двох кольорів за офіційною формулою:
nano ~/app/contrast.py"""Коефіцієнт контрасту двох кольорів.
Запуск: /opt/club/venv/bin/python contrast.py 999 ffffff
"""
import sys
def normalize(hex_color):
"""Приводить колір до шести символів: #999 і 999 → 999999."""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 3:
hex_color = "".join(c + c for c in hex_color)
if len(hex_color) != 6:
sys.exit(f"Це не схоже на колір: {hex_color}")
return hex_color
def luminance(hex_color):
"""Яскравість кольору числом від 0 (чорний) до 1 (білий)."""
hex_color = normalize(hex_color)
channels = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)]
linear = []
for c in channels:
linear.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
r, g, b = linear
return 0.2126 * r + 0.7152 * g + 0.0722 * b
if len(sys.argv) != 3:
sys.exit("Запуск: contrast.py КОЛІР_ТЕКСТУ КОЛІР_ФОНУ, наприклад 999 ffffff")
text_color, bg_color = sys.argv[1], sys.argv[2]
a, b = luminance(text_color), luminance(bg_color)
light, dark = max(a, b), min(a, b)
ratio = (light + 0.05) / (dark + 0.05)
verdict = "проходить" if ratio >= 4.5 else "ЗАМАЛО, треба 4.5"
print(f"{text_color} на {bg_color}: {ratio:.2f}:1 — {verdict}")Що робить формула: кожен із трьох каналів кольору (червоний, зелений, синій) переводиться з діапазону 0–255 у частку від одиниці, потім вирівнюється степеневою функцією — так робиться тому, що око сприймає яскравість нерівномірно. Далі три числа складаються з різною вагою: зелений впливає на яскравість найсильніше (0.7152), синій найслабше (0.0722). Отримані яскравості двох кольорів ділять одна на одну, додавши 0.05, щоб чорний не давав ділення на нуль.
Перевір усі свої пари «текст — фон»:
cd ~/app
/opt/club/venv/bin/python contrast.py 1A1A1A FFFFFF
/opt/club/venv/bin/python contrast.py FFFFFF 8A2C0D
/opt/club/venv/bin/python contrast.py 999999 FFFFFF
/opt/club/venv/bin/python contrast.py 999 FFFFFFПерші два мають дати «проходить», третій — «ЗАМАЛО»: це той самий світло-сірий із теорії, і скрипт має його відхилити. Четвертий — той самий колір у короткому записі з трьох символів, який часто пишуть у CSS; функція normalize подвоює кожен символ, тож результат має збігтися з третім рядком. Якщо скрипт відхиляє щось із твоїх кольорів, зроби колір тексту темнішим або фон світлішим і порахуй ще раз.
Запускати треба саме /opt/club/venv/bin/python — це той Python, у якому на сервері встановлено бібліотеки курсу. Той самий шлях ти писав на заняттях 02 і 04. Просто python3 теж запустить цей скрипт (сторонніх бібліотек у ньому немає), але звикай до одного повного шляху, щоб не плутатися далі.
Якщо в тебе на сторінці є блок @media (prefers-color-scheme: dark) з окремими кольорами для темної теми, порахуй і його пари теж. Автоперевірка дивиться на світлу тему, але людина з увімкненою темною побачить саме твої темні кольори.
10 Перевірити сторінку на ширині 360
Спочатку на комп’ютері. Відкрий свою сторінку в браузері, натисни F12 — відкриються інструменти розробника, службова панель браузера. У ній є режим перегляду сторінки як на телефоні: іконка з двома прямокутниками або Ctrl+Shift+M. Постав ширину 360 і перевір три речі:
- сторінка не їздить убік;
- кнопки натискаються пальцем без прицілювання;
- усі підписи й тексти видно повністю, нічого не обрізано.
Якщо сторінка все-таки їздить убік, знайди елемент, який виходить за межі екрана. Перейди у вкладку Console (там виконують команди JavaScript просто на відкритій сторінці) і встав цей код:
document.querySelectorAll('*').forEach(function (el) {
const r = el.getBoundingClientRect();
if (r.right > document.documentElement.clientWidth + 1) {
console.log(el.tagName, el.className, Math.round(r.right));
}
});Код проходить по всіх елементах сторінки й друкує ті, чий правий край виходить за межі екрана: назву тега, клас і координату правого краю. Перший рядок у списку — і є джерело проблеми, решта зазвичай лежить усередині нього.
Потім відкрий сторінку на власному телефоні: http://91.219.61.4/u/ЛОГІН/. Режим у браузері показує розміри, але не показує, як воно натискається пальцем.
11 Перевірити порожній стан
Автоперевірка дивиться на сторінку з порожнім списком. Побачити те саме, не видаляючи своїх записів, можна так: тимчасово додай умову, якої не виконує жоден рядок, у SQL-запит — той рядок у main.py, яким твоя програма питає в бази дані (SELECT * FROM habit ORDER BY id DESC означає «віддай усі рядки таблиці habit, новіші згори»). Відкрий ~/app/main.py і в ендпоінті списку заміни запит:
return query("SELECT * FROM habit WHERE 1 = 0 ORDER BY id DESC")Умова 1 = 0 хибна для кожного рядка, тож запит поверне порожній список. Записи при цьому лишаються в базі недоторканими. Перезапусти службу й онови сторінку:
systemctl --user restart club-apisystemctl — команда, якою в Linux керують службами. Ключ --user означає «моя власна служба, а не загальносерверна», club-api — назва твоєї служби, тієї програми на Python, яку система тримає запущеною. Ці самі команди ти писав на заняттях 02, 03 і 05. Без перезапуску правки в main.py ні на що не вплинуть: служба тримає в пам’яті ту версію коду, з якою її запустили.
Ти маєш побачити свій текст порожнього стану, а не порожнє місце й не «завантажую». Якщо все правильно — поверни запит як був і перезапусти службу ще раз:
nano ~/app/main.py
systemctl --user restart club-apiНе забудь повернути запит. Якщо лишити WHERE 1 = 0, пункт L06.1 зарахується, а L04.3 із четвертого заняття перестане проходити: список віддаватиме нуль записів, хоча в базі вони лишаються.
Стан «помилка» перевіряється ще простіше: зупини службу, онови сторінку, подивись на текст, запусти службу назад.
systemctl --user stop club-api
# подивись на сторінку в браузері
systemctl --user start club-apiЯкщо після stop сторінка все одно показує список. У частині груп служби налаштовані так, що система піднімає їх від першого ж запиту. Тоді зупинена служба стартує назад, щойно сторінка звернеться до api/, і помилки ти не побачиш. Це не поломка. Перевір стан «помилка» інакше: у main.py тимчасово зміни адресу ендпоінта списку (наприклад, api/habits на api/habits-x), перезапусти службу — сторінка отримає код 404 і покаже текст помилки. Потім поверни адресу як була.
12 Заповнити розділ pages і зробити коміт
nano ~/app/project.yamlЗнайди розділ pages і впиши селектори своїх елементів:
pages:
index: "/"
list_container: "#list"
item: ".item"
empty_state: "#empty"
form: "#add-form"Що означає кожен рядок:
index— адреса головної сторінки відносно твого сайту. Лишай/;list_container— селектор елемента, усередині якого лежать записи. У прикладі це<ul id="list">;item— селектор одного запису. У прикладі кожен<li>отримуєclass="item", тому.item;empty_state— селектор абзацу з текстом «записів ще немає»;form— селектор форми додавання. Саме всередині неї автоперевірка шукатиме поля й підписи до них.
Значення мають збігатися з тим, що справді написано в HTML. Якщо в розмітці id="items", а в project.yaml — #list, скрипт нічого не знайде й пункт не зарахує.
Тепер збережи зміни в git. Репозиторій — тека, за змінами в якій стежить git; у тебе це ~/app. Сторінка лежить не там, а в ~/www, тому git її не бачить і ніколи не побачить — спершу треба покласти в репозиторій її копію. Файл www-index.html ти завів для цього ще на занятті 03:
cp ~/www/index.html ~/app/www-index.html
cd ~/app
git add -A
git commit -m "Три стани списку, підписи до полів, підтвердження видалення"
git push origin masterПорядок команд саме такий і має значення. Коміт — збережений у git знімок стану файлів разом із підписом про те, що змінено; у знімок потрапляє тільки те, що лежало в репозиторії на момент git add. Якщо копіювати сторінку після коміта, у ньому будуть project.yaml і contrast.py, а сторінки, названої в повідомленні, — ні.
git push origin master надсилає коміти в спільне сховище. Гілка в наших проєктах зветься master; ту саму команду ти писав на заняттях 03, 04 і 05. Слово main тут не спрацює: такої гілки в репозиторії немає, і git відповість src refspec main does not match any.
Який файл головний. На занятті 03 порядок був зворотний: правити ~/app/www-index.html, а потім копіювати в ~/www/. Сьогодні кожну правку треба одразу дивитися в браузері, тому всі 12 кроків правлять саме ~/www/index.html, а в репозиторій копіюють готовий результат наприкінці. Головний файл — той, що в ~/www/. ~/app/www-index.html — його копія для git, а не другий варіант сторінки: редагувати його не треба, він щоразу перезаписується командою cp.
Що перевіряє автоматика
Чотири пункти. Перший — обов’язковий мінімум заняття: без нього решта не рахується. Усі чотири скрипт перевіряє сам, викладач тут нічого не оцінює очима.
Три з чотирьох пунктів вимагають браузера без вікна — це справжній браузер, запущений на сервері без екрана: він відкриває твою сторінку, виконує JavaScript, будує розмітку й дає скрипту виміряти те, що вийшло. Через це має значення, чи працює твоя сторінка насправді, а не тільки те, що написано у файлі.
| Код | Критерій | Що робить скрипт | Якщо не зараховано |
|---|---|---|---|
| L06.1 | порожня база: сторінка пояснює, що записів немає | Бере селектор із pages.empty_state і перевіряє, що такий елемент на сторінці є і в ньому є текст щонайменше з трьох слів. Далі робить копію твоєї бази, прибирає з копії всі записи й відкриває сторінку так, щоб список прийшов порожнім. На екрані має бути видно саме цей текст, а не порожнє місце й не напис «завантажую». Твоя справжня база не змінюється. |
Перевір по черзі: чи заповнено pages.empty_state; чи збігається селектор з id у розмітці; чи лежить текст у самому HTML, а не збирається в JavaScript; чи знімає код рядок loading.hidden = true перед тим, як показати порожній стан. Перевірити своїми очима — крок 11. |
| L06.2 | кожен input має свій label | Знаходить форму за селектором з pages.form, збирає всі поля вводу всередині неї (input, select, textarea, крім прихованих і кнопок) і для кожного шукає підпис: або <label for="…"> зі значенням, що дорівнює id поля, або label, усередині якого це поле лежить. Достатньо одного поля без підпису, щоб пункт не зарахувався. |
Найчастіше причина — друкарська помилка: for та id відрізняються однією літерою або регістром. Друга причина — поле, яке ти вважав очевидним і лишив без підпису: поле дати, поле кількості. Третя — форма з іншим id, ніж записано в pages.form. |
| L06.3 | немає горизонтальної прокрутки на 360 px | Відкриває сторінку у вікні шириною 360 пікселів, дочікується, поки завантажиться список, і порівнює повну ширину вмісту з шириною вікна. Якщо вміст ширший — пункт не проходить. | Знайди елемент, що виходить за межі екрана, кодом із кроку 10: він друкує такі елементи списком. Найчастіші причини — забутий рядок * { box-sizing: border-box; }, ширина в пікселях замість відсотків, довге слово чи адреса в назві запису без overflow-wrap: anywhere, картинка чи таблиця без max-width: 100%. |
| L06.4 | контраст тексту не нижчий за 4.5:1 | Бере основні текстові елементи сторінки — заголовки, абзаци, підписи, записи списку, — зчитує колір тексту й колір фону, який під ним фактично опинився, і рахує коефіцієнт контрасту за тією самою формулою, що й твій contrast.py. Мінімум для кожного — 4.5. |
Прогони свої пари кольорів через contrast.py (крок 9). Дивись у бік світло-сірих кольорів для дат і приміток та властивості opacity: вона робить напівпрозорим текст разом із блоком і знижує контраст непомітно. Якщо не знаєш, який саме елемент не пройшов, подивись у кабінеті — скрипт пише туди назву тега й отримане число. |
Домашнє завдання
- Закрити щонайменше три пункти зі списку зауважень. Це ті пункти з кроку 1, які не закрив жоден крок заняття. У повідомленні кожного коміта пиши, який саме пункт закрито:
git commit -m "Кнопка додавання не була видна на телефоні — перенесено під форму". На наступному занятті ці повідомлення читатимуть. - Показати сервіс ще одній людині. Іншій, не тій, що на занятті 05. Дай адресу, нічого не пояснюй і засікай час: скільки минуло від відкриття сторінки до першого доданого запису. Запиши число й те, на чому вона зупинялася.
- Перевірити з телефона в поганих умовах. Відкрий сторінку надворі при денному світлі й з яскравістю екрана десь на чверть. Те, що зникає, — саме те, що не пройде за контрастом.
- Заголовок вкладки й опис. Перевір, що
<title>містить назву твого продукту, а перший абзац підh1одним реченням пояснює, що це і для кого. Найкоротша перевірка: прочитай це речення вголос комусь, хто про твій проєкт не чув. - Порожній стан для помилки. Переконайся, що текст стану «помилка» не збігається з текстом стану «порожньо» і що обидва пояснюють, що робити далі. Перевір обома способами з кроку 11.
На початку наступного заняття буде питання «поясни рядок»: викладач покаже будь-який рядок із твого index.html — з розмітки, зі стилів або зі скрипту — і попросить сказати, що він робить.
Якщо щось не працює
Текст «записів ще немає» видно завжди, навіть коли записи є
Атрибут hidden у розмітці не проставлено, або JavaScript не знімає його правильно. Перевір два місця. Перше: у файлі має бути <p id="empty" hidden> — саме зі словом hidden у тезі. Друге: у функції loadItems перший блок рядків має ховати обидва повідомлення (empty.hidden = true, errBox.hidden = true) перед запитом, і тільки потім, за результатом, один із них показуватися.
Протилежна ситуація — порожній стан не показується ніколи — зазвичай означає, що loading лишився видимим і перекрив логіку. Перевір, що рядок loading.hidden = true виконується до перевірки items.length === 0.
Автоперевірка не бачить підписів, хоча вони на сторінці є
Порівняй for та id символ у символ: f-title і ftitle — різні значення, F-Title і f-title теж. Швидко подивитися всі пари можна прямо на сервері:
grep -o 'for="[^"]*"' ~/www/index.html
grep -o 'id="[^"]*"' ~/www/index.htmlgrep -o друкує не цілі рядки, а лише те, що збіглося зі зразком. Кожному значенню з першого списку має відповідати таке саме в другому. Друга можлива причина — поля лежать поза формою: скрипт шукає їх усередині елемента з pages.form, і поле, яке стоїть після </form>, він не побачить, але й підпис до нього не порахує.
Сторінка все одно їздить убік, хоча box-sizing додано
Запусти код із кроку 10 у вкладці Console і подивись на перший елемент у списку. Три найчастіші причини, яких box-sizing не усуває:
- довгий текст без пробілів у назві запису — адреса, назва файлу. Додай
overflow-wrap: anywhereелементу.item. Самеanywhere: у.itemстоїтьdisplay: flex, а там значенняbreak-wordмінімальної ширини не зменшує; - ширина, задана числом:
width: 500pxзамістьmax-width: 500px. Різниця в тому, щоmax-widthдозволяє елементу стати вужчим, коли екран малий; - таблиця. Якщо на сторінці є таблиця, загорни її в
<div style="overflow-x: auto">— тоді вбік їздитиме сама таблиця всередині свого блоку, а не вся сторінка.
Список порожній, а в базі записи є
Це стан «помилка», який ти прийняв за «порожньо», або навпаки. Відкрий інструменти розробника (F12), вкладку Network — там видно всі запити сторінки. Онови сторінку й знайди рядок із api/. Дивись на код відповіді:
- 200, а в тілі відповіді
[]— сервер відповів «зроблено», але список порожній:[]— це порожній список у форматі JSON, дві квадратні дужки без нічого всередині. Найімовірніше, у запиті лишилася умоваWHERE 1 = 0з кроку 11; - 404 — «такої адреси немає»: адреса в
fetchне збігається з адресою в@app.get. Перевір, що вfetchнаписано'api/habits'без косої на початку: коса на початку означає корінь сервера, а не твою теку; - 500 — помилка в коді самого ендпоінта. Текст помилки шукай у журналі служби (це записи, які твоя програма надрукувала під час роботи):
journalctl --user -u club-api -n 30.
Змінив файл, а в браузері старий вигляд
Спершу перевір, що змінився саме той файл: сторінку віддає ~/www/index.html, а не копія в ~/app. Потім онови сторінку з очищенням збереженої копії — Ctrl+Shift+R (на Mac Cmd+Shift+R): браузер зберігає файли сторінок у себе, щоб не завантажувати їх щоразу, і без цього може показувати вчорашній варіант.
Якщо після редагування сторінка стала порожньою або зникла її частина, найімовірніше десь незакритий тег або лапка. Найшвидший спосіб знайти — вкладка Console: помилки JavaScript із номером рядка вона показує прямо. Якщо помилка в розмітці, поверни збережену на кроці 2 копію й вноси зміни меншими шматками:
ls ~/backups/
cp ~/backups/index-2026-09-06.html ~/www/index.html