Changelog
Juni 2026
Section titled “Juni 2026”Week 2 (11 juni) — Bugfixes gallery-veld: reveal-race, z-index, label-forwarding + Modal-failsafe
Section titled “Week 2 (11 juni) — Bugfixes gallery-veld: reveal-race, z-index, label-forwarding + Modal-failsafe”Wijziging: drie gemelde gallery-bugs onderzocht en gefixt, plus een structurele Modal-hardening die een hele bug-klasse afsluit.
- Afbeeldingen bleven onzichtbaar (reveal-race):
BlurImage.reveal()zette de once-only latch vóór de null-check op de img-ref. Een pending IntersectionObserver- callback in het StrictMode unmount/remount-window claimde de latch zonder de.loaded-class toe te voegen → afbeelding geladen maar permanent op opacity 0. De latch wordt nu pas ná een geslaagde reveal gezet. - Hover-menu onzichtbaar (stacking): BlurImage’s
.picture-lazy-laag heeftz-index: 2; de hover-acties en “Geen alt”-badge van gallery-tegels hadden geen z-index en werden door de afbeeldingslaag afgedekt. Nuz-10. - Klik op lege ruimte opende de alt-modal (label-forwarding): de
FormField-wrapper is een<label>— een klik op lege ruimte binnen een label activeert het eerste button-element erin (native gedrag). Nieuwe div-gebaseerdeFormFieldGroupvoor velden waarvan de control zelf knoppen bevat (galerij, featured image, FAQ-knop). - Modal-failsafe tegen hangende exit-animaties: Motion’s exit draait op rAF en
pauzeert in verborgen tabs — AnimatePresence unmount dan nooit en een onzichtbare
backdrop (opacity 0, pointer-events auto) blokkeert de hele pagina. Zelfde bug-klasse
als de FormSection-accordion die eerder pure CSS werd. De Modal forceert nu een
unmount (en een gemiste
onExitComplete) ~700ms na sluiten via setTimeout, dat in verborgen tabs alleen gethrottled wordt. - Echte thumbnails in het gallery-grid:
EventGalleryItemheeft optionelethumb- enblur-velden — de pre-generated media-bibliotheek-thumbnail (klein bestand) en de LQIP base64 blur-placeholder, gevuld bij selectie via de picker (SelectedMediaItem.thumbnailUrl/blurDataUrl). De tegels laden nu dezelfde snelle thumbs als de picker i.p.v. de full-size originelen, lazy-loaded mét blur-up placeholder (BlurImage).sanitizeGalleryvalideertbluropdata:image/-prefix + max 10k chars. Legacy items vallen terug op een CF-resize vanurl; de publieke site blijfturlgebruiken. - Tegel-klik doet niets meer: de alt-modal opent uitsluitend via de potlood-knop (was: klik op de tegel — gevoelig voor accidentele modals). De post-drag click-guard is daarmee overbodig en verwijderd.
Week 2 (11 juni) — Event-gallery: ACF-stijl gallery-veld + multi-select media-picker
Section titled “Week 2 (11 juni) — Event-gallery: ACF-stijl gallery-veld + multi-select media-picker”Wijziging: het galerij-veld in de event-editor is omgebouwd van een verticale rijen-lijst (thumb + alt-input + op/neer-pijltjes per rij) naar een ACF-achtig thumbnail-grid, en de media-picker kan nu meerdere afbeeldingen in één keer toevoegen.
- Nieuw herbruikbaar
GalleryField(components/gallery-field.tsx): vierkante tegels in een 3/4-koloms grid, drag-and-drop herordenen (hele tegel is drag-target, viaSortableListmet nieuwestrategy="grid"→rectSortingStrategy), hover-acties per tegel (alt bewerken + verwijderen), klik op tegel → edit-modal met grote preview + alt-veld, ”+“-tegel met teller opent de picker, en een “Geen alt”-badge als a11y-nudge. Een post-drag click-guard voorkomt dat een drag de edit-modal opent. MediaPickerModalmulti-select (multiple+maxSelect+onSelectMultiple): klik togglet items (selectie-ring), footer toont “N geselecteerd · max M” met een “Toevoegen (N)“-batchknop. Uploads (incl. multi-file select + drop), stock-foto’s, externe URL’s en duplicaat-keuzes voegen in deze modus toe aan de selectie i.p.v. direct te sluiten. Bestaande single-select call-sites (featured image, block-sidebar) ongewijzigd;onSelectis nu optioneel.- EventEditPage: batch-add dedupliceert op url en capt op
EVENT_GALLERY_MAX(20);maxSelectis dynamisch het aantal resterende slots. Datamodel, save-sanitization en de publieke Swiper-carousel zijn ongewijzigd. - Audit-fixes: de Modal focus-trap respecteert nu al-gezette focus (
autoFocusop inputs werd voorheen overschreven door de sluitknop — geldt voor alle modals); de post-drag click-guard van gallery-tegels reset op de falling edge (geannuleerde drag slikte voorheen de eerstvolgende klik in); Enter in het alt-veld sluit de edit-modal.
Week 2 (11 juni) — Event-editor: Tiptap-pariteit met de block-editor + Tekststijl-fix
Section titled “Week 2 (11 juni) — Event-editor: Tiptap-pariteit met de block-editor + Tekststijl-fix”Wijziging: de twee WYSIWYG-velden in de event-editor (omschrijving + FAQ-antwoorden)
gebruiken nu exact dezelfde Tiptap-features en -styling als de block-editor, via gedeelde
bouwstenen uit rich-text.tsx.
- Feature-pariteit
FormRichText: heading-niveaus H1–H4 (de dropdown toonde H1/H4 al maar deed stil niets — levels stonden op [2,3]), Tekststijl-presets (Lead/Accent/Klein), per-item bullet-iconen (ListItemWithIcon, incl. icon-picker), Notion-style-/1.+ Enter → lijst (gedeeldetryListAutoStartOnEnter-helper), Escape → blur entrailingNodeuit. Undo/redo blijft bewust Tiptap-native — events hebben geen Zustand-store-history. Chrome (wrapper + toolbar) is gelijkgetrokken met de block-editor fixed-toolbar variant. - Tekststijl-bugfix (block-editor én event-editor): de kale
@tiptap/extension-text-stylekent geenclass-attribuut, waardoor de Tekststijl- dropdown overal kale<span>’s zonder class produceerde — de preset-styling (text-style-leadetc.) kwam nooit aan. Nieuwe gedeeldeTextStyleWithClass-extensie draagt de class door naar de HTML én parseert hem terug bij reload/paste, zodat tekststijlen save/load-cycles overleven. De dropdown toont nu ook correct de actieve stijl. - Keten al gereed: save-sanitization (DOMPurify-allowlist) en de publieke CSS
(
.li-bullet-icon,.text-style-*, prose) ondersteunden deze output al — geen wijzigingen aan API of public-site nodig.
Week 2 (10 juni) — Bugfix: event-detailpagina’s gaven 502 na prijs-migratie
Section titled “Week 2 (10 juni) — Bugfix: event-detailpagina’s gaven 502 na prijs-migratie”Wijziging: alle publieke event-detailpagina’s (events/[slug]) serveerden “Service
temporarily unavailable” (HTTP 502). De archief-lijst en gewone pagina’s bleven werken.
- Oorzaak: migratie 093 (gestructureerde prijs) deed een
DROP + CREATEop de detail- resolver-RPCpublic_resolve_event_by_slugomprice_donation/price_fromtoe te voegen, maar baseerde de kolomlijst op een oudere revisie. Daardoor vielengallery(mig 091) enfaq(mig 092) uit de return-signature. De Astro-detailpagina leestevent.gallery.lengthzonder guard →undefined→TypeErrortijdens SSR → middleware vangt de fout en serveert 502. De archief-RPC (public_resolve_events_for_site) bevat geen gallery/faq en bleef heel. - Fix: migratie 094 rebuildt de detail-RPC met de volledige kolomset (093-velden +
gallery+faqhersteld). Daarnaast normaliseert de public-site data-laag (getPublishedEventBySlug)gallerydefensief naar een lege array, zodat een toekomstige RPC-drift de detailpagina nooit meer kan laten crashen (faqwas al overal?? []-geguard). - Editor-polish: bij “Op donatiebasis” toont het (uitgeschakelde) prijsveld geen
€-teken en geen25-placeholder meer; de afgesneden intro-tekst in de FAQ/CTA-secties is verholpen (overbodige negatieve marge verwijderd).
Week 2 (10 juni) — Event-editor: inklapbare secties (per-gebruiker) + render-performance
Section titled “Week 2 (10 juni) — Event-editor: inklapbare secties (per-gebruiker) + render-performance”Wijziging: de event edit-form (/events/:id) heeft 8 lange secties onder elkaar; die
zijn nu individueel inklapbaar zodat de gebruiker focust op wat relevant is. De open/dicht-
voorkeur wordt per gebruiker onthouden, en de zware editors in het formulier renderen niet
langer mee bij elke toetsaanslag elders.
- Native CSS-accordion (
grid-template-rows): elkeFormSection-header is een toggle-knop; de inhoud schuift open/dicht via een0fr ↔ 1frgrid-rows-transitie — géén JS-animatieloop (geenrequestAnimationFrame), dus betrouwbaar en throttle-bestendig in achtergrond-tabs. Een eerdere MotionAnimatePresence-poging bleef hangen omdat de rAF-loop in een verborgen tab gepauzeerd wordt; de pure-CSS-variant heeft dat probleem niet. - Inhoud blijft gemount, maar
inertbij inklappen: de Tiptap-velden (omschrijving + FAQ-antwoorden) worden niet steeds opnieuw geïnitialiseerd. Ingeklapte inhoud krijgt hetinert-attribuut (React 19): niet tabbaar en weg uit de accessibility-tree.ChevronDownroteert 180°;aria-expanded+aria-controlskoppelen knop en paneel. - Per-gebruiker persistentie (
useCollapsibleSection): open/dicht-staat in localStorage onderbeam_sections_event_<userId>(default open).useSyncExternalStoremet een primitieve snapshot per sectie → togglen van één sectie re-rendert alleen díe sectie. Read-modify-write voorkomt lost-updates tussen parallelle secties; een handmatigeStorageEvent-dispatch synct de huidige tab, native storage-events syncen andere tabs. - Render-performance:
FaqItemEditor,FormRichTextenBlurImagezijnmemo’d en de event-editor-handlers (handleContentChange, FAQ add/move/remove) zijn metuseCallbackgestabiliseerd. Een toetsaanslag in één veld re-rendert niet meer álle Tiptap-editors, toolbars en galerij-thumbnails. - Reduced motion:
motion-reduce:transition-noneschakelt zowel de slide- als de chevron-rotatie uit.
Week 2 (10 juni) — Events: gestructureerde prijs (NL-masking, donatie, vanaf-prijs)
Section titled “Week 2 (10 juni) — Events: gestructureerde prijs (NL-masking, donatie, vanaf-prijs)”Wijziging: het vrije-tekst prijsveld op events is vervangen door een gestructureerd numeriek model met consistente weergave op élke render-surface (editor === public).
- Input-masking (
maskPriceInputNL): het prijsveld accepteert alleen cijfers en maskeert live naar NL-notatie tijdens het typen — duizendtal-scheiding., decimaal,, max 2 decimalen (bv.1234567→1.234.567,1234,5→1.234,5). Opslag gebeurt canoniek met een decimale punt zonder scheidingstekens (1234.56). - “Op donatiebasis” (checkbox): disablet het prijsveld en slaat
price = nullop; de publieke weergave toont “Op donatiebasis” i.p.v. een bedrag. - “Toon als vanaf-prijs” (conditionele checkbox): verschijnt alleen wanneer een numeriek bedrag is ingevuld; rendert “Vanaf €X”.
- Decimalen alleen wanneer relevant:
formatEventPriceNLtoont decimalen uitsluitend als ze ingevuld én ≠ 0 zijn (€25i.p.v.€25,00;€25,50blijft€25,50). - Eén gedeelde render-helper (
eventPriceDisplay): alle 5 surfaces (events-archive, detail-page, related-events, en de events-carousel in editor + public + Astro) gaan via dezelfde helper in@beam/shared/events. Archive/carousel-kaarten houden hetPrijs-label; de detail-page toont het bedrag zonder “Prijs:“-prefix. - Migratie 093: twee
boolean NOT NULL DEFAULT falsekolommen (price_donation,price_from) opevents; beide public-RPCs (public_resolve_events_for_site,public_resolve_event_by_slug) herbouwd (DROP + CREATE — TABLE-return signature) met de drie prijsvelden en opnieuwREVOKE ALL+GRANT EXECUTE TO anon, authenticated. Geen backfill — bestaande prijzen worden handmatig opnieuw ingevoerd.
Week 2 (10 juni) — Testimonials-grid: kaart-gap op het site-grid (--site-grid-gap)
Section titled “Week 2 (10 juni) — Testimonials-grid: kaart-gap op het site-grid (--site-grid-gap)”Bug: de statische testimonials-grid (.review-grid, 2/3 kolommen) en zijn mobiele
1-up carousel (--1) gebruikten een hardcoded gap: 1rem (16px), terwijl het canonieke
site-grid --site-grid-gap = 8px mobiel / 40px desktop is. Een achtergebleven comment
claimde nog dat 1rem “het kolom-ritme van gridContainer (gap-x-4)” matchte — maar sinds de
grid-gap naar --site-grid-gap is gegaan klopte dat niet meer: de kaart-randen lagen
niet op de 12-koloms gridlijnen, en de testimonials-grid week zichtbaar af van de
identieke testimonials-carousel (--grid, die --site-grid-gap al correct gebruikt).
- Fix (pure CSS):
.review-grid→column-gap: var(--site-grid-gap)(de verticalerow-gapblijft een vaste ritme-waarde van 1rem, los van de horizontale grid-as — zoals columns-block metgap-y-6). Metrepeat(2|3, 1fr)binnen de full-width span landen de kaart-randen daardoor exact op de kolom-grenzen, wiskundig identiek aan de--gridcarousel (3 kaarten × 4 kolommen, 2 kaarten × 6 kolommen). --1slide consistent:width: calc(100% + var(--site-grid-gap))+padding-right: var(--site-grid-gap)i.p.v.1rem, zodat de mobiele 1-up swipe-gap gelijk is aan de--gridcarousel op mobiel (8px i.p.v. 16px). De:last-childphantom-snap-fix blijft kloppen (width: 100%=calc(100% + gap) − gap).- Audit overige blocks: columns-block gebruikt al
gridContainer(.site-grid→column-gap: var(--site-grid-gap)) — correct. Features-block houdt zijn eigen component-internegap-6 sm:gap-8(gecentreerde icon-grid, géén grid-lijn-alignment, geen stale claim) — bewust ongemoeid. Geen andere blocks met hardcoded column-gaps. - Editor === public: zelfde gedeelde
review-card.css, één bron voor het grid-ritme.
Week 2 (9 juni) — Carousel: phantom-snap voorbij de laatste kaart weg
Section titled “Week 2 (9 juni) — Carousel: phantom-snap voorbij de laatste kaart weg”Bug: op de allerlaatste slide van de exact-fill carousels — --grid
(testimonials-carousel, events-carousel, related-events) én --1 (testimonials-grid
mobiel) — kon je nóg één keer op next klikken; de carousel schoof dan precies één
gap door (--grid: 40px desktop / 8px mobiel; --1: 1rem) zonder dat er een kaart
bijkwam — een lege na-snap.
- Oorzaak: elke
--grid-slide draagt de inter-card ruimte inpadding-right(--site-grid-gap, border-box) i.p.v. een container-gap— vereist omdat Swiper metslidesPerView: 'auto'elke slide viaoffsetWidthmeet (zie ADR-011). Maar de laatste slide heeft geen volgende kaart, dus die trailingpadding-righttelt onnodig mee in Swiper’svirtualSize.maxTranslatekwam daardoor exact één gap voorbij de laatste grid-aligned snap te liggen → een extra snap-positie. - Fix (pure CSS): een
:last-child-regel op.review-carousel-slide--grid(en analoog op.review-carousel-slide--1) zetpadding-right: 0én verkleint de slide-breedte met één gap (--gridper breakpoint N=1/2/3;--1één regel). De kaartbreedte blijft gelijk aan de overige kaarten (flex-basis − paddingongewijzigd), maarvirtualSizekrimpt met één gap →maxTranslatevalt nu samen met de laatste echte snap; de phantom-snap verdwijnt. --bleed(event-gallery) bewust ongemoeid: dat is een peek-layout met symmetrischepadding-left+padding-rightplus negatieve viewport-marges. De laatste snap is daar een legitieme partiële schuif die de laatste kaart vól in beeld brengt (nieuwe content zichtbaar) — géén dead-space-gap zoals bij de exact-fill modes. Zo’n:last-child-ingreep zou daar juist de bedoelde peek breken.- Editor === public: zelfde gedeelde CSS (
@beam/shared/.../review-card.css), dezelfde slide-structuur (slides als directe children van.review-carousel-container), dus:last-childmatcht identiek in editor en public. - Geverifieerd in de browser op 375 / 700 / 768 / 1280px (N=1/2/3): in elk geval
is de laatste kaart even breed als de andere, eindigt
snapGridexact opmaxTranslate(geen na-entry), blijftisEndtrue op de laatste grid-aligned snap, en verschuift een extra next-klik 0px. De laatste kaart-rechterrand ligt op de container-/gridlijn (1240 bij 1280px).--1(testimonials-grid mobiel) deelt exact hetzelfde mechanisme (zelfde Swiper, trailing-padding-oorzaak,:last-child-fix) en is via dezelfde regel gedekt. Zie ADR-011 → slide-padding.
Week 2 (9 juni) — Carousel-clipping: container-rand → viewport-rand (100vw)
Section titled “Week 2 (9 juni) — Carousel-clipping: container-rand → viewport-rand (100vw)”Wens: de --grid-carousels (testimonials-carousel, events-carousel, related-events)
moesten weggescrollde kaarten zichtbaar de page-gutter in laten sliden en pas op de
scherm-/viewport-rand (100vw) clippen — net als de --bleed event-gallery — in plaats
van hard te verdwijnen op de smalle content-container-rand (40..1240 bij 1280px). Staande
eis: “ten alle tijden zie je de cards uit de viewport sliden, niet eerder gehide uit de
container.”
- Oorzaak:
.review-carousel--gridhadoverflow: hidden, dus de clip lag op de content-container (de Swiper-viewport = 1200px breed). Een kaart die wegscrollde verdween daardoor op gridlijn 40/1240 i.p.v. op de schermrand. - Fix (pure CSS):
.review-carousel.review-carousel--grid→overflow: visible(de base.review-carouselstaat ook opvisible). De clip valt nu op het full-bleed block-<section>(overflow: hidden) dat het grid omhult — dat ís de viewport-box op de publieke site én de canvas-kolom-box in de editor.--contained(grid-mobiel 1-up) blijftoverflow: hidden;--bleedwas alvisible(ongewijzigd). - Editor === public per constructie:
--gridis container-relatief gesized (100%, géén100vw), dus de kaarten blijven grid-aligned en de clip-op-de-section gedraagt zich identiek bij canvas-breedte (editor) en viewport-breedte (public). Géén100vw- formule zoals--bleed, en géén negatieve bleed-margin → slide 0 blijft exact grid-aligned: de N+1e kaart begint precies op de section/viewport-rand, dus geen onbedoelde peek op desktop. - Mobiel (<640px, 1-up): de eerstvolgende kaart toont nu een kleine ~12px peek in de 20px-gutter, en een weggeschoven kaart slidet zichtbaar de gutter in tot de schermrand (0). Dit is het directe, gewenste gevolg van de staande eis (kaarten sliden uit de viewport, niet uit de container) en sluit aan op het peek-gevoel van de event-gallery.
- Nav-pijltjes flush tegen de container:
.review-carousel-btnging vanleft/right: -3.5rem(56px de gutter in → stak na de padding-fix ~16px voorbij de schermrand) naarleft/right: 0op de wrapper. De wrapper-box ís de content-container, dus de knop-buitenrand ligt nu exact op de container-rand (40 / 1240 desktop), altijd binnen de viewport op elke breedte, verticaal gecentreerd. De 44px-knop valt iets over de eerste/laatste kaart (standaard arrow-overlay) i.p.v. over de schermrand te steken. - Geverifieerd in de browser op 1280px, 768px en 375px: slide 0 toont op desktop exact 3
grid-aligned kaarten (40 / 413 / 453 / 827 / 867 / 1240) met de 4e kaart op de
viewport-rand (1280); na
slideNextslidet de uitgaande kaart zichtbaar door de gutter en clipt op 0 (niet op 40), de inkomende kaart komt binnen vanaf 1280 (niet 1240). De volledige overflow-keten van kaart → section bevat exact één clipper: de block-<section>op de viewport-box. Zie ADR-011 → cardsPerView → CSS-modes.
Week 2 (9 juni) — Site-brede grid-uitlijning: dubbele section-padding weg
Section titled “Week 2 (9 juni) — Site-brede grid-uitlijning: dubbele section-padding weg”Bug: de carousel-cards (en álle block-content) lagen ~32px (desktop) / ~20px (mobiel) naar binnen versprongen ten opzichte van het canonieke site-grid — al bij het laden, vóór enige navigatie. De header, footer én de debug grid-overlay tekenden het grid op 40..1240 (bij 1280px viewport), maar elk block-content-vlak zat op 72..1208. De vorige carousel-fix (gap → slide-padding) had de kaarten netjes op de gridlijnen binnen hun eigen content-vlak gezet — maar dat content-vlak zelf stond verschoven, dus op de pagina leken de kaarten nog steeds naast het grid te vallen.
- Oorzaak: elk block-
<section>droeg een eigen horizontale padding (px-5 sm:px-8= 20px mobiel / 32px desktop) bovenop depadding-inlinevan de geneste.site-container(20px mobiel / 40px desktop). Dubbele padding → het 12-koloms grid van blocks leefde 32px (desktop) binnen het canonieke grid dat header/footer/overlay gebruiken. De--container-paddingop.site-containerwas juist bedoeld als enige bron van de horizontale gutter (zie de comment insite-layout.css); de section-px-*was overgebleven ad-hoc padding. - Fix:
px-5 sm:px-8verwijderd van álle block-<section>s in beide apps (39 bestanden: 13 Astro publieke blocks + 26 dashboard editor/public blocks). De section blijft full-bleed (relative overflow-hidden+ spacing/corners); de.site-containerlevert nu als enige de gutter — structureel identiek aan header en footer. Content wordt site-breed 32px/zijde (desktop) resp. 20px/zijde (mobiel) breder. - Editor === public: dezelfde verwijdering is symmetrisch op de dashboard-editor- én
publieke block-bestanden toegepast; beide kanten erven dezelfde
.site-container-gutter uit@beam/shared. De events-pagina’s (event-gallery--bleed, related-events--grid) gebruikten alpadding-inline: var(--container-padding)/.site-containerdirect en hadden de dubbele padding nooit — ongewijzigd gelaten. - Geverifieerd in de browser op 1280px en 375px: elk
.site-container-content-vlak (header, alle blocks, footer) zit op 40..1240 (desktop) / 20..355 (mobiel), gelijk aan de grid-overlay. De testimonials-carousel-kaarten landen exact op de kolomlijnen (40 / 413 / 453 / 827 / 867 / 1240); related-events idem; de--bleedgallery start op gridlijn 40 en bleeds vanaf daar naar de schermrand (bedoeld peek-gedrag).
Week 2 (9 juni) — Carousel grid-uitlijning: gap → slide-padding
Section titled “Week 2 (9 juni) — Carousel grid-uitlijning: gap → slide-padding”Bug: de testimonials- en events-carousel-cards lagen bij de start netjes op de
12-koloms gridlijnen, maar liepen er na élke navigatiestap een stukje uit —
cumulatief een gap-breedte per slide (+40px, +80px, …). Oorzaak: de carousels
zetten de ruimte tussen kaarten met CSS gap op de container, maar Swiper draait
spaceBetween: 0 en meet bij slidesPerView: 'auto' elke slide via offsetWidth
— en CSS gap zit niet in offsetWidth. Swiper’s snap-pitch werd zo de kále
kaartbreedte (bv. 405px) terwijl de visuele pitch kaart + gap was (445px); elke
snap schoof dus precies een gap te weinig. Aan de start (geen transform) klopte de
CSS-layout nog, daarom leek het pas “stuk” ná het eerste navigeren.
- Fix (pure CSS): de inter-card ruimte verhuist van container-
gapnaarpadding-rightop de slide metbox-sizing: border-box. Padding zit wél inoffsetWidth, dus Swiper’s snap-pitch wordt kaart + gap → op élke snap weer grid-aligned. Geraakt:--grid(testimonials-carousel, events-carousel, related-events) en--1(testimonials-grid mobiel).--bleed(event-gallery) gebruikte dit padding-model al; de nu-overbodige--bleedcontainer-gap-override is opgeruimd (de base-container staat al opgap: 0). - Geen Swiper-config-wijziging → editor === public per constructie. De
responsive gap (
--site-grid-gap: 8px mobiel, 40px desktop) blijft in CSS, dus dashboard-editor en publieke site delen exact hetzelfde gedrag. Zie ADR-011 → cardsPerView → CSS-modes. - Geverifieerd in de browser op 1440/1280/800/700/400px: vrije snaps tonen identieke kaart-posities (geen drift); de laatste snap clampt de rechter kaart flush tegen de containerrand (standaard no-loop-gedrag).
Week 2 (9 juni) — Carousel-init fix: IntersectionObserver → directe init
Section titled “Week 2 (9 juni) — Carousel-init fix: IntersectionObserver → directe init”Bug: carousels op gewone pagina’s (bv. een testimonials- of events-carousel op
/over-ons) initialiseerden soms niet — geen sliden, knoppen deden niets — terwijl
de event-detailpagina’s wél werkten. Oorzaak: de publieke carousels lazy-initten
via een gedeelde IntersectionObserver die niet betrouwbaar vuurde wanneer de carousel
onder de fold stond; Swiper werd dan nooit aangemaakt. De event-gallery was hier
eerder al tegenaan gelopen en gebruikte daarom al directe init — vandaar het verschil.
- Fix:
carousel-observer.tsis herschreven vanobserveCarousels()(IO-lazy) naarinitCarousels()(directe init op load +astro:after-swapre-init). Alle vijf carousels (testimonials-carousel,events-carousel,testimonials-gridmobiel, event-gallery, related-events) lopen nu via deze ene helper. - Geen bundle-regressie: de Swiper-bundle werd al statisch geïmporteerd, dus lazy-init bespaarde alleen wat main-thread werk — niet de download.
- Editor === public: de publieke init is nu identiek aan de dashboard-editor
(
useSwiperCarousel), die ook direct initialiseert. Zie ADR-010 → Gevolgen.
Week 2 (9 juni) — Gedeeld carousel-optiemodel (ADR-011)
Section titled “Week 2 (9 juni) — Gedeeld carousel-optiemodel (ADR-011)”Alle vijf carousels delen nu één optie-contract (packages/shared/src/carousel.ts),
geïmporteerd door zowel de React dashboard-editor als de Astro publieke site. Daarmee is
editor === public geen afspraak meer maar een constructie: beide kanten genereren
dezelfde class-strings uit dezelfde helpers.
- Drie assen, in code per carousel vastgezet (geen sidebar-velden):
customCursor(true→ hover-cursor met richtingspijlen + klik-overal, alleen event-gallery;false→ vaste klikbare pijl-knoppen),cardsPerView('3-op-een-rij'/'bleed'/'1-up') endots(aan/uit). - Class-helpers als single source:
carouselViewportClass(),carouselSlideClass()encarouselWrapperClass()mappen de assen op de CSS-modes inreview-card.css.packages/sharedheeft geen swiper-dependency — het contract exporteert alleen het optie-model + class-strings, elke app importeert Swiper zelf viaCAROUSEL_BASE_CONFIG. 'bleed'is nu een herbruikbare mode (.review-carousel-slide--bleed,100vw-peek): de bespoke event-gallery-CSS is gegeneraliseerd. Veilig alleen op full-width publieke pagina’s —'3-op-een-rij'(--grid) en'1-up'(--contained) rekenen container-relatief (100%) en kloppen óók in de editor-canvas.- Carousel-mapping:
testimonials-carousel,events-carouselenrelated-events→ knoppen +'3-op-een-rij'+ dots; event-gallery → cursor +'bleed'+ dots;testimonials-grid(mobiel) → knoppen +'1-up'+ dots. Pijl-knoppen zijndisplay: noneonder 768px — op mobiel navigeert de bezoeker via swipe + dots.
Week 2 (9 juni) — Carousel-engine: Embla → Swiper (ADR-010)
Section titled “Week 2 (9 juni) — Carousel-engine: Embla → Swiper (ADR-010)”Alle carousels draaien nu op Swiper in plaats van Embla. ADR-009 is vervangen door ADR-010.
- Eén gedeelde configuratie op beide frameworks:
useSwiperCarousel(apps/dashboard/src/lib/hooks/use-swiper-carousel.ts) in de React-editor enobserveCarousels+ Swiper (apps/public-site/src/lib/carousel-observer.ts) op de Astro publieke site — exact dezelfde config (Navigation+Keyboard,loop: false,slidesPerView: 'auto',spaceBetween: 0,speed: 600). Geen drift meer tussen editor-preview en publieke site. - 5 carousels op dezelfde engine:
testimonials-carousel,events-carousel, de mobiele variant vantestimonials-grid, plus event-gallery en related-events op de events-detailpagina. - Custom cursor + click-anywhere voor de event-gallery via
swiper-cursor.ts/swiper-cursor.css— hover-cursor met richting-indicatie, klik-overal-navigatie. - Bundle: Swiper in een eigen
vendor-swiperchunk (wasvendor-embla); alleen geladen op pagina’s met carousel-blocks. Slide-layout blijft volledig in CSS (review-card.css).
Week 2 (8 juni) — Testimonial-carousel: exact-fill grid-uitlijning (geen peek)
Section titled “Week 2 (8 juni) — Testimonial-carousel: exact-fill grid-uitlijning (geen peek)”Vaste responsive carousel-opbouw voor testimonials-carousel
(packages/shared/src/testimonials/review-card.css + 3 render-sites):
- Nieuwe
.review-carousel-slide--gridclass: exact-fill layout met kaart-randen exact op de 12-koloms gridlijnen, géén peek. 1 kaart op volle breedte (12 kolommen) mobiel, 2 gelijke kaarten van 6 kolommen vanaf640px, 3 gelijke kaarten van 4 kolommen vanaf768px. Vervangt de oude--2/--3breedte-regels (carousel-only);--1blijft voor de grid-block mobile carousel. - Slide-breedte =
(100% − (N−1)·--site-grid-gap) / N, wat exact een heel kolom-blok beslaat (a·C + (a−1)·Gmeta = 12/Nkolommen). De slide-pitch is preciesakolom-pitches, dus elke snap blijft grid-aligned — een testimonials-carousel boven een features- of grid-blok lijnt 1-op-1 uit (randen op col 0/4/8/12 op desktop). - Inter-card gap =
--site-grid-gapvia.review-carousel--grid .review-carousel-container { gap: var(--site-grid-gap) }(0.5rem mobiel / 2.5rem ≥1024px) — het kolom-ritme vangridContainer. Geen per-slide padding of bleed-margin meer. Viewport.review-carousel--grid { overflow: hidden }: de kaarten vullen de container precies op, dus clippen op de container-rand geeft geen zichtbaar verlies. De grid-block mobile carousel (single--1card) houdt--contained. - Meetkundige afweging: met gelijke kaarten kun je niet tegelijk (1) N kaarten
zichtbaar, (2) een peek én (3) randen-op-lijnen hebben — “kies 2 van 3”. De vorige
iteratie koos peek + N kaarten (randen zweefden op
11/(12·N)≈ 3,67 kolommen); deze versie kiest randen-op-lijnen + N kaarten en laat de peek vallen. Geen100vw→ de uitlijning klopt óók in de editor-canvas (canvas ≠ window). - De configureerbare
slidesPerView-instelling (1/2/3) vervalt — layout is nu vast. De prop is@deprecatedmaar blijft optioneel inTestimonialsCarouselPropszodat bestaande block-data niet breekt.slidesPerViewFieldverwijderd uitfields.ts+blocks.ts(geen UI-control meer). - Dot-aantal corrigeert mee: SSR rendert
items.lengthdots (mobiel = 1-per-view, max snap-posities); client-sidesyncDotVisibility()verbergt overtollige dots op bredere viewports. Fixt tevens een latente onder-render van mobiele dots.
Week 1 (7 juni) — HTML-stripping in plain-text velden + CSP-fixes + security-audit
Section titled “Week 1 (7 juni) — HTML-stripping in plain-text velden + CSP-fixes + security-audit”Plakken stript HTML in sidebar plain-text velden (apps/dashboard/src/lib/sanitize.ts,
form-field.tsx):
- Nieuwe
htmlToPlainText()(browser-only, gebruiktDOMParser) zet block-grenzen (<br>,</p>,</div>, …) om naar newlines, dropt<script>/<style>en retourneerttextContent. handlePlainTextPasteop de text-<input>en<textarea>onderschept alléén pastes die rich text dragen (text/htmlaanwezig) — platte tekst valt door naar native paste zodat undo-historie behouden blijft. Single-line inputs collapsen newlines naar spaties; caret wordt na re-render hersteld viarequestAnimationFrame.
Testimonial-quotes als platte tekst opgeslagen én gerenderd — op publieke
pagina’s verschenen letterlijke <div>/<br>-tags in quotes (in de editor zag je
witregels). Oorzaak: de multiline contentEditable slaat innerHTML op:
- Nieuwe Workers-safe
htmlToText()inpackages/shared/src/sanitize.ts— pure regex, geenDOMParser/DOMPurify(niet beschikbaar op de Cloudflare Workers-runtime).&wordt als laatste gedecodeerd zodat&lt;de letterlijke<oplevert, niet<. cleanBlocksForSavenormaliseert quotes op save voortestimonials-grid,testimonials-carousel(props.testimonials.items[]) enfeatured-testimonial(props.testimonial).- Alle 4 render-sites draaien
htmlToText(item.quote)defensief (legacy data):shared-review-card.tsx,featured-testimonial-block.tsx(dashboard),TestimonialCard.astro,FeaturedTestimonialBlock.astro(public-site). .review-quotekrijgtwhite-space: pre-linezodat bewuste regelafbrekingen zichtbaar blijven.
CSP-fixes op de publieke site (apps/public-site/src/middleware.ts +
public/_headers — beide CSP-definities in sync gehouden):
- Externe brand-stylesheet fallback (
brandCssUrl<link>) volledig verwijderd uitPageLayout.astroen de 3 callers. Een cross-origin stylesheet is render-blocking én werd doorstyle-src 'self'geblokkeerd; bij SSR-fetch-fout degraderen we nu netjes naar het default Open Sans-font. Dit fixte óók een “preloaded but not used” warning: de fallback maaktehasBrandFontsten onrechtetrueop het foutpad. - Cloudflare Web Analytics expliciet toegestaan:
script-src+=https://static.cloudflareinsights.com,connect-src+=https://cloudflareinsights.com.
Security-audit op bovenstaande wijzigingen (beam-reviewer + Security Engineer):
- O(n²) DoS-guard — de tag-strip regex in
htmlToTextis kwadratisch op pathologische input (lange runs van niet-afgesloten<) en er was geen length-cap upstream (props: z.record(z.unknown())). Toegevoegd:HTML_TO_TEXT_MAX_INPUT = 10_000cap + output-contract-waarschuwing in de JSDoc (nooit naarset:html/dangerouslySetInnerHTML). - Paste-guard data-loss — de oude regex-sniffer stript-te platte tekst met
punthaken (bv.
<user@example.com>) naar leeg; vervangen door detext/htmlMIME-vlag als enige trigger. - XSS-conclusie: output gaat overal als auto-escaped tekstnode (nooit
set:html), dus tag-strip-imperfecties zijn cosmetisch, niet exploiteerbaar.
Mei 2026
Section titled “Mei 2026”Week 5 (26 mei) — Event cards redesign + invite-flow fixes + sitewide 1440px
Section titled “Week 5 (26 mei) — Event cards redesign + invite-flow fixes + sitewide 1440px”Event cards redesign (apps/public-site/src/pages/events/index.astro,
EventsCarouselBlock.astro + dashboard editor/public varianten):
- Placeholder: alleen gradient (geen day/month/title overlay); datum-badge staat nu altijd linksboven — ongeacht featured image.
- Content-gedeelte: titel, subtitel, Duur + Prijs inline (48px gap, 0.25rem
tussen label en waarde, body-text grootte), standaard Beam button via
getButtonClasses+ brand CSS-vars. - Prijs altijd met
€-prefix (zonder spatie). Display-helper strip-t bestaande€/EURuit user input. Form-input toont€als visuele prefix; DB-value bevat alleen het bedrag. - Lege prijs → label wordt niet getoond.
- Section-headers “Aankomende evenementen” / “Afgelopen evenementen” krijgen dubbele-regel structuur met dezelfde count-styling als pagina-totaal.
Sitewide max-breedte 960 → 1440px — gridContainer in
packages/shared/src/styles.ts plus events archive en single-event page. Alle
blocks (hero, columns, CTA, FAQ etc.) krijgen meer ademruimte.
Cache-invalidate bij sitenaam-wijziging — updateSiteName en updateSiteSlug
in apps/dashboard/src/lib/supabase/sites.ts triggert nu api.cache.invalidate(siteId).
Voorheen bleef de KV-cache tot 24u (TTL) stale na sitenaam-update.
Invite-flow: automatische site-switch na accept — een uitgenodigd persoon landde na accept op haar auto-bootstrapped eigen site i.p.v. de geaccepteerde site (“ik zie geen pagina’s”):
POST /team/invitations/acceptreturnt nusiteId(happy-path én “al lid”-tak).InviteAcceptPagedoetawait globalMutate('sites')om de sites-SWR cache te vernieuwen vóórswitchSite(siteId)— anders zouuseSites()auto-reset triggeren (storedId niet in stale lijst → fallback naardata[0]) en de switch terugdraaien.- Migratie 090 —
bootstrap_new_user()slaat de auto-site-creatie nu over wanneer er een pending, niet-verlopenteam_invitationsrij bestaat voor het email van de nieuwe user. Solo signups krijgen onveranderd hun eigen site.
Events-addon niet zichtbaar na site-switch — useIsEventsActive() las uit
een per-site localStorage cache die alleen bij sign-in werd warmgehouden voor
de huidige site. Bij site-switch was er geen entry voor de nieuwe site →
Events-item bleef permanent verborgen. Fix: useEffect op [siteId] triggert
refreshEventsActive(siteId) bij elke site-switch.
Sentry-ruis: TypeError Failed to fetch in fetchIsPlatformAdmin — transient
netwerkfouten (DNS-hapering, blocker-extensies, CF cold-start) leidden tot
unhandled promise rejections via prefetchIsPlatformAdmin() preload. Fix: bij
elke andere error dan 401/403 valt de fetcher terug op de gecachte localStorage
waarde (of false). Server blijft authoritative — admin-pages doen hun eigen
check.
Week 5 (26 mei) — Event slug history + selective CF CDN purge
Section titled “Week 5 (26 mei) — Event slug history + selective CF CDN purge”Probleem: Na het hernoemen van een event-slug ging de oude URL direct op 404 en propageerde de nieuwe slug 5-30s door de CF CDN. Externe bookmarks / backlinks waren stuk.
Oplossing — WordPress _wp_old_slug-patroon:
- Mig 088 —
event_slug_historytabel +archive_old_event_slugtrigger die bij elke slug-mutatie de oude slug archiveert. Backfill voor één bekende rename. - Mig 089 —
public_resolve_event_by_old_slugSECURITY DEFINER RPC voor anonymous public-site; retourneert alleen de huidige slug van gepubliceerde events. Geen direct SELECT opevent_slug_historyvoor anon. - Astro
/events/[slug]— fallback bij 404: lookup oude slug → 301 redirect naar de actuele detail-URL. Bookmarks / backlinks blijven werken. - Selectieve CF CDN purge —
/cache/invalidateaccepteert nu optionelepaths[]. API resolved server-side naar volledige URLs voor alle actieve hosts (subdomein + custom domains). Per event-mutatie purgen we alleen de detail-URL, de oude-slug-URL (bij rename) en/events/. Propagatie 1-3s i.p.v. 5-30s; onveranderde pagina’s blijven warm.
Week 4 (25 mei) — Events fase 4: archive design + WYSIWYG + carousel block + DX polish
Section titled “Week 4 (25 mei) — Events fase 4: archive design + WYSIWYG + carousel block + DX polish”Events archive — 3-koloms card layout (PR #53):
/events/toont nu een grid (1/2/3 cols op mobile/sm/lg) i.p.v. de list-view. Featured image bovenaan in aspect-16:10 met date-badge overlay, daaronder titel, datum, locatie en line-clamped beschrijving + “Bekijk evenement” CTA. Past-events met grayscale image die bij hover terugkleurt.- Event-count regel onder titel:
{N} event(s)(PR #59). - Link-underline fix:
.font-content awas scoped op de body en zette élke link op underline (ook in header/footer/CTA’s). Gescoped naar.prose a— alle rich-text blocks wrappen al in.prose, dus geen visueel verlies. Idem voor list-bullets (.font-content ul/ol/li→.prose ul/ol/li).
Event WYSIWYG omschrijving + lead-tekst splitsing (PR #54):
- Mig 083:
events.content_html TEXT(idempotent). - Mig 084:
public_resolve_event_by_sluguitgebreid metcontent_htmlveld. - Bestaande
descriptionblijft als korte lead-tekst intro (max 500 plain text); nieuwecontent_htmlis uitgebreide HTML uit een Tiptap WYSIWYG editor in het dashboard. - Nieuwe
FormRichTextcomponent (los van page-editor Zustand store) — herbruikbaar voor andere form-velden. - Sanitization-on-save via DOMPurify (
sanitizeContentHtmlin events.ts). Lege editor (<p></p>) wordt alsnullopgeslagen. - Detail-pagina rendert description met
border-l-4 border-primary-200als prominente intro, daarna content_html in een.prosecontainer.
Addon-aware archive picker in link + button modals (PR #55):
- Nieuwe declaratieve registry
apps/dashboard/src/lib/archive-pages.tsmet de addon → route mapping (events →/events/, label, icoon). - Nieuwe hook
useArchivePages()filtert op actieve addons. - MenusPage refactored — vervangen hardcoded “Evenementen” knop door iteratie over de hook (automatisch multi-addon ready).
- Link-edit-modal + button-edit-modal: nieuwe “Archief” subsectie in de PagePicker boven de pagina-lijst. Selected-state herkent archive via URL-match en toont addon-icoon + URL.
- Uitbreiden naar een nieuwe addon-archive: één entry in
ARCHIVE_PAGES, geen modal- of menu-wijzigingen meer nodig.
Header altijd renderen + sitenaam-fallback (PRs #56, #57, #60, #61):
- SiteHeader verborg zich volledig wanneer er geen menu items waren — logo en CTA staan los van menu-config, een site met alleen logo/CTA toonde geen header. Outer conditional verwijderd; mobile inline CTA-fallback wanneer er wel een CTA maar geen menu is.
- Mig 085:
public_resolve_site_chromeretourneert nuCOALESCE(ss.site_name, s.name) AS site_name— fallback van settings. site_name (optionele SEO-override, vaak leeg) naar sites.name (verplicht bij creatie). Geen meer hardcoded “Pagebuilder” fallback bij sites zonder expliciete site_name. - Tekst-logo (sitenaam-fallback) max 180px met truncate-ellipsis i.p.v. fixed 120px.
- Header-instellingen pagina: CTA-clear-knop (X-icoon op dezelfde positie als de pencil) leegt label én URL in één klik; preview rendert altijd zodat ook lege sites de design-effecten live zien.
Block-buttons basePath-prefix in dev (PR #58):
- Nieuwe helper
apps/public-site/src/lib/url.tsmetprefixInternalUrl. - 6 Astro blocks (CTA/Hero/Text/Columns/FAQ/Image) lezen
Astro.locals.basePathen wrappen sanitizeUrl-output zodat interne hrefs (/events/,/about) in dev correct werken oplocalhost:4421/s/<slug>/. - No-op in productie (basePath leeg) → geen runtime overhead.
Events Carousel block (PRs #62–#67):
- Nieuwe block type
events-carousel, addon-gated viaBlockDefinition.requiredAddon = 'events'. Block-picker(.modal) filteren opuseActiveAddonIds()— toekomstige addon-gated blocks gebruiken hetzelfde patroon. - Embla Carousel (
embla-carousel-reactin editor, vanilla in Astro). Prev/next + dots + keyboard nav (ArrowLeft/ArrowRight,Home/End).slidesToScroll: 1,trimSnaps. Lazy init via IntersectionObserver in Astro. - Cards-stramien identiek aan de archive page (PR #53). Title + subtitle
inline-editable via
EditableText/MaybeSelectable. - CSS hergebruikt via
@beam/shared/testimonials/review-card.css—review-carousel-*klassen zijn pure UI, generiek inzetbaar. data-selectable="true"op carousel-wrapper + dots-controls voorkomt dat prev/next/dot-clicks naar block-wrapper bubbelen (block selecteren).
Week 4 (25 mei) — Events addon + RLS lockdown
Section titled “Week 4 (25 mei) — Events addon + RLS lockdown”Addon-systeem & Events addon (Fase 1+2+3) — eerste installeerbare addon:
addonstabel (mig 069) — per-site activatie state, geen runtime config in DB (registry leeft inapps/dashboard/src/lib/addons/registry.ts)eventstabel (mig 071) — title, slug, description, start_at, end_at, location (JSONB met physical/online/hybrid types), status (draft/published/sold_out/ completed), featured_image_url (mig 072), cta_url + cta_label (mig 079, extern)- Dashboard CMS: AddonsPage activeren/deactiveren, EventsPage met sortering +
duplicate + inline-edit + bulk-select, EventEditPage met gesplitste date+time
velden, image picker, CTA, live URL-preview (lokaal
localhost:4421, prodprimary-domain.comofslug.builtwithbeam.com) - Public-site: editorial date-block archive (
/events/) met “Aankomend” en “Voorbij” secties + hero-overlay detail (/events/[slug]) met sticky agenda- card, JSON-LD Event schema, sanitizeUrl op alle externe links - Menu-builder: addon-aware “Archief” sectie met
/events/entry, dynamisch zichtbaar viauseIsAddonActive('events')
RLS lockdown — anon access dichtmaken (mig 074-078):
Anon kon via Supabase REST álle actieve sites + bijbehorende metadata, branding,
menus en events enumereren (USING (true) of IN (SELECT FROM sites) policies).
Vervangen door SECURITY DEFINER RPCs die per call alleen het minimum aan velden
teruggeven:
| RPC | Geeft terug |
|---|---|
public_resolve_site_by_slug(text) | site_id, slug |
public_resolve_site_by_domain(text) | site_id, slug, is_primary |
public_primary_domain_for_slug(text) | text (domain) |
public_resolve_site_chrome(uuid) | settings + homepage_slug |
public_resolve_brand(uuid) | logo_url, logos |
public_resolve_menu_items(uuid) | items + page joins |
public_resolve_events_for_site(uuid) | published+sold_out+completed events |
public_resolve_event_by_slug(uuid, text) | single event |
Anon mag de tabellen zelf niet meer SELECT’en ([] op alle endpoints). Patroon
gedocumenteerd in .claude/rules/security.md — “Anon-SELECT via RPC only”.
Platform_admin volledige CRUD bypass (mig 073, 080):
13 tabellen × 4 CRUD-operaties = 52 platform_admin bypass policies. Consistent
met events/addons/team-patroon. Fixt 42501 errors bij platform-admins die op
sites buiten hun team werken (menu aanmaken, page editen, etc.). Aparte
/admin/sites UI staat als backlog #24 (DB-laag klaar).
Bug fixes & audit findings (events addon):
- CRITICAL: slug
neqstille bug (PostgREST immutable builder fix) - CRITICAL:
online_url+cta_urlXSS-protectie viasanitizeUrl()+'#'sentinel filter - HIGH:
getSession()→getSessionUserId()in createEvent - HIGH: JSON-LD
numberOfItemsmismatch metitemListElement.length - HIGH:
err.messagetoast leakage → generieke fallback - LOW:
focusThrottleIntervalop useEvents, aria-labels op MoreVertical delete-site-modal: toont nu echte API error in toast (403/404/500/network) i.p.v. generieke “probeer opnieuw”
April 2026
Section titled “April 2026”Week 4 (29 apr) — Image block
Section titled “Week 4 (29 apr) — Image block”Solo afbeelding-block (image) met:
- Breedte: 4 presets —
small(col-span-6),normal(col-span-8, default),large(col-span-12 / 960px),fullscreen(viewport-bleed via 100vw + negatieve margin trick) - Hoogte: 4 presets —
landscape(16:9),portrait(3:4),square(1:1),original(vrije hoogte metobject-fit: contain, default) → matcht hetColumnImageHeightenum uitstyles.ts - Caption + alt zitten op het media-object zelf (
props.image), niet als aparte sidebar-velden.MediaObjectFieldheeft nuimagePickerShowAlt+imagePickerShowCaptionopt-in props die inline inputs onder de preview rendert. - Optionele link wrap rondom de figure
- Focal point wordt overgenomen via image picker (zelfde object-shape als background images)
- Astro renderer: 0 client-side JS, BlurImage met LQIP,
<figcaption>voor a11y,aboveFold→priorityeager-loading preloadBlockImagesextractor uitgebreid voorimage-blocks (kind:'inline')
Week 4 (21 apr) — Testimonials block-package
Section titled “Week 4 (21 apr) — Testimonials block-package”Drie nieuwe block types voor klantbeoordelingen, alle drie via één gedeelde
packages/shared/src/testimonials/ module
zodat card/quote/rating-styling exact identiek is tussen variants:
testimonials-grid— 2 of 3 kolommen, optionele mobile-carousel via Emblafeatured-testimonial— enkele gecentreerde quote (0 JS op publieke site)testimonials-carousel— Embla-gedreven slideshow met arrows + dots
Architectuur en infrastructuur:
- Embla Carousel als gedeelde carousel-engine (ADR-009).
Code-split via
React.lazy()+ dedicatedvendor-emblachunk; pagina’s zonder carousel betalen geen ~7 KB gzip kosten. - Shared rendering module met
types.ts,classes.ts(class-builders) enreview-card.css. React (editor + public) en Astro renderen via dezelfde classnames; geen drift tussen frameworks. - Pre-work — repeater compact mode in
content-sections.tsx:useEffect- synced refs +memo()opSortableRepeaterItemvoorkomen dat 11 sibling- items re-renderen bij elke keystroke. Compact-state per field-instance in localStorage. Layout-shift bij toggle = 0px (header op vaste Y-positie). - Pre-work —
preloadBlockImagesrefactor naar per-block-type extractor (columns + 3 testimonial blocks + group/columns recursie). Inline images preloaden single src tot 200px (geen volle 1920px srcSet voor 40px avatars).
Editor-UX-verbeteringen:
EditableText.keepEmptyprop — element blijft contentEditable bij lege value zodat user kan blijven typen na alles te wissen.FieldDefinition.visibleWhenaccepteert tweedeblockPropsargument → per-item velden kunnen gaten op block-level toggles (bv. Rating-veld alleen zichtbaar alsshowRatingaan).NumberStepper(alleen−/+-knoppen, step 0.5) — gebruikt voor rating.MediaObjectField(typeimage-picker) — slaat volledig media-object op{ url, media_id, blur_data_url, alt }i.p.v. enkel een URL-string.
Visuele consistentie:
- Card-stijl
cardaltijd witte surface + shadow + donkere tekst, ongeacht block-tekstmodus. Specificity (0,2,0) overschrijft Tailwindtext-whiteop light-blocks zodat de kaart-tekst leesbaar blijft. - Card-stijl
borderedborder-color =currentColor(volgt block-tekstkleur). .review-featuredoverschrijft padding niet; surface-eigenschappen komen van--card/--plain/--bordered. Author-rij is verticale stack: avatar/ logo, naam, rol/bedrijf gecentreerd.- Avatar + logo hebben dezelfde 40×40 footprint zodat meta-text op gelijke x-positie begint. Half-ster ratings (4.5) via twee-laags CSS overlay.
- Carousel pijltjes: position absolute buiten viewport (3.5rem in section- padding), z-index 10, witte solid surface met shadow.
- Grid + carousel + grid-overlay debug allemaal
1remkolom-gap. slidesToScroll: 1+containScroll: 'trimSnaps'→ per slide scrollen, dots = unieke views (geen dode dots).- Quote-mark
nonerendert echt niets (geen fallback decoratief teken). - Carousel viewport
overflow: visible(niethidden) zodat slides aan de rand niet hard worden afgekapt; section-overflow:hidden clipt op block-rand. - Cursor
grab/:active grabbingop carousel-viewport.
Quality gates:
- 4 kritieke + 11 belangrijke audit-bevindingen (van parallelle beam-reviewer, Performance Benchmarker en Senior Developer agents) opgelost vóór merge.
- 10 minor findings + UX-verfijningen verwerkt naar P3 backlog of doorgevoerd.
docs/block-types.md,docs/functionaliteit.md,docs/glossarium.md,docs/backlog.mdbijgewerkt. ADR-009 toegevoegd.
Week 1 (5 apr)
Section titled “Week 1 (5 apr)”Editor Code Quality & Accessibility Audit:
- Dead code verwijderd —
editor.tsx(ongebruikt, vervangen doorpage-editor.tsx) - History memory cap —
pushHistory()helper beperkt undo-stack tot 50 entries, voorkomt onbeperkt geheugengebruik - History dedup —
JSON.stringifyvergelijking vervangen doorupdatedAtcheck (O(1) ipv O(n)) - Store selectors —
ToolbarenCanvasgebruiken nu granulaire selectors (usePageMeta,useHistoryState,usePageBlocks) ipv volledige store destructuring - Code duplicatie —
useInlineEditor()hook (hover-to-edit),useEditableButtons()hook (button modal),resolvePatternBlocks()helper (canvas drop) geëxtraheerd - API validatie —
blocksveld in pages API:z.unknown()vervangen door typed schema met max 200 blocks;cascade-hrefsendpoint: Zod validatie toegevoegd - DOMPurify hardening —
url()waarden in style attributes worden gestript (tracking pixel preventie) - Accessibility — WAI-ARIA tablist op sidebar tabs,
aria-labelop toolbar knoppen,tabIndex+ keyboard handlers op blocks,<main>+role="application"landmarks, block picker<div>→<button>, toolbar zichtbaar bij keyboard focus
Week 1 (2 apr)
Section titled “Week 1 (2 apr)”Bunny Stream Video Upload (TUS Protocol):
- TUS resumable upload — Video’s worden direct naar Bunny CDN geüpload via TUS protocol. Beam API genereert SHA256 presigned headers via
crypto.subtle(Web Crypto API) - Client-side direct upload — Videobestand gaat niet via de API Worker, voorkomt payload-limieten en CPU-tijd
useVideoEncodingPoll()hook — Batch status polling met exponential backoff (2s → 30s), ref pattern voor stabiele callbacks, optimistic SWR cache update bij encoding completion- Encoding status — Polling via batch API endpoint (
PATCH /media/video/:id/status)
Button Kleur Systeem:
colorTokenvervangt hardcoded kleuren — Buttons gebruiken nulight,dark,auto(past aan op achtergrond), of brand tokens (primary,secondary,accent+ shades) in plaats van hex waarden- CSS injection preventie —
sanitizeColorToken()valideert tokens tegen een whitelist colorveld deprecated — Bewaard als fallback voor backwards compatibiliteit
Database migraties: 058 (backfill colorToken: 'primary' op buttons met oude hardcoded paarse kleur), 059 (idem, resterende gevallen)
Week 1 (3 apr)
Section titled “Week 1 (3 apr)”Rich Text Editor — Expand Modal & Performance:
- Single editor instance — Eén Tiptap editor die verplaatst wordt tussen canvas en modal (geen sync nodig)
- Expand modal — Sidebar-knop opent tekst in uitklapbare modal met
FixedToolbar+EditorContent - Static HTML tijdens animaties — Canvas en modal tonen statische HTML snapshot tijdens open/close animaties;
EditorContentmount/unmount gebeurt buiten animatieframes (~50ms ProseMirror overhead ontkoppeld van 60fps) - Deferred store writes — Bewerkingen in modal schrijven niet continu naar store; flush gebeurt pas bij sluiten
- Frozen height — Canvas behoudt hoogte wanneer editor naar modal verplaatst (synchrone DOM meting tijdens render)
Modal Component Verbeteringen:
- Focus trap (WCAG 2.1 §2.4.3) — Tab/Shift+Tab cyclet binnen modal, inclusief
[contenteditable]elementen - aria-labelledby via context —
Modalgenereert automatisch eentitleIden deelt deze via React context;ModalHeaderleest en koppelt het ID automatisch (werkt voor alle 17 modal consumers) - Escape stack — Module-level stack zodat alleen de bovenste modal reageert op Escape (gestapelde modals)
- Scroll lock counter — Reference counter voorkomt race conditions bij gestapelde modals
- Exit animatie —
onExitCompletecallback opAnimatePresencevoor deferred cleanup na exit transition - Event isolatie —
stopPropagation()op backdrop mousedown/click voorkomt sidebar clearing
Performance Optimalisaties (RichText):
- Zustand selectors — Individuele selectors i.p.v.
useEditorStore()destructuring; voorkomt re-renders bij ongerelateerde store updates - state.page bail-out — Zustand subscription skipt
getNestedValuetraversal alsstate.pagereferentie ongewijzigd (~80% reductie) - Echo prevention Map — Per-editor write timestamps i.p.v. single globals; veilig bij concurrent editors
- Cleanup op unmount — rAF cancellation,
_richTextActiveCountleak fix, orphanedexpandEditorPathopruiming
Performance Optimalisaties (Toolbar):
- shouldShow module-level — Stabiele functiereferentie voorkomt onnodige BubbleMenu recalculaties
- DOM leak fix —
useBubbleAnimationcleanup roeptpreviousRemove()aan bij unmount tijdens exit-animatie - Dead code —
handleLinkClickvereenvoudigd
Tekst Kleur Auto-detectie:
- Image/video achtergronden —
getTextColorClasses()default nu naar lichte tekst bij afbeelding/video achtergronden; alleen donkere tekst bij lichte overlay met >30% opacity
Overig:
useMemoinuseTextColorClasses— Voorkomt nieuwe object allocatie bij elke render- Lege gradient stops guard — Expliciete check voor lege
stopsarray in gradient detectie
Week 1 (1 apr)
Section titled “Week 1 (1 apr)”Rich Text Editor (Hero Block):
- Tiptap WYSIWYG editor — Hero block titel en subtitel gebruiken nu een Tiptap rich text editor met bubble menu in plaats van plain text
EditableText - Bubble menu — floating toolbar bij tekst selectie: vet, cursief, onderstrepen, doorhalen, link, heading levels (H1-H4), paragraaf, text style presets (Lead, Accent, Klein)
- Link bewerken —
LinkEditModalmet twee tabs: pagina selectie (PagePicker) of vrije URL invoer - Lazy loading — Tiptap dependencies in apart
vendor-editorchunk (~135KB gzipped), geladen viaReact.lazy() - Zustand history integratie — Tiptap’s built-in undo/redo uitgeschakeld, store history is single source of truth
- Backwards compatibel — bestaande plain text content wordt automatisch gewrapped in
<p>tags vianormalizeToHtml() - Sidebar vereenvoudigd — tag en size dropdowns verwijderd uit hero sidebar, heading keuze zit nu in bubble menu
- Vite chunk splitting —
manualChunksomgeschreven naar functie-form (fix voor pnpm hoisting)
Maart 2026
Section titled “Maart 2026”Week 5 (24 mrt)
Section titled “Week 5 (24 mrt)”Fix: Nested <a> tags in preview cards:
usePreviewMode()hook — Public block renderers (hero, buttons, cta, columns) detecteren of ze binnen eenPatternPreviewrenderen en tonen buttons als<span>in plaats van<a>, waardoor ongeldige geneste anchor tags voorkomen worden die React rendering konden crashen
Header Navigatie Configuratie:
- Twee navigatie layouts —
logo-menu-button(Logo—Menu—CTA) enmenu-logo-menu(Menu—Logo—Menu) - CTA Button — configureerbaar via
ButtonEditModaluit de editor, met brand kleuren en stijlen (solid/outline/transparent) - Header Design Configurator — achtergrond (brand kleuren + transparantie), border & schaduw, positie (sticky/static/fixed), hoogte (compact/default/tall), typografie per element
- Hide on scroll — header verdwijnt bij scrollen, verschijnt bij omhoog scrollen. CSS
transition: transform 240ms ease-out - Transparant bij hero — header wordt transparant als eerste block een achtergrondafbeelding/video heeft. Server-side gerenderd (geen JS flash). Tekstkleur past automatisch aan (licht/donker detectie)
- Live preview — sticky browser mockup met
BrowserMockupcomponent (CSSzoomvoor sticky header support). Morph animatie naar full-width via MotionlayoutId. Skeleton → content fade-in - Multi-logo upload — Brand → Elementen: meerdere logo varianten (Primary, Wit, Donker, Icoon). In header config selecteerbaar via
selectedLogoId - Brand kleuren in header —
ColorPresetRowmet shade picker (long-press/hover). Wit + transparant als extra opties - Sites query optimalisatie —
site_domainsjoin op de sites query: primary domain beschikbaar vanaf frame 1 (geen aparte fetch)
Database migraties: 049 (multi-logo), 050 (brand public read RLS), 055 (header nav type + design), 056 (brand logo), 057 (RLS header_menu_left_id)
Week 4 (20-23 mrt)
Section titled “Week 4 (20-23 mrt)”AI Content Generatie:
- Per-block AI generatie — ✨ sparkle knop per block in hover-toolbar, popover met intent input, smart merge (content wijzigen, styling behouden)
- Smart merge strategie — AI wijzigt alleen content (tekst, labels, items). Buttons behouden style/color/colorToken. Afbeeldingen en achtergronden onaangeroerd
- Verfijnen vs vervangen — AI bepaalt automatisch of content aangepast of volledig vervangen wordt op basis van de prompt
- Per-block style context — AI kent layout-instellingen (kolombreedte, achtergrond type, aantal items) per block type en past tekst lengte aan
- Bestaande content als context — Huidige teksten meegestuurd zodat AI kan verfijnen
- Brand Story tab — 5 card-secties op de Merk pagina: bedrijfsinfo, doelgroep, missie/waarden, tone of voice (3 sliders + aanspreekvorm), contactgegevens
- AI Block Schema — Machine-readable beschrijving van alle 7 block types met metadata, richtlijnen en voorbeelden (
packages/shared/src/ai/block-schema.ts) - AI Hero Test — 3 varianten van hero titel + subtitel via
POST /ai/generate-hero - Security hardening — Unicode newline sanitize, Anthropic client key rotation, tool-name check, brand context max 3000 chars, Zod output validatie, IDOR check, 25s timeout
- AI rate limit — 10 req/min per user
- Documentatie —
docs/ai-architectuur.md(dataflow, merge strategie, security),docs/ai-block-schema.md(per-block velden en verbeterpunten)
Settings Herstructurering:
- 4 subpagina’s — Settings opgesplitst in Mijn site (
/settings), SEO (/settings/seo), Domeinnamen (/settings/domains) en Gebruikers (/settings/users) - Mijn site — Site naam, homepage, site taal, afbeeldingen (favicon, touch icon, OG image), zoekmachines, gevarenzone
- SEO — Metadata, titel, social preview (read-only) — nieuw:
SettingsSeoPage.tsx - Domeinnamen — Subdomein + custom domains — nieuw:
SettingsDomainsPage.tsx - Gebruikers — Team management verplaatst van
/usersnaar/settings/users, site-scoped
Site-scoped Team Management (migraties 051-053):
owner_idverwijderd uitteam_membersenteam_invitations—site_idis de primaire scopeget_user_site_ids()vervangtget_team_owner_id()voor site-scoped queries- Bootstrap trigger
add_owner_to_site_teamvoegt owner automatisch toe als team member bij site creatie - Backfill migratie (053) — Alle bestaande sites krijgen owner in
team_membersals dat nog ontbrak
KV + ISR Cache Layer:
- KV namespace
BEAM_CACHEgebonden aan zowel API als public-site - Cache keys:
host:{hostname},site:{siteId},page:{siteId}:{slug},brand-css:{siteId} - Write-through: API schrijft naar KV bij publicatie, public site leest eerst uit KV
- Instant invalidatie bij publicatie (geen timer-based cache)
- ~1-5ms reads vs ~80-150ms DB reads
P0 Security Fixes:
- Unbounded queries —
.limit()toegevoegd aan 8 dashboard queries (domains, patterns, pages, menus, media, media_tags) - Pages PUT mass assignment — Zod validatie schema blokkeert
user_id,site_id,block_count,created_at site_settingsUNIQUE constraint — Voorkomt duplicate rijen per site (was oorzaak favicon bug)
Database fixes:
- UNIQUE constraint op
site_settings.site_id— voorkomt duplicaat rijen bij concurrent inserts
Database migraties: 047 (header nav type), 049-053 (RLS, site-scoped teams, backfill, site_settings UNIQUE)
Multi-site Support:
- Meerdere sites per owner — Max 10 actieve sites per owner, enforced via
check_site_quota()DB trigger - Site switcher — In user dropdown met favicon en sitenaam. Wisselen herlaadt alle site-scoped data
- Site aanmaken — Modal met naam invoer en auto-slug generatie. Bootstrap maakt automatisch settings en brand profile aan
- Soft delete —
DELETE /sites/:idzetdeleted_attimestamp. 14 dagen grace period.hardDeleteExpiredSites()cron ruimt R2, CF custom domains en DB op - Site naam bewerkbaar — In settings pagina, direct opslaan
- Site-scoped data — Alle SWR hooks geparametriseerd op
siteId. Menus en site_settings hebbensite_idkolom. Pages slug UNIQUE per site - Gecentraliseerde auth —
getSessionUserId()inlib/auth.tsvervangt 3 duplicate getUserId helpers - Service layer refactor —
siteIdparameter verplicht op settings, menus, pages service functies. Geen localStorage fallbacks meer
Header Navigation Types + Brand Logo:
- Header nav types —
HeaderNavTypeenHeaderDesigninterfaces in shared types.site_settingsuitgebreid metheader_nav_type,header_menu_left_id,header_button_text/url,header_designkolommen - Brand logo —
logo_urlkolom opbrand_profilestabel
Database migraties: 046 (multi-site), 047 (header nav type + menus/settings site scoping), 048 (brand logo), 049-050 (RLS policies)
Design Token Systeem Optimalisatie:
Design Token Systeem Optimalisatie:
- Variable fonts — Self-hosted statische fonts (62 bestanden, 3 weights per font) vervangen door 19 variable fonts + 3 statische fallbacks (Poppins, Lato, Proza Libre). Alle weights 100-900 correct gerenderd.
<link rel="preload">elimineert FOUT. CSP blijftfont-src 'self' - Shade preview — Kleurvarianten sectie in Colors tab met OKLCH shade scale (50-900) per brand kleur, click-to-copy
- BrandColorToken shades — Type uitgebreid met shade varianten (
primary-50t/maccent-900). Contrast vars voor alle 30 shades. Blocks kunnen shade varianten gebruiken als achtergrondkleur - Shade picker in editor — Expandable shade strip in block color settings na klik op brand kleur
- Performance fixes —
generateShadeScale()memoization met input validation, SSR brand CSS fetch timeout (3s), shade CSS vars in realtime sync
Brand Identity UI Uitbreiding:
- 7 brand kleuren — BrandColors uitgebreid met
neutralensurfacenaast primary, secondary, accent, textDark, textLight. Alle 6 mood presets bijgewerkt met unieke neutral/surface waarden - Vrije kleurkiezer — ColorInput component met native color picker + hex text input per kleur. Vervangt de oude preset-only selectie
- WCAG contrast badges — Client-side
checkWcagContrast()per kleurveld met inline pass/fail badge (ratio + level). Suggestie voor accessible alternatieven bij te laag contrast - Typography fijnafstelling — Heading weight/letter-spacing/line-height sliders, body weight/line-height sliders, type scale selector (compact/standaard/dramatisch)
- Export tab — 3 export kaarten (CSS Custom Properties, W3C Design Tokens JSON, Tailwind v4 @theme) met kopieer-naar-klembord en open-in-browser
- Versiegeschiedenis modal — Snapshot opslaan (label input), versie-lijst met relatieve timestamps, restore met audit logging
- Accessibility hardening — WAI-ARIA tabs pattern (keyboard nav, aria-selected, tabpanel), alle form controls met labels, aria-live regions voor toasts en status updates, aria-hidden op decoratieve iconen, ContrastBadge als role=“status”
- Security audit fixes —
safeFontNameregex uitgebreid met(, dubbele--brand-surfaceCSS var fix,generateDarkModeTokensdeduplicatie, version restore audit log
Eerdere features deze week:
- Semantic token laag — Automatisch afgeleide tokens:
--brand-surface,--brand-border,--brand-text-muted,--brand-on-primary,--brand-danger/success/warning/info. Semantic tokens in CSS + W3C JSON + dark mode overrides. - OKLCH shade generation —
generateShadeScale()genereert 50-900 kleurschaal per brand kleur via OKLCH kleurruimte. - Dark mode auto-generatie —
@media (prefers-color-scheme: dark)block in brand CSS met automatisch berekende dark mode tokens via OKLCH. - WCAG AA contrast checking —
checkWcagContrast(),getContrastRatio(),suggestAccessibleColor()in shared. API endpointGET /brand/:siteId/contrast. - W3C Design Token JSON export —
GET /brand/:siteId/tokensin W3C Design Tokens draft spec format. - Tailwind v4 @theme export —
GET /brand/:siteId/tokens/tailwind. - Token versioning —
token_versionstabel met auto-snapshot trigger.SECURITY DEFINERmetSET search_pathhardening. - Brand color tokens —
BrandColorTokentype voor achtergronden en buttons metvar(--brand-{token})CSS custom properties. - Self-hosted Google Fonts — 21 brand font families als self-hosted woff2 (19 variable + 3 static fallbacks), CSP
font-src 'self'only. - Brand realtime sync —
useBrandRealtimeSync()hook: BroadcastChannel + Supabase Realtime. - CF Image Resizing fix — Non-R2 URLs routeren door
media.builtwithbeam.com. - Font FOUT fix — Lazy font loading via IntersectionObserver, canvas opacity transitie.
- Vision docs alignment — 03-design-tokens.md consistent gemaakt met 01/02
Week 3 (17-19 mrt)
Section titled “Week 3 (17-19 mrt)”- Documentatie consolidatie — Alle docs samengevoegd in Starlight docs site met 7 secties + vision
- Router worker —
docs.builtwithbeam.comroutering naar docs Pages project - Tech debt opgelost — Alle openstaande tech debt items afgehandeld
- E2E test documentatie — CLAUDE.md en TECHNICAL_OVERVIEW.md bijgewerkt
Week 2 (10-16 mrt)
Section titled “Week 2 (10-16 mrt)”- Layered Context Architecture — 4-lagen systeem: hooks, rules, agents, context files
- Project agents — beam-reviewer, beam-block-builder, beam-migration, beam-api-builder, beam-preflight
- Scaffold script —
init-claudealias voor herbruikbare project setup
Week 1 (1-9 mrt)
Section titled “Week 1 (1-9 mrt)”- Fase 3 voltooid — Vite SPA dashboard op CF Pages
- ESLint cleanup — 172 warnings naar 0
- Sentry integratie — Error monitoring over alle 3 apps
- E2E tests — 21 Playwright tests (auth, pages, editor, media, smoke)
- Rate limiting — Sliding window per user (100/min general, 20/min uploads)
- Structured logging — JSON logs met request IDs
- Blur placeholders — LQIP via CF Image Resizing
Februari 2026
Section titled “Februari 2026”- Fase 2 voltooid — Hono API op CF Workers
- 8 route modules (pages, patterns, media, team, domains, stock-photos, cache, health)
- Bearer token auth + rate limiting
- R2 media pipeline met deduplicatie
- Team management met rollen en uitnodigingen
- Custom domains via CF Pages Custom Domains API
Januari 2026
Section titled “Januari 2026”-
Fase 1 voltooid — Astro publieke site op CF Pages
- 8 server-rendered block components
- Host-based routing voor custom domains
- CF Image Resizing voor responsive afbeeldingen
- CSP headers en XSS sanitisatie
- Performance: ~4KB JS, inline CSS, 103 Early Hints
-
Fase 0 voltooid — Security hardening
- RLS op alle tabellen
- SSRF preventie op externe media URLs
- XSS preventie via DOMPurify
- CSP headers op publieke site
- Bootstrap trigger voor nieuwe gebruikers