From 96b35cac76a29dd5862fccb3f6015e9a242a7aaa Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 23 Apr 2026 15:04:27 -0700 Subject: [PATCH 01/44] feat(expert-hub): add dedicated SCSS and JS assets for landing page --- foundation_cms/static/js/pages/expert_hub_page.js | 0 .../static/scss/pages/expert_hub_page.scss | 4 ++++ .../patterns/pages/profiles/expert_hub_page.html | 12 +++++++++++- frontend/redesign/build-css.js | 1 + frontend/redesign/esbuild.config.js | 5 +++++ 5 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 foundation_cms/static/js/pages/expert_hub_page.js create mode 100644 foundation_cms/static/scss/pages/expert_hub_page.scss 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..e69de29bb2d 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..ea3c798ac03 --- /dev/null +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -0,0 +1,4 @@ +@import "../redesign_base"; + +body.template-expert-hub-page { +} 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..164048a55ff 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -1,6 +1,16 @@ {% extends theme_base %} -{% load wagtailimages_tags i18n %} +{% load static 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 %}
diff --git a/frontend/redesign/build-css.js b/frontend/redesign/build-css.js index 0ca5e1927d8..9852fef4a1f 100644 --- a/frontend/redesign/build-css.js +++ b/frontend/redesign/build-css.js @@ -12,6 +12,7 @@ const entries = [ "redesign_migrated_content", "pages/campaign_page", "pages/expert_directory_page", + "pages/expert_hub_page", "pages/home_page", "pages/maintenance", "pages/search_page", diff --git a/frontend/redesign/esbuild.config.js b/frontend/redesign/esbuild.config.js index 5e25de3d1f2..68adcaae9a5 100644 --- a/frontend/redesign/esbuild.config.js +++ b/frontend/redesign/esbuild.config.js @@ -42,6 +42,11 @@ const sources = { jsx: false, bundle: true, }, + expert_hub_page: { + source: "pages/expert_hub_page.js", + jsx: false, + bundle: true, + }, redesign_migrated_content: { source: "redesign_migrated_content.js", jsx: false, From 8f99dbe59142470ff5917e127b1455d6c038754e Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 23 Apr 2026 17:27:50 -0700 Subject: [PATCH 02/44] feat(expert-hub): WIP landing page bubble viz, ported from prototype --- .../profiles/models/expert_hub_page.py | 2 - .../static/js/pages/expert_hub_page.js | 311 ++++++++++++++++++ .../static/scss/pages/expert_hub_page.scss | 149 +++++++++ .../pages/profiles/expert_hub_page.html | 94 ++---- frontend/redesign/package.json | 3 + yarn.lock | 29 ++ 6 files changed, 523 insertions(+), 65 deletions(-) diff --git a/foundation_cms/profiles/models/expert_hub_page.py b/foundation_cms/profiles/models/expert_hub_page.py index dcccb4433bd..3e21d63a092 100644 --- a/foundation_cms/profiles/models/expert_hub_page.py +++ b/foundation_cms/profiles/models/expert_hub_page.py @@ -79,8 +79,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/static/js/pages/expert_hub_page.js b/foundation_cms/static/js/pages/expert_hub_page.js index e69de29bb2d..4346cf568e6 100644 --- a/foundation_cms/static/js/pages/expert_hub_page.js +++ b/foundation_cms/static/js/pages/expert_hub_page.js @@ -0,0 +1,311 @@ +import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; +import { select } from "d3-selection"; +import { timer } from "d3-timer"; + +const TIER_CONFIG = [ + { + tier: 1, + positions: [[57, 56]], + }, + { + tier: 2, + positions: [ + [38, 48], + [56, 23], + [73, 33], + [90, 52], + [75, 62], + ], + }, + { + tier: 3, + positions: [ + [22, 58], + [26, 82], + [90, 20], + [64, 85], + [42, 76], + [7, 55], + [11, 81], + ], + }, +]; + +const SELECTORS = { + viz: "#expert-hub-viz", + bubbleList: "#expert-hub-bubble-list", + bubble: "#expert-hub-bubble-list .expert-hub-bubble", + copy: ".expert-hub-hero__copy", + linesSvg: "expert-hub-viz__lines-svg", +}; + +const CLASS_NAMES = { + ready: "expert-hub-viz--ready", + overlayActive: "expert-hub-bubble--overlay-active", + tier: (n) => `expert-hub-bubble--tier-${n}`, +}; + +const TIER_WEIGHT = { 1: 2.5, 2: 1.5, 3: 1 }; + +const TIER_BY_INDEX = TIER_CONFIG.flatMap(({ tier, positions }) => + positions.map((pos) => ({ tier, pos })), +); + +function computeTierBoundaries(n) { + const remaining = n - 1; + const tier2Count = Math.round(remaining * 0.4); + return { tier2Count }; +} + +function getTier(i, n) { + if (i < TIER_BY_INDEX.length) return TIER_BY_INDEX[i].tier; + const { tier2Count } = computeTierBoundaries(n); + if (i === 0) return 1; + if (i <= tier2Count) return 2; + return 3; +} + +function getPosition(i) { + return TIER_BY_INDEX[i]?.pos ?? [50, 50]; +} + +const PACK_DENSITY = 0.6; +const BUBBLE_SIZE_SCALE = 0.7; +const PACK_FACTOR = PACK_DENSITY * BUBBLE_SIZE_SCALE ** 2; + +const FLOAT_RADIUS_MIN = 4; +const FLOAT_RADIUS_MAX = 9; +const FLOAT_SPEED_MIN = 0.00025; +const FLOAT_SPEED_MAX = 0.00045; + +const COLLIDE_PADDING = 6; +const COLLIDE_STRENGTH = 0.9; +const COLLIDE_ITERATIONS = 3; +const ANCHOR_STRENGTH = 0.3; +const SIM_TICKS = 200; + +function randomFloat(min, max) { + return min + Math.random() * (max - min); +} + +function init() { + const viz = document.querySelector(SELECTORS.viz); + if (!viz) return; + + const vizRect = viz.getBoundingClientRect(); + const vizW = vizRect.width; + const vizH = vizRect.height; + + const copyEl = viz.querySelector(SELECTORS.copy); + const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; + const copyArea = copyRect ? copyRect.width * copyRect.height : 0; + const availableArea = vizW * vizH - copyArea; + + const els = Array.from(document.querySelectorAll(SELECTORS.bubble)); + const n = els.length; + + const totalWeightedUnits = els.reduce( + (sum, _, i) => sum + TIER_WEIGHT[getTier(i, n)], + 0, + ); + + const areaPerUnit = (availableArea * PACK_FACTOR) / totalWeightedUnits; + + const tierRadius = { + 1: Math.sqrt((areaPerUnit * TIER_WEIGHT[1]) / Math.PI), + 2: Math.sqrt((areaPerUnit * TIER_WEIGHT[2]) / Math.PI), + 3: Math.sqrt((areaPerUnit * TIER_WEIGHT[3]) / Math.PI), + }; + + const svg = select(viz) + .append("svg") + .attr("class", SELECTORS.linesSvg) + .attr("width", vizW) + .attr("height", vizH) + .style("position", "absolute") + .style("inset", "0") + .style("pointer-events", "none"); + + const linesGroup = svg.append("g"); + + const nodes = els.map((el, i) => { + const tier = getTier(i, n); + const size = Math.round(tierRadius[tier] * 2); + const [xPct, yPct] = getPosition(i); + const baseX = (xPct / 100) * vizW; + const baseY = (yPct / 100) * vizH; + const topics = el.dataset.topics + ? el.dataset.topics.split(",").map((t) => t.trim()) + : []; + + const floatR = randomFloat(FLOAT_RADIUS_MIN, FLOAT_RADIUS_MAX); + const floatSpeed = randomFloat(FLOAT_SPEED_MIN, FLOAT_SPEED_MAX); + const phaseX = randomFloat(0, Math.PI * 2); + const phaseY = randomFloat(0, Math.PI * 2); + + el.classList.add(CLASS_NAMES.tier(tier)); + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.position = "absolute"; + el.style.left = `${baseX}px`; + el.style.top = `${baseY}px`; + el.style.transform = "translate(-50%, -50%)"; + + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + + return { + el, + tier, + size, + baseX, + baseY, + topics, + floatR, + floatSpeed, + phaseX, + phaseY, + cx: baseX, + cy: baseY, + }; + }); + + const simNodes = nodes.map((n) => ({ + x: n.baseX, + y: n.baseY, + r: n.size / 2, + })); + + forceSimulation(simNodes) + .force( + "collide", + forceCollide((d) => d.r + COLLIDE_PADDING) + .strength(COLLIDE_STRENGTH) + .iterations(COLLIDE_ITERATIONS), + ) + .force("x", forceX((_, i) => nodes[i].baseX).strength(ANCHOR_STRENGTH)) + .force("y", forceY((_, i) => nodes[i].baseY).strength(ANCHOR_STRENGTH)) + .stop() + .tick(SIM_TICKS); + + simNodes.forEach((sn, i) => { + nodes[i].baseX = sn.x; + nodes[i].baseY = sn.y; + 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`; + }); + + let hoverIndex = null; + + function drawLines(hoveredIndex) { + linesGroup.selectAll("line").remove(); + + const hovered = nodes[hoveredIndex]; + if (!hovered) return; + + nodes.forEach((node, i) => { + if (i === hoveredIndex) return; + const shared = hovered.topics.filter((t) => node.topics.includes(t)); + if (shared.length === 0) return; + + linesGroup + .append("line") + .attr("x1", hovered.cx) + .attr("y1", hovered.cy) + .attr("x2", node.cx) + .attr("y2", node.cy) + .attr("stroke", "#f06c13") // color(orange, "300") + .attr("stroke-width", 1) + .style("opacity", 1); + }); + } + + function clearLines() { + linesGroup.selectAll("line").remove(); + } + + function applyOverlays(sourceIndex) { + const color = getComputedStyle(nodes[sourceIndex].el) + .getPropertyValue("--bubble-color") + .trim(); + 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"); + }); + } + + nodes.forEach((node, i) => { + node.el.addEventListener("click", () => { + window.location.href = node.el.dataset.url; + }); + + node.el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") node.el.click(); + }); + + node.el.addEventListener("mouseenter", () => { + hoverIndex = i; + drawLines(i); + applyOverlays(i); + }); + node.el.addEventListener("mouseleave", () => { + hoverIndex = null; + clearLines(); + clearOverlays(); + }); + }); + + timer((elapsed) => { + nodes.forEach((node) => { + const dx = + node.floatR * Math.sin(elapsed * node.floatSpeed + node.phaseX); + const dy = + node.floatR * Math.cos(elapsed * node.floatSpeed * 0.65 + node.phaseY); + + node.cx = node.baseX + dx; + node.cy = node.baseY + dy; + + node.el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; + }); + + const displayIndex = hoverIndex; + if (displayIndex !== null) { + const hovered = nodes[displayIndex]; + linesGroup.selectAll("line").each(function (_, lineIdx) { + const connectedLines = linesGroup.selectAll("line").nodes(); + const connectedNodes = nodes.filter((node, i) => { + if (i === displayIndex) return false; + return hovered.topics.some((t) => node.topics.includes(t)); + }); + + const lineEl = connectedLines[lineIdx]; + const targetNode = connectedNodes[lineIdx]; + if (lineEl && targetNode) { + select(lineEl) + .attr("x1", hovered.cx) + .attr("y1", hovered.cy) + .attr("x2", targetNode.cx) + .attr("y2", targetNode.cy); + } + }); + } + }); + + viz.classList.add(CLASS_NAMES.ready); +} + +// Only run the viz on large screens +const mediaQuery = window.matchMedia("(min-width: 64em)"); +if (mediaQuery.matches) { + init(); +} diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index ea3c798ac03..951ef3f523f 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -1,4 +1,153 @@ +@use "sass:map"; +@use "sass:list"; @import "../redesign_base"; +$bubble-palette: ( + 1: color(orange, "300"), + 2: color(blue, "300"), + 3: color(yellow, "300"), +); + body.template-expert-hub-page { + .expert-hub-hero { + &__copy { + max-width: 50%; + margin-bottom: 2rem; + } + + &__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; + } + } + + /* Mobile default: copy and bubbles flow vertically, no JS viz */ + .expert-hub-viz { + &__bubble-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; + } + } + + .expert-hub-bubble { + $palette-size: list.length($bubble-palette); + + @each $key, $c in $bubble-palette { + &:nth-child(#{$palette-size}n + #{$key}) { + --bubble-color: #{$c}; + } + } + + width: rem-calc(80); + height: rem-calc(80); + border-radius: 40%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + + &__topic-pill { + @include topic-pill-button-shape; + + display: none; + background: color(orange, "100"); + } + + &__image { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + /* Large and up: JS viz mode — bubbles are absolutely positioned */ + + @include breakpoint(large up) { + .expert-hub-viz { + position: relative; + width: 100%; + height: calc(100vh - #{$primary-nav-height}); + height: calc(100dvh - #{$primary-nav-height}); + opacity: 0; + transition: opacity 0.4s ease; + + &--ready { + opacity: 1; + } + + &__lines-svg { + overflow: visible; + } + + &__bubble-list { + display: block; + position: absolute; + inset: 0; + } + } + + .expert-hub-hero { + &__copy { + position: absolute; + top: 1rem; + left: 0; + z-index: 5; + } + } + + .expert-hub-bubble { + position: absolute; + transition: + scale 0.2s ease, + box-shadow 0.2s ease; + z-index: 1; + + &:hover { + z-index: 10; + } + + &__topic-pill { + display: block; + position: absolute; + bottom: 100%; + left: 0; + white-space: nowrap; + margin-bottom: rem-calc(8); + } + + &__image { + border-radius: inherit; + 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; + } + + &--overlay-active::after { + opacity: 0.5; + } + } + } } 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 164048a55ff..cc0e739b7b4 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -1,6 +1,6 @@ {% extends theme_base %} -{% load static wagtailimages_tags i18n %} +{% load static wagtailcore_tags wagtailimages_tags i18n %} {% block extra_css %} @@ -13,68 +13,36 @@ {% block body_class %}template-expert-hub-page{% endblock body_class %} {% block content %} -
-
- - {% if directory_url %} - {% trans "Explore all experts" %} - {% endif %} - -

{{ page.title }}

- {% if page.description %} -
{{ page.description|safe }}
- {% endif %} - - {% if featured_experts %} - - {% endif %} - +
+
+
+

{{ page.title }}

+ {% if page.description %} +
{{ page.description|richtext }}
+ {% endif %} + {% if directory_url %} + {% trans "Explore all experts" %} + {% endif %} +
+ +
    + {% for item in featured_experts %} +
  1. + {% if item.topic %}{{ item.topic.name }}{% endif %} + {% if item.expert.image %} + {% image item.expert.image fill-300x300 class="expert-hub-bubble__image" %} + {% endif %} +
  2. + {% endfor %} +
+
- {% if page.body %} - {% include "patterns/components/streamfield.html" with streamfield=page.body %} - {% endif %} -
+ {% if page.body %} + {% include "patterns/components/streamfield.html" with streamfield=page.body %} + {% endif %} {% endblock content %} diff --git a/frontend/redesign/package.json b/frontend/redesign/package.json index ca9ec0ed047..fa51a398dbb 100644 --- a/frontend/redesign/package.json +++ b/frontend/redesign/package.json @@ -44,6 +44,9 @@ "format:scss": "prettier \"../../foundation_cms/static/scss/**/*.scss\" --write --ignore-path .prettierignore" }, "dependencies": { + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-timer": "^3.0.1", "delicious-hamburgers": "^1.2.3", "esbuild": "^0.25.2", "foundation-sites": "^6.9.0", diff --git a/yarn.lock b/yarn.lock index 1f25fc5547e..1c6fbc61449 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1446,6 +1446,35 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +d3-force@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4" + integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg== + dependencies: + d3-dispatch "1 - 3" + d3-quadtree "1 - 3" + d3-timer "1 - 3" + +"d3-quadtree@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" + integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw== + +d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + +"d3-timer@1 - 3", d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" From 9bb380d7dc67743be1b775194adafb97ec7fa71e Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 23 Apr 2026 21:48:21 -0700 Subject: [PATCH 03/44] feat(expert-hub): improve landing page viz robustness and add hover tooltip --- .../static/js/pages/expert_hub_page.js | 405 ++++++++++++------ .../static/scss/pages/expert_hub_page.scss | 44 ++ .../pages/profiles/expert_hub_page.html | 6 + 3 files changed, 324 insertions(+), 131 deletions(-) diff --git a/foundation_cms/static/js/pages/expert_hub_page.js b/foundation_cms/static/js/pages/expert_hub_page.js index 4346cf568e6..31b842e7d9e 100644 --- a/foundation_cms/static/js/pages/expert_hub_page.js +++ b/foundation_cms/static/js/pages/expert_hub_page.js @@ -2,81 +2,68 @@ import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; import { select } from "d3-selection"; import { timer } from "d3-timer"; +// Golden angle for overflow phyllotaxis layout +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +// ─── Seed positions (% of full container) ──────────────────────────────────── +// Hand-tuned for the design composition: hero upper-center, others scattered +// to the right of the copy block. Only used for the first 12 nodes; beyond +// that, overflow nodes are placed via phyllotaxis in the available zone. const TIER_CONFIG = [ { tier: 1, - positions: [[57, 56]], + positions: [[56, 55]], }, { tier: 2, positions: [ - [38, 48], - [56, 23], - [73, 33], - [90, 52], - [75, 62], + [37, 46], + [60, 23], + [78, 31], + [92, 56], + [76, 62], + [22, 56], ], }, { tier: 3, positions: [ - [22, 58], - [26, 82], - [90, 20], + [16, 82], + [93, 20], [64, 85], - [42, 76], - [7, 55], - [11, 81], + [34, 76], + [7, 53], ], }, ]; const SELECTORS = { viz: "#expert-hub-viz", - bubbleList: "#expert-hub-bubble-list", bubble: "#expert-hub-bubble-list .expert-hub-bubble", copy: ".expert-hub-hero__copy", - linesSvg: "expert-hub-viz__lines-svg", + tooltipQuote: ".expert-hub-tooltip__quote", + tooltipName: ".expert-hub-tooltip__name", }; 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 TIER_WEIGHT = { 1: 2.5, 2: 1.5, 3: 1 }; +const TIER_WEIGHT = { 1: 4, 2: 2, 3: 1 }; const TIER_BY_INDEX = TIER_CONFIG.flatMap(({ tier, positions }) => positions.map((pos) => ({ tier, pos })), ); -function computeTierBoundaries(n) { - const remaining = n - 1; - const tier2Count = Math.round(remaining * 0.4); - return { tier2Count }; -} - -function getTier(i, n) { - if (i < TIER_BY_INDEX.length) return TIER_BY_INDEX[i].tier; - const { tier2Count } = computeTierBoundaries(n); - if (i === 0) return 1; - if (i <= tier2Count) return 2; - return 3; -} - -function getPosition(i) { - return TIER_BY_INDEX[i]?.pos ?? [50, 50]; -} - -const PACK_DENSITY = 0.6; -const BUBBLE_SIZE_SCALE = 0.7; -const PACK_FACTOR = PACK_DENSITY * BUBBLE_SIZE_SCALE ** 2; +const PACK_FACTOR = 0.3; // fraction of available area covered by bubble area -const FLOAT_RADIUS_MIN = 4; -const FLOAT_RADIUS_MAX = 9; -const FLOAT_SPEED_MIN = 0.00025; -const FLOAT_SPEED_MAX = 0.00045; +const FLOAT_RADIUS = [4, 9]; // [min, max] px orbit radius +const FLOAT_SPEED = [0.00025, 0.00045]; // [min, max] radians/ms +const FLOAT_Y_SPEED_RATIO = 0.65; // y-axis drifts slower than x for elliptical motion const COLLIDE_PADDING = 6; const COLLIDE_STRENGTH = 0.9; @@ -84,14 +71,75 @@ const COLLIDE_ITERATIONS = 3; const ANCHOR_STRENGTH = 0.3; const SIM_TICKS = 200; +const TOOLTIP_GAP = 12; +const TOOLTIP_EDGE_MARGIN = 8; +// Mirrors SCSS `@include breakpoint(large up)` — Foundation large = 64em +const VIZ_BREAKPOINT = "(min-width: 64em)"; + +/** + * @param {number} min + * @param {number} max + * @returns {number} + */ function randomFloat(min, max) { return min + Math.random() * (max - min); } -function init() { - const viz = document.querySelector(SELECTORS.viz); - if (!viz) return; +/** + * Returns the tier (1, 2, or 3) for node at index i. + * Seeded nodes look up TIER_BY_INDEX; overflow nodes are assigned + * proportionally (40% tier 2, 60% tier 3). + * + * @param {number} i - Node index (0-based) + * @param {number} n - Total node count + * @returns {1|2|3} + */ +function getTier(i, n) { + if (i < TIER_BY_INDEX.length) return TIER_BY_INDEX[i].tier; + const overflowIdx = i - TIER_BY_INDEX.length; + const overflowCount = n - TIER_BY_INDEX.length; + return overflowIdx < Math.round(overflowCount * 0.4) ? 2 : 3; +} +/** + * Returns absolute [x, y] seed coordinates for node at index i. + * Seeded nodes (i < 12) use the hand-tuned TIER_CONFIG 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) + * @param {number} n - Total node count + * @param {number} zoneLeft - Left edge of the available zone (px), i.e. copy block right edge + * @param {number} vizW - Viz container width (px) + * @param {number} vizH - Viz container height (px) + * @returns {[number, number]} [x, y] in px relative to the viz container + */ +function getSeedPosition(i, n, zoneLeft, vizW, vizH) { + if (i < TIER_BY_INDEX.length) { + const [xPct, yPct] = TIER_BY_INDEX[i].pos; + return [(xPct / 100) * vizW, (yPct / 100) * vizH]; + } + const zoneW = vizW - zoneLeft; + const cx = zoneLeft + zoneW / 2; + const cy = vizH / 2; + const maxR = 0.38 * Math.min(zoneW, vizH); + const overflowIdx = i - TIER_BY_INDEX.length; + const overflowCount = n - TIER_BY_INDEX.length; + const r = Math.sqrt((overflowIdx + 1) / overflowCount) * maxR; + const θ = overflowIdx * GOLDEN_ANGLE; + return [cx + r * Math.cos(θ), cy + r * Math.sin(θ)]; +} + +/** + * Initialises the bubble viz inside the given container element. + * Computes bubble sizes from available area, runs a static force simulation + * to resolve collisions, then starts the float animation loop. + * + * @param {HTMLElement} viz - The `#expert-hub-viz` container element + * @returns {() => void} Teardown function — stops the timer, removes the SVG, + * and resets all bubble styles so the viz can be re-initialised cleanly. + */ +function init(viz) { const vizRect = viz.getBoundingClientRect(); const vizW = vizRect.width; const vizH = vizRect.height; @@ -100,6 +148,7 @@ function init() { const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; const copyArea = copyRect ? copyRect.width * copyRect.height : 0; const availableArea = vizW * vizH - copyArea; + const zoneLeft = copyRect ? copyRect.right - vizRect.left : vizW * 0.4; const els = Array.from(document.querySelectorAll(SELECTORS.bubble)); const n = els.length; @@ -110,7 +159,6 @@ function init() { ); const areaPerUnit = (availableArea * PACK_FACTOR) / totalWeightedUnits; - const tierRadius = { 1: Math.sqrt((areaPerUnit * TIER_WEIGHT[1]) / Math.PI), 2: Math.sqrt((areaPerUnit * TIER_WEIGHT[2]) / Math.PI), @@ -119,7 +167,7 @@ function init() { const svg = select(viz) .append("svg") - .attr("class", SELECTORS.linesSvg) + .attr("class", CLASS_NAMES.linesSvg) .attr("width", vizW) .attr("height", vizH) .style("position", "absolute") @@ -128,21 +176,17 @@ function init() { const linesGroup = svg.append("g"); + const tooltip = viz.querySelector(`.${CLASS_NAMES.tooltip}`); + const nodes = els.map((el, i) => { const tier = getTier(i, n); const size = Math.round(tierRadius[tier] * 2); - const [xPct, yPct] = getPosition(i); - const baseX = (xPct / 100) * vizW; - const baseY = (yPct / 100) * vizH; + const [baseX, baseY] = getSeedPosition(i, n, zoneLeft, vizW, vizH); + const topics = el.dataset.topics ? el.dataset.topics.split(",").map((t) => t.trim()) : []; - const floatR = randomFloat(FLOAT_RADIUS_MIN, FLOAT_RADIUS_MAX); - const floatSpeed = randomFloat(FLOAT_SPEED_MIN, FLOAT_SPEED_MAX); - const phaseX = randomFloat(0, Math.PI * 2); - const phaseY = randomFloat(0, Math.PI * 2); - el.classList.add(CLASS_NAMES.tier(tier)); el.style.width = `${size}px`; el.style.height = `${size}px`; @@ -150,7 +194,6 @@ function init() { el.style.left = `${baseX}px`; el.style.top = `${baseY}px`; el.style.transform = "translate(-50%, -50%)"; - el.setAttribute("role", "button"); el.setAttribute("tabindex", "0"); @@ -161,19 +204,23 @@ function init() { baseX, baseY, topics, - floatR, - floatSpeed, - phaseX, - phaseY, + index: i, + quote: el.dataset.quote || "", + name: el.dataset.name || "", + floatR: randomFloat(...FLOAT_RADIUS), + floatSpeed: randomFloat(...FLOAT_SPEED), + phaseX: randomFloat(0, Math.PI * 2), + phaseY: randomFloat(0, Math.PI * 2), cx: baseX, cy: baseY, }; }); - const simNodes = nodes.map((n) => ({ - x: n.baseX, - y: n.baseY, - r: n.size / 2, + // Resolve collisions with a static force sim + const simNodes = nodes.map((node) => ({ + x: node.baseX, + y: node.baseY, + r: node.size / 2, })); forceSimulation(simNodes) @@ -197,39 +244,72 @@ function init() { nodes[i].el.style.top = `${sn.y}px`; }); - let hoverIndex = null; + // ─── Tooltip ─────────────────────────────────────────────────────────────── - function drawLines(hoveredIndex) { - linesGroup.selectAll("line").remove(); + function positionTooltip(node) { + const r = node.size / 2; + const tipW = tooltip.offsetWidth; + const tipH = tooltip.offsetHeight; - const hovered = nodes[hoveredIndex]; - if (!hovered) return; + let x = node.cx - tipW / 2; + let y = node.cy - r - tipH - TOOLTIP_GAP; + let tail = "bottom"; - nodes.forEach((node, i) => { - if (i === hoveredIndex) return; - const shared = hovered.topics.filter((t) => node.topics.includes(t)); - if (shared.length === 0) return; + if (y < TOOLTIP_EDGE_MARGIN) { + y = node.cy + r + TOOLTIP_GAP; + tail = "top"; + } - linesGroup - .append("line") - .attr("x1", hovered.cx) - .attr("y1", hovered.cy) - .attr("x2", node.cx) - .attr("y2", node.cy) - .attr("stroke", "#f06c13") // color(orange, "300") - .attr("stroke-width", 1) - .style("opacity", 1); - }); + x = Math.max( + TOOLTIP_EDGE_MARGIN, + Math.min(x, vizW - tipW - TOOLTIP_EDGE_MARGIN), + ); + + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + tooltip.dataset.tail = tail; + } + + function showTooltip(node, color) { + tooltip.style.setProperty("--tooltip-color", color); + tooltip.querySelector(SELECTORS.tooltipQuote).textContent = node.quote; + tooltip.querySelector(SELECTORS.tooltipName).textContent = `[${node.index + 1}] ${node.name}`; + tooltip.removeAttribute("hidden"); + positionTooltip(node); } - function clearLines() { - linesGroup.selectAll("line").remove(); + function hideTooltip() { + tooltip.setAttribute("hidden", ""); } - function applyOverlays(sourceIndex) { - const color = getComputedStyle(nodes[sourceIndex].el) - .getPropertyValue("--bubble-color") - .trim(); + // ─── Lines ───────────────────────────────────────────────────────────────── + + function updateLines(hoveredIndex) { + 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 hovered.topics.some((t) => node.topics.includes(t)); + }); + + linesGroup + .selectAll("line") + .data(targets, (d) => d.el) // key by DOM element — never misaligns + .join("line") + .attr("stroke", "#f06c13") // orange 300 + .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); @@ -244,68 +324,131 @@ function init() { }); } - nodes.forEach((node, i) => { - node.el.addEventListener("click", () => { - window.location.href = node.el.dataset.url; - }); + // ─── Events ──────────────────────────────────────────────────────────────── - node.el.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") node.el.click(); - }); + let hoverIndex = null; + const ac = new AbortController(); + const { signal } = ac; - node.el.addEventListener("mouseenter", () => { - hoverIndex = i; - drawLines(i); - applyOverlays(i); - }); - node.el.addEventListener("mouseleave", () => { - hoverIndex = null; - clearLines(); - clearOverlays(); - }); + nodes.forEach((node, i) => { + node.el.addEventListener( + "click", + () => { + window.location.href = node.el.dataset.url; + }, + { signal }, + ); + + node.el.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter" || e.key === " ") node.el.click(); + }, + { signal }, + ); + + node.el.addEventListener( + "mouseenter", + () => { + hoverIndex = i; + const color = getComputedStyle(node.el) + .getPropertyValue("--bubble-color") + .trim(); + updateLines(i); + applyOverlays(i, color); + showTooltip(node, color); + }, + { signal }, + ); + + node.el.addEventListener( + "mouseleave", + () => { + hoverIndex = null; + updateLines(null); + clearOverlays(); + hideTooltip(); + }, + { signal }, + ); }); - timer((elapsed) => { + // ─── Animation loop ──────────────────────────────────────────────────────── + + const t = timer((elapsed) => { nodes.forEach((node) => { const dx = node.floatR * Math.sin(elapsed * node.floatSpeed + node.phaseX); const dy = - node.floatR * Math.cos(elapsed * node.floatSpeed * 0.65 + node.phaseY); + node.floatR * Math.cos(elapsed * node.floatSpeed * FLOAT_Y_SPEED_RATIO + node.phaseY); node.cx = node.baseX + dx; node.cy = node.baseY + dy; - node.el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; }); - const displayIndex = hoverIndex; - if (displayIndex !== null) { - const hovered = nodes[displayIndex]; - linesGroup.selectAll("line").each(function (_, lineIdx) { - const connectedLines = linesGroup.selectAll("line").nodes(); - const connectedNodes = nodes.filter((node, i) => { - if (i === displayIndex) return false; - return hovered.topics.some((t) => node.topics.includes(t)); - }); - - const lineEl = connectedLines[lineIdx]; - const targetNode = connectedNodes[lineIdx]; - if (lineEl && targetNode) { - select(lineEl) - .attr("x1", hovered.cx) - .attr("y1", hovered.cy) - .attr("x2", targetNode.cx) - .attr("y2", targetNode.cy); - } - }); + if (hoverIndex !== null) { + const hovered = nodes[hoverIndex]; + linesGroup + .selectAll("line") + .attr("x1", hovered.cx) + .attr("y1", hovered.cy) + .attr("x2", (d) => d.cx) + .attr("y2", (d) => d.cy); + positionTooltip(nodes[hoverIndex]); } }); viz.classList.add(CLASS_NAMES.ready); + + // ─── Teardown ────────────────────────────────────────────────────────────── + + return () => { + t.stop(); + ac.abort(); + svg.remove(); + tooltip.setAttribute("hidden", ""); + tooltip.style.removeProperty("--tooltip-color"); + viz.classList.remove(CLASS_NAMES.ready); + nodes.forEach(({ el, tier }) => { + el.removeAttribute("style"); + el.removeAttribute("role"); + el.removeAttribute("tabindex"); + el.classList.remove(CLASS_NAMES.tier(tier)); + }); + }; } -// Only run the viz on large screens -const mediaQuery = window.matchMedia("(min-width: 64em)"); -if (mediaQuery.matches) { - init(); +/** + * 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. Only runs the viz when the viewport + * matches VIZ_BREAKPOINT (large and up). + */ +function setup() { + const viz = document.querySelector(SELECTORS.viz); + if (!viz) return; + + let cleanup = null; + let resizeTimer = null; + + function run() { + if (cleanup) { + cleanup(); + cleanup = null; + } + if (window.matchMedia(VIZ_BREAKPOINT).matches) { + cleanup = init(viz); + } + } + + const ro = new ResizeObserver(() => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(run, 150); + }); + + ro.observe(viz); + run(); } + +setup(); diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 951ef3f523f..37bb14b4376 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -82,6 +82,7 @@ body.template-expert-hub-page { width: 100%; height: calc(100vh - #{$primary-nav-height}); height: calc(100dvh - #{$primary-nav-height}); + max-height: rem-calc(900); opacity: 0; transition: opacity 0.4s ease; @@ -109,6 +110,49 @@ body.template-expert-hub-page { } } + .expert-hub-tooltip { + position: absolute; + z-index: 20; + background: var(--tooltip-color, white); + border-radius: rem-calc(8); + padding: rem-calc(16) rem-calc(20); + max-width: rem-calc(280); + filter: drop-shadow(0 rem-calc(4) rem-calc(16) rgba(0, 0, 0, 0.12)); + pointer-events: none; + + &__quote { + margin: 0 0 rem-calc(8); + font-size: rem-calc(14); + line-height: 1.5; + } + + &__name { + display: block; + font-size: rem-calc(14); + font-weight: bold; + } + + &::after { + content: ""; + position: absolute; + left: 50%; + translate: -50% 0; + border: rem-calc(8) solid transparent; + } + + &[data-tail="bottom"]::after { + bottom: rem-calc(-8); + border-top: rem-calc(8) solid var(--tooltip-color, white); + border-bottom: none; + } + + &[data-tail="top"]::after { + top: rem-calc(-8); + border-bottom: rem-calc(8) solid var(--tooltip-color, white); + border-top: none; + } + } + .expert-hub-bubble { position: absolute; transition: 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 cc0e739b7b4..cb06f95cf29 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -25,12 +25,18 @@

{{ page.title }}

{% endif %} + +
    {% for item in featured_experts %}
  1. {% if item.topic %}{{ item.topic.name }}{% endif %} {% if item.expert.image %} From ac229ce38a383cb73daa449b0a614bafc510f423 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 23 Apr 2026 21:58:28 -0700 Subject: [PATCH 04/44] refactor(expert-hub): extract viz logic to components/expert_hub_page/viz.js --- .../js/components/expert_hub_page/viz.js | 454 +++++++++++++++++ .../static/js/pages/expert_hub_page.js | 455 +----------------- .../static/scss/pages/expert_hub_page.scss | 2 +- 3 files changed, 457 insertions(+), 454 deletions(-) create mode 100644 foundation_cms/static/js/components/expert_hub_page/viz.js 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..bd93a35c02c --- /dev/null +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -0,0 +1,454 @@ +import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; +import { select } from "d3-selection"; +import { timer } from "d3-timer"; + +// Golden angle for overflow phyllotaxis layout +const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +// ─── Seed positions (% of full container) ──────────────────────────────────── +// Hand-tuned for the design composition: hero upper-center, others scattered +// to the right of the copy block. Only used for the first 12 nodes; beyond +// that, overflow nodes are placed via phyllotaxis in the available zone. +const TIER_CONFIG = [ + { + tier: 1, + positions: [[56, 55]], + }, + { + tier: 2, + positions: [ + [37, 46], + [60, 23], + [78, 31], + [92, 56], + [76, 62], + [22, 56], + ], + }, + { + tier: 3, + positions: [ + [16, 82], + [93, 20], + [64, 85], + [34, 76], + [7, 53], + ], + }, +]; + +const SELECTORS = { + viz: "#expert-hub-viz", + bubble: "#expert-hub-bubble-list .expert-hub-bubble", + copy: ".expert-hub-hero__copy", + tooltipQuote: ".expert-hub-tooltip__quote", + tooltipName: ".expert-hub-tooltip__name", +}; + +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 TIER_WEIGHT = { 1: 4, 2: 2, 3: 1 }; + +const TIER_BY_INDEX = TIER_CONFIG.flatMap(({ tier, positions }) => + positions.map((pos) => ({ tier, pos })), +); + +const PACK_FACTOR = 0.3; // fraction of available area covered by bubble area + +const FLOAT_RADIUS = [4, 9]; // [min, max] px orbit radius +const FLOAT_SPEED = [0.00025, 0.00045]; // [min, max] radians/ms +const FLOAT_Y_SPEED_RATIO = 0.65; // y-axis drifts slower than x for elliptical motion + +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 = 12; +const TOOLTIP_EDGE_MARGIN = 8; +// Mirrors SCSS `@include breakpoint(large up)` — Foundation large = 64em +const VIZ_BREAKPOINT = "(min-width: 64em)"; + +/** + * @param {number} min + * @param {number} max + * @returns {number} + */ +function randomFloat(min, max) { + return min + Math.random() * (max - min); +} + +/** + * Returns the tier (1, 2, or 3) for node at index i. + * Seeded nodes look up TIER_BY_INDEX; overflow nodes are assigned + * proportionally (40% tier 2, 60% tier 3). + * + * @param {number} i - Node index (0-based) + * @param {number} n - Total node count + * @returns {1|2|3} + */ +function getTier(i, n) { + if (i < TIER_BY_INDEX.length) return TIER_BY_INDEX[i].tier; + const overflowIdx = i - TIER_BY_INDEX.length; + const overflowCount = n - TIER_BY_INDEX.length; + return overflowIdx < Math.round(overflowCount * 0.4) ? 2 : 3; +} + +/** + * Returns absolute [x, y] seed coordinates for node at index i. + * Seeded nodes (i < 12) use the hand-tuned TIER_CONFIG 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) + * @param {number} n - Total node count + * @param {number} zoneLeft - Left edge of the available zone (px), i.e. copy block right edge + * @param {number} vizW - Viz container width (px) + * @param {number} vizH - Viz container height (px) + * @returns {[number, number]} [x, y] in px relative to the viz container + */ +function getSeedPosition(i, n, zoneLeft, vizW, vizH) { + if (i < TIER_BY_INDEX.length) { + const [xPct, yPct] = TIER_BY_INDEX[i].pos; + return [(xPct / 100) * vizW, (yPct / 100) * vizH]; + } + const zoneW = vizW - zoneLeft; + const cx = zoneLeft + zoneW / 2; + const cy = vizH / 2; + const maxR = 0.38 * Math.min(zoneW, vizH); + const overflowIdx = i - TIER_BY_INDEX.length; + const overflowCount = n - TIER_BY_INDEX.length; + const r = Math.sqrt((overflowIdx + 1) / overflowCount) * maxR; + const θ = overflowIdx * GOLDEN_ANGLE; + return [cx + r * Math.cos(θ), cy + r * Math.sin(θ)]; +} + +/** + * Initialises the bubble viz inside the given container element. + * Computes bubble sizes from available area, runs a static force simulation + * to resolve collisions, then starts the float animation loop. + * + * @param {HTMLElement} viz - The `#expert-hub-viz` container element + * @returns {() => void} Teardown function — stops the timer, removes the SVG, + * and resets all bubble styles so the viz can be re-initialised cleanly. + */ +function init(viz) { + const vizRect = viz.getBoundingClientRect(); + const vizW = vizRect.width; + const vizH = vizRect.height; + + const copyEl = viz.querySelector(SELECTORS.copy); + const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; + const copyArea = copyRect ? copyRect.width * copyRect.height : 0; + const availableArea = vizW * vizH - copyArea; + const zoneLeft = copyRect ? copyRect.right - vizRect.left : vizW * 0.4; + + const els = Array.from(document.querySelectorAll(SELECTORS.bubble)); + const n = els.length; + + const totalWeightedUnits = els.reduce( + (sum, _, i) => sum + TIER_WEIGHT[getTier(i, n)], + 0, + ); + + const areaPerUnit = (availableArea * PACK_FACTOR) / totalWeightedUnits; + const tierRadius = { + 1: Math.sqrt((areaPerUnit * TIER_WEIGHT[1]) / Math.PI), + 2: Math.sqrt((areaPerUnit * TIER_WEIGHT[2]) / Math.PI), + 3: Math.sqrt((areaPerUnit * TIER_WEIGHT[3]) / Math.PI), + }; + + const svg = select(viz) + .append("svg") + .attr("class", CLASS_NAMES.linesSvg) + .attr("width", vizW) + .attr("height", vizH) + .style("position", "absolute") + .style("inset", "0") + .style("pointer-events", "none"); + + const linesGroup = svg.append("g"); + + const tooltip = viz.querySelector(`.${CLASS_NAMES.tooltip}`); + + const nodes = els.map((el, i) => { + const tier = getTier(i, n); + const size = Math.round(tierRadius[tier] * 2); + const [baseX, baseY] = getSeedPosition(i, n, zoneLeft, vizW, vizH); + + const topics = el.dataset.topics + ? el.dataset.topics.split(",").map((t) => t.trim()) + : []; + + el.classList.add(CLASS_NAMES.tier(tier)); + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.position = "absolute"; + el.style.left = `${baseX}px`; + el.style.top = `${baseY}px`; + el.style.transform = "translate(-50%, -50%)"; + el.setAttribute("role", "button"); + el.setAttribute("tabindex", "0"); + + return { + el, + tier, + size, + baseX, + baseY, + topics, + index: i, + quote: el.dataset.quote || "", + name: el.dataset.name || "", + floatR: randomFloat(...FLOAT_RADIUS), + floatSpeed: randomFloat(...FLOAT_SPEED), + phaseX: randomFloat(0, Math.PI * 2), + phaseY: randomFloat(0, Math.PI * 2), + cx: baseX, + cy: baseY, + }; + }); + + // Resolve collisions with a static force sim + const simNodes = nodes.map((node) => ({ + x: node.baseX, + y: node.baseY, + 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].baseX).strength(ANCHOR_STRENGTH)) + .force("y", forceY((_, i) => nodes[i].baseY).strength(ANCHOR_STRENGTH)) + .stop() + .tick(SIM_TICKS); + + simNodes.forEach((sn, i) => { + nodes[i].baseX = sn.x; + nodes[i].baseY = sn.y; + 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`; + }); + + // ─── Tooltip ─────────────────────────────────────────────────────────────── + + function positionTooltip(node) { + 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), + ); + + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + tooltip.dataset.tail = tail; + } + + function showTooltip(node, color) { + tooltip.style.setProperty("--tooltip-color", color); + tooltip.querySelector(SELECTORS.tooltipQuote).textContent = node.quote; + tooltip.querySelector(SELECTORS.tooltipName).textContent = + `[${node.index + 1}] ${node.name}`; + tooltip.removeAttribute("hidden"); + positionTooltip(node); + } + + function hideTooltip() { + tooltip.setAttribute("hidden", ""); + } + + // ─── Lines ───────────────────────────────────────────────────────────────── + + function updateLines(hoveredIndex) { + 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 hovered.topics.some((t) => node.topics.includes(t)); + }); + + linesGroup + .selectAll("line") + .data(targets, (d) => d.el) // key by DOM element — never misaligns + .join("line") + .attr("stroke", "#f06c13") // orange 300 + .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 ──────────────────────────────────────────────────────────────── + + let hoverIndex = null; + const ac = new AbortController(); + const { signal } = ac; + + nodes.forEach((node, i) => { + node.el.addEventListener( + "click", + () => { + window.location.href = node.el.dataset.url; + }, + { signal }, + ); + + node.el.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter" || e.key === " ") node.el.click(); + }, + { signal }, + ); + + node.el.addEventListener( + "mouseenter", + () => { + hoverIndex = i; + const color = getComputedStyle(node.el) + .getPropertyValue("--bubble-color") + .trim(); + updateLines(i); + applyOverlays(i, color); + showTooltip(node, color); + }, + { signal }, + ); + + node.el.addEventListener( + "mouseleave", + () => { + hoverIndex = null; + updateLines(null); + clearOverlays(); + hideTooltip(); + }, + { signal }, + ); + }); + + // ─── Animation loop ──────────────────────────────────────────────────────── + + const t = timer((elapsed) => { + nodes.forEach((node) => { + const dx = + node.floatR * Math.sin(elapsed * node.floatSpeed + node.phaseX); + const dy = + node.floatR * + Math.cos(elapsed * node.floatSpeed * FLOAT_Y_SPEED_RATIO + node.phaseY); + + node.cx = node.baseX + dx; + node.cy = node.baseY + dy; + node.el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; + }); + + if (hoverIndex !== null) { + const hovered = nodes[hoverIndex]; + linesGroup + .selectAll("line") + .attr("x1", hovered.cx) + .attr("y1", hovered.cy) + .attr("x2", (d) => d.cx) + .attr("y2", (d) => d.cy); + positionTooltip(nodes[hoverIndex]); + } + }); + + viz.classList.add(CLASS_NAMES.ready); + + // ─── Teardown ────────────────────────────────────────────────────────────── + + return () => { + t.stop(); + ac.abort(); + svg.remove(); + tooltip.setAttribute("hidden", ""); + tooltip.style.removeProperty("--tooltip-color"); + viz.classList.remove(CLASS_NAMES.ready); + nodes.forEach(({ el, tier }) => { + el.removeAttribute("style"); + el.removeAttribute("role"); + el.removeAttribute("tabindex"); + 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. Only runs the viz when the viewport + * matches VIZ_BREAKPOINT (large and up). + */ +export function setupViz() { + const viz = document.querySelector(SELECTORS.viz); + if (!viz) return; + + let cleanup = null; + let resizeTimer = null; + + function run() { + if (cleanup) { + cleanup(); + cleanup = null; + } + if (window.matchMedia(VIZ_BREAKPOINT).matches) { + cleanup = init(viz); + } + } + + const ro = new ResizeObserver(() => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(run, 150); + }); + + ro.observe(viz); + run(); +} diff --git a/foundation_cms/static/js/pages/expert_hub_page.js b/foundation_cms/static/js/pages/expert_hub_page.js index 31b842e7d9e..aeea2d92ba7 100644 --- a/foundation_cms/static/js/pages/expert_hub_page.js +++ b/foundation_cms/static/js/pages/expert_hub_page.js @@ -1,454 +1,3 @@ -import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; -import { select } from "d3-selection"; -import { timer } from "d3-timer"; +import { setupViz } from "../components/expert_hub_page/viz"; -// Golden angle for overflow phyllotaxis layout -const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); - -// ─── Seed positions (% of full container) ──────────────────────────────────── -// Hand-tuned for the design composition: hero upper-center, others scattered -// to the right of the copy block. Only used for the first 12 nodes; beyond -// that, overflow nodes are placed via phyllotaxis in the available zone. -const TIER_CONFIG = [ - { - tier: 1, - positions: [[56, 55]], - }, - { - tier: 2, - positions: [ - [37, 46], - [60, 23], - [78, 31], - [92, 56], - [76, 62], - [22, 56], - ], - }, - { - tier: 3, - positions: [ - [16, 82], - [93, 20], - [64, 85], - [34, 76], - [7, 53], - ], - }, -]; - -const SELECTORS = { - viz: "#expert-hub-viz", - bubble: "#expert-hub-bubble-list .expert-hub-bubble", - copy: ".expert-hub-hero__copy", - tooltipQuote: ".expert-hub-tooltip__quote", - tooltipName: ".expert-hub-tooltip__name", -}; - -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 TIER_WEIGHT = { 1: 4, 2: 2, 3: 1 }; - -const TIER_BY_INDEX = TIER_CONFIG.flatMap(({ tier, positions }) => - positions.map((pos) => ({ tier, pos })), -); - -const PACK_FACTOR = 0.3; // fraction of available area covered by bubble area - -const FLOAT_RADIUS = [4, 9]; // [min, max] px orbit radius -const FLOAT_SPEED = [0.00025, 0.00045]; // [min, max] radians/ms -const FLOAT_Y_SPEED_RATIO = 0.65; // y-axis drifts slower than x for elliptical motion - -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 = 12; -const TOOLTIP_EDGE_MARGIN = 8; -// Mirrors SCSS `@include breakpoint(large up)` — Foundation large = 64em -const VIZ_BREAKPOINT = "(min-width: 64em)"; - -/** - * @param {number} min - * @param {number} max - * @returns {number} - */ -function randomFloat(min, max) { - return min + Math.random() * (max - min); -} - -/** - * Returns the tier (1, 2, or 3) for node at index i. - * Seeded nodes look up TIER_BY_INDEX; overflow nodes are assigned - * proportionally (40% tier 2, 60% tier 3). - * - * @param {number} i - Node index (0-based) - * @param {number} n - Total node count - * @returns {1|2|3} - */ -function getTier(i, n) { - if (i < TIER_BY_INDEX.length) return TIER_BY_INDEX[i].tier; - const overflowIdx = i - TIER_BY_INDEX.length; - const overflowCount = n - TIER_BY_INDEX.length; - return overflowIdx < Math.round(overflowCount * 0.4) ? 2 : 3; -} - -/** - * Returns absolute [x, y] seed coordinates for node at index i. - * Seeded nodes (i < 12) use the hand-tuned TIER_CONFIG 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) - * @param {number} n - Total node count - * @param {number} zoneLeft - Left edge of the available zone (px), i.e. copy block right edge - * @param {number} vizW - Viz container width (px) - * @param {number} vizH - Viz container height (px) - * @returns {[number, number]} [x, y] in px relative to the viz container - */ -function getSeedPosition(i, n, zoneLeft, vizW, vizH) { - if (i < TIER_BY_INDEX.length) { - const [xPct, yPct] = TIER_BY_INDEX[i].pos; - return [(xPct / 100) * vizW, (yPct / 100) * vizH]; - } - const zoneW = vizW - zoneLeft; - const cx = zoneLeft + zoneW / 2; - const cy = vizH / 2; - const maxR = 0.38 * Math.min(zoneW, vizH); - const overflowIdx = i - TIER_BY_INDEX.length; - const overflowCount = n - TIER_BY_INDEX.length; - const r = Math.sqrt((overflowIdx + 1) / overflowCount) * maxR; - const θ = overflowIdx * GOLDEN_ANGLE; - return [cx + r * Math.cos(θ), cy + r * Math.sin(θ)]; -} - -/** - * Initialises the bubble viz inside the given container element. - * Computes bubble sizes from available area, runs a static force simulation - * to resolve collisions, then starts the float animation loop. - * - * @param {HTMLElement} viz - The `#expert-hub-viz` container element - * @returns {() => void} Teardown function — stops the timer, removes the SVG, - * and resets all bubble styles so the viz can be re-initialised cleanly. - */ -function init(viz) { - const vizRect = viz.getBoundingClientRect(); - const vizW = vizRect.width; - const vizH = vizRect.height; - - const copyEl = viz.querySelector(SELECTORS.copy); - const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; - const copyArea = copyRect ? copyRect.width * copyRect.height : 0; - const availableArea = vizW * vizH - copyArea; - const zoneLeft = copyRect ? copyRect.right - vizRect.left : vizW * 0.4; - - const els = Array.from(document.querySelectorAll(SELECTORS.bubble)); - const n = els.length; - - const totalWeightedUnits = els.reduce( - (sum, _, i) => sum + TIER_WEIGHT[getTier(i, n)], - 0, - ); - - const areaPerUnit = (availableArea * PACK_FACTOR) / totalWeightedUnits; - const tierRadius = { - 1: Math.sqrt((areaPerUnit * TIER_WEIGHT[1]) / Math.PI), - 2: Math.sqrt((areaPerUnit * TIER_WEIGHT[2]) / Math.PI), - 3: Math.sqrt((areaPerUnit * TIER_WEIGHT[3]) / Math.PI), - }; - - const svg = select(viz) - .append("svg") - .attr("class", CLASS_NAMES.linesSvg) - .attr("width", vizW) - .attr("height", vizH) - .style("position", "absolute") - .style("inset", "0") - .style("pointer-events", "none"); - - const linesGroup = svg.append("g"); - - const tooltip = viz.querySelector(`.${CLASS_NAMES.tooltip}`); - - const nodes = els.map((el, i) => { - const tier = getTier(i, n); - const size = Math.round(tierRadius[tier] * 2); - const [baseX, baseY] = getSeedPosition(i, n, zoneLeft, vizW, vizH); - - const topics = el.dataset.topics - ? el.dataset.topics.split(",").map((t) => t.trim()) - : []; - - el.classList.add(CLASS_NAMES.tier(tier)); - el.style.width = `${size}px`; - el.style.height = `${size}px`; - el.style.position = "absolute"; - el.style.left = `${baseX}px`; - el.style.top = `${baseY}px`; - el.style.transform = "translate(-50%, -50%)"; - el.setAttribute("role", "button"); - el.setAttribute("tabindex", "0"); - - return { - el, - tier, - size, - baseX, - baseY, - topics, - index: i, - quote: el.dataset.quote || "", - name: el.dataset.name || "", - floatR: randomFloat(...FLOAT_RADIUS), - floatSpeed: randomFloat(...FLOAT_SPEED), - phaseX: randomFloat(0, Math.PI * 2), - phaseY: randomFloat(0, Math.PI * 2), - cx: baseX, - cy: baseY, - }; - }); - - // Resolve collisions with a static force sim - const simNodes = nodes.map((node) => ({ - x: node.baseX, - y: node.baseY, - 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].baseX).strength(ANCHOR_STRENGTH)) - .force("y", forceY((_, i) => nodes[i].baseY).strength(ANCHOR_STRENGTH)) - .stop() - .tick(SIM_TICKS); - - simNodes.forEach((sn, i) => { - nodes[i].baseX = sn.x; - nodes[i].baseY = sn.y; - 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`; - }); - - // ─── Tooltip ─────────────────────────────────────────────────────────────── - - function positionTooltip(node) { - 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), - ); - - tooltip.style.left = `${x}px`; - tooltip.style.top = `${y}px`; - tooltip.dataset.tail = tail; - } - - function showTooltip(node, color) { - tooltip.style.setProperty("--tooltip-color", color); - tooltip.querySelector(SELECTORS.tooltipQuote).textContent = node.quote; - tooltip.querySelector(SELECTORS.tooltipName).textContent = `[${node.index + 1}] ${node.name}`; - tooltip.removeAttribute("hidden"); - positionTooltip(node); - } - - function hideTooltip() { - tooltip.setAttribute("hidden", ""); - } - - // ─── Lines ───────────────────────────────────────────────────────────────── - - function updateLines(hoveredIndex) { - 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 hovered.topics.some((t) => node.topics.includes(t)); - }); - - linesGroup - .selectAll("line") - .data(targets, (d) => d.el) // key by DOM element — never misaligns - .join("line") - .attr("stroke", "#f06c13") // orange 300 - .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 ──────────────────────────────────────────────────────────────── - - let hoverIndex = null; - const ac = new AbortController(); - const { signal } = ac; - - nodes.forEach((node, i) => { - node.el.addEventListener( - "click", - () => { - window.location.href = node.el.dataset.url; - }, - { signal }, - ); - - node.el.addEventListener( - "keydown", - (e) => { - if (e.key === "Enter" || e.key === " ") node.el.click(); - }, - { signal }, - ); - - node.el.addEventListener( - "mouseenter", - () => { - hoverIndex = i; - const color = getComputedStyle(node.el) - .getPropertyValue("--bubble-color") - .trim(); - updateLines(i); - applyOverlays(i, color); - showTooltip(node, color); - }, - { signal }, - ); - - node.el.addEventListener( - "mouseleave", - () => { - hoverIndex = null; - updateLines(null); - clearOverlays(); - hideTooltip(); - }, - { signal }, - ); - }); - - // ─── Animation loop ──────────────────────────────────────────────────────── - - const t = timer((elapsed) => { - nodes.forEach((node) => { - const dx = - node.floatR * Math.sin(elapsed * node.floatSpeed + node.phaseX); - const dy = - node.floatR * Math.cos(elapsed * node.floatSpeed * FLOAT_Y_SPEED_RATIO + node.phaseY); - - node.cx = node.baseX + dx; - node.cy = node.baseY + dy; - node.el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; - }); - - if (hoverIndex !== null) { - const hovered = nodes[hoverIndex]; - linesGroup - .selectAll("line") - .attr("x1", hovered.cx) - .attr("y1", hovered.cy) - .attr("x2", (d) => d.cx) - .attr("y2", (d) => d.cy); - positionTooltip(nodes[hoverIndex]); - } - }); - - viz.classList.add(CLASS_NAMES.ready); - - // ─── Teardown ────────────────────────────────────────────────────────────── - - return () => { - t.stop(); - ac.abort(); - svg.remove(); - tooltip.setAttribute("hidden", ""); - tooltip.style.removeProperty("--tooltip-color"); - viz.classList.remove(CLASS_NAMES.ready); - nodes.forEach(({ el, tier }) => { - el.removeAttribute("style"); - el.removeAttribute("role"); - el.removeAttribute("tabindex"); - 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. Only runs the viz when the viewport - * matches VIZ_BREAKPOINT (large and up). - */ -function setup() { - const viz = document.querySelector(SELECTORS.viz); - if (!viz) return; - - let cleanup = null; - let resizeTimer = null; - - function run() { - if (cleanup) { - cleanup(); - cleanup = null; - } - if (window.matchMedia(VIZ_BREAKPOINT).matches) { - cleanup = init(viz); - } - } - - const ro = new ResizeObserver(() => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(run, 150); - }); - - ro.observe(viz); - run(); -} - -setup(); +setupViz(); diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 37bb14b4376..8ff11b46140 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -117,7 +117,7 @@ body.template-expert-hub-page { border-radius: rem-calc(8); padding: rem-calc(16) rem-calc(20); max-width: rem-calc(280); - filter: drop-shadow(0 rem-calc(4) rem-calc(16) rgba(0, 0, 0, 0.12)); + filter: drop-shadow(0 rem-calc(4) rem-calc(16) rgb(0 0 0 / 12%)); pointer-events: none; &__quote { From 1f1c4b77a9efe801e708cb849f3470d47323b61d Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 24 Apr 2026 11:53:03 -0700 Subject: [PATCH 05/44] feat(expert-hub): remove bubble floating animation from viz --- .../js/components/expert_hub_page/viz.js | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index bd93a35c02c..c9c068a321d 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -1,6 +1,5 @@ import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; import { select } from "d3-selection"; -import { timer } from "d3-timer"; // Golden angle for overflow phyllotaxis layout const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); @@ -61,10 +60,6 @@ const TIER_BY_INDEX = TIER_CONFIG.flatMap(({ tier, positions }) => const PACK_FACTOR = 0.3; // fraction of available area covered by bubble area -const FLOAT_RADIUS = [4, 9]; // [min, max] px orbit radius -const FLOAT_SPEED = [0.00025, 0.00045]; // [min, max] radians/ms -const FLOAT_Y_SPEED_RATIO = 0.65; // y-axis drifts slower than x for elliptical motion - const COLLIDE_PADDING = 6; const COLLIDE_STRENGTH = 0.9; const COLLIDE_ITERATIONS = 3; @@ -76,15 +71,6 @@ const TOOLTIP_EDGE_MARGIN = 8; // Mirrors SCSS `@include breakpoint(large up)` — Foundation large = 64em const VIZ_BREAKPOINT = "(min-width: 64em)"; -/** - * @param {number} min - * @param {number} max - * @returns {number} - */ -function randomFloat(min, max) { - return min + Math.random() * (max - min); -} - /** * Returns the tier (1, 2, or 3) for node at index i. * Seeded nodes look up TIER_BY_INDEX; overflow nodes are assigned @@ -132,8 +118,8 @@ function getSeedPosition(i, n, zoneLeft, vizW, vizH) { /** * Initialises the bubble viz inside the given container element. - * Computes bubble sizes from available area, runs a static force simulation - * to resolve collisions, then starts the float animation loop. + * Computes bubble sizes from available area, then runs a static force simulation + * to resolve collisions. * * @param {HTMLElement} viz - The `#expert-hub-viz` container element * @returns {() => void} Teardown function — stops the timer, removes the SVG, @@ -207,10 +193,6 @@ function init(viz) { index: i, quote: el.dataset.quote || "", name: el.dataset.name || "", - floatR: randomFloat(...FLOAT_RADIUS), - floatSpeed: randomFloat(...FLOAT_SPEED), - phaseX: randomFloat(0, Math.PI * 2), - phaseY: randomFloat(0, Math.PI * 2), cx: baseX, cy: baseY, }; @@ -327,7 +309,6 @@ function init(viz) { // ─── Events ──────────────────────────────────────────────────────────────── - let hoverIndex = null; const ac = new AbortController(); const { signal } = ac; @@ -351,7 +332,6 @@ function init(viz) { node.el.addEventListener( "mouseenter", () => { - hoverIndex = i; const color = getComputedStyle(node.el) .getPropertyValue("--bubble-color") .trim(); @@ -365,7 +345,6 @@ function init(viz) { node.el.addEventListener( "mouseleave", () => { - hoverIndex = null; updateLines(null); clearOverlays(); hideTooltip(); @@ -374,39 +353,11 @@ function init(viz) { ); }); - // ─── Animation loop ──────────────────────────────────────────────────────── - - const t = timer((elapsed) => { - nodes.forEach((node) => { - const dx = - node.floatR * Math.sin(elapsed * node.floatSpeed + node.phaseX); - const dy = - node.floatR * - Math.cos(elapsed * node.floatSpeed * FLOAT_Y_SPEED_RATIO + node.phaseY); - - node.cx = node.baseX + dx; - node.cy = node.baseY + dy; - node.el.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; - }); - - if (hoverIndex !== null) { - const hovered = nodes[hoverIndex]; - linesGroup - .selectAll("line") - .attr("x1", hovered.cx) - .attr("y1", hovered.cy) - .attr("x2", (d) => d.cx) - .attr("y2", (d) => d.cy); - positionTooltip(nodes[hoverIndex]); - } - }); - viz.classList.add(CLASS_NAMES.ready); // ─── Teardown ────────────────────────────────────────────────────────────── return () => { - t.stop(); ac.abort(); svg.remove(); tooltip.setAttribute("hidden", ""); From 882d35a759474c3f2f2035ef828eeb7fcbabb2aa Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 24 Apr 2026 12:27:04 -0700 Subject: [PATCH 06/44] feat(expert-hub): add staggered pop-in entrance animation for bubbles --- .../js/components/expert_hub_page/viz.js | 9 +++++++++ .../static/scss/pages/expert_hub_page.scss | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index c9c068a321d..7f5cf73f23f 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -226,6 +226,10 @@ function init(viz) { nodes[i].el.style.top = `${sn.y}px`; }); + nodes.forEach((node, i) => { + node.el.style.animationDelay = `${i * 80}ms`; + }); + // ─── Tooltip ─────────────────────────────────────────────────────────────── function positionTooltip(node) { @@ -395,7 +399,12 @@ export function setupViz() { } } + let initialFire = true; const ro = new ResizeObserver(() => { + if (initialFire) { + initialFire = false; + return; + } clearTimeout(resizeTimer); resizeTimer = setTimeout(run, 150); }); diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 8ff11b46140..024d91ec2ac 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -77,6 +77,18 @@ body.template-expert-hub-page { /* Large and up: JS viz mode — bubbles are absolutely positioned */ @include breakpoint(large up) { + @keyframes expert-hub-bubble-pop-in { + from { + scale: 0; + opacity: 0; + } + + to { + scale: 1; + opacity: 1; + } + } + .expert-hub-viz { position: relative; width: 100%; @@ -88,6 +100,11 @@ body.template-expert-hub-page { &--ready { opacity: 1; + + .expert-hub-bubble { + animation: expert-hub-bubble-pop-in 0.5s + cubic-bezier(0.34, 1.56, 0.64, 1) both; + } } &__lines-svg { @@ -155,6 +172,7 @@ body.template-expert-hub-page { .expert-hub-bubble { position: absolute; + transform-origin: 0 0; transition: scale 0.2s ease, box-shadow 0.2s ease; From 8f7b90bab9c2e7b92801f98e2a9dc376b7224037 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 24 Apr 2026 13:24:34 -0700 Subject: [PATCH 07/44] feat(expert-hub): enforce title and description length limits on ExpertHubPage --- .../0006_alter_experthubpage_description.py | 23 ++++++++++++++++++ .../profiles/models/expert_hub_page.py | 24 +++++++++++++++++-- .../js/components/expert_hub_page/viz.js | 9 +++---- 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py diff --git a/foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py b/foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py new file mode 100644 index 00000000000..3b285d4dc04 --- /dev/null +++ b/foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-04-24 20:18 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0005_alter_expertdirectorypage_topics_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/models/expert_hub_page.py b/foundation_cms/profiles/models/expert_hub_page.py index 3e21d63a092..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) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index 7f5cf73f23f..1edf1c4d4f7 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -21,17 +21,18 @@ const TIER_CONFIG = [ [78, 31], [92, 56], [76, 62], - [22, 56], + [22, 58], ], }, { tier: 3, positions: [ - [16, 82], + [22, 84], [93, 20], [64, 85], - [34, 76], - [7, 53], + [36, 76], + [7, 54], + [8, 77], ], }, ]; From 0b05d02b188688d57afd2a07d6a7b2d907b5cb9b Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 24 Apr 2026 14:22:33 -0700 Subject: [PATCH 08/44] feat(expert-hub): refine tooltip design and bubble colour palette --- .../js/components/expert_hub_page/viz.js | 10 ++--- .../static/scss/pages/expert_hub_page.scss | 42 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index 1edf1c4d4f7..55145231dd5 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -67,7 +67,7 @@ const COLLIDE_ITERATIONS = 3; const ANCHOR_STRENGTH = 0.3; const SIM_TICKS = 200; -const TOOLTIP_GAP = 12; +const TOOLTIP_GAP = -12; const TOOLTIP_EDGE_MARGIN = 8; // Mirrors SCSS `@include breakpoint(large up)` — Foundation large = 64em const VIZ_BREAKPOINT = "(min-width: 64em)"; @@ -337,12 +337,12 @@ function init(viz) { node.el.addEventListener( "mouseenter", () => { - const color = getComputedStyle(node.el) - .getPropertyValue("--bubble-color") - .trim(); + 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); - showTooltip(node, color); + showTooltip(node, tooltipColor); }, { signal }, ); diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 024d91ec2ac..b6f7d92285a 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -2,11 +2,7 @@ @use "sass:list"; @import "../redesign_base"; -$bubble-palette: ( - 1: color(orange, "300"), - 2: color(blue, "300"), - 3: color(yellow, "300"), -); +$bubble-palette: (orange, blue, yellow); body.template-expert-hub-page { .expert-hub-hero { @@ -45,9 +41,12 @@ body.template-expert-hub-page { .expert-hub-bubble { $palette-size: list.length($bubble-palette); - @each $key, $c in $bubble-palette { - &:nth-child(#{$palette-size}n + #{$key}) { - --bubble-color: #{$c}; + @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")}; } } @@ -77,6 +76,9 @@ body.template-expert-hub-page { /* Large and up: JS viz mode — bubbles are absolutely positioned */ @include breakpoint(large up) { + $tooltip-tail-width: rem-calc(12); + $tooltip-tail-height: rem-calc(24); + @keyframes expert-hub-bubble-pop-in { from { scale: 0; @@ -131,10 +133,9 @@ body.template-expert-hub-page { position: absolute; z-index: 20; background: var(--tooltip-color, white); - border-radius: rem-calc(8); - padding: rem-calc(16) rem-calc(20); - max-width: rem-calc(280); - filter: drop-shadow(0 rem-calc(4) rem-calc(16) rgb(0 0 0 / 12%)); + border-radius: rem-calc(4); + padding: rem-calc(18) rem-calc(24); + max-width: rem-calc(354); pointer-events: none; &__quote { @@ -144,28 +145,27 @@ body.template-expert-hub-page { } &__name { + @include mofo-text-style($header-styles, "h6", $header-font-family); + display: block; - font-size: rem-calc(14); - font-weight: bold; } &::after { content: ""; position: absolute; - left: 50%; - translate: -50% 0; - border: rem-calc(8) solid transparent; + right: rem-calc(12); + border: $tooltip-tail-width solid transparent; } &[data-tail="bottom"]::after { - bottom: rem-calc(-8); - border-top: rem-calc(8) solid var(--tooltip-color, white); + bottom: -$tooltip-tail-height; + border-top: $tooltip-tail-height solid var(--tooltip-color, white); border-bottom: none; } &[data-tail="top"]::after { - top: rem-calc(-8); - border-bottom: rem-calc(8) solid var(--tooltip-color, white); + top: -$tooltip-tail-height; + border-bottom: $tooltip-tail-height solid var(--tooltip-color, white); border-top: none; } } From 64cefc486e7c7a7a427359e95c41ddf5a3910520 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 24 Apr 2026 15:30:01 -0700 Subject: [PATCH 09/44] refactor(expert-hub): responsive viz with per-breakpoint configs and touch exclusion --- .../js/components/expert_hub_page/viz.js | 209 ++++++++++++------ .../static/scss/pages/expert_hub_page.scss | 13 +- 2 files changed, 149 insertions(+), 73 deletions(-) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index 55145231dd5..56a45be7c0a 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -4,38 +4,81 @@ import { select } from "d3-selection"; // Golden angle for overflow phyllotaxis layout const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); -// ─── Seed positions (% of full container) ──────────────────────────────────── -// Hand-tuned for the design composition: hero upper-center, others scattered -// to the right of the copy block. Only used for the first 12 nodes; beyond -// that, overflow nodes are placed via phyllotaxis in the available zone. -const TIER_CONFIG = [ - { - tier: 1, - positions: [[56, 55]], - }, - { - tier: 2, - positions: [ - [37, 46], - [60, 23], - [78, 31], - [92, 56], - [76, 62], - [22, 58], +// ─── Breakpoints (match SCSS customized-settings.scss) ─────────────────────── +const BREAKPOINTS = { + large: 1024, + xlarge: 1200, +}; + +// ─── Per-breakpoint starting positions (% of full container) ───────────────── +// Each config is hand-tuned for that viewport's available zone. +// Overflow nodes beyond the configured count use phyllotaxis in the right-of-copy zone. +const TIER_CONFIGS = { + // ≥ 1200px + xlarge: { + packFactor: 0.3, + tiers: [ + { + tier: 1, + positions: [[56, 55]], + }, + { + tier: 2, + positions: [ + [37, 46], + [60, 23], + [78, 31], + [92, 56], + [76, 62], + [22, 58], + ], + }, + { + tier: 3, + positions: [ + [22, 84], + [93, 20], + [64, 85], + [36, 76], + [7, 54], + [8, 77], + ], + }, ], }, - { - tier: 3, - positions: [ - [22, 84], - [93, 20], - [64, 85], - [36, 76], - [7, 54], - [8, 77], + // 1024–1199px + large: { + packFactor: 0.25, + tiers: [ + { + tier: 1, + positions: [[58, 49]], + }, + { + tier: 2, + positions: [ + [37, 49], + [60, 18], + [78, 31], + [92, 56], + [76, 62], + [23, 61], + ], + }, + { + tier: 3, + positions: [ + [22, 84], + [93, 20], + [62, 78], + [40, 74], + [7, 56], + [8, 77], + ], + }, ], }, -]; +}; const SELECTORS = { viz: "#expert-hub-viz", @@ -55,12 +98,6 @@ const CLASS_NAMES = { const TIER_WEIGHT = { 1: 4, 2: 2, 3: 1 }; -const TIER_BY_INDEX = TIER_CONFIG.flatMap(({ tier, positions }) => - positions.map((pos) => ({ tier, pos })), -); - -const PACK_FACTOR = 0.3; // fraction of available area covered by bubble area - const COLLIDE_PADDING = 6; const COLLIDE_STRENGTH = 0.9; const COLLIDE_ITERATIONS = 3; @@ -69,64 +106,85 @@ const SIM_TICKS = 200; const TOOLTIP_GAP = -12; const TOOLTIP_EDGE_MARGIN = 8; -// Mirrors SCSS `@include breakpoint(large up)` — Foundation large = 64em -const VIZ_BREAKPOINT = "(min-width: 64em)"; + +/** + * Returns the active breakpoint key for the current viewport, + * or null for touch devices and viewports below 1024px (both use the mobile layout). + * + * @returns {"xlarge"|"large"|null} + */ +function getBreakpoint() { + if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) + return null; + const w = window.innerWidth; + if (w >= BREAKPOINTS.xlarge) return "xlarge"; + if (w >= BREAKPOINTS.large) return "large"; + return null; +} /** * Returns the tier (1, 2, or 3) for node at index i. - * Seeded nodes look up TIER_BY_INDEX; overflow nodes are assigned + * Configured nodes look up tierByIndex; overflow nodes are assigned * proportionally (40% tier 2, 60% tier 3). * - * @param {number} i - Node index (0-based) - * @param {number} n - Total node count + * @param {number} i - Node index (0-based) + * @param {number} n - Total node count + * @param {Array} tierByIndex - Flattened position list for the active config * @returns {1|2|3} */ -function getTier(i, n) { - if (i < TIER_BY_INDEX.length) return TIER_BY_INDEX[i].tier; - const overflowIdx = i - TIER_BY_INDEX.length; - const overflowCount = n - TIER_BY_INDEX.length; +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; } /** - * Returns absolute [x, y] seed coordinates for node at index i. - * Seeded nodes (i < 12) use the hand-tuned TIER_CONFIG percentage table. + * 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) - * @param {number} n - Total node count - * @param {number} zoneLeft - Left edge of the available zone (px), i.e. copy block right edge - * @param {number} vizW - Viz container width (px) - * @param {number} vizH - Viz container height (px) + * @param {number} i - Node index (0-based) + * @param {number} n - Total node count + * @param {number} zoneLeft - Left edge of available zone (px) + * @param {number} vizW - Viz container width (px) + * @param {number} vizH - Viz container height (px) + * @param {Array} tierByIndex - Flattened position list for the active config * @returns {[number, number]} [x, y] in px relative to the viz container */ -function getSeedPosition(i, n, zoneLeft, vizW, vizH) { - if (i < TIER_BY_INDEX.length) { - const [xPct, yPct] = TIER_BY_INDEX[i].pos; +function getInitialPosition(i, n, zoneLeft, vizW, vizH, tierByIndex) { + 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 = 0.38 * Math.min(zoneW, vizH); - const overflowIdx = i - TIER_BY_INDEX.length; - const overflowCount = n - TIER_BY_INDEX.length; + 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(θ)]; } /** - * Initialises the bubble viz inside the given container element. + * 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 - * @returns {() => void} Teardown function — stops the timer, removes the SVG, - * and resets all bubble styles so the viz can be re-initialised cleanly. + * @param {HTMLElement} viz - The `#expert-hub-viz` container element + * @param {object} tierConfig - TIER_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) { +function init(viz, tierConfig) { + const { packFactor, tiers } = tierConfig; + const tierByIndex = tiers.flatMap(({ tier, positions }) => + positions.map((pos) => ({ tier, pos })), + ); + const vizRect = viz.getBoundingClientRect(); const vizW = vizRect.width; const vizH = vizRect.height; @@ -141,11 +199,11 @@ function init(viz) { const n = els.length; const totalWeightedUnits = els.reduce( - (sum, _, i) => sum + TIER_WEIGHT[getTier(i, n)], + (sum, _, i) => sum + TIER_WEIGHT[getTier(i, n, tierByIndex)], 0, ); - const areaPerUnit = (availableArea * PACK_FACTOR) / totalWeightedUnits; + const areaPerUnit = (availableArea * packFactor) / totalWeightedUnits; const tierRadius = { 1: Math.sqrt((areaPerUnit * TIER_WEIGHT[1]) / Math.PI), 2: Math.sqrt((areaPerUnit * TIER_WEIGHT[2]) / Math.PI), @@ -166,9 +224,16 @@ function init(viz) { const tooltip = viz.querySelector(`.${CLASS_NAMES.tooltip}`); const nodes = els.map((el, i) => { - const tier = getTier(i, n); + const tier = getTier(i, n, tierByIndex); const size = Math.round(tierRadius[tier] * 2); - const [baseX, baseY] = getSeedPosition(i, n, zoneLeft, vizW, vizH); + const [baseX, baseY] = getInitialPosition( + i, + n, + zoneLeft, + vizW, + vizH, + tierByIndex, + ); const topics = el.dataset.topics ? el.dataset.topics.split(",").map((t) => t.trim()) @@ -339,7 +404,9 @@ function init(viz) { () => { const style = getComputedStyle(node.el); const color = style.getPropertyValue("--bubble-color").trim(); - const tooltipColor = style.getPropertyValue("--bubble-color-light").trim(); + const tooltipColor = style + .getPropertyValue("--bubble-color-light") + .trim(); updateLines(i); applyOverlays(i, color); showTooltip(node, tooltipColor); @@ -380,8 +447,11 @@ function init(viz) { /** * 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. Only runs the viz when the viewport - * matches VIZ_BREAKPOINT (large and up). + * 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. + * Touch devices and viewports below 1024px return null from getBreakpoint() and get no viz. */ export function setupViz() { const viz = document.querySelector(SELECTORS.viz); @@ -395,8 +465,9 @@ export function setupViz() { cleanup(); cleanup = null; } - if (window.matchMedia(VIZ_BREAKPOINT).matches) { - cleanup = init(viz); + const bp = getBreakpoint(); + if (bp) { + cleanup = init(viz, TIER_CONFIGS[bp]); } } diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index b6f7d92285a..36438aa0b04 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -7,8 +7,11 @@ $bubble-palette: (orange, blue, yellow); body.template-expert-hub-page { .expert-hub-hero { &__copy { - max-width: 50%; margin-bottom: 2rem; + + @include breakpoint(large up) { + max-width: 50%; + } } &__title { @@ -25,7 +28,7 @@ body.template-expert-hub-page { } } - /* Mobile default: copy and bubbles flow vertically, no JS viz */ + /* Mobile default — touch devices and viewports below 1024px: placeholder until mobile implementation is built */ .expert-hub-viz { &__bubble-list { list-style: none; @@ -73,9 +76,11 @@ body.template-expert-hub-page { } } - /* Large and up: JS viz mode — bubbles are absolutely positioned */ + /* Hover-capable devices at 1024px and up: JS viz mode — bubbles are absolutely positioned */ + + /* Touch devices and viewports below 1024px use the mobile layout instead */ - @include breakpoint(large up) { + @media screen and (width >= 64em) and (hover: hover) and (pointer: fine) { $tooltip-tail-width: rem-calc(12); $tooltip-tail-height: rem-calc(24); From 2769f9def1c17ce74fe98aee3145d69f0f61babe Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 24 Apr 2026 16:42:47 -0700 Subject: [PATCH 10/44] feat(expert-hub): align tooltip tail dynamically with hovered bubble --- .../static/js/components/expert_hub_page/viz.js | 12 +++++++++++- .../static/scss/pages/expert_hub_page.scss | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index 56a45be7c0a..b60b0f48d54 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -104,8 +104,10 @@ const COLLIDE_ITERATIONS = 3; const ANCHOR_STRENGTH = 0.3; const SIM_TICKS = 200; -const TOOLTIP_GAP = -12; +const TOOLTIP_GAP = -5; const TOOLTIP_EDGE_MARGIN = 8; +// Must match $tooltip-tail-width in expert_hub_page.scss +const TOOLTIP_TAIL_HALF_WIDTH = 12; /** * Returns the active breakpoint key for the current viewport, @@ -317,8 +319,16 @@ function init(viz, tierConfig) { 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) - TOOLTIP_TAIL_HALF_WIDTH; + const tailRightClamped = Math.max( + TOOLTIP_TAIL_HALF_WIDTH, + Math.min(tailRight, tipW - TOOLTIP_TAIL_HALF_WIDTH * 3), + ); + tooltip.style.left = `${x}px`; tooltip.style.top = `${y}px`; + tooltip.style.setProperty("--tooltip-tail-right", `${tailRightClamped}px`); tooltip.dataset.tail = tail; } diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 36438aa0b04..70d0293792c 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -158,7 +158,7 @@ body.template-expert-hub-page { &::after { content: ""; position: absolute; - right: rem-calc(12); + right: var(--tooltip-tail-right, #{rem-calc(12)}); border: $tooltip-tail-width solid transparent; } From da8874a0bbe18e86562557b8aaefb23f8e022a51 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Wed, 29 Apr 2026 19:04:46 -0700 Subject: [PATCH 11/44] feat(expert-hub): responsive viz for all breakpoints; tap opens profile card on touch devices --- .../js/components/expert_hub_page/lightbox.js | 152 +++++++ .../components/expert_hub_page/viz-configs.js | 232 ++++++++++ .../js/components/expert_hub_page/viz.js | 410 +++++++++--------- .../static/scss/pages/expert_hub_page.scss | 228 +++++++--- .../pages/profiles/expert_hub_page.html | 17 +- 5 files changed, 771 insertions(+), 268 deletions(-) create mode 100644 foundation_cms/static/js/components/expert_hub_page/lightbox.js create mode 100644 foundation_cms/static/js/components/expert_hub_page/viz-configs.js 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..4f9802f320a --- /dev/null +++ b/foundation_cms/static/js/components/expert_hub_page/lightbox.js @@ -0,0 +1,152 @@ +const SELECTORS = { + close: "[data-lightbox-close]", + inner: ".expert-hub-card__inner", + image: ".expert-hub-card__image", + quote: ".expert-hub-card__quote", + 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 quoteEl = cardEl.querySelector(SELECTORS.quote); + const nameEl = cardEl.querySelector(SELECTORS.name); + const linkEl = cardEl.querySelector(SELECTORS.link); + + if (!inner || !imageEl || !quoteEl || !nameEl || !linkEl) { + return null; + } + + let previouslyFocused = null; + + /** + * @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(); + } + } + + /** + * Populates the overlay from `dataset` / image on a bubble and shows it. + * Locks body 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 ?? ""; + quoteEl.textContent = el.dataset.quote ?? ""; + 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"); + document.body.style.overflow = "hidden"; + closeBtn?.focus(); + } + + /** + * Hides the overlay, restores body scroll and prior focus if still in the document. + */ + function close() { + cardEl.setAttribute("hidden", ""); + inner.style.removeProperty("--bubble-color"); + document.body.style.overflow = ""; + + 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..147300c6519 --- /dev/null +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -0,0 +1,232 @@ +// ─── Breakpoints (match SCSS customized-settings.scss) ─────────────────────── +export const BREAKPOINTS = { sm: 375, md: 480, lg: 768, xl: 1024, xxl: 1200 }; + +// Shared tier weights for xl and xxl desktop configs +const WEIGHTS_DESKTOP = { 1: 4, 2: 2, 3: 1 }; + +// ─── 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. +export const CONFIGS = { + // ≥ 1200px + xxl: { + computeHeight: false, + packFactor: 0.3, + tierWeights: WEIGHTS_DESKTOP, + tiers: [ + { + tier: 1, + positions: [[56, 55]], + }, + { + tier: 2, + positions: [ + [37, 46], + [60, 23], + [78, 31], + [92, 56], + [76, 62], + [22, 58], + ], + }, + { + tier: 3, + positions: [ + [22, 84], + [93, 20], + [64, 85], + [36, 76], + [7, 54], + [8, 77], + ], + }, + ], + }, + // 1024–1199px + xl: { + computeHeight: false, + packFactor: 0.25, + tierWeights: WEIGHTS_DESKTOP, + tiers: [ + { + tier: 1, + positions: [[58, 49]], + }, + { + tier: 2, + positions: [ + [37, 49], + [60, 18], + [78, 31], + [92, 56], + [76, 62], + [23, 61], + ], + }, + { + tier: 3, + positions: [ + [22, 84], + [93, 20], + [62, 78], + [40, 74], + [7, 56], + [8, 77], + ], + }, + ], + }, + // 768–1023px + lg: { + computeHeight: true, + containerAspect: 2.2, + packFactor: 0.4, + tierWeights: { 1: 3.2, 2: 2.5, 3: 1.5, 4: 0.7 }, + tiers: [ + { + tier: 1, + positions: [[75, 36]], + }, + { + tier: 2, + positions: [[40, 12]], + }, + { + tier: 3, + positions: [ + [82, 13], + [18, 30], + [32, 50], + [48, 88], + [55, 69], + ], + }, + { + tier: 4, + positions: [ + [18, 67], + [82, 57], + [15, 82], + [82, 80], + [20, 98], + [80, 96], + ], + }, + ], + }, + // 480–767px + md: { + computeHeight: true, + containerAspect: 2.5, + packFactor: 0.33, + tierWeights: { 1: 3.2, 2: 2.5, 3: 1.5, 4: 0.7 }, + tiers: [ + { + tier: 1, + positions: [[74, 34]], + }, + { + tier: 2, + positions: [[40, 12]], + }, + { + tier: 3, + positions: [ + [80, 10], + [18, 28], + [32, 47], + [48, 88], + [56, 68], + ], + }, + { + tier: 4, + positions: [ + [18, 64], + [76, 55], + [15, 79], + [78, 82], + [20, 98], + [80, 96], + ], + }, + ], + }, + // 375–479px + sm: { + computeHeight: true, + containerAspect: 2.8, + packFactor: 0.22, + tierWeights: { 1: 4, 2: 2.5, 3: 1.5, 4: 0.7 }, + tiers: [ + { + tier: 1, + positions: [[70, 29]], + }, + { + tier: 2, + positions: [[40, 9]], + }, + { + tier: 3, + positions: [ + [80, 9], + [18, 27], + [28, 43], + [48, 83], + [55, 62], + ], + }, + { + tier: 4, + positions: [ + [18, 58], + [72, 47], + [14, 73], + [80, 76], + [20, 96], + [80, 92], + ], + }, + ], + }, + // < 375px + xs: { + computeHeight: true, + containerAspect: 3, + packFactor: 0.22, + tierWeights: { 1: 4, 2: 2.5, 3: 1.5, 4: 0.8 }, + tiers: [ + { + tier: 1, + positions: [[72, 31]], + }, + { + tier: 2, + positions: [[40, 9]], + }, + { + tier: 3, + positions: [ + [80, 12], + [18, 27], + [28, 45], + [43, 80], + [68, 64], + ], + }, + { + tier: 4, + positions: [ + [18, 61], + [72, 49], + [13, 74], + [77, 83], + [17, 94], + [65, 97], + ], + }, + ], + }, +}; diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index b60b0f48d54..de3b12a646b 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -1,91 +1,19 @@ 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)); -// ─── Breakpoints (match SCSS customized-settings.scss) ─────────────────────── -const BREAKPOINTS = { - large: 1024, - xlarge: 1200, -}; - -// ─── Per-breakpoint starting positions (% of full container) ───────────────── -// Each config is hand-tuned for that viewport's available zone. -// Overflow nodes beyond the configured count use phyllotaxis in the right-of-copy zone. -const TIER_CONFIGS = { - // ≥ 1200px - xlarge: { - packFactor: 0.3, - tiers: [ - { - tier: 1, - positions: [[56, 55]], - }, - { - tier: 2, - positions: [ - [37, 46], - [60, 23], - [78, 31], - [92, 56], - [76, 62], - [22, 58], - ], - }, - { - tier: 3, - positions: [ - [22, 84], - [93, 20], - [64, 85], - [36, 76], - [7, 54], - [8, 77], - ], - }, - ], - }, - // 1024–1199px - large: { - packFactor: 0.25, - tiers: [ - { - tier: 1, - positions: [[58, 49]], - }, - { - tier: 2, - positions: [ - [37, 49], - [60, 18], - [78, 31], - [92, 56], - [76, 62], - [23, 61], - ], - }, - { - tier: 3, - positions: [ - [22, 84], - [93, 20], - [62, 78], - [40, 74], - [7, 56], - [8, 77], - ], - }, - ], - }, -}; - const SELECTORS = { viz: "#expert-hub-viz", + bubbleList: "#expert-hub-bubble-list", bubble: "#expert-hub-bubble-list .expert-hub-bubble", copy: ".expert-hub-hero__copy", tooltipQuote: ".expert-hub-tooltip__quote", tooltipName: ".expert-hub-tooltip__name", + card: "#expert-hub-card", }; const CLASS_NAMES = { @@ -96,8 +24,6 @@ const CLASS_NAMES = { tier: (n) => `expert-hub-bubble--tier-${n}`, }; -const TIER_WEIGHT = { 1: 4, 2: 2, 3: 1 }; - const COLLIDE_PADDING = 6; const COLLIDE_STRENGTH = 0.9; const COLLIDE_ITERATIONS = 3; @@ -106,33 +32,40 @@ const SIM_TICKS = 200; const TOOLTIP_GAP = -5; const TOOLTIP_EDGE_MARGIN = 8; -// Must match $tooltip-tail-width in expert_hub_page.scss -const TOOLTIP_TAIL_HALF_WIDTH = 12; + +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, - * or null for touch devices and viewports below 1024px (both use the mobile layout). + * Returns the active breakpoint key for the current viewport. * - * @returns {"xlarge"|"large"|null} + * @returns {"xxl"|"xl"|"lg"|"md"|"sm"|"xs"} */ function getBreakpoint() { - if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) - return null; const w = window.innerWidth; - if (w >= BREAKPOINTS.xlarge) return "xlarge"; - if (w >= BREAKPOINTS.large) return "large"; - return null; + if (w >= BREAKPOINTS.xxl) return "xxl"; + if (w >= BREAKPOINTS.xl) return "xl"; + if (w >= BREAKPOINTS.lg) return "lg"; + if (w >= BREAKPOINTS.md) return "md"; + if (w >= BREAKPOINTS.sm) return "sm"; + return "xs"; } /** - * Returns the tier (1, 2, or 3) for node at index i. - * Configured nodes look up tierByIndex; overflow nodes are assigned - * proportionally (40% tier 2, 60% tier 3). + * 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 - * @returns {1|2|3} */ function getTier(i, n, tierByIndex) { if (i < tierByIndex.length) return tierByIndex[i].tier; @@ -141,105 +74,127 @@ function getTier(i, n, tierByIndex) { return overflowIdx < Math.round(overflowCount * 0.4) ? 2 : 3; } -/** - * 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) - * @param {number} n - Total node count - * @param {number} zoneLeft - Left edge of available zone (px) - * @param {number} vizW - Viz container width (px) - * @param {number} vizH - Viz container height (px) - * @param {Array} tierByIndex - Flattened position list for the active config - * @returns {[number, number]} [x, y] in px relative to the viz container - */ -function getInitialPosition(i, n, zoneLeft, vizW, vizH, tierByIndex) { - 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 = 0.38 * 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(θ)]; -} - /** * 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 {object} tierConfig - TIER_CONFIGS entry for the active breakpoint + * @param {HTMLElement} viz - The `#expert-hub-viz` container element + * @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, tierConfig) { - const { packFactor, tiers } = tierConfig; +function init(viz, config) { + const { computeHeight, containerAspect, packFactor, tierWeights, 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; - const vizH = vizRect.height; - const copyEl = viz.querySelector(SELECTORS.copy); + // 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 hero copy is a sibling of the bubble list, not inside it, + // so there is no overlap zone to subtract. Only desktop needs the copy rect. + const copyEl = computeHeight ? null : viz.querySelector(SELECTORS.copy); const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; const copyArea = copyRect ? copyRect.width * copyRect.height : 0; const availableArea = vizW * vizH - copyArea; const zoneLeft = copyRect ? copyRect.right - vizRect.left : vizW * 0.4; - const els = Array.from(document.querySelectorAll(SELECTORS.bubble)); - const n = els.length; - const totalWeightedUnits = els.reduce( - (sum, _, i) => sum + TIER_WEIGHT[getTier(i, n, tierByIndex)], + (sum, _, i) => sum + tierWeights[getTier(i, n, tierByIndex)], 0, ); const areaPerUnit = (availableArea * packFactor) / totalWeightedUnits; - const tierRadius = { - 1: Math.sqrt((areaPerUnit * TIER_WEIGHT[1]) / Math.PI), - 2: Math.sqrt((areaPerUnit * TIER_WEIGHT[2]) / Math.PI), - 3: Math.sqrt((areaPerUnit * TIER_WEIGHT[3]) / Math.PI), - }; + const tierRadius = Object.fromEntries( + Object.entries(tierWeights).map(([t, w]) => [ + t, + Math.sqrt((areaPerUnit * w) / Math.PI), + ]), + ); - const svg = select(viz) - .append("svg") - .attr("class", CLASS_NAMES.linesSvg) - .attr("width", vizW) - .attr("height", vizH) - .style("position", "absolute") - .style("inset", "0") - .style("pointer-events", "none"); + // 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) + .style("position", "absolute") + .style("inset", "0") + .style("pointer-events", "none"); - const linesGroup = svg.append("g"); + const linesGroup = svg ? svg.append("g") : null; const tooltip = viz.querySelector(`.${CLASS_NAMES.tooltip}`); + const tooltipQuote = tooltip?.querySelector(SELECTORS.tooltipQuote); + 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(viz.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, - n, - zoneLeft, - vizW, - vizH, - tierByIndex, - ); + const [baseX, baseY] = getInitialPosition(i); - const topics = el.dataset.topics - ? el.dataset.topics.split(",").map((t) => t.trim()) - : []; + const topic = (el.dataset.topic ?? "").trim(); el.classList.add(CLASS_NAMES.tier(tier)); el.style.width = `${size}px`; @@ -250,15 +205,14 @@ function init(viz, tierConfig) { el.style.transform = "translate(-50%, -50%)"; 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, - baseX, - baseY, - topics, - index: i, + topic, quote: el.dataset.quote || "", name: el.dataset.name || "", cx: baseX, @@ -268,8 +222,8 @@ function init(viz, tierConfig) { // Resolve collisions with a static force sim const simNodes = nodes.map((node) => ({ - x: node.baseX, - y: node.baseY, + x: node.cx, + y: node.cy, r: node.size / 2, })); @@ -280,27 +234,29 @@ function init(viz, tierConfig) { .strength(COLLIDE_STRENGTH) .iterations(COLLIDE_ITERATIONS), ) - .force("x", forceX((_, i) => nodes[i].baseX).strength(ANCHOR_STRENGTH)) - .force("y", forceY((_, i) => nodes[i].baseY).strength(ANCHOR_STRENGTH)) + .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].baseX = sn.x; - nodes[i].baseY = sn.y; 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`; }); - nodes.forEach((node, i) => { - node.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; @@ -320,10 +276,10 @@ function init(viz, tierConfig) { ); // Align tail with bubble centre, clamped so it stays within the tooltip - const tailRight = tipW - (node.cx - x) - TOOLTIP_TAIL_HALF_WIDTH; + const tailRight = tipW - (node.cx - x) - tooltipTailHalfWidth; const tailRightClamped = Math.max( - TOOLTIP_TAIL_HALF_WIDTH, - Math.min(tailRight, tipW - TOOLTIP_TAIL_HALF_WIDTH * 3), + tooltipTailHalfWidth, + Math.min(tailRight, tipW - tooltipTailHalfWidth * 3), ); tooltip.style.left = `${x}px`; @@ -333,21 +289,26 @@ function init(viz, tierConfig) { } function showTooltip(node, color) { + if (!tooltip || !tooltipQuote || !tooltipName) return; tooltip.style.setProperty("--tooltip-color", color); - tooltip.querySelector(SELECTORS.tooltipQuote).textContent = node.quote; - tooltip.querySelector(SELECTORS.tooltipName).textContent = - `[${node.index + 1}] ${node.name}`; + tooltipQuote.textContent = node.quote; + tooltipName.textContent = node.name; tooltip.removeAttribute("hidden"); positionTooltip(node); } function hideTooltip() { - tooltip.setAttribute("hidden", ""); + tooltip?.setAttribute("hidden", ""); } // ─── Lines ───────────────────────────────────────────────────────────────── + function clearLines() { + updateLines(null); + } + function updateLines(hoveredIndex) { + if (!linesGroup) return; if (hoveredIndex === null) { linesGroup.selectAll("line").data([]).join("line"); return; @@ -355,14 +316,18 @@ function init(viz, tierConfig) { const hovered = nodes[hoveredIndex]; const targets = nodes.filter((node, i) => { if (i === hoveredIndex) return false; - return hovered.topics.some((t) => node.topics.includes(t)); + 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", "#f06c13") // orange 300 + .attr("stroke", LINES_STROKE_COLOR) .attr("stroke-width", 1) .attr("x1", hovered.cx) .attr("y1", hovered.cy) @@ -396,7 +361,11 @@ function init(viz, tierConfig) { node.el.addEventListener( "click", () => { - window.location.href = node.el.dataset.url; + if (IS_TOUCH) { + lightbox?.open(node.el); + } else { + window.location.href = node.el.dataset.url; + } }, { signal }, ); @@ -404,36 +373,47 @@ function init(viz, tierConfig) { node.el.addEventListener( "keydown", (e) => { - if (e.key === "Enter" || e.key === " ") node.el.click(); + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + node.el.click(); + } }, { signal }, ); + }); - node.el.addEventListener( - "mouseenter", - () => { - 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); - showTooltip(node, tooltipColor); - }, - { signal }, - ); + if (!computeHeight) { + nodes.forEach((node, i) => { + node.el.addEventListener( + "mouseenter", + () => { + 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); + showTooltip(node, tooltipColor); + }, + { signal }, + ); + + node.el.addEventListener( + "mouseleave", + () => { + clearLines(); + clearOverlays(); + hideTooltip(); + }, + { signal }, + ); + }); + } - node.el.addEventListener( - "mouseleave", - () => { - updateLines(null); - clearOverlays(); - hideTooltip(); - }, - { signal }, - ); - }); + if (IS_TOUCH && lightbox) { + lightbox.bindListeners(signal); + } viz.classList.add(CLASS_NAMES.ready); @@ -441,14 +421,23 @@ function init(viz, tierConfig) { return () => { ac.abort(); - svg.remove(); - tooltip.setAttribute("hidden", ""); - tooltip.style.removeProperty("--tooltip-color"); + 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)); }); }; @@ -461,7 +450,6 @@ function init(viz, tierConfig) { * * The active breakpoint config is resolved on each run, so resizing across * a breakpoint boundary automatically picks up the right starting positions. - * Touch devices and viewports below 1024px return null from getBreakpoint() and get no viz. */ export function setupViz() { const viz = document.querySelector(SELECTORS.viz); @@ -476,9 +464,7 @@ export function setupViz() { cleanup = null; } const bp = getBreakpoint(); - if (bp) { - cleanup = init(viz, TIER_CONFIGS[bp]); - } + cleanup = init(viz, CONFIGS[bp]); } let initialFire = true; diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 70d0293792c..4d3c0273770 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -3,6 +3,7 @@ @import "../redesign_base"; $bubble-palette: (orange, blue, yellow); +$bubble-border-radius: 40%; body.template-expert-hub-page { .expert-hub-hero { @@ -28,16 +29,52 @@ body.template-expert-hub-page { } } - /* Mobile default — touch devices and viewports below 1024px: placeholder until mobile implementation is built */ + /* ── 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; - margin: 0; padding: 0; - display: flex; - flex-wrap: wrap; - gap: 1rem; - justify-content: center; + display: block; + overflow-y: auto; + + // Mobile: position: relative so the list flows below .expert-hub-hero__copy + // and 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 { + animation: expert-hub-bubble-pop-in 0.5s + cubic-bezier(0.34, 1.56, 0.64, 1) both; + } } } @@ -53,9 +90,10 @@ body.template-expert-hub-page { } } - width: rem-calc(80); - height: rem-calc(80); - border-radius: 40%; + position: absolute; + transform-origin: 0 0; + border-radius: $bubble-border-radius; + background: var(--bubble-color); display: flex; flex-direction: column; align-items: center; @@ -65,64 +103,144 @@ body.template-expert-hub-page { &__topic-pill { @include topic-pill-button-shape; - display: none; + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: rem-calc(4); + white-space: nowrap; background: color(orange, "100"); + + @include breakpoint(medium down) { + @include mofo-text-style($body-text-styles, "xsmall"); + + background: color(yellow, "200"); + } } &__image { width: 100%; height: 100%; object-fit: cover; + border-radius: inherit; + } + + &__name { + display: block; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: rem-calc(6); + font-size: rem-calc(11); + font-weight: 600; + line-height: 1.3; + text-align: center; + white-space: nowrap; + max-width: 120%; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; } } - /* Hover-capable devices at 1024px and up: JS viz mode — bubbles are absolutely positioned */ + /* ── Tap card overlay (all viewports) ────────────────────────────────────── */ - /* Touch devices and viewports below 1024px use the mobile layout instead */ + @keyframes expert-hub-card-fade-in { + from { + opacity: 0; + } + } - @media screen and (width >= 64em) and (hover: hover) and (pointer: fine) { - $tooltip-tail-width: rem-calc(12); - $tooltip-tail-height: rem-calc(24); + .expert-hub-card { + $image-size: rem-calc(54); + $inner-padding: rem-calc(24); + $panel-gap: rem-calc(8); - @keyframes expert-hub-bubble-pop-in { - from { - scale: 0; - opacity: 0; - } + position: fixed; + inset: 0; + background: rgb(color(neutral, "100"), 0.4); + backdrop-filter: blur(4px); + z-index: 100; - to { - scale: 1; - opacity: 1; - } + &[hidden] { + display: none; } - .expert-hub-viz { + &:not([hidden]) { + animation: expert-hub-card-fade-in 0.2s ease-out both; + } + + &__panel { + position: absolute; + left: $panel-gap; + right: $panel-gap; + bottom: rem-calc(46); + } + + .btn-close { + position: absolute; + top: 0; + right: 0; + transform: translateY(calc(-100% - #{$panel-gap})); + } + + &__inner { + margin: 0 auto; + width: clamp(rem-calc(280), 100%, 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 { + width: $image-size; + height: $image-size; + object-fit: cover; + border-radius: $bubble-border-radius; + display: block; + margin-top: -(($image-size / 2) + $inner-padding); + margin-bottom: rem-calc(16); + } + + &__quote { + 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(100vh - #{$primary-nav-height}); height: calc(100dvh - #{$primary-nav-height}); max-height: rem-calc(900); - opacity: 0; - transition: opacity 0.4s ease; - - &--ready { - opacity: 1; - .expert-hub-bubble { - animation: expert-hub-bubble-pop-in 0.5s - cubic-bezier(0.34, 1.56, 0.64, 1) both; - } + &__bubble-list { + position: absolute; + inset: 0; + overflow-y: hidden; } &__lines-svg { overflow: visible; } - - &__bubble-list { - display: block; - position: absolute; - inset: 0; - } } .expert-hub-hero { @@ -135,6 +253,9 @@ body.template-expert-hub-page { } .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); @@ -158,26 +279,26 @@ body.template-expert-hub-page { &::after { content: ""; position: absolute; - right: var(--tooltip-tail-right, #{rem-calc(12)}); - border: $tooltip-tail-width solid transparent; + right: var(--tooltip-tail-right, var(--tooltip-tail-width)); + border: var(--tooltip-tail-width) solid transparent; } &[data-tail="bottom"]::after { - bottom: -$tooltip-tail-height; - border-top: $tooltip-tail-height solid var(--tooltip-color, white); + 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: -$tooltip-tail-height; - border-bottom: $tooltip-tail-height solid var(--tooltip-color, white); + top: calc(-1 * var(--tooltip-tail-height)); + border-bottom: var(--tooltip-tail-height) solid + var(--tooltip-color, white); border-top: none; } } .expert-hub-bubble { - position: absolute; - transform-origin: 0 0; + margin: 0; transition: scale 0.2s ease, box-shadow 0.2s ease; @@ -188,11 +309,6 @@ body.template-expert-hub-page { } &__topic-pill { - display: block; - position: absolute; - bottom: 100%; - left: 0; - white-space: nowrap; margin-bottom: rem-calc(8); } @@ -201,6 +317,10 @@ body.template-expert-hub-page { pointer-events: none; } + &__name { + display: none; + } + &::after { content: ""; position: absolute; 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 cb06f95cf29..c6de3d4ea4e 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -15,6 +15,7 @@ {% block content %}
    +

    {{ page.title }}

    {% if page.description %} @@ -33,18 +34,30 @@

    {{ page.title }}

      {% for item in featured_experts %}
    1. + data-topic="{% if item.topic %}{{ item.topic.name }}{% endif %}"> {% if item.topic %}{{ item.topic.name }}{% endif %} {% if item.expert.image %} {% image item.expert.image fill-300x300 class="expert-hub-bubble__image" %} {% endif %} + {{ item.expert.title }}
    2. {% endfor %}
    + +
    From cc75dd9b038b6117e799d7ba63579f575ceb3715 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Wed, 29 Apr 2026 19:21:08 -0700 Subject: [PATCH 12/44] refactor(expert-hub): move static bubble and SVG styles from JS to SCSS --- foundation_cms/static/js/components/expert_hub_page/viz.js | 7 +------ foundation_cms/static/scss/pages/expert_hub_page.scss | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index de3b12a646b..c59e1ce83e7 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -144,10 +144,7 @@ function init(viz, config) { .append("svg") .attr("class", CLASS_NAMES.linesSvg) .attr("width", vizW) - .attr("height", vizH) - .style("position", "absolute") - .style("inset", "0") - .style("pointer-events", "none"); + .attr("height", vizH); const linesGroup = svg ? svg.append("g") : null; @@ -199,10 +196,8 @@ function init(viz, config) { el.classList.add(CLASS_NAMES.tier(tier)); el.style.width = `${size}px`; el.style.height = `${size}px`; - el.style.position = "absolute"; el.style.left = `${baseX}px`; el.style.top = `${baseY}px`; - el.style.transform = "translate(-50%, -50%)"; el.setAttribute("role", "button"); el.setAttribute("tabindex", "0"); const label = el.dataset.name?.trim(); diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 4d3c0273770..0f7d5fae027 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -91,6 +91,7 @@ body.template-expert-hub-page { } position: absolute; + transform: translate(-50%, -50%); transform-origin: 0 0; border-radius: $bubble-border-radius; background: var(--bubble-color); @@ -239,7 +240,10 @@ body.template-expert-hub-page { } &__lines-svg { + position: absolute; + inset: 0; overflow: visible; + pointer-events: none; } } From aa31a5d689ea2d3f1d59bfff76f6ade05323b33a Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Wed, 29 Apr 2026 20:31:24 -0700 Subject: [PATCH 13/44] fix mgiration files --- ...description.py => 0007_alter_experthubpage_description.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename foundation_cms/profiles/migrations/{0006_alter_experthubpage_description.py => 0007_alter_experthubpage_description.py} (80%) diff --git a/foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py b/foundation_cms/profiles/migrations/0007_alter_experthubpage_description.py similarity index 80% rename from foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py rename to foundation_cms/profiles/migrations/0007_alter_experthubpage_description.py index 3b285d4dc04..059baec6cfc 100644 --- a/foundation_cms/profiles/migrations/0006_alter_experthubpage_description.py +++ b/foundation_cms/profiles/migrations/0007_alter_experthubpage_description.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.29 on 2026-04-24 20:18 +# Generated by Django 4.2.29 on 2026-04-30 03:27 import wagtail.fields from django.db import migrations @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ("profiles", "0005_alter_expertdirectorypage_topics_and_more"), + ("profiles", "0006_alter_expertdirectorypage_body_and_more"), ] operations = [ From 0136335dbae76087abb37f8527a00ce0d830da52 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 30 Apr 2026 00:02:07 -0700 Subject: [PATCH 14/44] feat(expert-hub): add CTA section with yellow decorative shape --- .../expert-hub/yellow-tilted-square.svg | 1 + .../static/scss/pages/expert_hub_page.scss | 61 +++++++++++++++++++ .../pages/profiles/expert_hub_page.html | 15 +++++ 3 files changed, 77 insertions(+) create mode 100644 foundation_cms/static/images/expert-hub/yellow-tilted-square.svg 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/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 0f7d5fae027..c81372d33c1 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -341,4 +341,65 @@ body.template-expert-hub-page { } } } + + .expert-hub-cta { + position: relative; + margin-top: rem-calc(134); + 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; + } + + @include breakpoint(large up) { + margin-top: rem-calc(142); + 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) 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(16); + } + + &__body { + @include mofo-text-style($body-text-styles, "large"); + + margin-bottom: 0; + } + + &__btn { + margin: 0; + } + } } 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 c6de3d4ea4e..ed250356713 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -59,6 +59,21 @@

    {{ page.title }}

    + +
    +
    +
    +
    +
    +

    {% trans "Want to collab?" %}

    +

    {% trans "Reach out to us and we can work together!" %}

    +
    + {% trans "Contact Us" %} +
    +
    +
    +
    + {% if page.body %} From e462286eb53305a8e3ee9ec72fa5e5c707440e83 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 30 Apr 2026 00:33:32 -0700 Subject: [PATCH 15/44] refactor(expert-hub): fix bubble sizes independent of expert count --- .../components/expert_hub_page/viz-configs.js | 22 ++++++------------- .../js/components/expert_hub_page/viz.js | 16 ++------------ .../pages/profiles/expert_hub_page.html | 4 +++- 3 files changed, 12 insertions(+), 30 deletions(-) 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 index 147300c6519..13a05982015 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -1,19 +1,16 @@ // ─── Breakpoints (match SCSS customized-settings.scss) ─────────────────────── export const BREAKPOINTS = { sm: 375, md: 480, lg: 768, xl: 1024, xxl: 1200 }; -// Shared tier weights for xl and xxl desktop configs -const WEIGHTS_DESKTOP = { 1: 4, 2: 2, 3: 1 }; - // ─── 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 xxl: { computeHeight: false, - packFactor: 0.3, - tierWeights: WEIGHTS_DESKTOP, + tierRadiusPercent: { 1: 10, 2: 7, 3: 5 }, tiers: [ { tier: 1, @@ -46,8 +43,7 @@ export const CONFIGS = { // 1024–1199px xl: { computeHeight: false, - packFactor: 0.25, - tierWeights: WEIGHTS_DESKTOP, + tierRadiusPercent: { 1: 10, 2: 7, 3: 5 }, tiers: [ { tier: 1, @@ -81,8 +77,7 @@ export const CONFIGS = { lg: { computeHeight: true, containerAspect: 2.2, - packFactor: 0.4, - tierWeights: { 1: 3.2, 2: 2.5, 3: 1.5, 4: 0.7 }, + tierRadiusPercent: { 1: 23, 2: 19, 3: 14, 4: 9.5 }, tiers: [ { tier: 1, @@ -119,8 +114,7 @@ export const CONFIGS = { md: { computeHeight: true, containerAspect: 2.5, - packFactor: 0.33, - tierWeights: { 1: 3.2, 2: 2.5, 3: 1.5, 4: 0.7 }, + tierRadiusPercent: { 1: 25, 2: 20, 3: 14, 4: 10 }, tiers: [ { tier: 1, @@ -157,8 +151,7 @@ export const CONFIGS = { sm: { computeHeight: true, containerAspect: 2.8, - packFactor: 0.22, - tierWeights: { 1: 4, 2: 2.5, 3: 1.5, 4: 0.7 }, + tierRadiusPercent: { 1: 25, 2: 20, 3: 15, 4: 10 }, tiers: [ { tier: 1, @@ -195,8 +188,7 @@ export const CONFIGS = { xs: { computeHeight: true, containerAspect: 3, - packFactor: 0.22, - tierWeights: { 1: 4, 2: 2.5, 3: 1.5, 4: 0.8 }, + tierRadiusPercent: { 1: 22.5, 2: 18, 3: 13, 4: 9 }, tiers: [ { tier: 1, diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index c59e1ce83e7..20ed03fb9fc 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -85,8 +85,7 @@ function getTier(i, n, tierByIndex) { * bubble styles so the viz can be re-initialised cleanly. */ function init(viz, config) { - const { computeHeight, containerAspect, packFactor, tierWeights, tiers } = - config; + const { computeHeight, containerAspect, tierRadiusPercent, tiers } = config; const tierByIndex = tiers.flatMap(({ tier, positions }) => positions.map((pos) => ({ tier, pos })), ); @@ -120,21 +119,10 @@ function init(viz, config) { // so there is no overlap zone to subtract. Only desktop needs the copy rect. const copyEl = computeHeight ? null : viz.querySelector(SELECTORS.copy); const copyRect = copyEl ? copyEl.getBoundingClientRect() : null; - const copyArea = copyRect ? copyRect.width * copyRect.height : 0; - const availableArea = vizW * vizH - copyArea; const zoneLeft = copyRect ? copyRect.right - vizRect.left : vizW * 0.4; - const totalWeightedUnits = els.reduce( - (sum, _, i) => sum + tierWeights[getTier(i, n, tierByIndex)], - 0, - ); - - const areaPerUnit = (availableArea * packFactor) / totalWeightedUnits; const tierRadius = Object.fromEntries( - Object.entries(tierWeights).map(([t, w]) => [ - t, - Math.sqrt((areaPerUnit * w) / Math.PI), - ]), + Object.entries(tierRadiusPercent).map(([t, pct]) => [t, (pct / 100) * vizW]), ); // Lines SVG is desktop-only; mobile has no tooltip or lines interaction. 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 ed250356713..bc809c5eb5c 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -33,17 +33,19 @@

    {{ page.title }}

      {% for item in featured_experts %} + {% with forloop.counter as idx %}
    1. - {% if item.topic %}{{ item.topic.name }}{% endif %} + {% if item.topic %}{{ idx }}. {{ item.topic.name }}{% endif %} {% if item.expert.image %} {% image item.expert.image fill-300x300 class="expert-hub-bubble__image" %} {% endif %} {{ item.expert.title }}
    2. + {% endwith %} {% endfor %}
    From 58087df85c31c076c5467077ee89df514a3271bb Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 30 Apr 2026 01:08:38 -0700 Subject: [PATCH 16/44] fix linting/formatting issues --- foundation_cms/static/scss/pages/expert_hub_page.scss | 2 +- .../templates/patterns/pages/profiles/expert_hub_page.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index c81372d33c1..a2f632cd950 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -373,7 +373,7 @@ body.template-expert-hub-page { position: relative; z-index: 1; background: color(neutral, "100"); - border-radius: 0 rem-calc(48) 0 rem-calc(48); + border-radius: 0 rem-calc(48); padding: rem-calc(40); display: flex; flex-direction: column; 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 bc809c5eb5c..372f1493470 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -49,11 +49,11 @@

    {{ page.title }}

    {% endfor %}
- From 6f4db123b657fa4826751ebb34714f1598b1f4e5 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 30 Apr 2026 14:34:20 -0700 Subject: [PATCH 19/44] feat(expert-hub): polish viz interactions, bubble sizing, and CTA layout --- .../images/expert-hub/orange-sunburst.svg | 1 + .../components/expert_hub_page/viz-configs.js | 132 +++++++++--------- .../static/scss/pages/expert_hub_page.scss | 57 +++++--- 3 files changed, 105 insertions(+), 85 deletions(-) create mode 100644 foundation_cms/static/images/expert-hub/orange-sunburst.svg 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/js/components/expert_hub_page/viz-configs.js b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js index 13a05982015..34f17cfcd94 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -47,28 +47,28 @@ export const CONFIGS = { tiers: [ { tier: 1, - positions: [[58, 49]], + positions: [[58, 50]], }, { tier: 2, positions: [ - [37, 49], + [38, 51], [60, 18], [78, 31], [92, 56], - [76, 62], - [23, 61], + [76, 71], + [23, 59], ], }, { tier: 3, positions: [ - [22, 84], + [24, 84], [93, 20], - [62, 78], - [40, 74], + [60, 80], + [40, 78], [7, 56], - [8, 77], + [8, 81], ], }, ], @@ -77,35 +77,37 @@ export const CONFIGS = { lg: { computeHeight: true, containerAspect: 2.2, - tierRadiusPercent: { 1: 23, 2: 19, 3: 14, 4: 9.5 }, + tierRadiusPercent: { 1: 15, 2: 12, 3: 9, 4: 7.5 }, tiers: [ { tier: 1, - positions: [[75, 36]], + positions: [[49, 31]], }, { tier: 2, - positions: [[40, 12]], + positions: [ + [38, 11], + [85, 12], + ], }, { tier: 3, positions: [ - [82, 13], - [18, 30], - [32, 50], - [48, 88], - [55, 69], + [12, 15], + [62, 17], + [17, 28], + [88, 29], ], }, { tier: 4, positions: [ - [18, 67], - [82, 57], - [15, 82], - [82, 80], - [20, 98], - [80, 96], + [74, 39], + [10, 40], + [34, 45], + [59, 50], + [16, 53], + [89, 49], ], }, ], @@ -114,35 +116,37 @@ export const CONFIGS = { md: { computeHeight: true, containerAspect: 2.5, - tierRadiusPercent: { 1: 25, 2: 20, 3: 14, 4: 10 }, + tierRadiusPercent: { 1: 17, 2: 13, 3: 11, 4: 9 }, tiers: [ { tier: 1, - positions: [[74, 34]], + positions: [[52, 32]], }, { tier: 2, - positions: [[40, 12]], + positions: [ + [49, 11], + [81, 13], + ], }, { tier: 3, positions: [ - [80, 10], - [18, 28], - [32, 47], - [48, 88], - [56, 68], + [21, 15], + [20, 31], + [26, 47], + [84, 30], ], }, { tier: 4, positions: [ - [18, 64], - [76, 55], - [15, 79], - [78, 82], - [20, 98], - [80, 96], + [79, 44], + [41, 63], + [52, 49], + [73, 59], + [19, 65], + [64, 73], ], }, ], @@ -151,35 +155,35 @@ export const CONFIGS = { sm: { computeHeight: true, containerAspect: 2.8, - tierRadiusPercent: { 1: 25, 2: 20, 3: 15, 4: 10 }, + tierRadiusPercent: { 1: 23, 2: 18, 3: 15, 4: 12 }, tiers: [ { tier: 1, - positions: [[70, 29]], + positions: [[69, 34]], }, { tier: 2, - positions: [[40, 9]], + positions: [[30, 11]], }, { tier: 3, positions: [ - [80, 9], - [18, 27], - [28, 43], - [48, 83], - [55, 62], + [69, 12], + [22, 33], + [28, 52], + [23, 71], + [68, 55], ], }, { tier: 4, positions: [ - [18, 58], - [72, 47], - [14, 73], - [80, 76], - [20, 96], - [80, 92], + [73, 72], + [16, 91], + [50, 87], + [78, 94], + [35, 107], + [68, 111], ], }, ], @@ -188,35 +192,35 @@ export const CONFIGS = { xs: { computeHeight: true, containerAspect: 3, - tierRadiusPercent: { 1: 22.5, 2: 18, 3: 13, 4: 9 }, + tierRadiusPercent: { 1: 23, 2: 18, 3: 15, 4: 12 }, tiers: [ { tier: 1, - positions: [[72, 31]], + positions: [[69, 34]], }, { tier: 2, - positions: [[40, 9]], + positions: [[30, 9]], }, { tier: 3, positions: [ - [80, 12], - [18, 27], - [28, 45], - [43, 80], - [68, 64], + [69, 13], + [22, 33], + [28, 52], + [23, 71], + [68, 55], ], }, { tier: 4, positions: [ - [18, 61], - [72, 49], - [13, 74], - [77, 83], - [17, 94], - [65, 97], + [73, 72], + [16, 91], + [50, 87], + [78, 94], + [35, 107], + [68, 111], ], }, ], diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 60bce38bfa6..546d08be457 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -6,6 +6,10 @@ $bubble-palette: (orange, blue, yellow); $bubble-border-radius: 40%; body.template-expert-hub-page { + .main-content-wrapper { + overflow-x: clip; + } + .expert-hub-hero { &__copy { margin-bottom: 2rem; @@ -72,8 +76,9 @@ body.template-expert-hub-page { } .expert-hub-bubble { + visibility: visible; animation: expert-hub-bubble-pop-in 0.5s - cubic-bezier(0.34, 1.56, 0.64, 1) both; + cubic-bezier(0.34, 1.56, 0.64, 1) backwards; } } } @@ -100,6 +105,8 @@ body.template-expert-hub-page { align-items: center; justify-content: center; cursor: pointer; + scale: 1; + visibility: hidden; &__topic-pill { @include topic-pill-button-shape; @@ -109,6 +116,9 @@ body.template-expert-hub-page { 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) { @@ -126,20 +136,16 @@ body.template-expert-hub-page { } &__name { + @include mofo-text-style($body-text-styles, "xsmall"); + display: block; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); - margin-top: rem-calc(6); - font-size: rem-calc(11); - font-weight: 600; - line-height: 1.3; + margin-top: rem-calc(4); text-align: center; - white-space: nowrap; - max-width: 120%; - overflow: hidden; - text-overflow: ellipsis; + max-width: 100%; pointer-events: none; } } @@ -187,7 +193,8 @@ body.template-expert-hub-page { &__inner { margin: 0 auto; - width: clamp(rem-calc(280), 100%, rem-calc(480)); + width: 100%; + max-width: rem-calc(480); background: var(--bubble-color); border-radius: rem-calc(8); padding: $inner-padding; @@ -303,12 +310,11 @@ body.template-expert-hub-page { .expert-hub-bubble { margin: 0; - transition: - scale 0.2s ease, - box-shadow 0.2s ease; + transition: scale 0.2s ease; z-index: 1; &:hover { + scale: 1.08; z-index: 10; } @@ -321,10 +327,6 @@ body.template-expert-hub-page { pointer-events: none; } - &__name { - display: none; - } - &::after { content: ""; position: absolute; @@ -344,7 +346,7 @@ body.template-expert-hub-page { .expert-hub-cta { position: relative; - margin-top: rem-calc(134); + margin-top: rem-calc(104); margin-bottom: rem-calc(65); &::before { @@ -360,8 +362,21 @@ body.template-expert-hub-page { 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(142); + margin-top: rem-calc(72); margin-bottom: rem-calc(105); &::before { @@ -389,7 +404,7 @@ body.template-expert-hub-page { @include mofo-text-style($header-styles, "h4", $header-font-family); margin-top: 0; - margin-bottom: rem-calc(16); + margin-bottom: rem-calc(8); } &__body { @@ -400,7 +415,7 @@ body.template-expert-hub-page { &__btn { margin: 0; - align-self: center; + align-self: flex-start; } } } From 215763f3317efa0d5c0c56c35098f9a5f60cc41b Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 30 Apr 2026 14:56:39 -0700 Subject: [PATCH 20/44] styling tweaks --- .../components/expert_hub_page/viz-configs.js | 34 +++++++++---------- .../static/scss/pages/expert_hub_page.scss | 8 ++--- 2 files changed, 21 insertions(+), 21 deletions(-) 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 index 34f17cfcd94..3fd1d61205f 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -10,32 +10,32 @@ export const CONFIGS = { // ≥ 1200px xxl: { computeHeight: false, - tierRadiusPercent: { 1: 10, 2: 7, 3: 5 }, + tierRadiusPercent: { 1: 10, 2: 6.5, 3: 4.5 }, tiers: [ { tier: 1, - positions: [[56, 55]], + positions: [[59, 50]], }, { tier: 2, positions: [ - [37, 46], - [60, 23], - [78, 31], - [92, 56], - [76, 62], - [22, 58], + [40, 46], + [63, 15], + [78, 32], + [92, 57], + [77, 65], + [25, 54], ], }, { tier: 3, positions: [ - [22, 84], - [93, 20], - [64, 85], - [36, 76], - [7, 54], - [8, 77], + [21, 81], + [90, 21], + [50, 80], + [37, 76], + [10, 53], + [6, 78], ], }, ], @@ -116,7 +116,7 @@ export const CONFIGS = { md: { computeHeight: true, containerAspect: 2.5, - tierRadiusPercent: { 1: 17, 2: 13, 3: 11, 4: 9 }, + tierRadiusPercent: { 1: 18, 2: 13, 3: 11, 4: 9 }, tiers: [ { tier: 1, @@ -142,10 +142,10 @@ export const CONFIGS = { tier: 4, positions: [ [79, 44], - [41, 63], + [42, 65], [52, 49], [73, 59], - [19, 65], + [19, 62], [64, 73], ], }, diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 546d08be457..4572ab16ad6 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -165,6 +165,9 @@ body.template-expert-hub-page { position: fixed; inset: 0; + 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; @@ -178,10 +181,7 @@ body.template-expert-hub-page { } &__panel { - position: absolute; - left: $panel-gap; - right: $panel-gap; - bottom: rem-calc(46); + width: 100%; } .btn-close { From 35711c98914f78c09a98c05b41ecebbe79b3c849 Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Thu, 30 Apr 2026 20:49:49 -0700 Subject: [PATCH 21/44] feat(expert-hub): add decorative parallax arrows and simplify viz breakpoints --- .../images/expert-hub/loop-arrow-left.svg | 4 + .../images/expert-hub/loop-arrow-right.svg | 4 + .../components/expert_hub_page/viz-configs.js | 94 ++----------------- .../js/components/expert_hub_page/viz.js | 15 ++- .../static/scss/pages/expert_hub_page.scss | 74 +++++++++++++++ 5 files changed, 102 insertions(+), 89 deletions(-) create mode 100644 foundation_cms/static/images/expert-hub/loop-arrow-left.svg create mode 100644 foundation_cms/static/images/expert-hub/loop-arrow-right.svg 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/js/components/expert_hub_page/viz-configs.js b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js index 3fd1d61205f..df92a3d5170 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -1,5 +1,5 @@ // ─── Breakpoints (match SCSS customized-settings.scss) ─────────────────────── -export const BREAKPOINTS = { sm: 375, md: 480, lg: 768, xl: 1024, xxl: 1200 }; +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. @@ -8,7 +8,7 @@ export const BREAKPOINTS = { sm: 375, md: 480, lg: 768, xl: 1024, xxl: 1200 }; // tierRadiusPercent: fixed bubble radius per tier as a percentage of vizW (e.g. 7 = 7%). export const CONFIGS = { // ≥ 1200px - xxl: { + xl: { computeHeight: false, tierRadiusPercent: { 1: 10, 2: 6.5, 3: 4.5 }, tiers: [ @@ -34,14 +34,14 @@ export const CONFIGS = { [90, 21], [50, 80], [37, 76], - [10, 53], - [6, 78], + [11, 53], + [9, 78], ], }, ], }, // 1024–1199px - xl: { + lg: { computeHeight: false, tierRadiusPercent: { 1: 10, 2: 7, 3: 5 }, tiers: [ @@ -67,52 +67,13 @@ export const CONFIGS = { [93, 20], [60, 80], [40, 78], - [7, 56], - [8, 81], + [7, 59], + [8, 83], ], }, ], }, - // 768–1023px - lg: { - computeHeight: true, - containerAspect: 2.2, - tierRadiusPercent: { 1: 15, 2: 12, 3: 9, 4: 7.5 }, - tiers: [ - { - tier: 1, - positions: [[49, 31]], - }, - { - tier: 2, - positions: [ - [38, 11], - [85, 12], - ], - }, - { - tier: 3, - positions: [ - [12, 15], - [62, 17], - [17, 28], - [88, 29], - ], - }, - { - tier: 4, - positions: [ - [74, 39], - [10, 40], - [34, 45], - [59, 50], - [16, 53], - [89, 49], - ], - }, - ], - }, - // 480–767px + // 640–1023px md: { computeHeight: true, containerAspect: 2.5, @@ -151,45 +112,8 @@ export const CONFIGS = { }, ], }, - // 375–479px + // < 640px sm: { - computeHeight: true, - containerAspect: 2.8, - tierRadiusPercent: { 1: 23, 2: 18, 3: 15, 4: 12 }, - tiers: [ - { - tier: 1, - positions: [[69, 34]], - }, - { - tier: 2, - positions: [[30, 11]], - }, - { - tier: 3, - positions: [ - [69, 12], - [22, 33], - [28, 52], - [23, 71], - [68, 55], - ], - }, - { - tier: 4, - positions: [ - [73, 72], - [16, 91], - [50, 87], - [78, 94], - [35, 107], - [68, 111], - ], - }, - ], - }, - // < 375px - xs: { computeHeight: true, containerAspect: 3, tierRadiusPercent: { 1: 23, 2: 18, 3: 15, 4: 12 }, diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index 94dfeda5f50..eb1088b3bea 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -46,16 +46,14 @@ const IS_TOUCH = !window.matchMedia("(hover: hover) and (pointer: fine)") /** * Returns the active breakpoint key for the current viewport. * - * @returns {"xxl"|"xl"|"lg"|"md"|"sm"|"xs"} + * @returns {"xl"|"lg"|"md"|"sm"} */ function getBreakpoint() { const w = window.innerWidth; - if (w >= BREAKPOINTS.xxl) return "xxl"; if (w >= BREAKPOINTS.xl) return "xl"; if (w >= BREAKPOINTS.lg) return "lg"; if (w >= BREAKPOINTS.md) return "md"; - if (w >= BREAKPOINTS.sm) return "sm"; - return "xs"; + return "sm"; } /** @@ -465,4 +463,13 @@ export function setupViz() { 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/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 4572ab16ad6..8aefe3123aa 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -8,6 +8,76 @@ $bubble-border-radius: 40%; body.template-expert-hub-page { .main-content-wrapper { overflow-x: clip; + position: relative; + + &::before, + &::after { + content: ""; + position: absolute; + background-size: contain; + background-repeat: no-repeat; + pointer-events: none; + z-index: 0; + will-change: transform; + width: rem-calc(160); + height: rem-calc(160); + + @include breakpoint(medium up) { + width: 30vw; + height: 30vw; + } + + @include breakpoint(large up) { + width: 20vw; + height: 20vw; + } + + @include breakpoint(xlarge up) { + width: rem-calc(300); + height: rem-calc(300); + } + } + + &::before { + background-image: url("/static/foundation_cms/_images/expert-hub/loop-arrow-left.svg"); + left: rem-calc(-46); + top: 15%; + transform: translateY(var(--parallax-y-left, 0)); + + @include breakpoint(medium up) { + left: rem-calc(-40); + top: 32vh; + } + + @include breakpoint(large up) { + left: rem-calc(-65); + top: 48vh; + } + + @include breakpoint(xlarge up) { + left: rem-calc(-65); + top: 55vh; + } + } + + &::after { + background-image: url("/static/foundation_cms/_images/expert-hub/loop-arrow-right.svg"); + right: rem-calc(-60); + top: 28%; + transform: translateY(var(--parallax-y-right, 0)); + + @include breakpoint(medium up) { + top: 85vh; + } + + @include breakpoint(large up) { + top: 26vh; + } + + @include breakpoint(xlarge up) { + top: 18vh; + } + } } .expert-hub-hero { @@ -49,6 +119,7 @@ body.template-expert-hub-page { .expert-hub-viz { position: relative; + z-index: 1; &__loading { position: absolute; @@ -165,6 +236,8 @@ body.template-expert-hub-page { 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); @@ -238,6 +311,7 @@ body.template-expert-hub-page { .expert-hub-viz { width: 100%; height: calc(100dvh - #{$primary-nav-height}); + min-height: rem-calc(800); max-height: rem-calc(900); &__bubble-list { From 1bde9f57607a0b8221d9a0390bcaf69c4b219eeb Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 1 May 2026 10:25:01 -0700 Subject: [PATCH 22/44] layout adjustment and SCSS refactor --- .../components/expert_hub_page/viz-configs.js | 68 +++++++------- .../static/scss/pages/expert_hub_page.scss | 89 +++++++++---------- 2 files changed, 75 insertions(+), 82 deletions(-) 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 index df92a3d5170..0d2dbadb514 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -10,32 +10,32 @@ export const CONFIGS = { // ≥ 1200px xl: { computeHeight: false, - tierRadiusPercent: { 1: 10, 2: 6.5, 3: 4.5 }, + tierRadiusPercent: { 1: 8.4, 2: 6.3, 3: 4.6 }, tiers: [ { tier: 1, - positions: [[59, 50]], + positions: [[59, 46]], }, { tier: 2, positions: [ - [40, 46], + [41, 46], [63, 15], - [78, 32], + [78, 31], [92, 57], [77, 65], - [25, 54], + [25, 51], + [36, 78], ], }, { tier: 3, positions: [ - [21, 81], [90, 21], - [50, 80], - [37, 76], + [64, 77], + [50, 74], [11, 53], - [9, 78], + [19, 75], ], }, ], @@ -81,33 +81,33 @@ export const CONFIGS = { tiers: [ { tier: 1, - positions: [[52, 32]], + positions: [[52, 28]], }, { tier: 2, positions: [ - [49, 11], - [81, 13], + [49, 9], + [81, 11], ], }, { tier: 3, positions: [ - [21, 15], - [20, 31], - [26, 47], - [84, 30], + [15, 13], + [20, 29], + [17, 44], + [84, 26], ], }, { tier: 4, positions: [ - [79, 44], - [42, 65], - [52, 49], - [73, 59], - [19, 62], - [64, 73], + [83, 45], + [60, 46], + [41, 44], + [77, 55], + [19, 57], + [42, 58], ], }, ], @@ -124,27 +124,27 @@ export const CONFIGS = { }, { tier: 2, - positions: [[30, 9]], + positions: [[42, 9]], }, { tier: 3, positions: [ - [69, 13], - [22, 33], - [28, 52], - [23, 71], - [68, 55], + [79, 13], + [22, 26], + [28, 45], + [23, 63], + [68, 58], ], }, { tier: 4, positions: [ - [73, 72], - [16, 91], - [50, 87], - [78, 94], - [35, 107], - [68, 111], + [75, 73], + [16, 79], + [50, 82], + [78, 90], + [21, 95], + [53, 98], ], }, ], diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 8aefe3123aa..497500aadb9 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -10,73 +10,66 @@ body.template-expert-hub-page { overflow-x: clip; position: relative; - &::before, - &::after { - content: ""; - position: absolute; - background-size: contain; - background-repeat: no-repeat; - pointer-events: none; - z-index: 0; - will-change: transform; + // $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: 20vw; - height: 20vw; + width: 18vw; + height: 18vw; + #{$side}: -5vw; + top: list.nth($tops, 3); } @include breakpoint(xlarge up) { - width: rem-calc(300); - height: rem-calc(300); + width: 18vw; + height: 18vw; + max-width: rem-calc(290); + max-height: rem-calc(290); + #{$side}: rem-calc(-75); + top: list.nth($tops, 4); } } - &::before { - background-image: url("/static/foundation_cms/_images/expert-hub/loop-arrow-left.svg"); - left: rem-calc(-46); - top: 15%; - transform: translateY(var(--parallax-y-left, 0)); - - @include breakpoint(medium up) { - left: rem-calc(-40); - top: 32vh; - } - - @include breakpoint(large up) { - left: rem-calc(-65); - top: 48vh; - } + &::before, + &::after { + content: ""; + position: absolute; + background-size: contain; + background-repeat: no-repeat; + pointer-events: none; + z-index: 0; + will-change: transform; + } - @include breakpoint(xlarge up) { - left: rem-calc(-65); - top: 55vh; - } + &::before { + @include parallax-arrow( + "/static/foundation_cms/_images/expert-hub/loop-arrow-left.svg", + left, + parallax-y-left, + (43vh, 20vh, 48vh, rem-calc(500)) + ); } &::after { - background-image: url("/static/foundation_cms/_images/expert-hub/loop-arrow-right.svg"); - right: rem-calc(-60); - top: 28%; - transform: translateY(var(--parallax-y-right, 0)); - - @include breakpoint(medium up) { - top: 85vh; - } - - @include breakpoint(large up) { - top: 26vh; - } - - @include breakpoint(xlarge up) { - top: 18vh; - } + @include parallax-arrow( + "/static/foundation_cms/_images/expert-hub/loop-arrow-right.svg", + right, + parallax-y-right, + (83vh, 58vh, 26vh, rem-calc(200)) + ); } } From bec2b0d7d5f568690156b88d3c802645726d491c Mon Sep 17 00:00:00 2001 From: Mavis Ou Date: Fri, 1 May 2026 11:00:57 -0700 Subject: [PATCH 23/44] refactor(expert-hub): introduce .expert-hub-hero wrapper, separate copy from viz --- .../components/expert_hub_page/viz-configs.js | 6 +- .../js/components/expert_hub_page/viz.js | 19 ++++-- .../static/scss/pages/expert_hub_page.scss | 9 +-- .../pages/profiles/expert_hub_page.html | 65 ++++++++++--------- 4 files changed, 54 insertions(+), 45 deletions(-) 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 index 0d2dbadb514..00d942b04ab 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz-configs.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz-configs.js @@ -103,9 +103,9 @@ export const CONFIGS = { tier: 4, positions: [ [83, 45], - [60, 46], + [62, 46], [41, 44], - [77, 55], + [77, 58], [19, 57], [42, 58], ], @@ -133,7 +133,7 @@ export const CONFIGS = { [22, 26], [28, 45], [23, 63], - [68, 58], + [68, 57], ], }, { diff --git a/foundation_cms/static/js/components/expert_hub_page/viz.js b/foundation_cms/static/js/components/expert_hub_page/viz.js index eb1088b3bea..290f69ca4be 100644 --- a/foundation_cms/static/js/components/expert_hub_page/viz.js +++ b/foundation_cms/static/js/components/expert_hub_page/viz.js @@ -8,6 +8,7 @@ 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", @@ -77,12 +78,14 @@ function getTier(i, n, tierByIndex) { * 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 {object} config - CONFIGS entry for the active breakpoint + * @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, config) { +function init(viz, hero, config) { const { computeHeight, containerAspect, tierRadiusPercent, tiers } = config; const tierByIndex = tiers.flatMap(({ tier, positions }) => positions.map((pos) => ({ tier, pos })), @@ -113,9 +116,10 @@ function init(viz, config) { } const vizH = computeHeight ? vizW * containerAspect : vizRect.height; - // On mobile the hero copy is a sibling of the bubble list, not inside it, - // so there is no overlap zone to subtract. Only desktop needs the copy rect. - const copyEl = computeHeight ? null : viz.querySelector(SELECTORS.copy); + // 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; @@ -438,6 +442,7 @@ function init(viz, config) { export function setupViz() { const viz = document.querySelector(SELECTORS.viz); if (!viz) return; + const hero = viz.closest(SELECTORS.hero); let cleanup = null; let resizeTimer = null; @@ -448,7 +453,7 @@ export function setupViz() { cleanup = null; } const bp = getBreakpoint(); - cleanup = init(viz, CONFIGS[bp]); + cleanup = init(viz, hero, CONFIGS[bp]); } let initialFire = true; diff --git a/foundation_cms/static/scss/pages/expert_hub_page.scss b/foundation_cms/static/scss/pages/expert_hub_page.scss index 497500aadb9..1cd8e0c6662 100644 --- a/foundation_cms/static/scss/pages/expert_hub_page.scss +++ b/foundation_cms/static/scss/pages/expert_hub_page.scss @@ -59,7 +59,7 @@ body.template-expert-hub-page { "/static/foundation_cms/_images/expert-hub/loop-arrow-left.svg", left, parallax-y-left, - (43vh, 20vh, 48vh, rem-calc(500)) + (43vh, 23vh, 48vh, rem-calc(500)) ); } @@ -74,6 +74,9 @@ body.template-expert-hub-page { } .expert-hub-hero { + position: relative; + z-index: 1; + &__copy { margin-bottom: 2rem; @@ -112,7 +115,6 @@ body.template-expert-hub-page { .expert-hub-viz { position: relative; - z-index: 1; &__loading { position: absolute; @@ -128,8 +130,7 @@ body.template-expert-hub-page { display: block; overflow-y: auto; - // Mobile: position: relative so the list flows below .expert-hub-hero__copy - // and creates a containing block for absolutely positioned bubbles. + // Creates a containing block for absolutely positioned bubbles. // Height is set by JS after the force sim resolves. position: relative; } 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 fd8be068a9b..73ccbd920c6 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_hub_page.html @@ -14,8 +14,7 @@ {% block content %}
-
- +

{{ page.title }}

{% if page.description %} @@ -26,37 +25,41 @@

{{ page.title }}

{% endif %}
- +
+ + + -
    - {% for item in featured_experts %} - {% with forloop.counter as idx %} -
  1. - {% if item.topic %}{{ idx }}. {{ item.topic.name }}{% endif %} - {% if item.expert.image %} - {% image item.expert.image fill-300x300 class="expert-hub-bubble__image" %} - {% endif %} - {{ item.expert.title }} -
  2. - {% endwith %} - {% endfor %} -
+
    + {% for item in featured_experts %} + {% with forloop.counter as idx %} +
  1. + {% if item.topic %}{{ idx }}. {{ item.topic.name }}{% endif %} + {% if item.expert.image %} + {% image item.expert.image fill-300x300 class="expert-hub-bubble__image" %} + {% endif %} + {{ item.expert.title }} +
  2. + {% endwith %} + {% endfor %} +
-
+{% endblock content %} \ No newline at end of file diff --git a/frontend/redesign/build-css.js b/frontend/redesign/build-css.js index 04ebf6afa59..6dbd634f8bf 100644 --- a/frontend/redesign/build-css.js +++ b/frontend/redesign/build-css.js @@ -12,6 +12,8 @@ const entries = [ "redesign_migrated_content", "pages/campaign_page", "pages/expert_directory_page", + "pages/expert_hub_page", + "pages/expert_profile_page", "pages/home_page", "pages/maintenance", "pages/project_page", From 2eaa3b67dc839fde7e5fdf173d7a92d09cc28c49 Mon Sep 17 00:00:00 2001 From: mauricio Date: Fri, 15 May 2026 14:43:39 -0600 Subject: [PATCH 40/44] Fixed linting issues --- .../scss/pages/expert_profile_page.scss | 6 +- .../pages/profiles/expert_profile_page.html | 194 +++++++++--------- 2 files changed, 99 insertions(+), 101 deletions(-) diff --git a/foundation_cms/static/scss/pages/expert_profile_page.scss b/foundation_cms/static/scss/pages/expert_profile_page.scss index 998811f064c..49037b48d67 100644 --- a/foundation_cms/static/scss/pages/expert_profile_page.scss +++ b/foundation_cms/static/scss/pages/expert_profile_page.scss @@ -75,7 +75,6 @@ body { align-items: center; justify-content: center; margin-bottom: rem-calc(112); - max-width: rem-calc(1200); margin-inline: auto; @@ -108,7 +107,7 @@ body { ========================= */ .profile-projects { - padding: 0 0 rem-calc(96) 0; + padding: 0 0 rem-calc(96); &__title { @include mofo-text-style($header-styles, "h2", $header-font-family); @@ -120,8 +119,7 @@ body { &__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - column-gap: rem-calc(32); - row-gap: rem-calc(56); + gap: rem-calc(56) rem-calc(32); @include breakpoint(large down) { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/foundation_cms/templates/patterns/pages/profiles/expert_profile_page.html b/foundation_cms/templates/patterns/pages/profiles/expert_profile_page.html index 096eb9d94de..78fb941d14e 100644 --- a/foundation_cms/templates/patterns/pages/profiles/expert_profile_page.html +++ b/foundation_cms/templates/patterns/pages/profiles/expert_profile_page.html @@ -7,123 +7,123 @@ {% endblock extra_css %} {% block content %} -
-
-
+
+
+
-
-
- {% if page.role %} -

{{ page.role }}

- {% endif %} +
+
+ {% if page.role %} +

{{ page.role }}

+ {% endif %} -

{{ page.title }}

+

{{ page.title }}

-
- {% if page.location %} - {{ page.location.name }} - {% endif %} +
+ {% if page.location %} + {{ page.location.name }} + {% endif %} - {% if page.affiliation %} - {{ page.affiliation }} - {% endif %} -
+ {% if page.affiliation %} + {{ page.affiliation }} + {% endif %} +
- {% include "patterns/components/topic_pills.html" with topics=page.topics.all %} -
-
+ {% include "patterns/components/topic_pills.html" with topics=page.topics.all %} +
+
-
+
-
- {% if page.bio %} -
- {{ page.bio|safe }} -
- {% endif %} -
+
+ {% if page.bio %} +
+ {{ page.bio|safe }} +
+ {% endif %} +
-
- {% if page.image %} -
- {% image page.image fill-700x900 as profile_img %} - {{ page.title }} -
- {% endif %} -
+
+ {% if page.image %} +
+ {% image page.image fill-700x900 as profile_img %} + {{ page.title }} +
+ {% endif %} +
-
+
- {% if gallery_projects %} -
-

- {% trans "Projects" %} -

- -
- {% for project in gallery_projects %} -
- - {% if project.hero_image %} -
- {% image project.hero_image fill-600x400 as project_img %} - {% if project.hero_image_alt_text %}{{ project.hero_image_alt_text }}{% else %}{{ project.hero_image.title }}{% endif %} -
- {% endif %} + {% if gallery_projects %} +
+

+ {% trans "Projects" %} +

+ +
+ {% for project in gallery_projects %} +
+ + {% if project.hero_image %} +
+ {% image project.hero_image fill-600x400 as project_img %} + {% if project.hero_image_alt_text %}{{ project.hero_image_alt_text }}{% else %}{{ project.hero_image.title }}{% endif %} +
+ {% endif %} -
+
- {% include "patterns/components/topic_pills.html" with topics=project.topics.all %} + {% include "patterns/components/topic_pills.html" with topics=project.topics.all %} -

- - {{ project.title }} - -

+

+ + {{ project.title }} + +

- {% if project.lede_text %} -

- {{ project.lede_text }} -

- {% endif %} + {% if project.lede_text %} +

+ {{ project.lede_text }} +

+ {% endif %} - {% if project.cta_link %} -
- {% include "patterns/components/streamfield.html" with streamfield=project.cta_link %} -
- {% endif %} -
-
- {% endfor %} -
-
- {% endif %} + {% if project.cta_link %} +
+ {% include "patterns/components/streamfield.html" with streamfield=project.cta_link %} +
+ {% endif %} +
+
+ {% endfor %} +
+ + {% endif %} - {% if page.body %} -
- {% include "patterns/components/streamfield.html" with streamfield=page.body %} -
- {% endif %} + {% if page.body %} +
+ {% include "patterns/components/streamfield.html" with streamfield=page.body %} +
+ {% endif %} + + - - -
-
+ + {% endblock content %} \ No newline at end of file From 1d12ef262ae50c7e55d4e5e7984586164c682fa9 Mon Sep 17 00:00:00 2001 From: mauricio Date: Fri, 15 May 2026 15:49:26 -0600 Subject: [PATCH 41/44] Fixed linting issues --- .../static/scss/pages/expert_profile_page.scss | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/foundation_cms/static/scss/pages/expert_profile_page.scss b/foundation_cms/static/scss/pages/expert_profile_page.scss index 49037b48d67..d2bb3b1b950 100644 --- a/foundation_cms/static/scss/pages/expert_profile_page.scss +++ b/foundation_cms/static/scss/pages/expert_profile_page.scss @@ -77,7 +77,6 @@ body { margin-bottom: rem-calc(112); max-width: rem-calc(1200); margin-inline: auto; - &__bio { @include mofo-text-style($body-text-styles, "regular"); @@ -107,7 +106,7 @@ body { ========================= */ .profile-projects { - padding: 0 0 rem-calc(96); + padding-bottom: rem-calc(96); &__title { @include mofo-text-style($header-styles, "h2", $header-font-family); @@ -174,11 +173,7 @@ body { margin: 0 0 rem-calc(8); a { - @include mofo-text-style( - $header-styles, - "h6", - $header-font-family - ); + @include mofo-text-style($header-styles, "h6", $header-font-family); color: color(neutral, "900"); text-decoration: none; @@ -260,4 +255,4 @@ body { } } } -} \ No newline at end of file +} From 2e80a39d7ebfea39b27df6389e0dfdc0dbbaed71 Mon Sep 17 00:00:00 2001 From: mauricio Date: Fri, 15 May 2026 15:55:25 -0600 Subject: [PATCH 42/44] Fixed missing space lint --- foundation_cms/static/scss/pages/expert_profile_page.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/foundation_cms/static/scss/pages/expert_profile_page.scss b/foundation_cms/static/scss/pages/expert_profile_page.scss index d2bb3b1b950..45b8436f06a 100644 --- a/foundation_cms/static/scss/pages/expert_profile_page.scss +++ b/foundation_cms/static/scss/pages/expert_profile_page.scss @@ -77,6 +77,7 @@ body { margin-bottom: rem-calc(112); max-width: rem-calc(1200); margin-inline: auto; + &__bio { @include mofo-text-style($body-text-styles, "regular"); From 3c34bd4e59c50d09cfb8b449dc0468c76952709a Mon Sep 17 00:00:00 2001 From: mauricio Date: Fri, 15 May 2026 16:06:35 -0600 Subject: [PATCH 43/44] Fixed missing space --- foundation_cms/static/scss/pages/expert_profile_page.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation_cms/static/scss/pages/expert_profile_page.scss b/foundation_cms/static/scss/pages/expert_profile_page.scss index 45b8436f06a..1d2d068cc3e 100644 --- a/foundation_cms/static/scss/pages/expert_profile_page.scss +++ b/foundation_cms/static/scss/pages/expert_profile_page.scss @@ -77,7 +77,7 @@ body { margin-bottom: rem-calc(112); max-width: rem-calc(1200); margin-inline: auto; - + &__bio { @include mofo-text-style($body-text-styles, "regular"); From 72b46982c3c3d8b493a4228ef12189f8c7a4ade1 Mon Sep 17 00:00:00 2001 From: mauricio Date: Fri, 15 May 2026 16:38:22 -0600 Subject: [PATCH 44/44] Fixed failed nav test --- foundation_cms/navigation/factories.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",