diff --git a/.claude/skills/wagtail-snippet/SKILL.md b/.claude/skills/wagtail-snippet/SKILL.md
new file mode 100644
index 000000000..05dc5c6c7
--- /dev/null
+++ b/.claude/skills/wagtail-snippet/SKILL.md
@@ -0,0 +1,359 @@
+---
+name: wagtail-snippet
+description: Guide for creating new Wagtail snippets with page integration in Springfield. Covers model patterns, migrations, templates, JS, tests, and common pitfalls learned from production incidents.
+---
+
+# Creating a Wagtail Snippet with Page Integration
+
+Create a new Wagtail snippet following Springfield conventions, based on the name and purpose the user described.
+
+Before writing any code, study the existing patterns:
+- **Snippet models:** `springfield/cms/models/snippets.py`
+- **Page integration:** `springfield/cms/models/pages.py`
+- **Template tags:** `springfield/cms/templatetags/cms_tags.py`
+- **Existing tests:** `springfield/cms/tests/` (look at existing snippet tests for reference)
+
+---
+
+## Step 1: Define the Snippet Model
+
+**File:** `springfield/cms/models/snippets.py`
+
+Use the standard mixin chain and register the snippet:
+
+```python
+class MySnippet(FluentPreviewableMixin, BaseDraftTranslatableSnippetMixin, models.Model):
+ """One-line description of what this snippet does."""
+
+ # Fields
+ heading = RichTextField(features=HEADING_TEXT_FEATURES, blank=True)
+ # ... add fields with help_text explaining behavior to editors
+
+ panels = [FieldPanel("heading")]
+
+ # Fields that don't change per locale (URLs, images, config values, etc.)
+ override_translatable_fields = [SynchronizedField("field_name")]
+
+ class Meta(BaseDraftTranslatableSnippetMixin.Meta):
+ verbose_name = "My Snippet"
+ verbose_name_plural = "My Snippets"
+
+ def __str__(self):
+ from springfield.cms.templatetags.cms_tags import remove_tags
+ return f"{remove_tags(richtext(self.heading))} - {self.locale}"
+
+ def get_preview_context(self, request, mode_name):
+ # IMPORTANT: If your template needs context beyond `value`/`object`,
+ # you MUST override this. Otherwise the preview will crash.
+ context = super().get_preview_context(request, mode_name)
+ # Build any extra context your template needs
+ return context
+
+ def get_preview_template(self, request, mode_name):
+ return "cms/snippets/my-snippet-preview.html"
+
+ def clean(self):
+ # Add validation here
+ return super().clean()
+
+
+register_snippet(MySnippet) # Don't forget this!
+```
+
+### Key rules:
+- Always add `help_text` to fields editors interact with
+- Override `get_preview_context()` if your template needs more than just `value`
+- Always call `register_snippet()` after the class definition
+- Add `clean()` validation for field dependencies (e.g., "at least one of X or Y required")
+
+---
+
+## Step 2: Choose Your Architecture
+
+There are two patterns for connecting snippets to pages. Choose one:
+
+### Pattern A: Template-Tag Only (preferred for simple cases)
+
+The snippet is fetched in the template via a template tag. No `get_context()` needed on pages.
+
+- Page model: just a boolean flag (`show_my_snippet`)
+- Template tag: queries snippet by locale
+- Template: calls tag, sets `value`, includes snippet template
+
+**Advantages:** Simple, no code duplication across pages, fewer bugs.
+
+### Pattern B: get_context() + Template Tag (for per-page overrides)
+
+The page's `get_context()` builds extra context (e.g., resolved override values). Use this when pages need to customize snippet behavior individually.
+
+- Page model: boolean flag + override fields
+- `get_context()`: queries snippet, builds context dict
+- Template tag: also queries snippet for `value`
+- Template: needs both `value` and the extra context
+
+**Advantages:** Supports per-page customization.
+**Risks:** Every page type needs `get_context()`, context can be None, preview needs `get_preview_context()`.
+
+### If using Pattern B:
+- Create a shared helper function for the query (avoid duplicating logic)
+- **NEVER import from `fixtures/` in production code** -- fixtures use `update_or_create` which writes to DB on every call
+- Add None guards in both `get_context()` and the template
+- Add the `get_context()` to ALL page types that use the template
+
+---
+
+## Step 3: Add Page Fields
+
+**File:** `springfield/cms/models/pages.py`
+
+```python
+# Boolean flag to enable the snippet
+show_my_snippet = models.BooleanField(
+ default=False,
+ help_text="If true, the snippet will be displayed on this page.",
+)
+
+# If using per-page overrides (Pattern B):
+override_field = models.CharField(
+ blank=True,
+ help_text="Explain what this overrides and what takes priority.",
+)
+```
+
+Group override fields in a collapsed panel:
+
+```python
+content_panels = [
+ ...,
+ FieldPanel("show_my_snippet"),
+ MultiFieldPanel(
+ [FieldRowPanel([
+ FieldPanel("override_field"),
+ ])],
+ heading="My Snippet Overrides",
+ classname="collapsed",
+ ),
+]
+```
+
+Add to ALL page types that should support the snippet. Check every page's template to confirm it uses the snippet block.
+
+---
+
+## Step 4: Template Tag
+
+**File:** `springfield/cms/templatetags/cms_tags.py`
+
+```python
+@pass_context
+@library.global_function
+def get_my_snippet(context):
+ from springfield.cms.models.snippets import MySnippet
+
+ locale = None
+ if "page" in context and hasattr(context["page"], "locale"):
+ locale = context["page"].locale
+ elif "self" in context and hasattr(context["self"], "locale"):
+ locale = context["self"].locale
+
+ if locale:
+ return MySnippet.objects.filter(locale=locale).live().first()
+ return None
+```
+
+This returns one snippet per locale. If you need multiple snippets, use a FK on the page model instead.
+
+---
+
+## Step 5: Templates
+
+**Snippet template** (`cms/snippets/my-snippet.html`):
+- Always guard against None context: `{% if value %}` or `{% if extra_context %}`
+- Use `{{ value.heading|richtext }}` for rich text fields
+- Fix whitespace in tags: `{% if x %}` not `{%if x %}`
+- Don't leak raw URLs in alt text
+
+**Preview template** (`cms/snippets/my-snippet-preview.html`):
+```html
+{% extends "cms/base-flare26.html" %}
+{% block content %}
+
+ {% set value = object %}
+ {% include "cms/snippets/my-snippet.html" %}
+
+{% endblock content %}
+```
+
+**Page template integration** (in `{% block my_block %}`):
+```html
+{% if page.show_my_snippet %}
+ {% set snippet = get_my_snippet() %}
+ {% if snippet %}
+ {% set value = snippet %}
+ {% include "cms/snippets/my-snippet.html" %}
+ {% endif %}
+{% endif %}
+```
+
+---
+
+## Step 6: JavaScript
+
+If the snippet has interactive behavior:
+
+- **Scope all DOM queries** to the snippet element: `snippetEl.querySelector(...)` not `document.querySelector(...)`
+- **Use `js-` prefixed classes** for JS hooks (see Step 12)
+- **Cookie/state persistence:** If saving user preferences (e.g., dismissed, collapsed), respect the consent API. Separate "don't auto-show" from "don't attach handlers" — always attach handlers so the user can still interact
+- **Avoid state flicker:** If the server can determine the initial state (e.g., via a cookie), pass it to the template in `get_context()` rather than relying on JS to fix it after page load
+
+---
+
+## Step 7: CSS
+
+- Use CSS logical properties (`block-size`, `inline-size`, `inset`) not physical properties
+- Use `overflow: hidden` on animated containers to prevent content flash during transitions
+- Add `cursor: pointer` on interactive elements
+- `@starting-style` is not supported in all browsers -- ensure graceful degradation
+- Use design token variables (`--token-*`) not hardcoded colors
+
+---
+
+## Step 8: Migration
+
+### Before creating the migration:
+- Remove any fields from the model that you added then removed during development
+- The migration should represent the FINAL schema only
+- Never create-then-drop a field in the same PR (this has caused production outages)
+
+### Process:
+```bash
+python manage.py makemigrations cms
+```
+
+Then:
+1. Add the MPL license header to the generated file
+2. Run `ruff check --fix` and `ruff format` on it
+3. Break up long lines (especially `field=models.ForeignKey(...)` with help_text)
+4. Run `pre-commit run --all-files` to verify
+
+### If main has new migrations:
+```bash
+git merge origin/main
+python manage.py makemigrations --merge --noinput
+```
+
+This creates a merge migration -- standard practice in this project. Add the license header and format it.
+
+### Rules:
+- One clean migration per feature -- squash if you iterated
+- No field drops in the same PR as field adds
+- `makemigrations --check --dry-run` must pass before PR
+- Run the full test suite before opening a PR, not just your new tests
+
+---
+
+## Step 9: Tests
+
+**File:** `springfield/cms/tests/test_my_snippet.py`
+
+Follow the existing test patterns in `springfield/cms/tests/`:
+
+1. **Unit tests** for resolution/helper logic (use `SimpleNamespace` mocks, no DB)
+2. **Validation tests** for `clean()` on snippet and page models
+3. **Rendering tests** parametrized across all page types that support the snippet
+4. **Template tag tests** for locale lookup, missing snippet, fallback behavior
+
+```python
+# Parametrize across all pages that support the feature
+PAGES_WITH_MY_SNIPPET = [
+ get_thanks_page,
+ get_freeform_page_2026_with_snippet,
+ get_whats_new_page_with_snippet,
+ get_whats_new_page_2026_with_snippet,
+]
+
+@pytest.mark.parametrize("get_page_fn", PAGES_WITH_MY_SNIPPET)
+def test_snippet_renders_when_flag_on(get_page_fn, minimal_site, rf):
+ page = get_page_fn()
+ page.show_my_snippet = True
+ # ... serve and assert
+```
+
+---
+
+## Step 10: Fixtures
+
+**File:** `springfield/cms/fixtures/snippet_fixtures.py`
+
+Create a fixture helper for tests:
+
+```python
+def get_my_snippet() -> MySnippet:
+ locale = Locale.get_default()
+ snippet, _ = MySnippet.objects.update_or_create(
+ id=settings.MY_SNIPPET_ID,
+ defaults={
+ "locale": locale,
+ "heading": "Test heading
",
+ # ... other fields
+ },
+ )
+ return snippet
+```
+
+**IMPORTANT:** Fixture helpers are for test setup ONLY. Never import them in production code (models, views, template tags). They use `update_or_create` which writes to the DB on every call.
+
+---
+
+## Step 11: DB Export
+
+**File:** `bin/export-db-to-sqlite.sh`
+
+New snippet models must be added to the DB export script. If you skip this, prod data exports won't include your snippet and other devs pulling the DB will have missing data.
+
+---
+
+## Step 12: JS Class Naming
+
+Use `js-` prefixed classes for JavaScript hooks instead of styling classes:
+
+```html
+
+
+
+
+
+```
+
+This prevents CSS refactors from breaking JS behavior. Style classes can change freely; `js-` classes are the contract with JavaScript.
+
+---
+
+## Step 13: Scope to the Right Pages
+
+Not every page type needs every feature. Before adding fields and `get_context()` to a page model, verify:
+- Does that page type's template actually use the snippet block?
+- Is there a real use case for the snippet on that page?
+- Older or legacy page types may not need new features
+
+Remove what doesn't belong — unused fields and dead code on page models create confusion and test overhead.
+
+---
+
+## Common Pitfalls
+
+| Pitfall | How to avoid |
+|---------|-------------|
+| Forgot `register_snippet()` | Snippet won't appear in admin |
+| Fixture imported in `get_context()` | DB write on every page load -- use a read-only query |
+| Missing `get_context()` on one page type | Template crashes with `UndefinedError` on that page |
+| Dead `get_context()` on pages that don't use the feature | Remove it -- check the template chain |
+| No `get_preview_context()` | Admin preview crashes if template needs extra context |
+| Global JS selectors | Wrong elements get modified if class names are reused |
+| Styling classes used as JS hooks | CSS changes break JS -- use `js-` prefixed classes |
+| Create-then-drop field in same PR | Can cause outage during deploy -- squash migrations |
+| No None guard in template | Crashes when snippet doesn't exist for locale |
+| Missing `help_text` on fields | Editors don't understand field behavior or precedence |
+| Snippet not in DB export script | Other devs pulling prod data won't have the snippet |
+| Feature added to pages that don't need it | Dead code, wasted test coverage, confusing admin UI |
+| Template tag needs request but doesn't receive it | Pass `request` explicitly if tag needs cookies or session |
diff --git a/springfield/cms/migrations/0086_freeformpage2026_marketing_attribution_mode_and_more.py b/springfield/cms/migrations/0086_freeformpage2026_marketing_attribution_mode_and_more.py
new file mode 100644
index 000000000..6274d7cf9
--- /dev/null
+++ b/springfield/cms/migrations/0086_freeformpage2026_marketing_attribution_mode_and_more.py
@@ -0,0 +1,45 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+# Generated by Django 5.2.14 on 2026-05-13 19:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0085_alter_freeformpage2026_body_class"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="freeformpage2026",
+ name="marketing_attribution_mode",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("", "Disabled"),
+ ("enabled", "Enabled (always promoted)"),
+ ("param", "Enabled only when ?promoted=1 in URL"),
+ ],
+ help_text=(
+ "Controls whether this page is treated as an externally-promoted "
+ "landing page (adds the 'Share how you discovered Firefox' opt-out "
+ "checkbox, consent banner support for EU visitors, and stub "
+ "attribution for CPA tracking). Use 'Enabled only when ?promoted=1' "
+ "for landing pages that serve both organic and paid traffic — the "
+ "promoted treatment is only rendered for visitors arriving via the "
+ "?promoted=1 query param. Must not be used together with the "
+ "'Set as default browser' checkbox on download buttons."
+ ),
+ max_length=20,
+ verbose_name="Marketing Attribution Mode",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="freeformpage2026",
+ name="enable_marketing_attribution",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/springfield/cms/migrations/0087_migrate_marketing_attribution.py b/springfield/cms/migrations/0087_migrate_marketing_attribution.py
new file mode 100644
index 000000000..d1b6a586a
--- /dev/null
+++ b/springfield/cms/migrations/0087_migrate_marketing_attribution.py
@@ -0,0 +1,31 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+from django.db import migrations
+
+
+def forwards(apps, schema_editor):
+ """Copy enable_marketing_attribution=True to marketing_attribution_mode='enabled'."""
+ FreeFormPage2026 = apps.get_model("cms", "FreeFormPage2026")
+ FreeFormPage2026.objects.filter(enable_marketing_attribution=True).update(
+ marketing_attribution_mode="enabled",
+ )
+
+
+def backwards(apps, schema_editor):
+ """Reverse: copy marketing_attribution_mode='enabled' back to enable_marketing_attribution=True."""
+ FreeFormPage2026 = apps.get_model("cms", "FreeFormPage2026")
+ FreeFormPage2026.objects.filter(marketing_attribution_mode="enabled").update(
+ enable_marketing_attribution=True,
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0086_freeformpage2026_marketing_attribution_mode_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(forwards, backwards),
+ ]
diff --git a/springfield/cms/models/base.py b/springfield/cms/models/base.py
index a69da8735..4efdcd7ce 100644
--- a/springfield/cms/models/base.py
+++ b/springfield/cms/models/base.py
@@ -21,20 +21,49 @@
class PromotedPageMixin(models.Model):
"""Mixin for pages that can receive externally promoted traffic (e.g. Google Ads, Meta)."""
- enable_marketing_attribution = models.BooleanField(
- default=False,
+ MARKETING_ATTRIBUTION_MODES = (
+ ("", "Disabled"),
+ ("enabled", "Enabled (always promoted)"),
+ ("param", "Enabled only when ?promoted=1 in URL"),
+ )
+
+ marketing_attribution_mode = models.CharField(
+ max_length=20,
+ blank=True,
+ choices=MARKETING_ATTRIBUTION_MODES,
+ verbose_name="Marketing Attribution Mode",
help_text=(
- "Enable marketing attribution for externally promoted pages. "
- "Adds the 'Share how you discovered Firefox' opt-out checkbox, "
- "consent banner support for EU visitors, and stub attribution "
- "for CPA tracking. Must not be used together with the 'Set as "
- "default browser' checkbox on download buttons."
+ "Controls whether this page is treated as an externally-promoted "
+ "landing page (adds the 'Share how you discovered Firefox' opt-out "
+ "checkbox, consent banner support for EU visitors, and stub "
+ "attribution for CPA tracking). Use 'Enabled only when ?promoted=1' "
+ "for landing pages that serve both organic and paid traffic — the "
+ "promoted treatment is only rendered for visitors arriving via the "
+ "?promoted=1 query param. Must not be used together with the "
+ "'Set as default browser' checkbox on download buttons."
),
)
+ # LEGACY — kept for one deploy cycle to avoid create-then-drop in the same PR.
+ # Will be removed in a follow-up PR. Do not reference in templates or new code.
+ enable_marketing_attribution = models.BooleanField(default=False)
+
class Meta:
abstract = True
+ def is_promoted_page(self, request=None):
+ """Server-side evaluation: should this request render as promoted?"""
+ if self.marketing_attribution_mode == "enabled":
+ return True
+ if self.marketing_attribution_mode == "param" and request is not None:
+ return request.GET.get("promoted") == "1"
+ return False
+
+ def get_context(self, request, *args, **kwargs):
+ context = super().get_context(request, *args, **kwargs)
+ context["is_promoted_page"] = self.is_promoted_page(request)
+ return context
+
@method_decorator(never_cache, name="serve_password_required_response")
class AbstractSpringfieldCMSPage(WagtailBasePage):
diff --git a/springfield/cms/models/pages.py b/springfield/cms/models/pages.py
index 03a94077c..94602b013 100644
--- a/springfield/cms/models/pages.py
+++ b/springfield/cms/models/pages.py
@@ -933,7 +933,7 @@ class FreeFormPage2026(PromotedPageMixin, UTMParamsMixin, QRCodeFloatingSnippetM
]
promote_panels = AbstractSpringfieldCMSPage.promote_panels + [
- FieldPanel("enable_marketing_attribution"),
+ FieldPanel("marketing_attribution_mode"),
]
class Meta:
@@ -945,7 +945,10 @@ def __str__(self):
@property
def noindex(self):
- return self.enable_marketing_attribution
+ # Only "always promoted" pages noindex. "Param" mode pages keep the base URL
+ # indexable for organic traffic — Googlebot crawls without query params and
+ # sees the organic version of the page.
+ return self.marketing_attribution_mode == "enabled"
class WhatsNewIndexPage(AbstractSpringfieldCMSPage):
diff --git a/springfield/cms/templates/cms/base-flare26.html b/springfield/cms/templates/cms/base-flare26.html
index c48fc15c8..d2eee1c63 100644
--- a/springfield/cms/templates/cms/base-flare26.html
+++ b/springfield/cms/templates/cms/base-flare26.html
@@ -30,7 +30,7 @@
{% endif %}
{% endblock %}
{% block marketing_attribution %}
- {% if page is defined and page.enable_marketing_attribution is defined %}data-promoted-page="{{ page.enable_marketing_attribution }}"{% endif %}
+ {% if is_promoted_page %}data-promoted-page="True"{% endif %}
{% endblock marketing_attribution %}
{% block html_attrs %}{% endblock %}
>
@@ -78,7 +78,7 @@
{% endblock %}
{% block page_css %}
- {% if page is defined and page.enable_marketing_attribution %}
+ {% if is_promoted_page %}
{{ css_bundle('firefox_marketing_opt_out') }}
{% endif %}
{% if page is defined and page.body_class %}
@@ -212,7 +212,7 @@
{{ page.title if page is defined and page.title is defined else 'Firefox' }}
{# Download as default needs to come after stub attribution #}
{{ js_bundle('download_as_default') }}
- {% if page is defined and page.enable_marketing_attribution %}
+ {% if is_promoted_page %}
{# Marketing opt-out runs on all evergreen browsers plus IE9 and above. #}
{{ js_bundle('firefox_marketing_opt_out') }}
diff --git a/springfield/cms/templates/cms/blocks/download-firefox-button.html b/springfield/cms/templates/cms/blocks/download-firefox-button.html
index 008b8f98c..26b2bae39 100644
--- a/springfield/cms/templates/cms/blocks/download-firefox-button.html
+++ b/springfield/cms/templates/cms/blocks/download-firefox-button.html
@@ -16,7 +16,7 @@
icon_position="{{ value.settings.icon_position }}"
icon_name="{{ value.settings.icon }}"
show_default_browser_checkbox="{{ value.settings.show_default_browser_checkbox }}"
- enable_marketing_attribution="{{ 'true' if page is defined and page.enable_marketing_attribution else '' }}"
+ enable_marketing_attribution="{{ 'true' if is_promoted_page else '' }}"
show_store_button=true
exclude_unsupported_content="{{ not value.settings.show_extra_links }}"
/>