Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
96b35ca
feat(expert-hub): add dedicated SCSS and JS assets for landing page
mmmavis Apr 23, 2026
8f99dbe
feat(expert-hub): WIP landing page bubble viz, ported from prototype
mmmavis Apr 24, 2026
9bb380d
feat(expert-hub): improve landing page viz robustness and add hover t…
mmmavis Apr 24, 2026
ac229ce
refactor(expert-hub): extract viz logic to components/expert_hub_page…
mmmavis Apr 24, 2026
1f1c4b7
feat(expert-hub): remove bubble floating animation from viz
mmmavis Apr 24, 2026
882d35a
feat(expert-hub): add staggered pop-in entrance animation for bubbles
mmmavis Apr 24, 2026
8f7b90b
feat(expert-hub): enforce title and description length limits on Expe…
mmmavis Apr 24, 2026
0b05d02
feat(expert-hub): refine tooltip design and bubble colour palette
mmmavis Apr 24, 2026
64cefc4
refactor(expert-hub): responsive viz with per-breakpoint configs and …
mmmavis Apr 24, 2026
2769f9d
feat(expert-hub): align tooltip tail dynamically with hovered bubble
mmmavis Apr 24, 2026
da8874a
feat(expert-hub): responsive viz for all breakpoints; tap opens profi…
mmmavis Apr 30, 2026
cc75dd9
refactor(expert-hub): move static bubble and SVG styles from JS to SCSS
mmmavis Apr 30, 2026
aae669d
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis Apr 30, 2026
aa31a5d
fix mgiration files
mmmavis Apr 30, 2026
0136335
feat(expert-hub): add CTA section with yellow decorative shape
mmmavis Apr 30, 2026
e462286
refactor(expert-hub): fix bubble sizes independent of expert count
mmmavis Apr 30, 2026
58087df
fix linting/formatting issues
mmmavis Apr 30, 2026
42591cc
feat(expert-hub): add blurb field and wire into viz tooltip and card
mmmavis Apr 30, 2026
1f8ba26
fix(expert-hub): wire CTA button to press center and fix vertical str…
mmmavis Apr 30, 2026
6f4db12
feat(expert-hub): polish viz interactions, bubble sizing, and CTA layout
mmmavis Apr 30, 2026
215763f
styling tweaks
mmmavis Apr 30, 2026
35711c9
feat(expert-hub): add decorative parallax arrows and simplify viz bre…
mmmavis May 1, 2026
1bde9f5
layout adjustment and SCSS refactor
mmmavis May 1, 2026
bec2b0d
refactor(expert-hub): introduce .expert-hub-hero wrapper, separate co…
mmmavis May 1, 2026
8ed2287
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis May 1, 2026
863ffb8
refactor(migrations): renumber expert hub migrations to 0008–0009
mmmavis May 1, 2026
3188267
fix(migrations): add missing 0008_alter_experthubpage_description
mmmavis May 1, 2026
f640741
fix(expert-hub): fix lightbox scroll-to-top and flicker on iOS
mmmavis May 1, 2026
d7d464c
chore(deps): remove unused d3-timer from expert hub dependencies
mmmavis May 1, 2026
411dde0
fix linting/formatting issues
mmmavis May 1, 2026
75d125a
fix dependency file name in migration file
mmmavis May 1, 2026
d8e6a72
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis May 11, 2026
f849c1c
chore(migrations): renumber profiles migrations 0008–0009 to 0009–0010
mmmavis May 11, 2026
dbb56cc
chore(migrations): renumber profiles migrations 0008–0009 to 0009–0010
mmmavis May 11, 2026
30536a4
Merge branch 'TP1-3742-expert-hub-landing-page' of github.com:mozilla…
mmmavis May 11, 2026
e0a3ab3
revert(migrations): rename remove_char_limits back to 0008
mmmavis May 11, 2026
20592bd
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis May 13, 2026
768f5d1
fix(expert-hub): fix lightbox overlay invisible on iOS when page is s…
mmmavis May 13, 2026
667654c
feat(expert-hub): clip bubble images to blob shape using SVG mask
mmmavis May 13, 2026
a9f2fae
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis May 13, 2026
b9c0d60
fix migration files
mmmavis May 13, 2026
b4f3c52
chore(factories): increase featured experts seed count from 6 to 13
mmmavis May 13, 2026
cba4634
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis May 14, 2026
138dae9
Merge branch 'main' into TP1-3742-expert-hub-landing-page
mmmavis May 14, 2026
3220d22
Fix merge conflicts
mmmavis May 14, 2026
aba8660
fix(expert-hub): resolve hover flicker and z-index issues in bubble v…
mmmavis May 14, 2026
56ea75e
Styled expert profile page
Mauricio-RC May 15, 2026
2eaa3b6
Fixed linting issues
Mauricio-RC May 15, 2026
1d12ef2
Fixed linting issues
Mauricio-RC May 15, 2026
2e80a39
Fixed missing space lint
Mauricio-RC May 15, 2026
3c34bd4
Fixed missing space
Mauricio-RC May 15, 2026
72b4698
Fixed failed nav test
Mauricio-RC May 15, 2026
10652a1
Merge branch 'TP1-3742-expert-hub-landing-page' into TP1-3743-expert-…
Mauricio-RC May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion foundation_cms/navigation/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class Params:
)
external_url_link = factory.Trait(
link_to="external_url",
external_url=factory.Faker("url"),
external_url=factory.Faker("url", schemes=["https"]),
)
relative_url_link = factory.Trait(
link_to="relative_url",
Expand Down
6 changes: 4 additions & 2 deletions foundation_cms/profiles/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def generate(seed):
bio=fake.paragraph(nb_sentences=3),
location=country_codes[i % len(country_codes)],
affiliation=fake.company(),
blurb=fake.sentence(nb_words=12)[:115],
seo_title=name,
search_description=fake.sentence(nb_words=10).rstrip("."),
)
Expand All @@ -99,10 +100,10 @@ def generate(seed):
# Link featured experts to hub
print("Linking featured experts to Expert Hub Page...")
if not hub.featured_experts.exists():
for i, expert in enumerate(expert_pages[:6]):
for i, expert in enumerate(expert_pages[:13]):
ExpertHubFeaturedExpert.objects.create(hub_page=hub, expert=expert, sort_order=i)
hub.save_revision().publish()
print(f" {min(6, len(expert_pages))} featured experts linked.")
print(f" {min(13, len(expert_pages))} featured experts linked.")
else:
print(" Featured experts already linked.")

