Skip to content

Changelog

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 heeft z-index: 2; de hover-acties en “Geen alt”-badge van gallery-tegels hadden geen z-index en werden door de afbeeldingslaag afgedekt. Nu z-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-gebaseerde FormFieldGroup voor 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: EventGalleryItem heeft optionele thumb- en blur-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). sanitizeGallery valideert blur op data:image/-prefix + max 10k chars. Legacy items vallen terug op een CF-resize van url; de publieke site blijft url gebruiken.
  • 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.
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, via SortableList met nieuwe strategy="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.
  • MediaPickerModal multi-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; onSelect is nu optioneel.
  • EventEditPage: batch-add dedupliceert op url en capt op EVENT_GALLERY_MAX (20); maxSelect is 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 (autoFocus op 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 (gedeelde tryListAutoStartOnEnter-helper), Escape → blur en trailingNode uit. 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-style kent geen class-attribuut, waardoor de Tekststijl- dropdown overal kale <span>’s zonder class produceerde — de preset-styling (text-style-lead etc.) kwam nooit aan. Nieuwe gedeelde TextStyleWithClass-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 + CREATE op de detail- resolver-RPC public_resolve_event_by_slug om price_donation/price_from toe te voegen, maar baseerde de kolomlijst op een oudere revisie. Daardoor vielen gallery (mig 091) en faq (mig 092) uit de return-signature. De Astro-detailpagina leest event.gallery.length zonder guard → undefinedTypeError tijdens 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 + faq hersteld). Daarnaast normaliseert de public-site data-laag (getPublishedEventBySlug) gallery defensief naar een lege array, zodat een toekomstige RPC-drift de detailpagina nooit meer kan laten crashen (faq was al overal ?? []-geguard).
  • Editor-polish: bij “Op donatiebasis” toont het (uitgeschakelde) prijsveld geen -teken en geen 25-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): elke FormSection-header is een toggle-knop; de inhoud schuift open/dicht via een 0fr ↔ 1fr grid-rows-transitie — géén JS-animatieloop (geen requestAnimationFrame), dus betrouwbaar en throttle-bestendig in achtergrond-tabs. Een eerdere Motion AnimatePresence-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 inert bij inklappen: de Tiptap-velden (omschrijving + FAQ-antwoorden) worden niet steeds opnieuw geïnitialiseerd. Ingeklapte inhoud krijgt het inert-attribuut (React 19): niet tabbaar en weg uit de accessibility-tree. ChevronDown roteert 180°; aria-expanded + aria-controls koppelen knop en paneel.
  • Per-gebruiker persistentie (useCollapsibleSection): open/dicht-staat in localStorage onder beam_sections_event_<userId> (default open). useSyncExternalStore met 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 handmatige StorageEvent-dispatch synct de huidige tab, native storage-events syncen andere tabs.
  • Render-performance: FaqItemEditor, FormRichText en BlurImage zijn memo’d en de event-editor-handlers (handleContentChange, FAQ add/move/remove) zijn met useCallback gestabiliseerd. Een toetsaanslag in één veld re-rendert niet meer álle Tiptap-editors, toolbars en galerij-thumbnails.
  • Reduced motion: motion-reduce:transition-none schakelt 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. 12345671.234.567, 1234,51.234,5). Opslag gebeurt canoniek met een decimale punt zonder scheidingstekens (1234.56).
  • “Op donatiebasis” (checkbox): disablet het prijsveld en slaat price = null op; 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: formatEventPriceNL toont decimalen uitsluitend als ze ingevuld én ≠ 0 zijn (€25 i.p.v. €25,00; €25,50 blijft €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 het Prijs-label; de detail-page toont het bedrag zonder “Prijs:“-prefix.
  • Migratie 093: twee boolean NOT NULL DEFAULT false kolommen (price_donation, price_from) op events; beide public-RPCs (public_resolve_events_for_site, public_resolve_event_by_slug) herbouwd (DROP + CREATE — TABLE-return signature) met de drie prijsvelden en opnieuw REVOKE 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-gridcolumn-gap: var(--site-grid-gap) (de verticale row-gap blijft een vaste ritme-waarde van 1rem, los van de horizontale grid-as — zoals columns-block met gap-y-6). Met repeat(2|3, 1fr) binnen de full-width span landen de kaart-randen daardoor exact op de kolom-grenzen, wiskundig identiek aan de --grid carousel (3 kaarten × 4 kolommen, 2 kaarten × 6 kolommen).
  • --1 slide 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 --grid carousel op mobiel (8px i.p.v. 16px). De :last-child phantom-snap-fix blijft kloppen (width: 100% = calc(100% + gap) − gap).
  • Audit overige blocks: columns-block gebruikt al gridContainer (.site-gridcolumn-gap: var(--site-grid-gap)) — correct. Features-block houdt zijn eigen component-interne gap-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.
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 in padding-right (--site-grid-gap, border-box) i.p.v. een container-gap — vereist omdat Swiper met slidesPerView: 'auto' elke slide via offsetWidth meet (zie ADR-011). Maar de laatste slide heeft geen volgende kaart, dus die trailing padding-right telt onnodig mee in Swiper’s virtualSize. maxTranslate kwam 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) zet padding-right: 0 én verkleint de slide-breedte met één gap (--grid per breakpoint N=1/2/3; --1 één regel). De kaartbreedte blijft gelijk aan de overige kaarten (flex-basis − padding ongewijzigd), maar virtualSize krimpt met één gap → maxTranslate valt nu samen met de laatste echte snap; de phantom-snap verdwijnt.
  • --bleed (event-gallery) bewust ongemoeid: dat is een peek-layout met symmetrische padding-left+padding-right plus 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-child matcht 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 snapGrid exact op maxTranslate (geen na-entry), blijft isEnd true 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.
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--grid had overflow: 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--gridoverflow: visible (de base .review-carousel staat ook op visible). 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) blijft overflow: hidden; --bleed was al visible (ongewijzigd).
  • Editor === public per constructie: --grid is container-relatief gesized (100%, géén 100vw), dus de kaarten blijven grid-aligned en de clip-op-de-section gedraagt zich identiek bij canvas-breedte (editor) en viewport-breedte (public). Géén 100vw- 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-btn ging van left/right: -3.5rem (56px de gutter in → stak na de padding-fix ~16px voorbij de schermrand) naar left/right: 0 op 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 slideNext slidet 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 de padding-inline van 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-padding op .site-container was juist bedoeld als enige bron van de horizontale gutter (zie de comment in site-layout.css); de section-px-* was overgebleven ad-hoc padding.
  • Fix: px-5 sm:px-8 verwijderd 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-container levert 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 al padding-inline: var(--container-padding) / .site-container direct 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 --bleed gallery start op gridlijn 40 en bleeds vanaf daar naar de schermrand (bedoeld peek-gedrag).
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-gap naar padding-right op de slide met box-sizing: border-box. Padding zit wél in offsetWidth, 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 --bleed container-gap-override is opgeruimd (de base-container staat al op gap: 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).
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.ts is herschreven van observeCarousels() (IO-lazy) naar initCarousels() (directe init op load + astro:after-swap re-init). Alle vijf carousels (testimonials-carousel, events-carousel, testimonials-grid mobiel, 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.

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') en dots (aan/uit).
  • Class-helpers als single source: carouselViewportClass(), carouselSlideClass() en carouselWrapperClass() mappen de assen op de CSS-modes in review-card.css. packages/shared heeft geen swiper-dependency — het contract exporteert alleen het optie-model + class-strings, elke app importeert Swiper zelf via CAROUSEL_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-carousel en related-events → knoppen + '3-op-een-rij' + dots; event-gallery → cursor + 'bleed' + dots; testimonials-grid (mobiel) → knoppen + '1-up' + dots. Pijl-knoppen zijn display: none onder 768px — op mobiel navigeert de bezoeker via swipe + dots.

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 en observeCarousels + 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 van testimonials-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-swiper chunk (was vendor-embla); alleen geladen op pagina’s met carousel-blocks. Slide-layout blijft volledig in CSS (review-card.css).
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--grid class: 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 vanaf 640px, 3 gelijke kaarten van 4 kolommen vanaf 768px. Vervangt de oude --2/--3 breedte-regels (carousel-only); --1 blijft 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)·G met a = 12/N kolommen). De slide-pitch is precies a kolom-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-gap via .review-carousel--grid .review-carousel-container { gap: var(--site-grid-gap) } (0.5rem mobiel / 2.5rem ≥1024px) — het kolom-ritme van gridContainer. 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 --1 card) 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. Geen 100vw → 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 @deprecated maar blijft optioneel in TestimonialsCarouselProps zodat bestaande block-data niet breekt. slidesPerViewField verwijderd uit fields.ts + blocks.ts (geen UI-control meer).
  • Dot-aantal corrigeert mee: SSR rendert items.length dots (mobiel = 1-per-view, max snap-posities); client-side syncDotVisibility() 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, gebruikt DOMParser) zet block-grenzen (<br>, </p>, </div>, …) om naar newlines, dropt <script>/<style> en retourneert textContent.
  • handlePlainTextPaste op de text-<input> en <textarea> onderschept alléén pastes die rich text dragen (text/html aanwezig) — 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 via requestAnimationFrame.

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() in packages/shared/src/sanitize.ts — pure regex, geen DOMParser/DOMPurify (niet beschikbaar op de Cloudflare Workers-runtime). &amp; wordt als laatste gedecodeerd zodat &amp;lt; de letterlijke &lt; oplevert, niet <.
  • cleanBlocksForSave normaliseert quotes op save voor testimonials-grid, testimonials-carousel (props.testimonials.items[]) en featured-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-quote krijgt white-space: pre-line zodat 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 uit PageLayout.astro en de 3 callers. Een cross-origin stylesheet is render-blocking én werd door style-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 maakte hasBrandFonts ten onrechte true op 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 htmlToText is 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_000 cap + output-contract-waarschuwing in de JSDoc (nooit naar set:html/dangerouslySetInnerHTML).
  • Paste-guard data-loss — de oude regex-sniffer stript-te platte tekst met punthaken (bv. <user@example.com>) naar leeg; vervangen door de text/html MIME-vlag als enige trigger.
  • XSS-conclusie: output gaat overal als auto-escaped tekstnode (nooit set:html), dus tag-strip-imperfecties zijn cosmetisch, niet exploiteerbaar.

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 /EUR uit 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 → 1440pxgridContainer 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-wijzigingupdateSiteName 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/accept returnt nu siteId (happy-path én “al lid”-tak).
  • InviteAcceptPage doet await globalMutate('sites') om de sites-SWR cache te vernieuwen vóór switchSite(siteId) — anders zou useSites() auto-reset triggeren (storedId niet in stale lijst → fallback naar data[0]) en de switch terugdraaien.
  • Migratie 090bootstrap_new_user() slaat de auto-site-creatie nu over wanneer er een pending, niet-verlopen team_invitations rij bestaat voor het email van de nieuwe user. Solo signups krijgen onveranderd hun eigen site.

