{{ item.expert.title }}
- {% if item.expert.role %} -{{ item.expert.role }}
- {% endif %} - {% if item.expert.affiliation %} -{{ item.expert.affiliation }}
- {% endif %} - {% if item.topic %} - {{ item.topic.name }} - {% endif %} -diff --git a/foundation_cms/navigation/factories.py b/foundation_cms/navigation/factories.py index 1cb540a8f2a..06ef118500c 100644 --- a/foundation_cms/navigation/factories.py +++ b/foundation_cms/navigation/factories.py @@ -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", diff --git a/foundation_cms/profiles/factories.py b/foundation_cms/profiles/factories.py index 9a7228546a4..83d898d182e 100644 --- a/foundation_cms/profiles/factories.py +++ b/foundation_cms/profiles/factories.py @@ -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("."), ) @@ -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.") @@ -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) diff --git a/foundation_cms/profiles/migrations/0010_alter_experthubpage_description.py b/foundation_cms/profiles/migrations/0010_alter_experthubpage_description.py new file mode 100644 index 00000000000..e6f2bb2b14b --- /dev/null +++ b/foundation_cms/profiles/migrations/0010_alter_experthubpage_description.py @@ -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, + ), + ), + ] diff --git a/foundation_cms/profiles/migrations/0011_expertprofilepage_blurb.py b/foundation_cms/profiles/migrations/0011_expertprofilepage_blurb.py new file mode 100644 index 00000000000..b788f67cb6e --- /dev/null +++ b/foundation_cms/profiles/migrations/0011_expertprofilepage_blurb.py @@ -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, + ), + ), + ] diff --git a/foundation_cms/profiles/models/expert_hub_page.py b/foundation_cms/profiles/models/expert_hub_page.py index dcccb4433bd..1f788ea238b 100644 --- a/foundation_cms/profiles/models/expert_hub_page.py +++ b/foundation_cms/profiles/models/expert_hub_page.py @@ -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, @@ -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", @@ -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"], ) @@ -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.", @@ -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) @@ -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() diff --git a/foundation_cms/profiles/models/expert_profile_page.py b/foundation_cms/profiles/models/expert_profile_page.py index 5063bb9396e..ec48ce3d3f6 100644 --- a/foundation_cms/profiles/models/expert_profile_page.py +++ b/foundation_cms/profiles/models/expert_profile_page.py @@ -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"), ] diff --git a/foundation_cms/static/images/expert-hub/bubble-mask.svg b/foundation_cms/static/images/expert-hub/bubble-mask.svg new file mode 100644 index 00000000000..aaf204733b8 --- /dev/null +++ b/foundation_cms/static/images/expert-hub/bubble-mask.svg @@ -0,0 +1,3 @@ + diff --git a/foundation_cms/static/images/expert-hub/loop-arrow-left.svg b/foundation_cms/static/images/expert-hub/loop-arrow-left.svg new file mode 100644 index 00000000000..a216a11f100 --- /dev/null +++ b/foundation_cms/static/images/expert-hub/loop-arrow-left.svg @@ -0,0 +1,4 @@ + diff --git a/foundation_cms/static/images/expert-hub/loop-arrow-right.svg b/foundation_cms/static/images/expert-hub/loop-arrow-right.svg new file mode 100644 index 00000000000..765f2bba1ec --- /dev/null +++ b/foundation_cms/static/images/expert-hub/loop-arrow-right.svg @@ -0,0 +1,4 @@ + diff --git a/foundation_cms/static/images/expert-hub/orange-sunburst.svg b/foundation_cms/static/images/expert-hub/orange-sunburst.svg new file mode 100644 index 00000000000..185778cab9d --- /dev/null +++ b/foundation_cms/static/images/expert-hub/orange-sunburst.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/foundation_cms/static/images/expert-hub/yellow-tilted-square.svg b/foundation_cms/static/images/expert-hub/yellow-tilted-square.svg new file mode 100644 index 00000000000..5275e582a91 --- /dev/null +++ b/foundation_cms/static/images/expert-hub/yellow-tilted-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/foundation_cms/static/js/components/expert_hub_page/lightbox.js b/foundation_cms/static/js/components/expert_hub_page/lightbox.js new file mode 100644 index 00000000000..7a244aa7839 --- /dev/null +++ b/foundation_cms/static/js/components/expert_hub_page/lightbox.js @@ -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
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 , 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 : 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 }; +} diff --git a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js new file mode 100644 index 00000000000..334492d47a8 --- /dev/null +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -0,0 +1,152 @@ +// ─── Breakpoints (match SCSS customized-settings.scss) ─────────────────────── +export const BREAKPOINTS = { sm: 375, md: 640, lg: 1024, xl: 1200 }; + +// ─── Per-breakpoint configs ─────────────────────────────────────────────────── +// Mobile configs (xs–lg): computeHeight=true, JS sets viz height from simulation output. +// Desktop configs (xl–xxl): computeHeight=false, CSS sets viz height. +// All entries use the same d3-force simulation; only starting positions differ. +// tierRadiusPercent: fixed bubble radius per tier as a percentage of vizW (e.g. 7 = 7%). +export const CONFIGS = { + // ≥ 1200px + xl: { + computeHeight: false, + tierRadiusPercent: { 1: 10.3, 2: 6.3, 3: 4.5 }, + tiers: [ + { + tier: 1, + positions: [[58, 46]], + }, + { + tier: 2, + positions: [ + [39, 46], + [63, 15], + [78, 31], + [92, 57], + [77, 65], + [25, 51], + [36, 78], + ], + }, + { + tier: 3, + positions: [ + [90, 21], + [64, 78], + [50, 76], + [11, 53], + [19, 75], + ], + }, + ], + }, + // 1024–1199px + lg: { + computeHeight: false, + tierRadiusPercent: { 1: 10, 2: 7, 3: 5 }, + tiers: [ + { + tier: 1, + positions: [[58, 50]], + }, + { + tier: 2, + positions: [ + [38, 51], + [60, 18], + [78, 31], + [92, 56], + [76, 71], + [23, 59], + ], + }, + { + tier: 3, + positions: [ + [24, 84], + [93, 20], + [60, 80], + [40, 78], + [7, 59], + [8, 83], + ], + }, + ], + }, + // 640–1023px + md: { + computeHeight: true, + containerAspect: 2.5, + tierRadiusPercent: { 1: 18, 2: 13, 3: 11, 4: 9 }, + tiers: [ + { + tier: 1, + positions: [[52, 28]], + }, + { + tier: 2, + positions: [ + [49, 9], + [81, 11], + ], + }, + { + tier: 3, + positions: [ + [15, 13], + [20, 29], + [17, 44], + [84, 26], + ], + }, + { + tier: 4, + positions: [ + [83, 45], + [62, 46], + [41, 44], + [77, 58], + [19, 57], + [42, 58], + ], + }, + ], + }, + // < 640px + sm: { + computeHeight: true, + containerAspect: 3, + tierRadiusPercent: { 1: 23, 2: 18, 3: 15, 4: 12 }, + tiers: [ + { + tier: 1, + positions: [[69, 34]], + }, + { + tier: 2, + positions: [[42, 9]], + }, + { + tier: 3, + positions: [ + [79, 13], + [22, 26], + [28, 45], + [23, 63], + [68, 57], + ], + }, + { + tier: 4, + positions: [ + [75, 73], + [16, 79], + [50, 82], + [78, 90], + [21, 95], + [53, 98], + ], + }, + ], + }, +}; diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js new file mode 100644 index 00000000000..4b9263dee15 --- /dev/null +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -0,0 +1,487 @@ +import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; +import { select } from "d3-selection"; +import { setupLightbox } from "./lightbox"; +import { BREAKPOINTS, CONFIGS } from "./viz-configs"; + +// Golden angle for overflow phyllotaxis layout +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +const SELECTORS = { + viz: "#expert-hub-viz", + hero: ".expert-hub-hero", + bubbleList: "#expert-hub-bubble-list", + bubble: "#expert-hub-bubble-list .expert-hub-bubble", + copy: ".expert-hub-hero__copy", + tooltipBlurb: ".expert-hub-tooltip__blurb", + tooltipName: ".expert-hub-tooltip__name", + card: "#expert-hub-card", +}; + +const CLASS_NAMES = { + ready: "expert-hub-viz--ready", + linesSvg: "expert-hub-viz__lines-svg", + tooltip: "expert-hub-tooltip", + overlayActive: "expert-hub-bubble--overlay-active", + tier: (n) => `expert-hub-bubble--tier-${n}`, +}; + +const COLLIDE_PADDING = 6; +const COLLIDE_STRENGTH = 0.9; +const COLLIDE_ITERATIONS = 3; +const ANCHOR_STRENGTH = 0.3; +const SIM_TICKS = 200; + +const TOOLTIP_GAP = -5; +const TOOLTIP_EDGE_MARGIN = 8; + +const COMPUTED_HEIGHT_PADDING = 40; +// Fraction of the available zone's smaller dimension used as the spiral radius for overflow +// bubbles. 0.5 would place the outermost centre at the zone edge (clipping since bubbles +// have physical size); 0.38 was tuned visually to keep them clear of the edge. +const OVERFLOW_SPREAD = 0.38; +const LINES_STROKE_COLOR = "#f06c13"; // orange 300 + +const IS_TOUCH = !window.matchMedia("(hover: hover) and (pointer: fine)") + .matches; + +/** + * Returns the active breakpoint key for the current viewport. + * + * @returns {"xl"|"lg"|"md"|"sm"} + */ +function getBreakpoint() { + const w = window.innerWidth; + if (w >= BREAKPOINTS.xl) return "xl"; + if (w >= BREAKPOINTS.lg) return "lg"; + if (w >= BREAKPOINTS.md) return "md"; + return "sm"; +} + +/** + * Returns the tier for node at index i. + * Configured nodes use tierByIndex; overflow nodes are tier 2 or 3 only + * (40% tier 2, 60% tier 3). + * + * @param {number} i - Node index (0-based) + * @param {number} n - Total node count + * @param {Array} tierByIndex - Flattened position list for the active config + */ +function getTier(i, n, tierByIndex) { + if (i < tierByIndex.length) return tierByIndex[i].tier; + const overflowIdx = i - tierByIndex.length; + const overflowCount = n - tierByIndex.length; + return overflowIdx < Math.round(overflowCount * 0.4) ? 2 : 3; +} + +/** + * Initialises the bubble viz for the given breakpoint config. + * Computes bubble sizes from available area, then runs a static force simulation + * to resolve collisions. + * + * @param {HTMLElement} viz - The `#expert-hub-viz` container element + * @param {HTMLElement|null} hero - The `.expert-hub-hero` wrapper; used to + * measure the copy block on desktop to determine the bubble placement zone + * @param {object} config - CONFIGS entry for the active breakpoint + * @returns {() => void} Teardown function — removes the SVG and resets all + * bubble styles so the viz can be re-initialised cleanly. + */ +function init(viz, hero, config) { + const { computeHeight, containerAspect, tierRadiusPercent, tiers } = config; + const tierByIndex = tiers.flatMap(({ tier, positions }) => + positions.map((pos) => ({ tier, pos })), + ); + + const bubbleList = viz.querySelector(SELECTORS.bubbleList); + const els = Array.from(viz.querySelectorAll(SELECTORS.bubble)); + const n = els.length; + + if (n === 0) { + return () => {}; + } + + if (computeHeight && !bubbleList) { + console.warn( + "expert-hub viz: missing bubble list for computeHeight layout", + ); + return () => {}; + } + + const vizRect = viz.getBoundingClientRect(); + const vizW = vizRect.width; + + // Mobile: set provisional height on the bubble list so positions can be + // calculated before the sim resolves. Synchronous — browser never paints this. + if (computeHeight) { + bubbleList.style.height = `${vizW * containerAspect}px`; + } + const vizH = computeHeight ? vizW * containerAspect : vizRect.height; + + // On mobile the copy stacks above the viz in normal flow — no overlap to + // subtract. On desktop the copy is absolutely positioned over the left of the + // hero; measure it to find the right-hand zone where bubbles are placed. + const copyEl = computeHeight + ? null + : (hero?.querySelector(SELECTORS.copy) ?? null); + const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; + const zoneLeft = copyRect ? copyRect.right - vizRect.left : vizW * 0.4; + + const tierRadius = Object.fromEntries( + Object.entries(tierRadiusPercent).map(([t, pct]) => [ + t, + (pct / 100) * vizW, + ]), + ); + + // Lines SVG is desktop-only; mobile has no tooltip or lines interaction. + const svg = computeHeight + ? null + : select(viz) + .append("svg") + .attr("class", CLASS_NAMES.linesSvg) + .attr("width", vizW) + .attr("height", vizH); + + const linesGroup = svg ? svg.append("g") : null; + + const tooltip = viz.querySelector(`.${CLASS_NAMES.tooltip}`); + const tooltipBlurb = tooltip?.querySelector(SELECTORS.tooltipBlurb); + const tooltipName = tooltip?.querySelector(SELECTORS.tooltipName); + const tooltipTailRaw = tooltip + ? parseFloat( + getComputedStyle(tooltip).getPropertyValue("--tooltip-tail-width"), + ) + : NaN; + const tooltipTailHalfWidth = Number.isFinite(tooltipTailRaw) + ? tooltipTailRaw / 2 + : 6; + const lightbox = setupLightbox(document.querySelector(SELECTORS.card)); + + /** + * Returns the absolute [x, y] starting position for node at index i. + * Configured nodes use the per-breakpoint percentage table. + * Overflow nodes use a golden-angle phyllotaxis spiral centred in the + * available zone to the right of the copy block. + * + * @param {number} i - Node index (0-based) + * @returns {[number, number]} [x, y] in px relative to the viz container + */ + function getInitialPosition(i) { + if (i < tierByIndex.length) { + const [xPct, yPct] = tierByIndex[i].pos; + return [(xPct / 100) * vizW, (yPct / 100) * vizH]; + } + const zoneW = vizW - zoneLeft; + const cx = zoneLeft + zoneW / 2; + const cy = vizH / 2; + const maxR = OVERFLOW_SPREAD * Math.min(zoneW, vizH); + const overflowIdx = i - tierByIndex.length; + const overflowCount = n - tierByIndex.length; + const r = Math.sqrt((overflowIdx + 1) / overflowCount) * maxR; + const θ = overflowIdx * GOLDEN_ANGLE; + return [cx + r * Math.cos(θ), cy + r * Math.sin(θ)]; + } + + const nodes = els.map((el, i) => { + const tier = getTier(i, n, tierByIndex); + const size = Math.round(tierRadius[tier] * 2); + const [baseX, baseY] = getInitialPosition(i); + + const topic = (el.dataset.topic ?? "").trim(); + + el.classList.add(CLASS_NAMES.tier(tier)); + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.left = `${baseX}px`; + el.style.top = `${baseY}px`; + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + const label = el.dataset.name?.trim(); + if (label) el.setAttribute("aria-label", label); + + return { + el, + tier, + size, + topic, + blurb: el.dataset.blurb || "", + name: el.dataset.name || "", + cx: baseX, + cy: baseY, + }; + }); + + // Resolve collisions with a static force sim + const simNodes = nodes.map((node) => ({ + x: node.cx, + y: node.cy, + r: node.size / 2, + })); + + forceSimulation(simNodes) + .force( + "collide", + forceCollide((d) => d.r + COLLIDE_PADDING) + .strength(COLLIDE_STRENGTH) + .iterations(COLLIDE_ITERATIONS), + ) + .force("x", forceX((_, i) => nodes[i].cx).strength(ANCHOR_STRENGTH)) + .force("y", forceY((_, i) => nodes[i].cy).strength(ANCHOR_STRENGTH)) + .stop() + .tick(SIM_TICKS); + + simNodes.forEach((sn, i) => { + nodes[i].cx = sn.x; + nodes[i].cy = sn.y; + nodes[i].el.style.left = `${sn.x}px`; + nodes[i].el.style.top = `${sn.y}px`; + nodes[i].el.style.animationDelay = `${i * 80}ms`; + }); + + // Mobile: snap bubble list height to actual bubble extents + if (computeHeight && simNodes.length > 0) { + const maxBottom = Math.max(...simNodes.map((sn) => sn.y + sn.r)); + bubbleList.style.height = `${maxBottom + COMPUTED_HEIGHT_PADDING}px`; + } + + // ─── Tooltip ─────────────────────────────────────────────────────────────── + + function positionTooltip(node) { + if (!tooltip) return; + const r = node.size / 2; + const tipW = tooltip.offsetWidth; + const tipH = tooltip.offsetHeight; + + let x = node.cx - tipW / 2; + let y = node.cy - r - tipH - TOOLTIP_GAP; + let tail = "bottom"; + + if (y < TOOLTIP_EDGE_MARGIN) { + y = node.cy + r + TOOLTIP_GAP; + tail = "top"; + } + + x = Math.max( + TOOLTIP_EDGE_MARGIN, + Math.min(x, vizW - tipW - TOOLTIP_EDGE_MARGIN), + ); + + // Align tail with bubble centre, clamped so it stays within the tooltip + const tailRight = tipW - (node.cx - x) - tooltipTailHalfWidth; + const tailRightClamped = Math.max( + tooltipTailHalfWidth, + Math.min(tailRight, tipW - tooltipTailHalfWidth * 3), + ); + + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + tooltip.style.setProperty("--tooltip-tail-right", `${tailRightClamped}px`); + tooltip.dataset.tail = tail; + } + + function showTooltip(node, color) { + if (!tooltip || !tooltipBlurb || !tooltipName) return; + tooltip.style.setProperty("--tooltip-color", color); + tooltipBlurb.textContent = node.blurb; + tooltipName.textContent = node.name; + tooltip.removeAttribute("hidden"); + positionTooltip(node); + } + + function hideTooltip() { + tooltip?.setAttribute("hidden", ""); + } + + // ─── Lines ───────────────────────────────────────────────────────────────── + + function clearLines() { + updateLines(null); + } + + function updateLines(hoveredIndex) { + if (!linesGroup) return; + if (hoveredIndex === null) { + linesGroup.selectAll("line").data([]).join("line"); + return; + } + const hovered = nodes[hoveredIndex]; + const targets = nodes.filter((node, i) => { + if (i === hoveredIndex) return false; + return ( + Boolean(hovered.topic) && + Boolean(node.topic) && + hovered.topic === node.topic + ); + }); + + linesGroup + .selectAll("line") + .data(targets, (d) => d.el) // key by DOM element — never misaligns + .join("line") + .attr("stroke", LINES_STROKE_COLOR) + .attr("stroke-width", 1) + .attr("x1", hovered.cx) + .attr("y1", hovered.cy) + .attr("x2", (d) => d.cx) + .attr("y2", (d) => d.cy); + } + + // ─── Overlays ────────────────────────────────────────────────────────────── + + function applyOverlays(sourceIndex, color) { + nodes.forEach((node, i) => { + if (i === sourceIndex) return; + node.el.style.setProperty("--overlay-color", color); + node.el.classList.add(CLASS_NAMES.overlayActive); + }); + } + + function clearOverlays() { + nodes.forEach((node) => { + node.el.classList.remove(CLASS_NAMES.overlayActive); + node.el.style.removeProperty("--overlay-color"); + }); + } + + // ─── Events ──────────────────────────────────────────────────────────────── + + const ac = new AbortController(); + const { signal } = ac; + + nodes.forEach((node, i) => { + node.el.addEventListener( + "click", + () => { + if (IS_TOUCH) { + lightbox?.open(node.el); + } else { + window.location.href = node.el.dataset.url; + } + }, + { signal }, + ); + + node.el.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + node.el.click(); + } + }, + { signal }, + ); + }); + + if (!computeHeight) { + let hideTimer = null; + + nodes.forEach((node, i) => { + node.el.addEventListener( + "mouseenter", + () => { + clearTimeout(hideTimer); + const style = getComputedStyle(node.el); + const color = style.getPropertyValue("--bubble-color").trim(); + const tooltipColor = style + .getPropertyValue("--bubble-color-light") + .trim(); + updateLines(i); + applyOverlays(i, color); + if (node.blurb) showTooltip(node, tooltipColor); + }, + { signal }, + ); + + node.el.addEventListener( + "mouseleave", + () => { + hideTimer = setTimeout(() => { + clearLines(); + clearOverlays(); + hideTooltip(); + }, 80); + }, + { signal }, + ); + }); + } + + if (IS_TOUCH && lightbox) { + lightbox.bindListeners(signal); + } + + viz.classList.add(CLASS_NAMES.ready); + + // ─── Teardown ────────────────────────────────────────────────────────────── + + return () => { + ac.abort(); + svg?.remove(); + if (tooltip) { + tooltip.setAttribute("hidden", ""); + tooltip.style.removeProperty("--tooltip-color"); + tooltip.style.removeProperty("--tooltip-tail-right"); + delete tooltip.dataset.tail; + } + lightbox?.close(); + viz.classList.remove(CLASS_NAMES.ready); + if (computeHeight) { + bubbleList.style.removeProperty("height"); + } + nodes.forEach(({ el, tier }) => { + el.removeAttribute("style"); + el.removeAttribute("role"); + el.removeAttribute("tabindex"); + el.removeAttribute("aria-label"); + el.classList.remove(CLASS_NAMES.tier(tier)); + }); + }; +} + +/** + * Entry point. Queries the viz container, then starts and manages the viz + * lifecycle via ResizeObserver — tearing down and re-initialising on resize + * so layout measurements stay accurate. + * + * The active breakpoint config is resolved on each run, so resizing across + * a breakpoint boundary automatically picks up the right starting positions. + */ +export function setupViz() { + const viz = document.querySelector(SELECTORS.viz); + if (!viz) return; + const hero = viz.closest(SELECTORS.hero); + + let cleanup = null; + let resizeTimer = null; + + function run() { + if (cleanup) { + cleanup(); + cleanup = null; + } + const bp = getBreakpoint(); + cleanup = init(viz, hero, CONFIGS[bp]); + } + + let initialFire = true; + const ro = new ResizeObserver(() => { + if (initialFire) { + initialFire = false; + return; + } + clearTimeout(resizeTimer); + resizeTimer = setTimeout(run, 150); + }); + + ro.observe(viz); + run(); + + const root = document.documentElement; + const updateParallax = () => { + const y = window.scrollY; + root.style.setProperty("--parallax-y-left", `${y * 0.3}px`); + root.style.setProperty("--parallax-y-right", `${y * 0.5}px`); + }; + window.addEventListener("scroll", updateParallax, { passive: true }); + updateParallax(); +} diff --git a/foundation_cms/static/js/pages/expert_hub_page.js b/foundation_cms/static/js/pages/expert_hub_page.js new file mode 100644 index 00000000000..aeea2d92ba7 --- /dev/null +++ b/foundation_cms/static/js/pages/expert_hub_page.js @@ -0,0 +1,3 @@ +import { setupViz } from "../components/expert_hub_page/viz"; + +setupViz(); diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss new file mode 100644 index 00000000000..6b9882b6e8e --- /dev/null +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -0,0 +1,521 @@ +@use "sass:map"; +@use "sass:list"; +@import "../redesign_base"; + +$bubble-palette: (orange, blue, yellow); + +@mixin expert-hub-bubble-mask { + mask-image: url("/static/foundation_cms/_images/expert-hub/bubble-mask.svg"); + mask-size: 100% 100%; + mask-repeat: no-repeat; +} + +body.template-expert-hub-page { + .main-content-wrapper { + overflow-x: clip; + position: relative; + + // $tops: (base, medium, large, xlarge) — offsets are mirrored for left/right + @mixin parallax-arrow($image, $side, $css-var, $tops) { + background-image: url($image); + width: rem-calc(160); + height: rem-calc(160); + #{$side}: rem-calc(-46); + top: list.nth($tops, 1); + transform: translateY(var(--#{$css-var}, 0)); + + @include breakpoint(medium up) { + width: 30vw; + height: 30vw; + #{$side}: rem-calc(-46); + top: list.nth($tops, 2); + } + + @include breakpoint(large up) { + width: 18vw; + height: 18vw; + #{$side}: -5vw; + top: list.nth($tops, 3); + } + + @include breakpoint(xlarge up) { + width: 18vw; + height: 18vw; + max-width: rem-calc(290); + max-height: rem-calc(290); + #{$side}: rem-calc(-75); + top: list.nth($tops, 4); + } + } + + &::before, + &::after { + content: ""; + position: absolute; + background-size: contain; + background-repeat: no-repeat; + pointer-events: none; + z-index: 0; + will-change: transform; + } + + &::before { + @include parallax-arrow( + "/static/foundation_cms/_images/expert-hub/loop-arrow-left.svg", + left, + parallax-y-left, + (43vh, 23vh, 48vh, rem-calc(500)) + ); + } + + &::after { + @include parallax-arrow( + "/static/foundation_cms/_images/expert-hub/loop-arrow-right.svg", + right, + parallax-y-right, + (83vh, 58vh, 26vh, rem-calc(200)) + ); + } + } + + .expert-hub-hero { + margin-top: 3rem; + position: relative; + z-index: 1; + + &__copy { + margin-bottom: 2rem; + + @include breakpoint(large up) { + max-width: 50%; + } + } + + &__title { + @include mofo-text-style($header-styles, "h1", $header-font-family); + + margin-top: 0; + margin-bottom: 1rem; + } + + &__body { + @include mofo-text-style($lede-text-styles, "regular", $body-font-family); + + margin-bottom: 2rem; + } + } + + /* ── Base styles (all viewports) ─────────────────────────────────────────── */ + + @keyframes expert-hub-bubble-pop-in { + from { + scale: 0; + opacity: 0; + } + + to { + scale: 1; + opacity: 1; + } + } + + .expert-hub-viz { + position: relative; + + &__loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + + &__bubble-list { + list-style: none; + padding: 0; + display: block; + overflow-y: hidden; + + // Creates a containing block for absolutely positioned bubbles. + // Height is set by JS after the force sim resolves. + position: relative; + } + + &--ready { + .expert-hub-viz__loading { + display: none; + } + + .expert-hub-bubble { + visibility: visible; + animation: expert-hub-bubble-pop-in 0.5s + cubic-bezier(0.34, 1.56, 0.64, 1) backwards; + } + } + } + + .expert-hub-bubble { + $palette-size: list.length($bubble-palette); + + @for $i from 1 through $palette-size { + $name: list.nth($bubble-palette, $i); + + &:nth-child(#{$palette-size}n + #{$i}) { + --bubble-color: #{color($name, "300")}; + --bubble-color-light: #{color($name, "100")}; + } + } + + position: absolute; + transform: translate(-50%, -50%); + transform-origin: 0 0; + + &::after { + @include expert-hub-bubble-mask; + + content: ""; + position: absolute; + inset: 0; + background: var(--bubble-color); + pointer-events: none; + z-index: -1; + } + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + scale: 1; + visibility: hidden; + + &__topic-pill { + @include topic-pill-button-shape; + + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: rem-calc(4); + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + background: color(orange, "100"); + + @include breakpoint(medium down) { + @include mofo-text-style($body-text-styles, "xsmall"); + } + } + + &__image { + @include expert-hub-bubble-mask; + + width: 100%; + height: 100%; + object-fit: cover; + } + + &__name { + @include mofo-text-style($body-text-styles, "xsmall"); + + display: block; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: rem-calc(4); + text-align: center; + max-width: 100%; + pointer-events: none; + } + } + + /* ── Tap card overlay (all viewports) ────────────────────────────────────── */ + + @keyframes expert-hub-card-fade-in { + from { + opacity: 0; + } + } + + .expert-hub-card { + $image-size: rem-calc(54); + $inner-padding: rem-calc(24); + $panel-gap: rem-calc(8); + + position: fixed; + inset: 0; + height: 100vh; // fallback for iOS < 15.4 + height: 100dvh; // fixes Safari dynamic toolbar clipping + display: flex; + align-items: flex-end; + padding: 0 $panel-gap rem-calc(46); + background: rgb(color(neutral, "100"), 0.4); + backdrop-filter: blur(4px); + z-index: 100; + + &[hidden] { + display: none; + } + + &:not([hidden]) { + animation: expert-hub-card-fade-in 0.2s ease-out both; + } + + &__panel { + width: 100%; + } + + .btn-close { + position: absolute; + top: 0; + right: 0; + transform: translateY(calc(-100% - #{$panel-gap})); + } + + &__inner { + margin: 0 auto; + width: 100%; + max-width: rem-calc(480); + background: var(--bubble-color); + border-radius: rem-calc(8); + padding: $inner-padding; + position: relative; + overflow: visible; + display: flex; + flex-direction: column; + } + + &__image { + @include expert-hub-bubble-mask; + + width: $image-size; + height: $image-size; + object-fit: cover; + display: block; + margin-top: -(($image-size / 2) + $inner-padding); + margin-bottom: rem-calc(16); + } + + &__blurb { + margin: 0 0 rem-calc(8); + } + + &__name { + @include mofo-text-style($header-styles, "h6", $header-font-family); + + display: block; + margin-bottom: rem-calc(36); + } + + &__link { + @include cta-link(map.get($cta-link-icon-sizes, regular), "regular"); + + align-self: flex-end; + } + } + + /* ── Desktop: 1024px and up ──────────────────────────────────────────────── */ + + @include breakpoint(large up) { + .expert-hub-viz { + width: 100%; + height: calc(100dvh - #{$primary-nav-height}); + min-height: rem-calc(800); + max-height: rem-calc(900); + + &__bubble-list { + position: absolute; + inset: 0; + overflow-y: hidden; + } + + &__lines-svg { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; + } + } + + .expert-hub-hero { + margin-top: 3rem; + + &__copy { + position: absolute; + top: 0; + left: 0; + z-index: 5; + } + } + + .expert-hub-tooltip { + --tooltip-tail-width: 12px; + --tooltip-tail-height: calc(var(--tooltip-tail-width) * 2); + + position: absolute; + z-index: 20; + background: var(--tooltip-color, white); + border-radius: rem-calc(4); + padding: rem-calc(18) rem-calc(24); + max-width: rem-calc(354); + pointer-events: none; + + &__blurb { + margin: 0 0 rem-calc(8); + font-size: rem-calc(14); + line-height: 1.5; + } + + &__name { + @include mofo-text-style($header-styles, "h6", $header-font-family); + + display: block; + } + + &::after { + content: ""; + position: absolute; + right: var(--tooltip-tail-right, var(--tooltip-tail-width)); + border: var(--tooltip-tail-width) solid transparent; + } + + &[data-tail="bottom"]::after { + bottom: calc(-1 * var(--tooltip-tail-height)); + border-top: var(--tooltip-tail-height) solid var(--tooltip-color, white); + border-bottom: none; + } + + &[data-tail="top"]::after { + top: calc(-1 * var(--tooltip-tail-height)); + border-bottom: var(--tooltip-tail-height) solid + var(--tooltip-color, white); + border-top: none; + } + } + + .expert-hub-bubble { + $pill-gap: rem-calc(8); + + margin: 0; + transition: scale 0.2s ease; + z-index: 1; + + // Transparent bridge covering the pill's margin gap so the bubble stays + // :hover as the cursor crosses from pill to bubble, preventing scale flicker. + &::before { + content: ""; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + height: $pill-gap; + pointer-events: auto; + } + + &:hover { + scale: $image-hover-scale; + z-index: 10; + } + + &__topic-pill { + margin-bottom: $pill-gap; + } + + &__image { + pointer-events: none; + } + + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--overlay-color); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; + z-index: auto; + } + + &--overlay-active::after { + opacity: 0.5; + } + } + } + + .expert-hub-cta { + position: relative; + margin-top: rem-calc(104); + margin-bottom: rem-calc(65); + + &::before { + content: ""; + position: absolute; + bottom: rem-calc(-55); + left: rem-calc(-36); + width: rem-calc(180); + height: rem-calc(180); + background-image: url("/static/foundation_cms/_images/expert-hub/yellow-tilted-square.svg"); + background-size: contain; + background-repeat: no-repeat; + background-position: bottom left; + } + + &::after { + content: ""; + position: absolute; + top: rem-calc(-120); + right: rem-calc(-52); + width: rem-calc(270); + height: rem-calc(270); + background-image: url("/static/foundation_cms/_images/expert-hub/orange-sunburst.svg"); + background-size: contain; + background-repeat: no-repeat; + background-position: top right; + } + + @include breakpoint(large up) { + margin-top: rem-calc(72); + margin-bottom: rem-calc(105); + + &::before { + left: rem-calc(-60); + } + } + + &__inner { + position: relative; + z-index: 1; + background: color(neutral, "100"); + border-radius: 0 rem-calc(48); + padding: rem-calc(40); + display: flex; + flex-direction: column; + gap: rem-calc(24) rem-calc(16); + + @include breakpoint(large up) { + flex-direction: row; + justify-content: space-between; + } + } + + &__title { + @include mofo-text-style($header-styles, "h4", $header-font-family); + + margin-top: 0; + margin-bottom: rem-calc(8); + } + + &__body { + @include mofo-text-style($body-text-styles, "large"); + + margin-bottom: 0; + } + + &__btn { + margin: 0; + align-self: flex-start; + } + } +} diff --git a/foundation_cms/static/scss/pages/expert_profile_page.scss b/foundation_cms/static/scss/pages/expert_profile_page.scss new file mode 100644 index 00000000000..1d2d068cc3e --- /dev/null +++ b/foundation_cms/static/scss/pages/expert_profile_page.scss @@ -0,0 +1,259 @@ +@use "sass:map"; +@use "sass:list"; +@import "../redesign_base"; + +body { + .profile-page { + padding-top: rem-calc(64); + background-color: color(neutral, "100"); + + &__article { + overflow: hidden; + } + } + + /* ========================= + HERO + ========================= */ + + .profile-hero { + text-align: center; + margin-bottom: rem-calc(80); + + &__intro { + max-width: rem-calc(900); + margin: 0 auto; + } + + &__eyebrow { + @include mofo-text-style($body-text-styles, "small"); + + margin-bottom: rem-calc(12); + color: color(neutral, "700"); + } + + &__title { + @include mofo-text-style($header-styles, "h1", $header-font-family); + + margin-top: 0; + margin-bottom: rem-calc(24); + max-width: rem-calc(900); + margin-inline: auto; + } + + &__meta { + display: flex; + justify-content: center; + align-items: center; + gap: rem-calc(16); + flex-wrap: wrap; + margin-bottom: rem-calc(24); + + span { + @include mofo-text-style($body-text-styles, "small"); + + position: relative; + + &:not(:last-child)::after { + content: "|"; + margin-left: rem-calc(16); + color: color(neutral, "500"); + } + } + } + + .topic-pills { + justify-content: center; + } + } + + /* ========================= + CONTENT + ========================= */ + + .profile-content { + align-items: center; + justify-content: center; + margin-bottom: rem-calc(112); + max-width: rem-calc(1200); + margin-inline: auto; + + &__bio { + @include mofo-text-style($body-text-styles, "regular"); + + color: color(neutral, "900"); + display: flex; + align-items: center; + + p { + margin-bottom: rem-calc(24); + } + } + + &__image-wrapper { + position: relative; + width: 100%; + } + + &__image { + width: 100%; + display: block; + object-fit: cover; + } + } + + /* ========================= + PROJECTS + ========================= */ + + .profile-projects { + padding-bottom: rem-calc(96); + + &__title { + @include mofo-text-style($header-styles, "h2", $header-font-family); + + text-align: center; + margin-bottom: rem-calc(56); + } + + &__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: rem-calc(56) rem-calc(32); + + @include breakpoint(large down) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include breakpoint(medium down) { + grid-template-columns: 1fr; + row-gap: rem-calc(40); + } + } + } + + .profile-project-card { + background: transparent; + display: flex; + flex-direction: column; + + &__image-wrapper { + overflow: hidden; + margin-bottom: rem-calc(16); + background: #f7e9ef; + aspect-ratio: 4 / 3; + border-radius: rem-calc(2); + } + + &__image { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + transition: transform 0.35s ease; + } + + &:hover &__image { + transform: scale(1.03); + } + + &__content { + padding: 0; + } + + .topic-pills { + margin-bottom: rem-calc(12); + + .topic-pill { + transform: scale(0.9); + transform-origin: left center; + } + } + + &__title { + margin: 0 0 rem-calc(8); + + a { + @include mofo-text-style($header-styles, "h6", $header-font-family); + + color: color(neutral, "900"); + text-decoration: none; + line-height: 1.3; + + &:hover { + text-decoration: underline; + } + } + } + + &__text { + @include mofo-text-style($body-text-styles, "small"); + + color: color(neutral, "700"); + margin-bottom: rem-calc(16); + line-height: 1.5; + } + + &__cta { + margin-top: auto; + + a { + @include mofo-text-style($body-text-styles, "small"); + + display: inline-flex; + align-items: center; + gap: rem-calc(6); + color: color(neutral, "900"); + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + + &::after { + content: "→"; + transition: transform 0.2s ease; + } + + &:hover::after { + transform: translateX(2px); + } + } + } + } + + /* ========================= + BODY + ========================= */ + + .profile-body { + padding-bottom: rem-calc(80); + } + + /* ========================= + RESPONSIVE + ========================= */ + + @include breakpoint(medium down) { + .profile-page { + padding-top: rem-calc(32); + } + + .profile-hero { + margin-bottom: rem-calc(48); + } + + .profile-content { + row-gap: rem-calc(32); + + &__bio { + order: 2; + } + + &__image-wrapper { + order: 1; + } + } + } +} diff --git a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html index a503f34b115..c5259e81951 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -1,70 +1,86 @@ {% extends theme_base %} -{% load wagtailimages_tags i18n %} +{% load static wagtailcore_tags wagtailimages_tags i18n %} + +{% block extra_css %} + +{% endblock extra_css %} + +{% block extra_js %} + +{% endblock extra_js %} + +{% block body_class %}template-expert-hub-page{% endblock body_class %} {% block content %} -{{ item.expert.role }}
- {% endif %} - {% if item.expert.affiliation %} -{{ item.expert.affiliation }}
- {% endif %} - {% if item.topic %} - {{ item.topic.name }} - {% endif %} -{% trans "Reach out to us and we can work together!" %}
+{{ page.role }}
{% endif %} -{{ page.role }}
- {% endif %} + +{{ page.affiliation }}
+ +{{ page.location.name }}
+ +{{ project.lede_text }}
++ {{ project.lede_text }} +
{% endif %} {% if project.cta_link %} - {% include "patterns/components/streamfield.html" with streamfield=project.cta_link %} +{{ link.description }}
- {% endif %} - {{ link.url }} -