You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Next submodule after rl_page_title and rl_menu_link: A/B test any image or media field variant on any fieldable entity, using the same Thompson Sampling infrastructure. Variants are Media entity references, so each variant carries its own alt text, focal point, credit, and responsive image style metadata automatically.
Two discovery modes from day one:
Per-entity vertical tab on the host entity edit form — the "test the hero image on my one landing page" flow. Highest adoption path.
Per-bundle standalone admin form — tests the same field_hero across every article at once. Power-user flow.
Primary reward signal: engagement after image visibility (IntersectionObserver impression + N-second engagement timer). Secondary optional reward: click on the parent anchor if the image is linked.
Naming decision: rl_image vs rl_media
Recommendation: rl_media.
Drupal-native concept is Media, not Image. Variants are stored as Media entity references, so the module naturally supports video, remote_video, audio, and document Media types without a rename.
rl_image becomes a misnomer the moment anyone adds video variants to a hero field that accepts Media references (common setup).
The module description and admin page labels can still foreground "Image variants" / "Hero image testing" to match how users search for the feature. The machine name is rl_media; the human-visible tab label adapts to the field type on the bundle.
Why now
rl_page_title and rl_menu_link validated the variant-selector pattern for text. rl_media extends it to visual media, where outcome variance is typically much higher than text. Landing-page hero images, product shots, article thumbnails, and section banners are the highest-leverage visual elements on any content-heavy Drupal site.
The shared base classes shipped in PR #35 (VariantExperimentInterface, VariantSelectorBase, VariantExperimentDeleteFormBase, VariantArmsTrait, StorageSchema pattern with hash-indexed lookups) mean this submodule costs ~2,200 LOC across ~15 files to build — not 6,000 LOC across 40 files.
Scope
In scope for v1
Core image field type on any fieldable entity
entity_reference fields targeting media bundles on any fieldable entity
Media types: image (primary), video / remote_video / audio / document (secondary, same mechanism)
Per-entity experiments (test one specific node's hero image)
Per-bundle experiments (test all article thumbnails at once)
Multilingual via langcode + LANGCODE_NOT_SPECIFIED fallback, same pattern as existing submodules
Vertical tab UX on any entity form with a matching field
Standalone admin form for bundle-wide experiments
Drush CLI (rl:media:{create,list,get,update,delete}) for full GUI / TUI parity
Views-powered admin list with live stats columns
Decorator for RL reports showing variant thumbnails
Cascading cleanup via hook_entity_predelete on host entity deletion
Cascading cleanup via hook_entity_predelete on variant media deletion
Cascading cleanup via field config delete when a target field is removed from a bundle
E2E coverage via scripts/e2e/test-media-crud.sh
Out of scope for v1
CKEditor inline images (body-embedded <img> tags): not field-formatter-addressable, needs a CKEditor plugin. Phase 2.
SVG icons rendered via inline-svg: don't go through image formatters.
Video playback engagement metrics (quartile events, completion rate): impression + dwell time only for v1.
Data model
Content entity rl_media_experiment with base fields:
id: int (auto, primary key)uuid: stringlabel: string (human-readable name, e.g. "Homepage hero")host_entity_type: string ('node', 'media', 'commerce_product', 'taxonomy_term', etc.)host_bundle: string ('article', 'product', etc.)host_entity_id: int (nullable; NULL means a bundle-wide experiment)field_name: string (machine name of the target image/media field)variants_data: string_long (JSON-encoded list of media entity IDs)langcode: string (default LANGCODE_NOT_SPECIFIED)enabled: bool (default TRUE)cache_ttl: int (seconds; default 60, raisable for LCP-sensitive fields)lookup_hash: string (64) [UNIQUE] (computed in preSave)created: timestampchanged: timestamp
Secondary composite index on (host_entity_type, host_bundle, host_entity_id, field_name, langcode) — used by cascade cleanup queries
Secondary index on (host_entity_type, host_bundle, field_name) — used by bundle-wide fallback lookups
RL experiment ID: rl_media-{12-char-sha1-of-lookup-hash}. Arm v0 is the host entity's current field value, read live at render time and never stored. Arms v1..vN are the variant media IDs.
Runtime fallback chain (resolved in a single indexed IN query, matching the pattern introduced in PR #35):
Entity-specific + language-specific match
Entity-specific + LANGCODE_NOT_SPECIFIED match
Bundle-wide + language-specific match
Bundle-wide + LANGCODE_NOT_SPECIFIED match
No experiment — original renders unchanged
Admin UX flows
Flow 1: Create via vertical tab (per-entity, primary path)
User navigates to /node/42/edit
Scrolls to the vertical tabs at the bottom (advanced tab group)
Sees a tab labelled "Image variants" (label adapts to field type; "Media variants" if the bundle has mixed media fields). Closed by default.
Clicks to expand. Inside the tab:
┌─ Image variants ─────────────────────────────────────────────┐
│ │
│ Which image field do you want to test? │
│ [ field_hero (Hero image) ▼ ] │
│ │
│ Current (always tested as the control) │
│ ┌─────────────────────┐ │
│ │ [thumbnail] │ sunset-alps.jpg │
│ │ │ "Sunset over the Alps" │
│ └─────────────────────┘ │
│ │
│ Alternative versions │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ [thumb] [x] │ │ [thumb] [x] │ │ + Add media │ │
│ │ dawn.jpg │ │ dusk.jpg │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ⚠ dusk.jpg has aspect ratio 16:9 while your control is 4:3. │
│ The page layout may shift when variants rotate. │
│ │
│ Language: [ - All languages - ▼ ] │
│ │
│ [ ✓ ] Serve variants to visitors │
│ │
│ Experiment name: [ Homepage hero ] │
│ │
│ Saved when you save the node. │
└──────────────────────────────────────────────────────────────┘
Clicks "Add media" → Drupal's Media Library widget opens in a modal
Selects 1+ media entities of a bundle the target field accepts
Modal closes; thumbnails appear in the Alternative versions list
If variant aspect ratios differ from the control by >10%, an inline warning appears (non-blocking)
Clicks "Save node" (parent entity's save button) — node + experiment are written in one submission
Entity builder pattern: parent save writes the node, then a custom submit handler reads the vertical tab values and creates/updates the rl_media_experiment
Same purge-first atomicity as rl_page_title: if the experiment write fails, a warning surfaces pointing to /admin/reports/rl but the node save still commits
Flow 2: Create via standalone admin form (bundle-wide, power-user path)
User navigates to /admin/config/services/rl-media
Views-powered list of all experiments. Clicks "+ Add image/media experiment"
Standalone form at /admin/config/services/rl-media/add:
Experiment name: [ ]
Target entity type: [ node ▼ ]
Target bundle: [ article ▼ ] (populated based on entity type)
Target field: [ field_hero (Hero image) ▼ ] (populated based on bundle, filtered to image/media fields)
Scope:
( ) Specific entity: [ autocomplete by title ]
(•) All entities in this bundle
Alternative versions: [ Media Library widget ]
(For bundle-wide experiments, each entity's own current image
is always tested as the control. You only supply variants.)
Language: [ - All languages - ▼ ]
[ ✓ ] Serve variants to visitors
Cache TTL (advanced): [ 60 seconds ]
Raise this for LCP-sensitive images (e.g. 300 s) to protect
Core Web Vitals while still rotating variants. Default 60 s.
[ Save experiment ]
Validation:
Field exists on the bundle and is an image-rendering type
At least one variant media entity
Duplicate detection via lookup_hash (covers bundle-wide experiments too)
If scope = specific entity, the entity must exist and have the field
Aspect ratio warnings surface here too (non-blocking)
On save: redirect to admin list, status message, experiment starts rotating on the next request
Flow 3: Read — admin list
Views-powered table at /admin/config/services/rl-media:
Name
Target
Variants
Impressions
Leader
Eng. rate
Status
Ops
Homepage hero
node:article:42:field_hero (en)
3
12,450
dawn.jpg
12.1%
Active
Edit · Delete · Report
All article thumbs
node:article:*:field_thumbnail (und)
4
8,922
alt1.jpg
9.4%
Active
Edit · Delete · Report
Paused test
node:article:99:field_hero (en)
2
3,200
—
7.2%
Paused
Edit · Delete · Report
Filters: entity type, bundle, field name, enabled status, langcode
Search: experiment name
Label column links to the RL report (matches rl_page_title's behaviour via hook_preprocess_views_view_field)
Request hits /node/42; page cache override kicks in (60s TTL or higher if configured on the experiment)
Node renders; hook_entity_view_alter() fires
For each image/media field on the entity:
a. Compute up to four candidate lookup hashes (entity/lang, entity/und, bundle/lang, bundle/und)
b. Single indexed IN query resolves all candidates
c. First match in priority order wins
If an experiment matches:
a. ExperimentManager::getThompsonScores() over [original_media_id, ...variant_media_ids]
b. Swap the field item's target_id with the winning media ID
c. Drupal's normal image formatter renders the swapped media with its alt text, focal point, credit, and responsive srcset
d. hook_preprocess_image_formatter injects data-rl-media-experiment-id and data-rl-media-arm-id on the rendered <img>
e. rl_media/tracking library is attached via hook_page_attachments
On the client:
a. IntersectionObserver watches tracked <img> tags
b. Image enters viewport → record impression via sendBeacon to rl.php
c. Engagement timer starts (default 10s)
d. User still on page after 10s → record reward
e. sessionStorage dedupe key rl-media-reward-{experimentId}-{armId} prevents double-counting within a session while still permitting per-arm counting if Thompson Sampling rotates mid-session (matches PR feat: rl_page_title and rl_menu_link modules for A/B testing #35 pattern)
Flow 5: Update via vertical tab
User returns to /node/42/edit
Expands the "Image variants" tab
Existing state loads:
Target field preselected (read-only if there's collected data, editable if no data yet)
Variants appear as thumbnails with × buttons for removal
Stats summary at the top
Modifies variants (add one, remove one) or toggles enabled
Target field changed → retarget: old rl_experiment_id captured via pendingPurgeRlExperimentId → new entity saved → old analytics purged in save() after the entity commit, wrapped in try/catch with user warning pointing to /admin/reports/rl
Removed variants remain in the RL registry for historical reports but stop rotating at runtime
Flow 6: Update via standalone form
From admin list, click Edit
Standard EntityForm loads pre-filled
User modifies and saves
Same retarget detection + post-save purge pattern as rl_page_title / rl_menu_link
Flow 7: Delete via vertical tab
User expands "Image variants" tab
Clicks "Stop testing and delete collected data" link
Confirmation dialog (AJAX modal or standard confirmation page):
This will permanently delete the experiment "Homepage hero" and all 12,450 collected impressions. The variants you selected will no longer rotate for visitors. This cannot be undone.
On confirm:
a. Purge RL analytics first (turns, rewards, totals, snapshots, registry) in a transaction
b. Delete the rl_media_experiment entity
c. Redirect back to the node edit form; tab shows empty state
If purge fails, messenger warning with link to /admin/reports/rl for manual cleanup, matching rl_page_title
Flow 8: Delete via standalone form
User clicks Delete in the admin list row
Dedicated delete confirmation form (inherits from VariantExperimentDeleteFormBase)
Standard flow: purge analytics first, then delete entity, redirect to list
Flow 9: Delete cascading — host entity deleted
User deletes /node/42
hook_entity_predelete in rl_media.module fires for node entity type
Indexed query on (host_entity_type, host_bundle, host_entity_id) finds all matching experiments (across all languages and all fields)
For each: purge analytics, delete experiment
No orphans
Flow 10: Delete cascading — variant media deleted
User deletes a media entity from /admin/content/media
hook_entity_predelete in rl_media.module fires for media entity type
Query finds experiments whose variants_data JSON contains the deleted media ID
For each match:
a. Remove that variant from variants_data and save the experiment
b. If the experiment drops to zero variants, disable it and surface a messenger warning to the site admin
Open question: alternative behaviour is to block the media delete with a validation error ("This media is used in A/B test 'Homepage hero'. Remove it from the test first."), matching how Drupal core blocks deleting taxonomy terms in use. Recommendation: auto-remove + warning, matches how Drupal handles entity_reference cascades elsewhere.
Flow 11: Delete cascading — target field removed from bundle
User deletes field_hero from the article bundle via field UI
Field UI shows a blocking confirmation: "3 A/B experiments use this field. Deleting it will permanently remove those experiments and their 45,230 collected impressions. Continue?"
On confirm, hook_field_storage_config_delete (or equivalent) purges all matching experiments
No orphans
Flow 12: Disable vs delete
Two distinct actions with different semantics, clearly separated in the UI:
Admin UI copy makes this distinction obvious. Disable is the default "pause" action.
Flow 13: Multilingual scenarios
User creates experiment for /node/42 with langcode = en → hash A
Later creates another for /node/42 with langcode = es → different hash B, allowed
Later creates another with langcode = und ("all languages") → hash C, allowed, acts as fallback for any language
Runtime on a French page: (node, article, 42, field_hero, fr) → miss; (node, article, 42, field_hero, und) → hit hash C, serves fallback variant
Runtime on an English page: (node, article, 42, field_hero, en) → hit hash A, serves the English variant (takes precedence over und)
Each language accumulates its own Thompson Sampling state
Flow 14: Edge case — aspect ratio mismatch warning
Triggered on save if any variant's aspect ratio differs from the control by more than 10%:
Computed server-side from each media's image file metadata
Non-blocking (warning, not error)
User can override and save — the warning is a guardrail, not a restriction
Same warning shown inline in the vertical tab preview, updated live as variants are added/removed
Flow 15: Edge case — LCP protection
Experiments on LCP-critical images should raise cache_ttl to protect Core Web Vitals. There's no reliable server-side way to detect which field is the LCP element, so for v1:
Ship the cache_ttl field on the experiment entity (default 60s)
Document clearly in the standalone form and vertical tab help text: "Raise this for above-the-fold hero images to protect Core Web Vitals"
Open question: should we ship a heuristic warning (e.g. "this is the first image field on a content entity view mode, may be LCP") or leave it to docs? Recommendation: docs-only for v1, revisit after we have telemetry.
Flow 16: Edge case — field type changes
If an admin changes field_hero from an image field to a text field via field UI:
The field storage type migration runs
hook_field_storage_config_update fires
Any experiments targeting the now-invalid field are disabled (not deleted) and a messenger warning surfaces
Admin can manually delete them from the list
Flow 17: Clone / duplicate (phase 2, deferred)
From admin list, Clone operation pre-fills a new experiment form with:
Label: "Copy of {original}"
Same target entity type / bundle / field
Empty variants (or copied, user's choice via a checkbox)
Disabled by default (don't start rotating until explicitly enabled)
Nice to have; not blocking for v1.
Runtime mechanics summary
Hook: hook_entity_view_alter() — full entity context available for cache tag generation
Swap point: replace target_id on the field item; Drupal's normal image formatter renders the result, preserving all image styles, responsive srcsets, focal points, and alt text
Cache tags: rl_media:{host_entity_type}:{host_bundle}:{host_entity_id}:{field_name} per experiment, plus rl_media:all for global invalidation (forward-looking, matches rl_page_title)
Cache TTL override: CacheManager::overridePageCacheIfShorter($experiment->cache_ttl), default 60s, raisable per experiment
Creating/updating/deleting an experiment invalidates:
rl_media:all — global, catches first-time experiment creation even if the target entity was rendered before the module was installed
rl_media:{host_entity_type}:{host_bundle}:{host_entity_id}:{field_name} — targeted invalidation for retargets and updates
Host entity cache tags are honoured through normal Drupal cache-graph traversal
Technical constraints
Addressed by design
Aspect ratio drift → layout shift → inline warnings on save (Flow 14)
LCP preload → per-experiment cache_ttl field (Flow 15)
Responsive srcset regeneration → swapped media has its own image styles, srcset rebuilds automatically (this is why Media references are the right variant model over raw file references)
Focal point / smart crop → each media entity carries its own focal point; swapping media means the focal point swaps with it → correct by construction
Alt text per variant → each media entity has its own alt text → correct by construction
Translation → media entities can be translated; variant rotation is scoped per langcode, so a French-translated variant is only served on French requests → correct by construction
Open design questions
Variant media deletion behaviour (Flow 10): auto-remove + warning, or block the delete? Recommendation: auto-remove with warning, matches how Drupal handles entity_reference cascades elsewhere.
LCP heuristic warning (Flow 15): ship a weak heuristic or docs-only? Recommendation: docs-only for v1, revisit after telemetry.
Naming: rl_media vs rl_image. Recommendation: rl_media.
Vertical tab label: static "Image variants" or dynamic based on field type? Recommendation: dynamic — "Image variants" when bundle has only image fields, "Media variants" when mixed.
Clone operation (Flow 17): ship in v1 or phase 2? Recommendation: phase 2, keep v1 scope tight.
Dependencies
rl (parent module, ^1.x) — required
media (Drupal core) — required
media_library (Drupal core) — required for the vertical tab widget
field (Drupal core) — implicit
views (Drupal core) — for the admin list
No contrib dependencies.
Implementation plan
Shared base classes from PR #35 (VariantExperimentInterface, VariantSelectorBase, VariantExperimentDeleteFormBase, VariantArmsTrait, StorageSchema pattern, hash-indexed lookup) already exist and are reused. New code needed:
Total: ~2,200 LOC across ~15 files, including tests and docs. Roughly 5–7 days of focused work.
Phases
Phase 1 (MVP): Entity + storage + selector + standalone form + runtime swap + tracking JS. No vertical tab, no cascades, no Drush. Goal: prove the runtime swap works end-to-end.
Phase 2 (Vertical tab + cascades): Vertical tab UX, host entity predelete cascade, variant media predelete cascade, field config delete cascade. Goal: feature parity with rl_page_title.
Phase 3 (CLI + AI + docs): Drush commands, AI skill files, README, CHANGELOG entry. Goal: GUI/TUI/AI parity.
Phase 4 (Polish): Aspect ratio warnings, LCP cache_ttl field, clone operation, thumbnail previews in the Views-powered admin list. Optional; ship whenever.
Acceptance criteria
A user can create an experiment from a node edit form's vertical tab, add 2 variants via Media Library, save, and see variants rotate on the rendered node
A user can create a bundle-wide experiment via the standalone admin form and see it apply to every matching entity
Multilingual: English and Spanish experiments for the same entity do not share Thompson Sampling state
Deleting the host entity cascades cleanup of all associated experiments and analytics
Deleting a variant media entity removes it from experiments without breaking remaining variants
Deleting the target field from a bundle surfaces a blocking confirmation with impression counts, then purges cleanly on confirm
Disabling an experiment stops rotation without losing analytics
Purging analytics via the delete form leaves no orphans in rl_arm_data, rl_experiment_totals, rl_arm_snapshots, or rl_experiment_registry
Page cache is invalidated via rl_media:* tags when an experiment is created or modified
loading="lazy" images are tracked correctly via IntersectionObserver (impression on scroll into view, not on page load)
Aspect ratio mismatches surface a non-blocking warning in the admin UI
All Drush commands (rl:media:{create,list,get,update,delete}) have --dry-run support and parseable YAML output
scripts/e2e/test-media-crud.sh exercises CRUD, duplicate detection, multilingual isolation, variant cascade cleanup, and dry-run — all passing
PHPCS + PHPStan clean on all new code
README documents trash module compatibility, retarget semantics, and known limitations honestly (matching the rl_page_title / rl_menu_link precedent)
Non-goals (explicit)
Body-embedded <img> tag testing (CKEditor plugin required, phase 2)
Commerce product image variant testing as a distinct submodule — this submodule already covers it (Commerce products are fieldable entities with image fields)
Video playback engagement metrics (quartile events, completion rate) — impression + dwell only for v1
Would be followed by rl_block (block content testing) in the roadmap
Deliberately skips rl_cta / rl_form_button — ruled out as not feasible due to heterogeneous discoverability across core / custom / Webform / contact forms
Note: Do not implement yet. This issue is a design spec, not a work order. Ship after PR #35 lands and soaks on a production site for long enough to validate the shared-base-class pattern.
Summary
Next submodule after
rl_page_titleandrl_menu_link: A/B test any image or media field variant on any fieldable entity, using the same Thompson Sampling infrastructure. Variants are Media entity references, so each variant carries its own alt text, focal point, credit, and responsive image style metadata automatically.Two discovery modes from day one:
field_heroacross every article at once. Power-user flow.Primary reward signal: engagement after image visibility (IntersectionObserver impression + N-second engagement timer). Secondary optional reward: click on the parent anchor if the image is linked.
Naming decision: rl_image vs rl_media
Recommendation:
rl_media.video,remote_video,audio, anddocumentMedia types without a rename.rl_imagebecomes a misnomer the moment anyone adds video variants to a hero field that accepts Media references (common setup).rl_media; the human-visible tab label adapts to the field type on the bundle.Why now
rl_page_titleandrl_menu_linkvalidated the variant-selector pattern for text.rl_mediaextends it to visual media, where outcome variance is typically much higher than text. Landing-page hero images, product shots, article thumbnails, and section banners are the highest-leverage visual elements on any content-heavy Drupal site.The shared base classes shipped in PR #35 (
VariantExperimentInterface,VariantSelectorBase,VariantExperimentDeleteFormBase,VariantArmsTrait,StorageSchemapattern with hash-indexed lookups) mean this submodule costs ~2,200 LOC across ~15 files to build — not 6,000 LOC across 40 files.Scope
In scope for v1
imagefield type on any fieldable entityentity_referencefields targetingmediabundles on any fieldable entityimage(primary),video/remote_video/audio/document(secondary, same mechanism)langcode+LANGCODE_NOT_SPECIFIEDfallback, same pattern as existing submodulesrl:media:{create,list,get,update,delete}) for full GUI / TUI parityhook_entity_predeleteon host entity deletionhook_entity_predeleteon variant media deletionscripts/e2e/test-media-crud.shOut of scope for v1
<img>tags): not field-formatter-addressable, needs a CKEditor plugin. Phase 2.inline-svg: don't go through image formatters.Data model
Content entity
rl_media_experimentwith base fields:lookup_hash = Crypt::hashBase64("{host_entity_type}:{host_bundle}:{host_entity_id|*}:{field_name}|{langcode}")Storage schema (mirrors PR #35's hash-indexed pattern):
lookup_hash(host_entity_type, host_bundle, host_entity_id, field_name, langcode)— used by cascade cleanup queries(host_entity_type, host_bundle, field_name)— used by bundle-wide fallback lookupsRL experiment ID:
rl_media-{12-char-sha1-of-lookup-hash}. Armv0is the host entity's current field value, read live at render time and never stored. Armsv1..vNare the variant media IDs.Runtime fallback chain (resolved in a single indexed
INquery, matching the pattern introduced in PR #35):LANGCODE_NOT_SPECIFIEDmatchLANGCODE_NOT_SPECIFIEDmatchAdmin UX flows
Flow 1: Create via vertical tab (per-entity, primary path)
/node/42/editrl_media_experiment/admin/reports/rlbut the node save still commitsFlow 2: Create via standalone admin form (bundle-wide, power-user path)
/admin/config/services/rl-media/admin/config/services/rl-media/add:lookup_hash(covers bundle-wide experiments too)Flow 3: Read — admin list
Views-powered table at
/admin/config/services/rl-media:hook_preprocess_views_view_field)turns > 0)Flow 4: Read — runtime behaviour
/node/42; page cache override kicks in (60s TTL or higher if configured on the experiment)hook_entity_view_alter()firesa. Compute up to four candidate lookup hashes (entity/lang, entity/und, bundle/lang, bundle/und)
b. Single indexed
INquery resolves all candidatesc. First match in priority order wins
a.
ExperimentManager::getThompsonScores()over[original_media_id, ...variant_media_ids]b. Swap the field item's
target_idwith the winning media IDc. Drupal's normal image formatter renders the swapped media with its alt text, focal point, credit, and responsive srcset
d.
hook_preprocess_image_formatterinjectsdata-rl-media-experiment-idanddata-rl-media-arm-idon the rendered<img>e.
rl_media/trackinglibrary is attached viahook_page_attachmentsa.
IntersectionObserverwatches tracked<img>tagsb. Image enters viewport → record impression via
sendBeacontorl.phpc. Engagement timer starts (default 10s)
d. User still on page after 10s → record reward
e. sessionStorage dedupe key
rl-media-reward-{experimentId}-{armId}prevents double-counting within a session while still permitting per-arm counting if Thompson Sampling rotates mid-session (matches PR feat: rl_page_title and rl_menu_link modules for A/B testing #35 pattern)Flow 5: Update via vertical tab
/node/42/editrl_experiment_idcaptured viapendingPurgeRlExperimentId→ new entity saved → old analytics purged insave()after the entity commit, wrapped in try/catch with user warning pointing to/admin/reports/rlFlow 6: Update via standalone form
Flow 7: Delete via vertical tab
a. Purge RL analytics first (turns, rewards, totals, snapshots, registry) in a transaction
b. Delete the
rl_media_experimententityc. Redirect back to the node edit form; tab shows empty state
/admin/reports/rlfor manual cleanup, matching rl_page_titleFlow 8: Delete via standalone form
VariantExperimentDeleteFormBase)Flow 9: Delete cascading — host entity deleted
/node/42hook_entity_predeleteinrl_media.modulefires fornodeentity type(host_entity_type, host_bundle, host_entity_id)finds all matching experiments (across all languages and all fields)Flow 10: Delete cascading — variant media deleted
/admin/content/mediahook_entity_predeleteinrl_media.modulefires formediaentity typevariants_dataJSON contains the deleted media IDa. Remove that variant from
variants_dataand save the experimentb. If the experiment drops to zero variants, disable it and surface a messenger warning to the site admin
entity_referencecascades elsewhere.Flow 11: Delete cascading — target field removed from bundle
field_herofrom the article bundle via field UIhook_field_storage_config_delete(or equivalent) purges all matching experimentsFlow 12: Disable vs delete
Two distinct actions with different semantics, clearly separated in the UI:
Admin UI copy makes this distinction obvious. Disable is the default "pause" action.
Flow 13: Multilingual scenarios
/node/42withlangcode = en→ hash A/node/42withlangcode = es→ different hash B, allowedlangcode = und("all languages") → hash C, allowed, acts as fallback for any language(node, article, 42, field_hero, fr)→ miss;(node, article, 42, field_hero, und)→ hit hash C, serves fallback variant(node, article, 42, field_hero, en)→ hit hash A, serves the English variant (takes precedence overund)Flow 14: Edge case — aspect ratio mismatch warning
Triggered on save if any variant's aspect ratio differs from the control by more than 10%:
Flow 15: Edge case — LCP protection
Experiments on LCP-critical images should raise
cache_ttlto protect Core Web Vitals. There's no reliable server-side way to detect which field is the LCP element, so for v1:cache_ttlfield on the experiment entity (default 60s)Flow 16: Edge case — field type changes
If an admin changes
field_herofrom animagefield to atextfield via field UI:hook_field_storage_config_updatefiresFlow 17: Clone / duplicate (phase 2, deferred)
From admin list, Clone operation pre-fills a new experiment form with:
Nice to have; not blocking for v1.
Runtime mechanics summary
hook_entity_view_alter()— full entity context available for cache tag generationtarget_idon the field item; Drupal's normal image formatter renders the result, preserving all image styles, responsive srcsets, focal points, and alt textrl_media:{host_entity_type}:{host_bundle}:{host_entity_id}:{field_name}per experiment, plusrl_media:allfor global invalidation (forward-looking, matches rl_page_title)CacheManager::overridePageCacheIfShorter($experiment->cache_ttl), default 60s, raisable per experimentdata-rl-media-experiment-id+data-rl-media-arm-idinjected viahook_preprocess_image_formatterand/orhook_preprocess_field__field_namefallbackhook_page_attachmentswhen any rl_media experiment is active on the current renderReward signal
IntersectionObserverfires when a tracked<img>enters the viewport. Correctly handlesloading="lazy".<a>tag if the image is linked, auto-detected at runtime — no configuration needednavigator.sendBeacontorl.php, existing parent-module endpoint, zero new server infrastructurerl-media-reward-{experimentId}-{armId}, matches PR feat: rl_page_title and rl_menu_link modules for A/B testing #35 per-arm patternCache invalidation
rl_media:all— global, catches first-time experiment creation even if the target entity was rendered before the module was installedrl_media:{host_entity_type}:{host_bundle}:{host_entity_id}:{field_name}— targeted invalidation for retargets and updatesTechnical constraints
Addressed by design
cache_ttlfield (Flow 15)langcode, so a French-translated variant is only served on French requests → correct by constructionOpen design questions
rl_mediavsrl_image. Recommendation:rl_media.Dependencies
rl(parent module, ^1.x) — requiredmedia(Drupal core) — requiredmedia_library(Drupal core) — required for the vertical tab widgetfield(Drupal core) — implicitviews(Drupal core) — for the admin listNo contrib dependencies.
Implementation plan
Shared base classes from PR #35 (
VariantExperimentInterface,VariantSelectorBase,VariantExperimentDeleteFormBase,VariantArmsTrait,StorageSchemapattern, hash-indexed lookup) already exist and are reused. New code needed:Entity/MediaExperiment.php,MediaExperimentStorageSchema.phpService/MediaVariantSelector.phpForm/MediaExperimentForm.phpForm/MediaExperimentDeleteForm.phpDecorator/MediaDecorator.phprl_media.moduleDrush/Commands/RlMediaCommands.phprl_media.services.yml+drush.services.ymljs/media-tracking.jsconfig/install/views.view.rl_media_experiment.yml.claude/skills/rl_media/SKILL.md,.agents/skills/rl_media/SKILL.mdscripts/e2e/test-media-crud.shTotal: ~2,200 LOC across ~15 files, including tests and docs. Roughly 5–7 days of focused work.
Phases
cache_ttlfield, clone operation, thumbnail previews in the Views-powered admin list. Optional; ship whenever.Acceptance criteria
rl_arm_data,rl_experiment_totals,rl_arm_snapshots, orrl_experiment_registryrl_media:*tags when an experiment is created or modifiedloading="lazy"images are tracked correctly via IntersectionObserver (impression on scroll into view, not on page load)rl:media:{create,list,get,update,delete}) have--dry-runsupport and parseable YAML outputscripts/e2e/test-media-crud.shexercises CRUD, duplicate detection, multilingual isolation, variant cascade cleanup, and dry-run — all passingNon-goals (explicit)
<img>tag testing (CKEditor plugin required, phase 2)Related
VariantExperimentInterface,VariantSelectorBase,StorageSchemahash-indexed lookup pattern)rl_block(block content testing) in the roadmaprl_cta/rl_form_button— ruled out as not feasible due to heterogeneous discoverability across core / custom / Webform / contact formsNote: Do not implement yet. This issue is a design spec, not a work order. Ship after PR #35 lands and soaks on a production site for long enough to validate the shared-base-class pattern.