Michał Świąder — Osobista wizytówka artysty: dwujęzyczna one-page strona gitarzysty fingerstyle, nauczyciela i kompozytora z Krakowa. Projekt objął pełen cykl - od briefu strategicznego, przez persony i scenariusze UX, specyfikacje wizualne, po build, acceptance testing i wdrożenie produkcyjne na VPS z Dokploy.
📋 Metryki projektu
- Status: Zakończone (produkcja, Dokploy)
- Rola: Full-Stack Developer / Architect / UX Designer
- Zakres: Pełny cykl - od discovery i strategii, przez UX/UI design, po implementację, CMS i deployment
- Cel: Autentyczna, niezależna wizytówka artysty-nauczyciela, która subkomunikuje filozofię "guitar psychologist" przez design, nie przez hasła
🚀 Ewolucja projektu (Product Journey)
- Discovery i strategia — Zaczęliśmy od briefu: wizja, positioning, tone of voice, kierunek wizualny (Minimal + Local/Artisan), SEO keywords lokalne. Zbudowałem 3 persony (Piotr Poszukiwacz - uczeń na plateau, Natalia Nasłuchująca - fanka, Kasia Koncertowa - organizatorka), zmapowałem 18 driving forces i spriorytetyzowałem cechy przez scoring F×I×F. Każda decyzja na stronie ma uzasadnienie w konkretnej potrzebie konkretnej osoby.
- Scenariusze UX i specyfikacje — 3 scenariusze UX pokrywające 7 stron. Każdy widok dostał pełną specyfikację: Object Registry, nawigacja, sekcje Accessibility/Responsive/SEO.
- Visual Design i Design Delivery — Prototypy HTML (one-page, blog-list, blog-single), design tokens (kolory, typografia, spacing, cienie, radius), kontrakt dla builda, test scenario. Libre Caslon Text + Fira Sans, logo to odręczny podpis Michała w SVG (stroke 1.5).
- Build — Next.js 16.3 + React 19.2 + Tailwind v4 z tokenami w @theme. Warstwy app/lib/components/content/messages, PL bez prefiksu + EN pod /en, 6 tras (/, /blog, /blog/[slug] + odpowiedniki EN; wpisy bloga generowane na pierwsze żądanie przez ISR), JSON-LD (Person + BlogPosting + ItemList + BreadcrumbList), sitemap/robots, custom 404, mobile menu z focus trap, YouTube facade (youtube-nocookie, lazy load).
- Acceptance testing — 22 testy (HP, ER, EC, DS, AX), pierwsza runda 21/22. Dwa issue Low (hardkodowany hex w komponencie, touch targety poniżej 44px) - naprawione, retest PASS. Status: approved.
- Integracja CMS — Strapi v5.51 + PostgreSQL 16 w monorepo (cms/). Trzy content-type: blog-post, tag, nagranie. i18n PL/EN, webhook on-demand revalidation (revalidateTag dla blog-posts i nagran), ISR 300s jako fallback, paginacja listy 6 na stronę, tagi jako relacja manyToMany, YouTube facade dla nagrań (max 7, sortowane po order). Build przechodzi bez dostępu do CMS - po deployu pierwsze żądanie wyzwala ISR, a webhook z CMS odświeża treść natychmiast po publikacji.
- Wdrożenie produkcyjne — Docker Compose + Traefik (Dokploy), healthcheck + condition, noindex CMS, multi-stage Dockerfile (node:22-alpine).
🎯 Problem biznesowy
Gitarzysta fingerstyle, nauczyciel i kompozytor z Krakowa istnieje w sieci rozproszony przez social media. Bez strony, tak naprawdę bez swojego miejsca w sieci. Potrzebuje własnego, niezależnego miejsca, które łączy ludzi z nim bezpośrednio - przez lekcje lub zwykły kontakt zainspirowany muzyką i filozofią.
❌ "Bóle" i wyzwania
- Brak własnego miejsca: Social media jako jedyne źródło informacji - zależność od algorytmów, brak kontroli nad narracją.
- Subkomunikacja, nie eksplicja: Filozofia "guitar psychologist" / "psychoterapia gitarowa" ma być odczuwana, nigdy wypisana. Design, ton, zdjęcia, muzyka przekonują podświadomie.
- Dwujęzyczność: PL/EN bez duplikacji kodu i treści, pełna parzystość wpisów bloga.
- Zarządzanie treścią: Michał sam publikuje wpisy i nagrania - bez dotykania kodu, bez redeployu.
- SEO dla niszy: "lekcje fingerstyle Kraków", "lekcje gitary Kraków" - konkurencja o nazwisko w Google wymaga structured data i dynamicznego sitemap.
- Dostępność: Strona artysty-nauczyciela musi być dostępna dla wszystkich, w tym osób korzystających z czytników ekranu.
- Współdzielony VPS: Izolacja zasobów Docker (porty, nazwy kontenerów, wolumeny, sieci) bez kolizji z innymi projektami.
💡 Dlaczego to działa? (Podejście inżynieryjne)
- Każda decyzja designowa ma uzasadnienie w personach: Nie "ładna strona", tylko strona, która transformuje potencjalnych uczniów z "podbij i wyjdź" w ludzi, którzy czują połączenie i wybierają kontakt.
- Subkomunikacja przez design: Split hero z autentycznym zdjęciem (Michał boso z gitarą), odręczny podpis SVG jako logo, organiczna estetyka (jasne tło, turkusowy akcent, humanistyczna typografia) - wszystko subkomunikuje "guitar psychologist" bez wypisywania tego.
- Jedna ścieżka kontaktu, wiele intencji: "Napisz do mnie" / "Napisz wiadomość" obsługuje ucznia (lekcje), fana (wiadomość), organizatora (występy) - bez kategoryzacji, bez formularza rezerwacji, bez cennika, bez lejka sprzedażowego.
- Headless CMS (Strapi v5): Michał sam dodaje wpisy bloga i nagrania YouTube - dwujęzycznie, przez panel, bez dotykania kodu. Gdy publikuje w panelu, webhook uderza na front i czyści cache blog-posts oraz nagran (revalidateTag). ISR 300s to bezpieczna siatka, gdyby webhook nie doszedł.
- Build bez CMS: Strapi nie musi być włączone, żeby zbudować front. Guard NEXT_PHASE w lib/strapi.ts zwraca puste dane podczas builda, a po deployu pierwsze żądanie wyzwala ISR i webhook z CMS odświeża treść natychmiast po publikacji. Build jest niezależny od infrastruktury CMS.
- Custom controllers Strapi v5: Okazało się, że REST API Strapi v5.51 nie serializuje atrybutów relacji (tagi, lokalizacje) dla żądań publicznych - pole relacji w ogóle nie trafia do odpowiedzi. Obejście: custom controllers nadpisują find/findOne i używają Documents API, które populuje relacje prawidłowo. Front dostaje płaski, własny format odpowiedzi.
- WCAG AAA dla tekstu: Poszedł krok dalej niż wymaga standard - kontrast tekstu 7:1+ (standard AA wymaga 4.5:1). Tokeny w globals.css @theme pociemnione tak, żeby najgorsze tło (canvas #f9f8f6) trzymało 7.4:1+. WCAG 1.4.11 (non-text 3:1): obwódki CTA z border-ink/50.
- YouTube facade: Zamiast ładować iframe'y na starcie, pokazuję miniaturę + przycisk play. iframe ładuje się dopiero po kliknięciu (youtube-nocookie, lazy load, pełna dostępność keyboard). Zero zbędnych skryptów na pierwszym paint.
- Auto-hide header: Sticky + translate - scroll w dół ukrywa, scroll w górę pokazuje. Pauza przy otwartym menu, wyłączony przy prefers-reduced-motion.
📈 Wpływ na biznes (ROI)
| Metryka | Przed | Po wdrożeniu | Znaczenie |
|---|---|---|---|
| Obecność w sieci | Tylko social media | Własna domena + dwujęzyczna wizytówka | Pełna kontrola nad narracją i brandingiem |
| Zarządzanie treścią | Ręczne / przez developera | Samodzielne via Strapi CMS (blog + nagrania) | Niezależność, zero kosztów bieżących |
| Publikacja wpisu | Redeploy aplikacji | Webhook on-demand (natychmiast) | Czas publish → widoczność ≤ 1 min |
| SEO | Brak structured data | JSON-LD + dynamic sitemap + hreflang | Wyższa widoczność w Google na nazwisko i lokalne keywords |
| Dostępność | Niezagospodarowana | WCAG 2.2 AA (AAA dla tekstu), focus trap, ARIA, skip link | Strona dostępna dla wszystkich |
| Autentyczność | Ciemna fasada, dystans | Organiczna, ciepła, autentyczna (boso z gitarą, podpis SVG) | Subkomunikacja "guitar psychologist" przez design |
| Kontakt | Formularz / social media | Jedna ścieżka "Napisz do mnie" (mailto) | Bez presji, bez lejka, naturalne |
Lighthouse Scores
Natywna optymalizacja obrazów (next/image, WebP/AVIF, lazy load), Server Components domyślnie i eliminacja zbędnych skryptów (YouTube facade) dają mocny fundament pod wynik wydajności. Finalne Lighthouse scores warto zmierzyć na produkcji i wstawić tu realne wartości.
"Strona, która nie krzyczy - subkomunikuje. Organiczna, ciepła, autentyczna wizytówka artysty-nauczyciela, gdzie design sam przekonuje, zanim ktokolwiek przeczyta słowo."
🛠️ Wyzwania techniczne (Engineering Deep Dive)
- Dwujęzyczność bez biblioteki i18n: Zamiast ciągnąć bibliotekę, napisałem własne, lekkie i18n - PL domyślny (bez prefiksu), EN pod /en/. Każdy widoczny tekst przez t("klucz") (klient) lub import paczki (serwer). Paczki messages/pl.json i messages/en.json z kluczami kropkowymi. Mniej zależności, pełna kontrola.
- Next 16.3 not-found przy wielu root layoutach: Bug (vercel/next.js #59180) - Turbopack nie kompiluje not-found przy wielu root layoutach. Obejście: pojedynczy root layout (lang="pl" serwerowo) + kliencki LangSetter (useLayoutEffect ustawia documentElement.lang po hydracji, WCAG 3.1.1, bez mismatch) w app/en/layout.tsx. Custom 404 rozpoznaje locale po ścieżce (usePathname) - 404 na /en jest po angielsku, z lang="en" i linkami /en.
- og:type ginący na /en: Next shallow-merge liczy się z pól - ostatni segment nadpisuje cały openGraph. Fix: en/layout.tsx dostaje pełny obiekt openGraph, nie tylko wybrane pola.
- Strapi v5.51 relacje w REST API: REST API nie serializuje atrybutów relacji (tags, localizations) dla żądań publicznych ani z tokenem read-only. Documents API (strapi.documents) populuje relacje prawidłowo. Decyzja: custom controllers nadpisują find/findOne, używają Documents API, wystawiają płaski format (
{ data, meta: { page, pageSize, pageCount, total } }). Paginacja przez limit/start (nie page/pageSize), count liczy tylko opublikowane. - Webhook revalidation idempotentny: revalidateTag("blog-posts") i revalidateTag("nagran") czyszczą listę, strony paginacji, wpisy i sekcję Muzyka naraz (zamiast listy revalidatePath). Webhook rejestrowany idempotentnie w bootstrapie Strapi (REVALIDATE_URL), nagłówek x-revalidate-token z webhooks.defaultHeaders. Route handler force-dynamic, walidacja sekretu (brak/błędny → 401).
- WCAG AAA na canvas #f9f8f6: Najgorszym tłem jest canvas #f9f8f6 (ciemniejsze niż biel), więc szarości musiały iść poniżej ~#565656. ink-muted #505050, accent-strong #005c55 - kontrasty AAA utrzymane także na papierowym tle i w przyciskach.
- Stretched-link na kartach bloga: Overlay kart dostał z-index 1 - kontener obrazu (position:relative) malował się nad pseudo-elementem wg kolejności DOM i obraz w karcie featured nie był klikalny. Po poprawce klik w obraz prowadzi do wpisu (1 link na kartę dla screen readera).
- Unikalność na współdzielonym VPS: Prefix ms- na wszystkie nazwy globalne Docker (sieci, wolumeny, container_name w dev). Porty hosta dev: 3200/1348/5434 (zero kolizji z innymi projektami na tym samym VPS). Prod: traefik (WEB_DOMAIN/CMS_DOMAIN), healthcheck + condition, noindex CMS, dokploy-network external.
🛠️ Architektura i stack techniczny
- Frontend: Next.js 16.3 (App Router, ISR 300s + webhook), React 19.2, TypeScript (strict, noUncheckedIndexedAccess), Tailwind CSS v4 (@theme z tokenami)
- Typografia: next/font (Libre Caslon Text + Fira Sans)
- CMS: Strapi v5.51 + PostgreSQL 16 (headless, server-side only, monorepo cms/)
- Content-types: blog-post (i18n, draftAndPublish), tag (i18n, manyToMany), nagranie (YouTube, sortowane po order, max 7)
- Obrazy: next/image z remotePatterns (STRAPI_PUBLIC_URL + i.ytimg.com), WebP/AVIF, lazy load, responsywne rozmiary
- i18n: Własne, lekkie (PL domyślny bez prefiksu, EN /en/), paczki
messages/{pl,en}.json - Structured Data: JSON-LD (Person, BlogPosting, ItemList, BreadcrumbList)
- Dostępność: WCAG 2.2 AA (AAA dla tekstu), SkipLink, focus-visible, focus trap (mobile menu), ARIA, prefers-reduced-motion, touch targety ≥ 44px
- Publikacja: Webhook on-demand revalidation (revalidateTag blog-posts + nagran, /api/revalidate, REVALIDATE_SECRET) + ISR 300s fallback
- Infra: Docker Compose (dev + prod), Traefik (Dokploy), multi-stage Dockerfile (node:22-alpine), unikalne nazwy ms-*, healthcheck + condition
- SEO: hreflang (pl, en, x-default), dynamic sitemap (iteruje paginację obu locale), robots.txt, OpenGraph (jeden spójny obraz OG)
🎨 Kluczowe decyzje projektowe
- Organiczna estetyka (Minimal + Local/Artisan): Jasne tło #f9f8f6 (kartka papieru), turkusowy akcent, humanistyczna typografia (Libre Caslon Text serif + Fira Sans). Ciepło, naturalność, akustyczny charakter - strona sama komunikuje filozofię Michała.
- Split hero z autentycznym zdjęciem: Michał boso na podłodze z gitarą (nie korpo-portret). Odręczny podpis SVG jako logo (stroke 1.5). CTA "Poznajmy się" / "Posłuchaj utworów" (nie "Zapisz się"). First impression, które sprawia, że Piotr czuje "to jest to".
- About w pierwszej osobie: Osobista, nie CV. Historia i filozofia - dlaczego Michał gra i uczy. Subkomunikacja "muzyka jako narzędzie rozwoju" (nie wypisana). Wyeksponowany portret, cytat o słuchaniu z border-l-4 accent.
- Muzyka jako proof live: YouTube facade (nagrania z CMS, max 7, miniatura + przycisk play, iframe po kliknięciu, youtube-nocookie, lazy load, keyboard accessible). Dowód performerski dla Kasi (organizatorka), wspierający scroll dla Piotra.
- Ciemna stopka Kontakt: Świadoma decyzja - kontrast z jasnym one-page, mailto michalswiadermusic@gmail.com, social links (YT/IG/FB), brak formularza rezerwacji. Twórca (czyli ja) w stopce.
- Jedna ścieżka kontaktu: "Napisz do mnie" / "Napisz wiadomość" / "Porozmawiajmy o lekcjach" - bez kategoryzacji intencji, bez cennika, bez lejka sprzedażowego. Mailto + social links w stopce.
- Design tokens jako jedyna prawda: Kolory, typografia, spacing, cienie, radius w design-tokens.md i globals.css @theme. Komponenty trzymają się tokenów (poza pojedynczym dekoracyjnym fill w SVG play). WCAG AAA kontrasty udokumentowane w tokenach.
- Server Components domyślnie: "use client" tylko tam, gdzie niezbędne (stan, eventy, hooki przeglądarki - MobileMenu, LangSetter, YouTubeEmbed, 404). Warstwy: app/ (routing, metadata, JSON-LD), lib/ (czyste funkcje, dostęp do danych), components/ (ui/ sekcje/ chrome/), content/ (dane one-page), messages/ (stringi UI).
Artefakty
- front: https://michalswiader.pl
- backend: Strapi v5 (panel admin, niepubliczny)
- Kod źródłowy: RafalWojciechRolsky/michal-swiader-page
