Een deeltjesbioscoop zonder afhankelijkheden
Twintig jaar bouwde ik werk dat niemand ziet. Dit is de zichtbare soort, uit elkaar gehaald met dezelfde discipline.
Infrastructuurwerk heeft een vreemde esthetiek: goed gedaan merkt niemand het op. Een back-up draait twintig jaar stil, en dan op een dag niet meer, en op die dag leert iedereen dat hij bestaat. Dat is de maat van onzichtbaar werk — het wordt alleen beoordeeld wanneer het faalt.
Deze site is een poging die gewoonte naar de oppervlakte te halen. Wat volgt is geen decoratie, maar het uit elkaar gehaalde ding. Wat er is, waarom het er is, en wat het kost.
De beperking komt eerst
Eén regel bepaalde alles: geen externe aanvragen.
Dat is geen slogan, dat is een response-header. Het beveiligingsbeleid van de site luidt:
Content-Security-Policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:;
connect-src 'self'; object-src 'none'; base-uri 'self';
form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests
Op het moment dat je default-src 'self' schrijft, sluiten je opties zich. Geen GSAP, geen three.js, geen Lenis, zelfs geen enkel lettertype van een CDN. Wil je een animatiebibliotheek, dan schrijf je die. Wil je scroll-verzachting, dan schrijf je die. Wil je een deeltjesmotor — ja, ook die.
De regel bewees zichzelf eenmaal onverwacht. Cloudflare injecteert zijn eigen analysescript aan de edge. Het beleid van de site blokkeerde het:
Loading the script 'https://static.cloudflareinsights.com/beacon.min.js/…'
violates the following Content Security Policy directive: "script-src 'self'"
Op een lokale server geeft Lighthouse Best Practices 100; live geeft het 93. Die zeven punten zijn precies dit. Het beleid versoepelen was makkelijk geweest. In plaats daarvan werd de beacon uitgezet — want een regel betekent pas iets als hij ook op het ongelegen moment geldt.
Waar de deeltjes leven
Alle beweging op de pagina wordt op één <canvas> met WebGL2 getekend. Posities en snelheden van de deeltjes staan niet op de CPU; ze leven in twee texturen, en elk frame zet een fragment-shader ze verder. Het resultaat wordt naar een textuur geschreven, en het volgende frame wordt die textuur de invoer. De oudste truc in GPGPU: de berekening van textuur naar textuur doen, nooit naar het scherm.
In de praktijk betekent dit dat het aantal deeltjes de CPU niet aangaat. Op desktop draait het veld op volle dichtheid, op een telefoon zakt het naar 12.100 deeltjes — maar in beide gevallen is het werk per frame dezelfde shader-aanroep.
Twee texturen aanhouden is een klein maar dragend detail. Je kunt dezelfde textuur niet in één doorgang lezen en beschrijven; dat is ongedefinieerd gedrag. Dus wisselen twee buffers elkaar af: dit frame leest A en schrijft B, het volgende wisselen de rollen. Deze ping-pong is de basishygiëne van GPGPU.
Kleur is een aparte berekening. De scène wordt in HDR getekend, heldere gebieden worden eruit gehaald en vervaagd (selectieve bloom), en een ACES-curve brengt alles terug naar het weergeefbare bereik. Die drie stappen voorkomen de makkelijkste fout bij additief tekenen: dat alles naar wit uitbrandt. De verificatiesuite meet precies dat — de piek-luma per frame moet onder de drempel blijven.
Apparaatverschillen worden geregeld door een ladder van vier trappen:
| Trap | Wat uitgaat |
|---|---|
| 0 | Niets — volledige bioscoop |
| 1 | Geen zoom-blur, bloom naar twee doorgangen |
| 2 | Deeltjesaantal daalt naar 55% |
| 3 | WebGL1-terugval — GPGPU helemaal uit |
Trap 3 is belangrijk: in een browser zonder WebGL2 stopt de pagina niet met werken, hij verliest alleen de simulatielaag. Tekst, navigatie, contact — alles blijft.
Acht talen, één bron van waarheid
De site verschijnt in acht talen: Turks in de wortel, de andere zeven in eigen mappen. Maar acht pagina's worden niet met de hand geschreven.
De Turkse pagina is de master. Elk vertaalbaar knooppunt draagt een markering:
<span data-i="about.e1t">Kurumsal altyapı</span>
Een generator leest die markeringen en haalt i18n/tr.json eruit — momenteel 102 sleutels. De andere zeven talen vullen dezelfde sleutels met eigen tekst, en de generator drukt de pagina's af. De vertaalbare velden van de JSON-LD gaan door dezelfde pijplijn: functietitel, plaats, expertisegebieden, dienstbeschrijving.
De hardste regel van deze opzet: een ontbrekende sleutel stopt de build. Geen fallback, geen terugval op Engels. Als één taal achterloopt, gaat er niets live. Beter stoppen dan een stil half vertaalde pagina publiceren.
Dat de Turkse pagina niet wordt gegenereerd is ook opzettelijk. De master wordt met de hand geschreven omdat hij meetcommentaar, maskermarges en optische uitlijningsnotities bevat — verplaats je die naar een generator, dan worden ze onleesbaar.
Het resultaat is meetbaar: over de acht pagina's zijn de hreflang-verwijzingen in 64 van de 64 richtingen wederkerig, en x-default klopt op alle acht.
Lettertypen zijn het zwaarste deel
De hele pagina is 172 KB over elf bronnen. De verdeling verrast:
| Post | Gewicht |
|---|---|
| Lettertypen (woff2) | 116 KB |
| JavaScript | 56 KB |
| Afbeeldingen | 0 KB |
| CSS | 0 KB — inline |
Er staat geen enkele <img> op de site. Alle visuele last zit in canvas en SVG. Daartegenover staan vijftien woff2-bestanden: vier families voor Latijn, aparte vervangers voor Cyrillisch. De Cyrillische bestanden hangen via unicode-range aan dezelfde familienamen — de Russische pagina downloadt dus nooit een Latijns bestand; de browser kiest zelf.
De @font-face-declaraties staan ingebed in de HTML. Dat is een gemeten beslissing: een aparte stylesheet was de enige render-blokkerende bron, en inbedden tilde de mobiele Lighthouse-score van 99 naar 100.
Echte overdracht na compressie:
| Bron | Ruw | Brotli | Verhouding |
|---|---|---|---|
| HTML | 87.890 B | 20.675 B | 76,5% |
app.js | 186.011 B | 55.354 B | 70,2% |
Zonder meting heet niets „klaar"
De repository bevat twee verificatiesuites: 72 tests voor scènefysica, 100 voor vertaling en de discovery-laag. Beide draaien in een echte browser en geen van beide kijkt naar een screenshot — ze lezen luma van het canvas, bevragen toestandskanalen en toetsen drempels numeriek.
De reden is eenvoudig: visuele verificatie liegt. Een paneel kan vanzelf scrollen, een tabblad naar de achtergrond vallen, requestAnimationFrame kan nooit getikt hebben. De screenshot zegt „werkt" terwijl er niets is begonnen.
Hoe meting liegt
Het meest tijdrovende hier was niet de code. Het was leren de meting zelf niet te vertrouwen.
Eén voorbeeld. Een pagina opende in een automatiseringsbrowser en de inhoud bleef zeventig seconden onzichtbaar. Zwart scherm, het openingsgordijn bevroren. De voor de hand liggende lezing: de pagina hangt.
Drie dingen werden gemeten voordat die conclusie werd getrokken:
document.visibilityState → "hidden"
rAF-frames in 2 seconden → 0
openingsvoortgang → 0
Het tabblad stond op de achtergrond. requestAnimationFrame tikt niet in achtergrondtabbladen; de sequentie was nooit begonnen. Er was geen fout op de pagina — de fout zat in de meetomgeving.
Dat stond één stap af van als „bevinding" in een rapport te belanden. De repository heeft daar een geschreven regel voor: noem niets „klaar" zonder meting — en meet ook wat je denkt gezien te hebben. Een screenshot is geen bewijs; bewijs is de van het canvas gelezen luma, of het toestandskanaal zelf.
Twee stille fouten
Het interessantste dat een audit oplevert, is dat waar hij niet naar zocht.
Ten eerste: het veld lastmod in de sitemap las zijn datum uit de vorige sitemap. De generator nam zijn eigen uitvoer als invoer. Eenmaal geschreven zou de waarde nooit vooruitgaan — hoezeer de inhoud ook veranderde, het bestand toonde voor altijd dezelfde dag.
Een bevroren versheidssignaal wordt een leugen. Een verouderde lastmod is erger dan geen: hij vertelt een crawler dat deze pagina niet verandert. De datum leeft nu op precies één plek, en de sitemap volgt hem.
Ten tweede: de lijst met vertaalbare JSON-LD-velden stond in de generator op twee plaatsen gedupliceerd — één voor extractie, één voor het schrijven. Wat gebeurt er als een nieuw veld maar aan één ervan wordt toegevoegd? Het veld wordt stil niet vertaald. Turkse tekst wordt letterlijk gekopieerd naar de gestructureerde data van zeven taalpagina's, en geen van de honderd tests merkt het.
Dat was een val die precies zou breken wat hij moest herstellen: wie het ontbrekende description-veld toevoegt, zou ongemerkt Turks in zeven talen hebben geschreven. De lijst is nu één bron.
De gedeelde les: de gevaarlijkste fouten in een systeem zijn die waarbij geen enkele test faalt.
De geweigerde truc
De eerlijkste regel van dit project staat niet in een foutrapport. Hij staat in een beslissingsverslag.
Op mobiel kan Lighthouse voor de startpagina geen Largest Contentful Paint produceren. De oorzaak is de openingssequentie: vier seconden gordijn, dan de bevriezing bij contact, dan de uitbarsting, dan de inktovergang. Daarvoor is geen DOM-tekst zichtbaar, en Lighthouse sluit zijn trace veel eerder. Resultaat: NO_LCP — de metriek is niet berekenbaar.
Een correctie werd gemeten. Zet je de begin-opaciteit van de inhoudslaag op 0.004 in plaats van 0, dan daalt LCP van 9.052 ms naar 164 ms, en wordt de volledige hero-kop als LCP-kandidaat gestempeld. Er zijn geen visuele kosten: 0,004 opaciteit draagt hoogstens 1/255 eenheid bij, dus niets.
Precies daarom werd hij geweigerd.
LCP is gedefinieerd als het grootste in de viewport zichtbare tekstblok. Een kop op 0,004 opaciteit is niet zichtbaar. Chromiums opacity: 0-uitzondering bestaat om precies dit patroon te blokkeren; 0,004 glipt langs de rand ervan. Het getal zou verbeteren, de waarheid niet: de bezoeker zou de kop nog steeds negen seconden later zien.
De beslissing kwam zo in de documentatie: breng dit patroon niet terug.
Waarom deze pagina anders is
De pagina die u leest laadt de hierboven beschreven motor niet. Het deeltjesveld erachter is een apart, heel klein script; de tekst is bij de eerste paint volledig zichtbaar en wacht achter geen enkele opacity-poort.
De reden is de vorige sectie. Een tekst bestaat om gelezen en geciteerd te worden. De filmische opening is de handtekening van de startpagina, maar op een tekstpagina zou diezelfde sequentie niet alleen de meting vertragen, maar het lezen. Voor dit onderdeel is een budget vastgelegd — LCP onder 2,5 seconden, pagina-JavaScript onder 60 KB, geen opaciteitspoort op de inhoud — en de verificatiesuite controleert het bij elke run.
Dezelfde discipline, ander resultaat. Als de esthetiek van onzichtbaar werk is dat het nooit faalt, dan is de esthetiek van zichtbaar werk dat het meetbaar is.