Changeset 4989294 in Klonkt for src


Ignore:
Timestamp:
08/20/2026 01:41:27 AM (3 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
d8f8f07
Parents:
48481cd
git-author:
Robin <roboburr@…> (08/20/2026 01:19:43 AM)
git-committer:
Robin <roboburr@…> (08/20/2026 01:41:27 AM)
Message:

Lezen: het snappen terug naar CSS, en alleen schakelen bij stilstand

Robins bezwaar (20-8): "scroll-snap op het element is iets anders dan touch
events, requestAnimationFrame etc. dan gaan we te veel van de view doen."
Dat klopte. Ik had een scroll-engine in JavaScript gebouwd bovenop een
CSS-functie -- vangzones uitrekenen, snappunten per bericht armeren, de
richting afleiden uit scrollposities -- en dat vecht met de browser in
plaats van hem te gebruiken.

Weg dus: 111 regels snap-logica eruit. Het snappen doet CSS, met
scroll-snap-type:y proximity en scroll-snap-align:start. De bovenkant van
bericht N+1 IS de onderkant van bericht N, dus dat is precies "grijp de
onderkant en snap naar de volgende post".

Nagemeten wat proximity uit zichzelf doet, en dat bleek genoeg:

10, 30, 60, 100, 150, 200px voor de grens -> snapt
300, 400px -> blijft staan

Ongeveer 200-300px vangzone, dicht bij de 150 die ik met de hand nabouwde.
Die hele berekening was overbodig.

WAT ER IN JAVASCRIPT BLIJFT is één schakelaar: scroll-snap-type aan of uit
op de scroller, want omhoog scrollen moet vrij blijven en CSS kent geen
richtingsgevoelig snappen. De richting komt uit de INVOER (wiel, vinger,
toets) en niet uit scroll-events -- die bevatten ook de bewegingen van de
browser zelf, en zijn snap-animatie gaat soms omhoog. Wie daaruit de
richting afleidt leest de browser en niet de gebruiker.

EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat was de tweede vondst, na
Robins melding dat het snappen soms oversloeg en niet op de bovenkant
landde. Gemeten:

snappen UIT, beweging naar 1459 loopt
-> halverwege snappen AAN gezet
-> eindigt op 1459, NIET op een berichtgrens

De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar
of van de uitloop; zet je hem daar net vóór of ná om, dan krijg je geen
snap of een snap op het verkeerde moment. En de oude code schakelde bij
ELK wiel-event en ELKE vingerbeweging. Bij stilstand omzetten is wel
onschuldig -- dezelfde proef gaf 0px sprong.

Nu dus: richting bepalen bij het BEGIN van een gebaar, daarna niets meer
aanraken tot de scroll stil is (scrollend, met een timer van 120ms voor
browsers die dat event niet kennen; op iOS loopt de uitloop door ná
touchend, dus loslaten is niet hetzelfde als klaar).

Gemeten na de wijziging: omkeren tijdens een gebaar wordt genegeerd,
landing 80px voor de grens komt exact op 1709 uit, en een nieuw gebaar
omhoog zet het snappen uit.

Ook geprobeerd en verworpen: scroll-snap-type:y mandatory zou "direct
snappen" geven, maar trok in een lang bericht 273px terug -- precies de
val die er net uit was.

scroll-behavior:smooth is van de scroller af. Dat maakte elke beweging
traag, ook het snappen; de browser animeert een snap uit zichzelf al en
korter. De terug-naar-boven-knop vraagt zijn animatie expliciet aan en
blijft dus vloeiend, met zijn twee stappen: eerst de top van dit bericht,
dan de kop van de pagina.

style.css naar v78, MOD_V naar 16.

Suite 1169/1169.

Co-Authored-By: Claude Opus 5 <noreply@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    r48481cd r4989294  
    24622462html:has(body.on-home[data-feed-view="reader"]) {
    24632463    scroll-snap-type: y proximity;
    2464     /* Vloeiend, niet in een klap. Zonder dit springt een snap er in EEN frame
    2465        naartoe en voelt het als een hik in plaats van als meegeven -- Barts
    2466        melding (20-8). Geldt ook voor de terug-naar-boven-knop en voor een
    2467        ankersprong, en dat is precies wat je wilt.
    2468        Uitgezet bij prefers-reduced-motion, onderaan dit blok. */
    2469     scroll-behavior: smooth;
     2464    /* GEEN scroll-behavior:smooth hier. Dat maakte elke beweging op deze
     2465       scroller traag, ook het snappen zelf -- en een snap moet vlot zijn
     2466       (Robin, 20-8: "vlot maar geanimeerd, mag niet te lang duren"). De browser
     2467       animeert een snap uit zichzelf al, en korter dan zijn smooth-scroll.
     2468       De terug-naar-boven-knop vraagt zijn animatie expliciet aan in
     2469       mod/read.js, dus die blijft vloeiend. */
    24702470}
    24712471/* En de browser niet óók laten compenseren als Load more er berichten bij zet:
  • src/assets/js/mod/read.js

    r48481cd r4989294  
    11/**
    2  * De leesweergave: tikken op een bericht opent dat bericht.
     2 * De leesweergave: tikken op een bericht opent dat bericht, en een knop om terug
     3 * naar boven te gaan.
    34 *
    45 * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
    56 * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
    6  * weg, en dat is winst: Lezen is nu een DERDE WEERGAVE van de feed
     7 * weg, en dat is winst: Lezen is nu een WEERGAVE van de feed
    78 * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
    89 * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
    9  * doet CSS. Wat overblijft is dit ene gemak.
     10 * doet CSS.
     11 *
     12 * HET SNAPPEN STAAT HIER MET OPZET NIET IN. Ik heb dat een ronde lang wel
     13 * geprobeerd -- richting bijhouden, een vangzone uitrekenen, per scroll-event
     14 * het snappunt verzetten -- en dat is de verkeerde laag. Robins bezwaar (20-8):
     15 * "scroll-snap op het element is iets anders dan touch events,
     16 * requestAnimationFrame etc. dan gaan we te veel van de view doen." Klopt, en
     17 * het vocht ook met de browser: de scroll-events bevatten OOK de bewegingen van
     18 * zijn eigen snap-animatie, dus de richting die je eruit afleidt is niet die van
     19 * de gebruiker.
     20 *
     21 * Wat er nodig was, was een regel minder in de CSS en niet honderd erbij hier:
     22 * zie style.css bij .feed-reader .read-post.
    1023 *
    1124 * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
    12  * voor toetsenbord en schermlezer; dit hier is er voor een duim.
    13  *
    14  * Vier uitzonderingen, want een tik die je niet bedoelde is erger dan geen tik:
    15  * iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen werking,
    16  * een geselecteerde tekst is geen tik, een verschoven vinger is scrollen, en
    17  * cmd/ctrl-klik hoort de browser zelf af te handelen.
    18  */
    19 
    20 /**
    21  * Snappen alleen waar het HELPT.
    22  *
    23  * Elk bericht heeft scroll-snap-align:start en min-height:100svh. Voor een kort
    24  * bericht is dat precies goed: het vult het scherm en de volgende klikt netjes
    25  * op zijn plek. Voor een LANG bericht is het een val -- je leest naar beneden en
    26  * de browser trekt je terug naar de bovenrand, waardoor het scrollen lijkt te
    27  * stoppen terwijl je gewoon doorscrollt. Barts melding (20-8).
    28  *
    29  * "Past dit bericht op het scherm?" is een vraag over gemeten hoogte, en die kan
    30  * CSS niet stellen -- vandaar hier. Past het niet, dan gaat de snap eraf en lees
    31  * je ononderbroken door tot het volgende bericht wel weer een snappunt is.
    32  *
    33  * De marge van 4px vangt afrondingsverschillen tussen svh en de echte hoogte;
    34  * zonder die speling wipt een bericht dat toevallig exact past heen en weer.
    35  */
    36 /**
    37  * Wanneer is de bovenkant van een bericht een snappunt?
    38  *
    39  * Alleen zolang je er nog NIET voorbij bent. Zit de bovenrand op of onder de
    40  * bovenkant van het scherm, dan kom je er nog aan en mag hij vangen. Is hij
    41  * eenmaal voorbij -- je leest in het bericht -- dan gaat de snap eraf.
    42  *
    43  * Robins formulering (20-8), en die is preciezer dan wat ik er eerst van maakte:
    44  * "in de post zelf mag er nooit gesnapt worden, ook niet bovenin; enkel de
    45  * onderkant mag snappen naar de volgende post, dus daar de bovenkant van".
    46  * Precies dat: de onderkant van bericht N is de bovenkant van N+1, en die is een
    47  * doelwit omdat je hem nadert. De bovenkant van het bericht waar je IN zit is
    48  * dat niet meer, want daar ben je voorbij.
    49  *
    50  * Zonder deze regel trok proximity je terug naar de bovenrand zodra je een paar
    51  * regels verder scrolde, en leek het of het scrollen vastliep.
    52  *
    53  * De 4px is speling voor afronding: tijdens het snappen zelf loopt top naar 0 en
    54  * mag hij niet halverwege afhaken.
    55  */
    56 const SPELING = 4;
    57 
    58 /**
    59  * En snappen doet alleen mee op de weg NAAR BENEDEN (Robin, 20-8).
    60  *
    61  * Omhoog scroll je om iets terug te zoeken, en dan is elke vangst een hindernis:
    62  * je wilt zelf bepalen waar je stopt. Omlaag lees je door, en dan helpt de
    63  * grens juist. Dus bij omhoog gaat de snap er overal af.
    64  *
    65  * De drempel van 2px is er tegen richtingsruis: tijdens een vloeiende snap
    66  * schommelt scrollY een fractie, en zonder speling zou de richting dan heen en
    67  * weer klappen -- precies midden in de beweging die net soepel moest zijn.
    68  */
    69 let laatsteY = 0;
    70 let omlaag = true;
    71 
    72 /**
    73  * Hoe DICHT bij de grens hij vangt: de onderste VANGZONE pixels van een bericht.
    74  *
    75  * In pixels, en bewust niet inhoudelijk. Ik probeerde het aan de voet van het
    76  * bericht te hangen ("ben je de reacties voorbij"), maar bij een KORT bericht
    77  * gaat dat mis: min-height rekt zo'n bericht tot een vol scherm, dus de voet
    78  * staat middenin met lege ruimte eronder en de zone begint op een willekeurige
    79  * plek. Robin ving dat (20-8) voordat het uitgerold stond.
    80  *
    81  * Eerst stond dit op 20px en dat was te krap om ooit te vangen. Honderdvijftig
    82  * is ruwweg het laatste stukje van een bericht: kom je daarbinnen tot stilstand,
    83  * dan trekt hij de streep recht naar de bovenkant van het volgende. Stop je
    84  * eerder, dan blijf je gewoon staan.
    85  *
    86  * Eén getal, dus makkelijk bij te stellen als het onder een duim anders voelt.
    87  */
    88 const VANGZONE = 150;
    89 
    90 function ijkEen(art) {
    91   const top = art.getBoundingClientRect().top;
    92   // Omhoog: nooit. Omlaag: alleen een grens die je NADERT en die binnen de zone
    93   // ligt. De ondergrens blijft krap (SPELING): eenmaal voorbij wordt er niet
    94   // teruggetrokken, ook niet over een paar pixels.
    95   const wil = (!omlaag || top < -SPELING || top > VANGZONE) ? 'none' : '';
    96   // Alleen aanraken als het echt verandert: elke stijlwijziging tijdens een
    97   // vloeiende scroll is een kans op een hik, zeker op iOS.
    98   if (art.style.scrollSnapAlign !== wil) art.style.scrollSnapAlign = wil;
    99 }
    100 
    101 function ijkSnappen() {
    102   const y = window.scrollY;
    103   if (Math.abs(y - laatsteY) > 2) omlaag = y > laatsteY;
    104   laatsteY = y;
    105   document.querySelectorAll('.feed-reader .read-post').forEach(ijkEen);
    106 }
     25 * voor toetsenbord en schermlezer; de tik hieronder is er voor een duim.
     26 *
     27 * Vier uitzonderingen op die tik, want een tik die je niet bedoelde is erger dan
     28 * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
     29 * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
     30 * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
     31 */
    10732
    10833/**
     
    11136 * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
    11237 * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
    113  * dus twee verschillende dingen -- dat scheelt op mobiel een halve minuut vegen.
     38 * dus twee verschillende dingen -- dat scheelt op mobiel een hoop vegen.
    11439 */
    11540function naarBoven() {
     
    15378}
    15479
     80/**
     81 * Omhoog scrollen blijft VRIJ (Robin, 20-8).
     82 *
     83 * Snappen hoort bij doorlezen; ga je terug, dan zoek je iets en bepaal je zelf
     84 * waar je stopt. CSS kent geen richtingsgevoelig snappen, dus dat ene stukje
     85 * moet hier -- maar dan ook niet meer dan dat: we zetten de CSS-functie aan of
     86 * uit op de scroller. Geen vangzones, geen snappunten per bericht, geen
     87 * scrollpositie-boekhouding.
     88 *
     89 * DE RICHTING KOMT UIT DE INVOER, niet uit scroll-events. Gemeten op dev: die
     90 * events bevatten ook de bewegingen van de browser zelf -- zijn snap-animatie en
     91 * de rubber-band -- en die gaan soms omhoog. Wie daaruit de richting afleidt,
     92 * leest de browser en niet de gebruiker.
     93 *
     94 * EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat is de tweede les, en die
     95 * kostte een ronde. Eerst zette dit de schakelaar om bij ELK wiel-event en ELKE
     96 * vingerbeweging, dus binnen een veeg klapte hij meerdere keren heen en weer.
     97 * De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar of
     98 * van de uitloop, en of je daar net vóór of net ná zit bepaalt dan of er
     99 * gesnapt wordt. Gemeten (Robins melding "soms triggert het terwijl we nog aan
     100 * het doorscrollen zijn"):
     101 *
     102 *     snappen UIT, beweging naar 1459 loopt
     103 *     -> halverwege snappen AAN gezet
     104 *     -> eindigt op 1459, NIET op een berichtgrens
     105 *
     106 * Bij stilstand omzetten is wel onschuldig: dezelfde proef gaf 0px sprong.
     107 * Vandaar: richting bepalen bij het BEGIN van een gebaar, en daarna niets meer
     108 * aanraken tot de scroll echt stil is.
     109 *
     110 * Wat je daarvoor inlevert: binnen een veeg ligt de stand vast. Draai je
     111 * halverwege om zonder los te laten, dan geldt de stand van dat gebaar nog. Een
     112 * besluit per gebaar is voorspelbaar; het omklappen halverwege was het probleem.
     113 */
     114const RUST_MS = 120;
     115
     116let bezig = false;          // loopt er een gebaar of een uitloop?
     117let rustTimer = null;
     118
     119function zetSnappen(aan) {
     120  const el = document.documentElement;
     121  const wil = aan ? '' : 'none';
     122  if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
     123}
     124
     125/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
     126function nieuwGebaar(naarBeneden) {
     127  if (bezig) return;
     128  bezig = true;
     129  zetSnappen(naarBeneden);
     130}
     131
     132/**
     133 * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
     134 * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
     135 * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
     136 */
     137function rustNu() { bezig = false; }
     138function planRust() {
     139  clearTimeout(rustTimer);
     140  rustTimer = setTimeout(rustNu, RUST_MS);
     141}
     142
     143function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
     144let raakY = 0;
     145function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
     146function opRaakBeweeg(e) {
     147  if (!e.touches || !e.touches[0]) return;
     148  const y = e.touches[0].clientY;
     149  // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
     150  // duim die stilstaat.
     151  if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
     152}
     153function opToets(e) {
     154  if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
     155  else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
     156}
     157
    155158let knop = null;
    156159let opScroll = null;
     
    167170  s.addEventListener('click', onTap);
    168171
    169   // De knop staat in de HTML (read-top.ejs), zodat hij er ook is zonder deze
    170   // module -- dan doet hij niets, maar hij springt niet in beeld bij het laden.
     172  // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
     173  // hij niets, maar hij springt niet in beeld bij het laden.
    171174  knop = document.getElementById('read-top');
    172175  if (knop) {
     
    175178  }
    176179
    177   // Eén luisteraar, en losmaken bij een volgende init(): deze module draait bij
    178   // ELKE paginawissel en anders stapelen ze op.
    179180  if (opScroll) {
    180181    window.removeEventListener('scroll', opScroll);
     182    window.removeEventListener('scrollend', rustNu);
    181183    window.removeEventListener('resize', opScroll);
     184    window.removeEventListener('wheel', opWiel);
     185    window.removeEventListener('touchstart', opRaakStart);
     186    window.removeEventListener('touchmove', opRaakBeweeg);
     187    window.removeEventListener('keydown', opToets);
    182188  }
    183   // Eén keer per frame, niet per scroll-event: scroll vuurt tientallen keren per
    184   // seconde en we raken hier de stijl van elk bericht aan.
    185   let gepland = false;
    186   opScroll = () => {
    187     if (knop) toonKnop(knop);
    188     if (gepland) return;
    189     gepland = true;
    190     requestAnimationFrame(() => { gepland = false; ijkSnappen(); });
    191   };
     189  // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
     190  // levend; pas als het stil blijft mag een nieuwe richting gelden.
     191  opScroll = () => { if (knop) toonKnop(knop); planRust(); };
    192192  window.addEventListener('scroll', opScroll, { passive: true });
     193  window.addEventListener('scrollend', rustNu);
    193194  window.addEventListener('resize', opScroll, { passive: true });
    194   ijkSnappen();   // ook meteen bij binnenkomst, niet pas bij de eerste scroll
     195  window.addEventListener('wheel', opWiel, { passive: true });
     196  window.addEventListener('touchstart', opRaakStart, { passive: true });
     197  window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
     198  window.addEventListener('keydown', opToets);
    195199}
    196200
  • src/views/shell.ejs

    r48481cd r4989294  
    222222
    223223<!-- v9 stylesheet (full palette system) -->
    224 <link rel="stylesheet" href="/assets/css/style.css?v=77">
     224<link rel="stylesheet" href="/assets/css/style.css?v=78">
    225225<script>
    226226/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
     
    505505  // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
    506506  // bumpen kost een bugfix die nooit aankomt.
    507   var MOD_V = 13;
     507  var MOD_V = 16;
    508508
    509509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Note: See TracChangeset for help on using the changeset viewer.