Jednostránkové osobní portfolio, dvojjazyčné (čeština / angličtina). Výsledkem je pořád statický web — HTML, CSS a JavaScript bez serveru —, ale skládá ho build přes Vite.
Do října 2026 web žádný build neměl a bylo to záměrné: jeden HTML soubor, jedno CSS, jeden skript, nic k instalaci. Build vznikl kvůli plánované animované 3D scéně na pozadí (Three.js, GSAP ScrollTrigger, Lenis):
- Knihovny musí být z npm, ne z CDN. CSP povoluje jen skripty z vlastní
domény (
script-src 'self'), takže cokoli z cdnjs nebo unpkg by prohlížeč odmítl. Bez buildu by se knihovny musely ručně kopírovat do repozitáře a aktualizovat po jedné. - Bez buildu by nefungovaly addony Three.js. Importují holý specifikátor
'three', na který prohlížeč potřebuje importmapu. Ta se dá zapsat jen inline — a inline skript CSP zakazuje, pokud se mu nepovolí hashem. - Rozdělení kódu. Three.js má sám kolem 170 kB (gzip). Vite ho dá do
samostatného souboru, který se stáhne jen tehdy, když scéna opravdu poběží —
ne pro
prefers-reduced-motion, úsporu dat nebo prohlížeč bez WebGL.
Produkční build nevkládá do HTML žádný spustitelný inline skript, takže CSP
zůstala beze změny. Jediný <script> bez src je application/ld+json,
který prohlížeč nespouští.
index.html obsah stránky, meta tagy, JSON-LD, favicon jako inline SVG
vercel.json build pro Vercel, bezpečnostní hlavičky, cache pro fonty
package.json závislosti a skripty (dev / build / preview)
src/style.css veškeré styly včetně @font-face
src/app.js přepínač jazyka, úvodní loader, odhalování sekcí, navigace
src/scene/ volitelná 3D scéna a plynulý scroll (viz níž)
public/assets/fonts/ Space Grotesk a JetBrains Mono (woff2)
public/assets/img/ náhledový obrázek pro sdílení
src/ Vite zpracuje a výsledné soubory pojmenuje hashem. public/ kopíruje
do buildu beze změny a pod stejnou cestou — fonty a náhledový obrázek proto
leží tam, protože je odkazují pevné adresy (preload v <head> a absolutní
og:image).
Texty obou jazykových verzí jsou přímo v index.html v atributech data-cs a data-en.
Přepínač jazyka je jen prohodí — druhá stránka ani překladový soubor neexistuje.
Když se tedy něco upravuje, je potřeba upravit obě varianty textu.
src/app.js posílá na window dvě události pro kód, který na stránce závisí:
km:ready— loader doběhl a stránka je odkrytákm:lang— přepnul se jazyk,event.detail.langje"cs"nebo"en"
A dvě poslouchá — posílá je 3D scéna (níž), pokud běží:
km:scene— scéna je zkompilovaná; loader na ni čeká nejvýš 1,2 skm:trace— paket z glóbu doletěl na první hop; traceroute na něj čeká nejvýš 2,5 s
Vrstva navíc v src/scene/. Stránka na ní nezávisí — bez ní vypadá a funguje
přesně jako dřív.
main.js brána: rozhodne, co se stáhne (viz tabulka níž)
scroll.js Lenis, odkazy v rámci stránky, předávky mezi sekcemi (ScrollTrigger)
states.js co scéna dělá v které sekci — jen data, jednotky jsou popsané v souboru
layout.js kam se glóbus vejde v hero — měřeno z DOM, ne odhadnuté
scene.js renderer, míchání stavů podle scrollu, umístění, smyčka, odkrytí
globe.js drátěný ikosaedr, tělo, uzly (fibonacci, shlukování), aura, uzel „já“
packets.js oblouky s pakety a svítící stopou
links.js zvýrazněná spojení v sekci projektů
orbits.js oběžné dráhy se satelity
beacon.js paket od uzlu „já“ k divákovi v kontaktu
trace.js paket z glóbu na první hop traceroute v hero
mask.js díra ve scéně pod nadpisem hero a pod hlavičkou (kontrast)
stars.js hvězdy
glow.js textury pro body, sprity a pulz
debug.js přehled pro ?debug=1
| návštěvník | co dostane |
|---|---|
prefers-reduced-motion |
nic — nativní scroll, CSS pozadí (mřížka + glow) |
| úspora dat nebo bez WebGL2 | jen plynulý scroll; CSS pozadí zůstává |
| ostatní | plynulý scroll i scéna |
Three.js je za dynamickým importem, takže si ho první dva řádky tabulky
vůbec nestáhnou. Když scéna běží, má <html> třídu .has-scene — podle ní
se schovají glow divy a za text se přidá tmavá clona, která drží kontrast
nad 4,5 : 1.
Barvy scéna čte z CSS proměnných --violet, --magenta, --text a --ink,
takže změna palety v :root se do ní propíše sama.
Každá sekce má v states.js svůj stav (klíčem je id sekce): kde glóbus je
a jak je velký, jas, rotace, hustota a rychlost paketů, shlukování uzlů,
zvýraznění uzlu „já“, rozestup prstenců, úhel kamery… Mezi sousedními
sekcemi se scéna míchá podle pozice scrollu — předávka začne, když horní hrana
další sekce dojede na 85 % výšky okna, a skončí na 35 %. Funguje oběma směry;
mezi předávkami stav drží. Po kliknutí na odkaz v navigaci míří scéna rovnou
do cílového stavu a sekce mezi tím nepřehrává.
| sekce | scéna |
|---|---|
| hero | glóbus vedle nadpisu, tak velký, jak místo dovolí, plný jas |
| 01 O mně | kamera se přiblíží k uzlu „já“ (magenta, pulzující kroužek), zbytek ustoupí a ztmavne |
| 02 Čemu se věnuju | uzly se shluknou — jeden shluk na položku sekce, oblouky vedou mezi nimi |
| 03 Praxe | proud paketů: víc oblouků, rychlejší pakety i rotace |
| 04 Vzdělání | prstence se rozestoupí a srovnají, satelity zpomalí |
| 05 Projekty | klid, jedno zvýrazněné spojení na projekt |
| 06 Kontakt | glóbus uprostřed za obsahem, uzel „já“ posílá pakety k divákovi |
Počet shluků a spojení se bere z HTML (.row v #work a #projects), takže
nový projekt dostane i nové spojení. Na dotykových zařízeních
(pointer: coarse) jsou všechny stavy zjednodušené: glóbus za textem, nižší
jas, kamera se nepřibližuje, poloviční počty prvků.
Glóbus nesmí zasáhnout do nadpisu ani traceroute a musí být celý vidět, takže
jeho velikost určuje volné místo vedle nadpisu. Na 1440×900 je to asi 36 %
výšky okna, na 1920×1080 asi 48 % (tabulka je v layout.js). Limitem je
nadpis: mezi 1280 a 1440 px má už plnou velikost a jeho druhý řádek sahá do
~70 % šířky. Kdyby se zalomil do čtyř řádků (max-width: 13ch), uvolnilo by
se místo na ~55 % — to by ale byla změna designu.
Loader drží na 100 %, dokud se scéna nezkompiluje, ale nejvýš 1,2 s — pak stránku odkryje a scéna se dorovná po něm. Počítadlo neztrácí krok s reálným časem: v záložce otevřené na pozadí běží časovače jednou za sekundu a loader počítaný jen po tikách tam trval 12 s.
Když scéna běží, vyletí po odkrytí z glóbu paket, doletí na první uzel traceroute a teprve pak se trasa začne kreslit. Paket je prvek stránky, ne scény — za traceroute je clona, která by ho v canvasu pohltila.
S parametrem ?debug=1 se v rohu ukáže přehled: aktuální stav, která
předávka běží a jak daleko je, scroll, umístění glóbu a FPS. Funguje i na
produkci, ale kód se stáhne jen s parametrem.
Ve vývojovém režimu (npm run dev) je v konzoli window.__kmScene — dá se
s ním krokovat čas, číst aktuální stav a umístění a přepočítat layout. Do
produkčního buildu se nedostane.
Space Grotesk a JetBrains Mono jsou uložené v public/assets/fonts/, ne
načítané z Google Fonts. Důvody dva: nenačítá se nic z cizí domény (takže se
ani neodesílá IP adresa návštěvníka třetí straně) a odpadá čekání na DNS a TLS
handshake ještě před vykreslením prvního písmene.
Jsou to variabilní fonty — jeden soubor pokrývá celý rozsah řezů, proto
je v @font-face uvedený font-weight jako rozsah (400 700) a ne jedna
hodnota. Proto také stačí čtyři soubory místo deseti.
Rozdělení na latin a latin-ext přes unicode-range znamená, že soubor
s diakritikou se stáhne jen tehdy, když ho stránka potřebuje.
Oba fonty jsou pod SIL Open Font License 1.1.
Potřeba je Node.js ^20.19 nebo >=22.12 (stejný požadavek jako má Vite).
npm installVývojový server s okamžitým překreslením po uložení:
npm run devProdukční build do dist/ a jeho lokální náhled — to je přesně to, co pak
poběží na Vercelu:
npm run buildnpm run previewPozor na jednu věc: npm run preview neposílá hlavičky z vercel.json, takže
CSP se v něm neprojeví. Chybu typu „Refused to load…“ lokálně neuvidíš —
změny, které by s CSP mohly kolidovat, je potřeba ověřit na preview nasazení.
Web běží na https://krystofmoravec.vercel.app/ a nasazuje se automaticky
z GitHubu (repozitář morykeu/personal_web).
- push do větve
main→ produkční nasazení - push do jiné větve nebo pull request → náhledové (preview) nasazení s vlastní URL
Nastavení buildu je ve vercel.json, takže přebíjí to, co je v projektu na
Vercelu naklikané:
| Nastavení | Hodnota |
|---|---|
| Framework Preset | Vite |
| Build Command | npm run build |
| Output Directory | dist |
| Install Command | (výchozí — npm install) |
vercel.json nastavuje bezpečnostní hlavičky (Content-Security-Policy,
X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Permissions-Policy) a roční cache pro fonty.
CSP má u style-src hodnotu 'unsafe-inline'. Není to nedbalost — stránka
má inline style="animation-delay:…" přímo na prvcích SVG a <noscript>
blok se styly. Bez toho by se traceroute animace nespustila. Kdyby se ty
inline styly někdy přesunuly do tříd, dá se 'unsafe-inline' odebrat.
Při úpravě hlaviček je dobré je nejdřív ověřit lokálně, protože špatná CSP umí stránku rozbít až na produkci — chyby se objeví v konzoli prohlížeče jako „Refused to load…".
<link rel="canonical"> a og:url v <head> obsahují produkční adresu
natvrdo. Při přechodu na vlastní doménu je potřeba přepsat obě — a vždy
jako absolutní adresu včetně https:// a koncového lomítka. Bez schématu
si prohlížeč i vyhledávače hodnotu vyloží jako relativní cestu.
public/assets/img/og.jpg (1200×630 px) je to, co se zobrazí při sdílení
odkazu na LinkedInu, Facebooku, X nebo v Discordu. Je odkazovaný absolutní
adresou z og:image a twitter:image.
Po jeho výměně je potřeba dát platformám vědět, že mají načíst novou verzi — mají odkazy dlouho v cache:
- LinkedIn: https://www.linkedin.com/post-inspector/
- Facebook: https://developers.facebook.com/tools/debug/
Jednodušší alternativa je nahrát obrázek pod novým názvem a přepsat cestu
v <head> — nová adresa cache obejde.
Stránka cílí na WCAG 2.1 AA:
- veškerý text má kontrast alespoň 4,5 : 1 vůči pozadí
- všechny interaktivní prvky mají viditelný
:focus-visiblerámeček - nadpisy jdou v pořadí
h1→h2→h3, bez přeskakování úrovní - orientační body
banner/main/contentinfoa pojmenované sekce - respektuje
prefers-reduced-motion— animace se vypnou - bez JavaScriptu se úvodní loader přeskočí a obsah zůstane čitelný
Ten poslední bod hlídá blok <noscript> v <head>. Jeho pravidla mají
!important záměrně: Vite při buildu přesune hlavní stylesheet až za něj,
takže bez toho by bez JS přetekl loader přes celou stránku.