Skip to content

[RFC] rl_media: A/B testing for image and media field variants using Thompson Sampling #38

Description

@jjroelofs

Summary

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:

  1. Per-entity vertical tab on the host entity edit form — the "test the hero image on my one landing page" flow. Highest adoption path.
  2. 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.
  • CSS background images: no structured Drupal hook.
  • Image style / crop / focal-point-alone testing: users create multiple Media entities instead.
  • Responsive image breakpoint testing: too niche.
  • 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:              string
label:             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:           timestamp
changed:           timestamp

lookup_hash = Crypt::hashBase64("{host_entity_type}:{host_bundle}:{host_entity_id|*}:{field_name}|{langcode}")

Storage schema (mirrors PR #35's hash-indexed pattern):

  • UNIQUE index on lookup_hash
  • 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):

  1. Entity-specific + language-specific match
  2. Entity-specific + LANGCODE_NOT_SPECIFIED match
  3. Bundle-wide + language-specific match
  4. Bundle-wide + LANGCODE_NOT_SPECIFIED match
  5. No experiment — original renders unchanged

Admin UX flows

Flow 1: Create via vertical tab (per-entity, primary path)

  1. User navigates to /node/42/edit
  2. Scrolls to the vertical tabs at the bottom (advanced tab group)
  3. Sees a tab labelled "Image variants" (label adapts to field type; "Media variants" if the bundle has mixed media fields). Closed by default.
  4. 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.                                │
└──────────────────────────────────────────────────────────────┘
  1. Clicks "Add media" → Drupal's Media Library widget opens in a modal
  2. Selects 1+ media entities of a bundle the target field accepts
  3. Modal closes; thumbnails appear in the Alternative versions list
  4. If variant aspect ratios differ from the control by >10%, an inline warning appears (non-blocking)
  5. 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
  6. On subsequent edits, the tab shows live stats:
┌─ Image variants ─────────────────────────────────────────────┐
│ Active experiment: Homepage hero                             │
│ 12,450 impressions · leading variant: dawn.jpg (12.1%)       │
│ View full report →                                           │
│                                                              │
│ [...variant management widgets...]                           │
│                                                              │
│ ┌────────────────────────────────────────┐                   │
│ │ Stop testing and delete collected data │                   │
│ └────────────────────────────────────────┘                   │
└──────────────────────────────────────────────────────────────┘

Flow 2: Create via standalone admin form (bundle-wide, power-user path)

  1. User navigates to /admin/config/services/rl-media
  2. Views-powered list of all experiments. Clicks "+ Add image/media experiment"
  3. 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 ]
  1. 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)
  2. 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)
  • Operations column: Edit (standalone form), Delete (confirm + purge), Report (per-experiment RL report; gated on turns > 0)

Flow 4: Read — runtime behaviour

  1. Request hits /node/42; page cache override kicks in (60s TTL or higher if configured on the experiment)
  2. Node renders; hook_entity_view_alter() fires
  3. 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
  4. 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
  5. 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

  1. User returns to /node/42/edit
  2. Expands the "Image variants" tab
  3. 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
  4. Modifies variants (add one, remove one) or toggles enabled
  5. Clicks "Save node"
  6. Submit handler detects changes:
    • Variants changed → update experiment, analytics preserved
    • 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
  7. Removed variants remain in the RL registry for historical reports but stop rotating at runtime

Flow 6: Update via standalone form

  1. From admin list, click Edit
  2. Standard EntityForm loads pre-filled
  3. User modifies and saves
  4. Same retarget detection + post-save purge pattern as rl_page_title / rl_menu_link

Flow 7: Delete via vertical tab

  1. User expands "Image variants" tab
  2. Clicks "Stop testing and delete collected data" link
  3. 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.

  4. 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
  5. If purge fails, messenger warning with link to /admin/reports/rl for manual cleanup, matching rl_page_title

Flow 8: Delete via standalone form

  1. User clicks Delete in the admin list row
  2. Dedicated delete confirmation form (inherits from VariantExperimentDeleteFormBase)
  3. Standard flow: purge analytics first, then delete entity, redirect to list