Expand Down Expand Up @@ -151,5 +152,6 @@ class Meta:
bio = factory.Faker("paragraph", nb_sentences=3)
location = "US"
affiliation = factory.Faker("company")
blurb = factory.LazyAttribute(lambda _: get_faker().sentence(nb_words=12)[:115])
seo_title = factory.Faker("sentence", nb_words=3)
search_description = factory.Faker("sentence", nb_words=10)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-04-30 03:27

import wagtail.fields
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("profiles", "0009_alter_expertdirectorypage_body_and_more"),
]

operations = [
migrations.AlterField(
model_name="experthubpage",
name="description",
field=wagtail.fields.RichTextField(
blank=True,
help_text="Optional description to display on the experts hub page (max 120 characters).",
max_length=120,
),
),
]
22 changes: 22 additions & 0 deletions foundation_cms/profiles/migrations/0011_expertprofilepage_blurb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.29 on 2026-04-30 08:17

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("profiles", "0010_alter_experthubpage_description"),
]

operations = [
migrations.AddField(
model_name="expertprofilepage",
name="blurb",
field=models.CharField(
blank=True,
help_text="Short promotional summary shown in the Expert Hub landing page visualization (max 115 characters).",
max_length=115,
),
),
]
26 changes: 22 additions & 4 deletions foundation_cms/profiles/models/expert_hub_page.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.admin.forms import WagtailAdminPageForm
from wagtail.admin.panels import (
FieldPanel,
InlinePanel,
Expand All @@ -14,6 +16,17 @@
from foundation_cms.profiles.models.expert_directory_page import ExpertDirectoryPage


# `title` lives on Wagtail's base `wagtailcore_page` table, not on this model's
# table, so we can't override its max_length via a field definition or migration.
# Instead we enforce the limit at the form level (maxlength attr + field validation)
# and in clean() below as a model-level backstop.
class ExpertHubPageAdminForm(WagtailAdminPageForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["title"].max_length = 25
self.fields["title"].widget.attrs["maxlength"] = 25


class ExpertHubFeaturedExpert(TranslatableMixin, Orderable):
hub_page = ParentalKey(
"profiles.ExpertHubPage",
Expand All @@ -38,10 +51,12 @@ class Meta(TranslatableMixin.Meta, Orderable.Meta):

class ExpertHubPage(AbstractBasePage):
max_count = 1
base_form_class = ExpertHubPageAdminForm

description = RichTextField(
blank=True,
help_text="Optional description to display on the experts hub page.",
max_length=120,
help_text="Optional description to display on the experts hub page (max 120 characters).",
features=["bold", "italic", "link"],
)

Expand All @@ -51,7 +66,7 @@ class ExpertHubPage(AbstractBasePage):
content_panels = AbstractBasePage.content_panels + [
FieldPanel("description"),
MultiFieldPanel(
[InlinePanel("featured_experts", label="Expert", min_num=1, max_num=12)],
[InlinePanel("featured_experts", label="Expert", min_num=1, max_num=13)],
heading="Featured Experts",
classname="collapsible",
help_text="Experts will be grouped by their first assigned topic.",
Expand All @@ -67,6 +82,11 @@ class ExpertHubPage(AbstractBasePage):
class Meta:
verbose_name = "Expert Hub Page"

def clean(self):
super().clean()
if len(self.title) > 25:
raise ValidationError({"title": "Title must be 25 characters or fewer."})

def get_context(self, request):
context = super().get_context(request)

Expand All @@ -79,8 +99,6 @@ def get_context(self, request):
topic = fe.expert.topics.first()
featured_experts.append({"expert": fe.expert, "topic": topic})

# Sort featured experts by topic name so that they are grouped by topic in the landing page display
featured_experts.sort(key=lambda item: (item["topic"].name if item["topic"] else ""))
context["featured_experts"] = featured_experts

directory = self.get_children().type(ExpertDirectoryPage).live().first()
Expand Down
8 changes: 8 additions & 0 deletions foundation_cms/profiles/models/expert_profile_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,22 @@ class ExpertProfilePage(AbstractProfilePage):
help_text="Organization or institution.",
)

blurb = CharField(
max_length=115,
blank=True,
help_text="Short promotional summary shown in the Expert Hub landing page visualization (max 115 characters).",
)

content_panels = AbstractProfilePage.content_panels + [
FieldPanel("affiliation"),
FieldPanel("blurb"),
InlinePanel("external_links", label="External Links", max_num=10),
FieldPanel("body"),
]

translatable_fields = AbstractProfilePage.translatable_fields + [
TranslatableField("affiliation"),
TranslatableField("blurb"),
TranslatableField("body"),
]

Expand Down
3 changes: 3 additions & 0 deletions foundation_cms/static/images/expert-hub/bubble-mask.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions foundation_cms/static/images/expert-hub/loop-arrow-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions foundation_cms/static/images/expert-hub/loop-arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
210 changes: 210 additions & 0 deletions foundation_cms/static/js/components/expert_hub_page/lightbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
const SELECTORS = {
close: "[data-lightbox-close]",
inner: ".expert-hub-card__inner",
image: ".expert-hub-card__image",
blurb: ".expert-hub-card__blurb",
name: ".expert-hub-card__name",
link: ".expert-hub-card__link",
bubbleImage: ".expert-hub-bubble__image",
};

const FOCUSABLE_SELECTOR = [
"button:not([disabled])",
"a[href]:not([disabled])",
'input:not([disabled]):not([type="hidden"])',
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");

/**
* Wires the expert profile overlay: fills content from a bubble, manages scroll lock,
* focus return, Tab trapping, backdrop click, and Escape.
*
* @param {HTMLElement | null} cardEl - `#expert-hub-card` dialog root
* @returns {null | {
* open: function(HTMLElement): void,
* close: function(): void,
* bindListeners: function(AbortSignal): void,
* }}
* `null` if `cardEl` is missing or required panel nodes are not found in the DOM.
*/
export function setupLightbox(cardEl) {
if (!cardEl) return null;

const closeBtn = cardEl.querySelector(SELECTORS.close);
const inner = cardEl.querySelector(SELECTORS.inner);
const imageEl = cardEl.querySelector(SELECTORS.image);
const blurbEl = cardEl.querySelector(SELECTORS.blurb);
const nameEl = cardEl.querySelector(SELECTORS.name);
const linkEl = cardEl.querySelector(SELECTORS.link);

if (!inner || !imageEl || !blurbEl || !nameEl || !linkEl) {
return null;
}

// Move the dialog to be a direct child of <body> so that position:fixed
// always resolves to the viewport, regardless of any transforms or
// will-change on ancestor elements inside the page content.
if (cardEl.parentElement !== document.body) {
document.body.appendChild(cardEl);
}

let previouslyFocused = null;
let savedScrollY = 0;

/**
* @returns {HTMLElement[]} Focusable controls inside the dialog (for Tab trapping).
*/
function listFocusables() {
return [...cardEl.querySelectorAll(FOCUSABLE_SELECTOR)];
}

/**
* Keeps keyboard focus inside the dialog while it is open.
*
* @param {KeyboardEvent} e
*/
function trapTabKey(e) {
if (e.key !== "Tab" || cardEl.hasAttribute("hidden")) return;
const items = listFocusables();
if (items.length === 0) return;

const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
const activeInside = cardEl.contains(active);

if (e.shiftKey) {
if (!activeInside || active === first) {
e.preventDefault();
last.focus();
}
} else if (!activeInside || active === last) {
e.preventDefault();
first.focus();
}
}

/**
* Blocks touchmove on the document while the overlay is open.
* Passes through events that originate inside a scrollable child of the panel
* so any overflow content within the card remains scrollable.
*
* @param {TouchEvent} e
*/
function preventTouchScroll(e) {
if (!e.cancelable) return;
let el = e.target;
while (el && el !== cardEl) {
if (el.scrollHeight > el.clientHeight) return;
el = el.parentElement;
}
e.preventDefault();
}

/**
* Blocks mouse-wheel scroll on the document while the overlay is open.
* Used instead of overflow:hidden on <html>, which causes iOS Safari to
* anchor position:fixed elements at the document origin (y=0) rather than
* the visual viewport when the page is already scrolled.
*
* @param {WheelEvent} e
*/
function preventWheelScroll(e) {
if (!e.cancelable) return;
e.preventDefault();
}

/**
* Populates the overlay from `dataset` / image on a bubble and shows it.
* Locks scroll and moves focus to the close control.
*
* @param {HTMLElement} el - `.expert-hub-bubble` list item
*/
function open(el) {
const bubbleImg = el.querySelector(SELECTORS.bubbleImage);
imageEl.src = bubbleImg?.src ?? "";
imageEl.alt = el.dataset.name ?? "";
blurbEl.textContent = el.dataset.blurb ?? "";
nameEl.textContent = el.dataset.name ?? "";
const profileUrl = el.dataset.url?.trim();
if (profileUrl) linkEl.href = profileUrl;
else linkEl.removeAttribute("href");

const color = getComputedStyle(el)
.getPropertyValue("--bubble-color")
.trim();
inner.style.setProperty("--bubble-color", color || "");

previouslyFocused =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;

cardEl.removeAttribute("hidden");

// Scroll lock:
// - touchmove preventDefault is the only mechanism that reliably stops iOS Safari.
// - wheel preventDefault stops desktop mouse-wheel scroll.
// We intentionally avoid overflow:hidden on <html>: iOS Safari incorrectly
// anchors position:fixed elements to the document origin (y=0) when that is
// set, so the overlay would be invisible when the page is scrolled down.
savedScrollY = window.scrollY;
document.addEventListener("touchmove", preventTouchScroll, {
passive: false,
});
document.addEventListener("wheel", preventWheelScroll, { passive: false });

// Defer focus to the next frame so the overlay is fully painted before
// iOS calculates the focused element's position (avoids scroll-to-top).
requestAnimationFrame(() => {
if (!cardEl.hasAttribute("hidden"))
closeBtn?.focus({ preventScroll: true });
});
}

/**
* Hides the overlay, restores scroll and prior focus if still in the document.
*/
function close() {
cardEl.setAttribute("hidden", "");
inner.style.removeProperty("--bubble-color");

document.removeEventListener("touchmove", preventTouchScroll);
document.removeEventListener("wheel", preventWheelScroll);
window.scrollTo(0, savedScrollY);

const restore = previouslyFocused;
previouslyFocused = null;
if (restore?.isConnected) {
restore.focus({ preventScroll: true });
}
}

/**
* Registers close, backdrop, Tab trap, and Escape handlers; aborted when `signal` aborts.
*
* @param {AbortSignal} signal - Typically from `AbortController` owned by the viz teardown.
*/
function bindListeners(signal) {
closeBtn?.addEventListener("click", close, { signal });
cardEl.addEventListener(
"click",
(e) => {
if (e.target === cardEl) close();
},
{ signal },
);
cardEl.addEventListener("keydown", trapTabKey, { signal });
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape" && !cardEl.hasAttribute("hidden")) close();
},
{ signal },
);
}

return { open, close, bindListeners };
}
Loading
Loading