Events-addon niet zichtbaar na site-switchuseIsEventsActive() 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 088event_slug_history tabel + archive_old_event_slug trigger die bij elke slug-mutatie de oude slug archiveert. Backfill voor één bekende rename.
  • Mig 089public_resolve_event_by_old_slug SECURITY DEFINER RPC voor anonymous public-site; retourneert alleen de huidige slug van gepubliceerde events. Geen direct SELECT op event_slug_history voor 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/invalidate accepteert nu optionele paths[]. 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.
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 a was 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_slug uitgebreid met content_html veld.
  • Bestaande description blijft als korte lead-tekst intro (max 500 plain text); nieuwe content_html is uitgebreide HTML uit een Tiptap WYSIWYG editor in het dashboard.
  • Nieuwe FormRichText component (los van page-editor Zustand store) — herbruikbaar voor andere form-velden.
  • Sanitization-on-save via DOMPurify (sanitizeContentHtml in events.ts). Lege editor (<p></p>) wordt als null opgeslagen.
  • Detail-pagina rendert description met border-l-4 border-primary-200 als prominente intro, daarna content_html in een .prose container.

Addon-aware archive picker in link + button modals (PR #55):

  • Nieuwe declaratieve registry apps/dashboard/src/lib/archive-pages.ts met 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_chrome retourneert nu COALESCE(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.ts met prefixInternalUrl.
  • 6 Astro blocks (CTA/Hero/Text/Columns/FAQ/Image) lezen Astro.locals.basePath en wrappen sanitizeUrl-output zodat interne hrefs (/events/, /about) in dev correct werken op localhost: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 via BlockDefinition.requiredAddon = 'events'. Block-picker(.modal) filteren op useActiveAddonIds() — toekomstige addon-gated blocks gebruiken hetzelfde patroon.
  • Embla Carousel (embla-carousel-react in 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.cssreview-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:

  • addons tabel (mig 069) — per-site activatie state, geen runtime config in DB (registry leeft in apps/dashboard/src/lib/addons/registry.ts)
  • events tabel (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, prod primary-domain.com of slug.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 via useIsAddonActive('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:

RPCGeeft 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 neq stille bug (PostgREST immutable builder fix)
  • CRITICAL: online_url + cta_url XSS-protectie via sanitizeUrl() + '#' sentinel filter
  • HIGH: getSession()getSessionUserId() in createEvent
  • HIGH: JSON-LD numberOfItems mismatch met itemListElement.length
  • HIGH: err.message toast leakage → generieke fallback
  • LOW: focusThrottleInterval op 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”

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 met object-fit: contain, default) → matcht het ColumnImageHeight enum uit styles.ts
  • Caption + alt zitten op het media-object zelf (props.image), niet als aparte sidebar-velden. MediaObjectField heeft nu imagePickerShowAlt + imagePickerShowCaption opt-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, aboveFoldpriority eager-loading
  • preloadBlockImages extractor uitgebreid voor image-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 Embla
  • featured-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() + dedicated vendor-embla chunk; pagina’s zonder carousel betalen geen ~7 KB gzip kosten.
  • Shared rendering module met types.ts, classes.ts (class-builders) en review-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() op SortableRepeaterItem voorkomen 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 — preloadBlockImages refactor 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.keepEmpty prop — element blijft contentEditable bij lege value zodat user kan blijven typen na alles te wissen.
  • FieldDefinition.visibleWhen accepteert tweede blockProps argument → per-item velden kunnen gaten op block-level toggles (bv. Rating-veld alleen zichtbaar als showRating aan).
  • NumberStepper (alleen /+-knoppen, step 0.5) — gebruikt voor rating.
  • MediaObjectField (type image-picker) — slaat volledig media-object op { url, media_id, blur_data_url, alt } i.p.v. enkel een URL-string.

Visuele consistentie:

  • Card-stijl card altijd witte surface + shadow + donkere tekst, ongeacht block-tekstmodus. Specificity (0,2,0) overschrijft Tailwind text-white op light-blocks zodat de kaart-tekst leesbaar blijft.
  • Card-stijl bordered border-color = currentColor (volgt block-tekstkleur).
  • .review-featured overschrijft 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 1rem kolom-gap.
  • slidesToScroll: 1 + containScroll: 'trimSnaps' → per slide scrollen, dots = unieke views (geen dode dots).
  • Quote-mark none rendert echt niets (geen fallback decoratief teken).
  • Carousel viewport overflow: visible (niet hidden) zodat slides aan de rand niet hard worden afgekapt; section-overflow:hidden clipt op block-rand.
  • Cursor grab / :active grabbing op 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.md bijgewerkt. ADR-009 toegevoegd.

Editor Code Quality & Accessibility Audit:

  • Dead code verwijderdeditor.tsx (ongebruikt, vervangen door page-editor.tsx)
  • History memory cappushHistory() helper beperkt undo-stack tot 50 entries, voorkomt onbeperkt geheugengebruik
  • History dedupJSON.stringify vergelijking vervangen door updatedAt check (O(1) ipv O(n))
  • Store selectorsToolbar en Canvas gebruiken nu granulaire selectors (usePageMeta, useHistoryState, usePageBlocks) ipv volledige store destructuring
  • Code duplicatieuseInlineEditor() hook (hover-to-edit), useEditableButtons() hook (button modal), resolvePatternBlocks() helper (canvas drop) geëxtraheerd
  • API validatieblocks veld in pages API: z.unknown() vervangen door typed schema met max 200 blocks; cascade-hrefs endpoint: Zod validatie toegevoegd
  • DOMPurify hardeningurl() waarden in style attributes worden gestript (tracking pixel preventie)
  • Accessibility — WAI-ARIA tablist op sidebar tabs, aria-label op toolbar knoppen, tabIndex + keyboard handlers op blocks, <main> + role="application" landmarks, block picker <div><button>, toolbar zichtbaar bij keyboard focus

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:

  • colorToken vervangt hardcoded kleuren — Buttons gebruiken nu light, dark, auto (past aan op achtergrond), of brand tokens (primary, secondary, accent + shades) in plaats van hex waarden
  • CSS injection preventiesanitizeColorToken() valideert tokens tegen een whitelist
  • color veld deprecated — Bewaard als fallback voor backwards compatibiliteit

Database migraties: 058 (backfill colorToken: 'primary' op buttons met oude hardcoded paarse kleur), 059 (idem, resterende gevallen)

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; EditorContent mount/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 contextModal genereert automatisch een titleId en deelt deze via React context; ModalHeader leest 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 animatieonExitComplete callback op AnimatePresence voor deferred cleanup na exit transition
  • Event isolatiestopPropagation() 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 getNestedValue traversal als state.page referentie ongewijzigd (~80% reductie)
  • Echo prevention Map — Per-editor write timestamps i.p.v. single globals; veilig bij concurrent editors
  • Cleanup op unmount — rAF cancellation, _richTextActiveCount leak fix, orphaned expandEditorPath opruiming

Performance Optimalisaties (Toolbar):

  • shouldShow module-level — Stabiele functiereferentie voorkomt onnodige BubbleMenu recalculaties
  • DOM leak fixuseBubbleAnimation cleanup roept previousRemove() aan bij unmount tijdens exit-animatie
  • Dead codehandleLinkClick vereenvoudigd

Tekst Kleur Auto-detectie:

  • Image/video achtergrondengetTextColorClasses() default nu naar lichte tekst bij afbeelding/video achtergronden; alleen donkere tekst bij lichte overlay met >30% opacity

Overig:

  • useMemo in useTextColorClasses — Voorkomt nieuwe object allocatie bij elke render
  • Lege gradient stops guard — Expliciete check voor lege stops array in gradient detectie

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 bewerkenLinkEditModal met twee tabs: pagina selectie (PagePicker) of vrije URL invoer
  • Lazy loading — Tiptap dependencies in apart vendor-editor chunk (~135KB gzipped), geladen via React.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 via normalizeToHtml()
  • Sidebar vereenvoudigd — tag en size dropdowns verwijderd uit hero sidebar, heading keuze zit nu in bubble menu
  • Vite chunk splittingmanualChunks omgeschreven naar functie-form (fix voor pnpm hoisting)

Fix: Nested <a> tags in preview cards:

  • usePreviewMode() hook — Public block renderers (hero, buttons, cta, columns) detecteren of ze binnen een PatternPreview renderen 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 layoutslogo-menu-button (Logo—Menu—CTA) en menu-logo-menu (Menu—Logo—Menu)
  • CTA Button — configureerbaar via ButtonEditModal uit 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 BrowserMockup component (CSS zoom voor sticky header support). Morph animatie naar full-width via Motion layoutId. 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 headerColorPresetRow met shade picker (long-press/hover). Wit + transparant als extra opties
  • Sites query optimalisatiesite_domains join 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)

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
  • Documentatiedocs/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 /users naar /settings/users, site-scoped

Site-scoped Team Management (migraties 051-053):

  • owner_id verwijderd uit team_members en team_invitationssite_id is de primaire scope
  • get_user_site_ids() vervangt get_team_owner_id() voor site-scoped queries
  • Bootstrap trigger add_owner_to_site_team voegt owner automatisch toe als team member bij site creatie
  • Backfill migratie (053) — Alle bestaande sites krijgen owner in team_members als dat nog ontbrak

KV + ISR Cache Layer:

  • KV namespace BEAM_CACHE gebonden 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_settings UNIQUE 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 deleteDELETE /sites/:id zet deleted_at timestamp. 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 hebben site_id kolom. Pages slug UNIQUE per site
  • Gecentraliseerde authgetSessionUserId() in lib/auth.ts vervangt 3 duplicate getUserId helpers
  • Service layer refactorsiteId parameter verplicht op settings, menus, pages service functies. Geen localStorage fallbacks meer

Header Navigation Types + Brand Logo:

  • Header nav typesHeaderNavType en HeaderDesign interfaces in shared types. site_settings uitgebreid met header_nav_type, header_menu_left_id, header_button_text/url, header_design kolommen
  • Brand logologo_url kolom op brand_profiles tabel

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 blijft font-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-50 t/m accent-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 fixesgenerateShadeScale() 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 neutral en surface naast 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 fixessafeFontName regex uitgebreid met (, dubbele --brand-surface CSS var fix, generateDarkModeTokens deduplicatie, 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 generationgenerateShadeScale() 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 checkingcheckWcagContrast(), getContrastRatio(), suggestAccessibleColor() in shared. API endpoint GET /brand/:siteId/contrast.
  • W3C Design Token JSON exportGET /brand/:siteId/tokens in W3C Design Tokens draft spec format.
  • Tailwind v4 @theme exportGET /brand/:siteId/tokens/tailwind.
  • Token versioningtoken_versions tabel met auto-snapshot trigger. SECURITY DEFINER met SET search_path hardening.
  • Brand color tokensBrandColorToken type voor achtergronden en buttons met var(--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 syncuseBrandRealtimeSync() 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
  • Documentatie consolidatie — Alle docs samengevoegd in Starlight docs site met 7 secties + vision
  • Router workerdocs.builtwithbeam.com routering naar docs Pages project
  • Tech debt opgelost — Alle openstaande tech debt items afgehandeld
  • E2E test documentatie — CLAUDE.md en TECHNICAL_OVERVIEW.md bijgewerkt
  • 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 scriptinit-claude alias voor herbruikbare project setup
  • 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
  • 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
  • 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