Flow 9: Delete cascading — host entity deleted

  1. User deletes /node/42
  2. hook_entity_predelete in rl_media.module fires for node entity type
  3. Indexed query on (host_entity_type, host_bundle, host_entity_id) finds all matching experiments (across all languages and all fields)
  4. For each: purge analytics, delete experiment
  5. No orphans

Flow 10: Delete cascading — variant media deleted

  1. User deletes a media entity from /admin/content/media
  2. hook_entity_predelete in rl_media.module fires for media entity type
  3. Query finds experiments whose variants_data JSON contains the deleted media ID
  4. 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
  5. 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

  1. User deletes field_hero from the article bundle via field UI
  2. 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?"
  3. On confirm, hook_field_storage_config_delete (or equivalent) purges all matching experiments
  4. No orphans

Flow 12: Disable vs delete

Two distinct actions with different semantics, clearly separated in the UI:

Action Where Effect Reversible?
Disable (uncheck "Serve variants to visitors") Vertical tab or standalone form Experiment stays, variants stop rotating, analytics preserved Yes — re-enable anytime, rotation resumes
Delete ("Stop testing and delete collected data") Dedicated link in vertical tab or admin list row Experiment + all analytics purged permanently No

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:

  1. The field storage type migration runs
  2. hook_field_storage_config_update fires
  3. Any experiments targeting the now-invalid field are disabled (not deleted) and a messenger warning surfaces
  4. 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
  • Tracking attributes: data-rl-media-experiment-id + data-rl-media-arm-id injected via hook_preprocess_image_formatter and/or hook_preprocess_field__field_name fallback
  • Tracking library: attached via hook_page_attachments when any rl_media experiment is active on the current render

Reward signal

  • Impression: IntersectionObserver fires when a tracked <img> enters the viewport. Correctly handles loading="lazy".
  • Reward: engagement timer started after impression, 10s dwell = reward (matches rl_page_title bounce proxy; configurable per experiment)
  • Optional secondary reward: click on the parent <a> tag if the image is linked, auto-detected at runtime — no configuration needed
  • Transport: navigator.sendBeacon to rl.php, existing parent-module endpoint, zero new server infrastructure
  • Dedupe: sessionStorage key rl-media-reward-{experimentId}-{armId}, matches PR feat: rl_page_title and rl_menu_link modules for A/B testing #35 per-arm pattern

Cache invalidation

  • 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

  1. Aspect ratio drift → layout shift → inline warnings on save (Flow 14)
  2. LCP preload → per-experiment cache_ttl field (Flow 15)
  3. 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)
  4. Focal point / smart crop → each media entity carries its own focal point; swapping media means the focal point swaps with it → correct by construction
  5. Alt text per variant → each media entity has its own alt text → correct by construction
  6. 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:

Area Files LOC estimate
Entity class + StorageSchema Entity/MediaExperiment.php, MediaExperimentStorageSchema.php ~250
Runtime selector Service/MediaVariantSelector.php ~80
Form (standalone) Form/MediaExperimentForm.php ~220
Form (delete) Form/MediaExperimentDeleteForm.php ~15 (thin subclass)
Decorator Decorator/MediaDecorator.php ~80
Module file (hooks) rl_media.module ~350 (view alter, form alter vertical tab, preprocess image formatter, page attachments, entity_predelete cascades for host + variant media, field delete cascade)
Drush commands Drush/Commands/RlMediaCommands.php ~400
Services YAML rl_media.services.yml + drush.services.yml ~30
Info + permissions + libraries + action links 4 yaml files ~30
Tracking JS js/media-tracking.js ~120
Views config config/install/views.view.rl_media_experiment.yml ~300 (copied from rl_page_title pattern)
AI skill docs .claude/skills/rl_media/SKILL.md, .agents/skills/rl_media/SKILL.md ~200
E2E test scripts/e2e/test-media-crud.sh ~150

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)
  • SVG icon testing (not field-formatter-addressable)
  • CSS background image testing (no structured hook)
  • Image-style / crop / focal-point-alone testing (users create multiple Media entities)
  • Responsive image breakpoint testing (too niche)
  • 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

Related


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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions