From c2d8368f2a4f21ba53cfda24dc253456bd9d546a Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 24 Jun 2026 21:25:43 +0100 Subject: [PATCH 1/2] Group steering-committee teams with connecting colour bars (#808) In the team grid, members could be orphaned across line breaks, making it ambiguous which sub-team a wrapped member belonged to. Each team now reads as one connected run: a coloured bar links every tile in a team (title + members), bridging the gap between tiles and breaking cleanly at the row edge so it resumes on the next line when a team wraps. Each team gets a distinct colour; Steering & Guidance (which have no sub-teams) stay plain. Grouping and colours are now baked in at generation time, replacing the fragile runtime CSV name-matching/reorder script. Co-Authored-By: Claude Opus 4.8 (1M context) --- content/about/steering-committee/index.md | 535 +++++++++------------- scripts/generate_sc_profiles.py | 333 ++++---------- static/css/steering-committee.css | 111 ++--- 3 files changed, 331 insertions(+), 648 deletions(-) diff --git a/content/about/steering-committee/index.md b/content/about/steering-committee/index.md index 2e846383415..10e28fb305a 100644 --- a/content/about/steering-committee/index.md +++ b/content/about/steering-committee/index.md @@ -29,74 +29,102 @@ layout: single

Core mission-driven teams advancing open scholarship, social justice, and community sustainability.

-
+
Team

Community & Sustainability

-
- dr. Steven Verheyen -
+ +
+
+ dr. Steven Verheyen +
+

dr. Steven Verheyen

Director

-
- Adira Daniel -
+ +
+
+ Adira Daniel +
+

Adira Daniel

Co-Director

-
+ +
Team

Social Justice & DEIA

-
- Dr. Sarah A. Sauvé -
+ +
+
+ Dr. Sarah A. Sauvé +
+

Dr. Sarah A. Sauvé

Director

-
+ +
Team

Meta-science & Research

-
- Dr Rachel Heyard -
+ +
+
+ Dr Rachel Heyard +
+

Dr Rachel Heyard

Director

-
- Tom Heyman -
+ +
+
+ Tom Heyman +
+

Tom Heyman

Deputy-Director

-
- Berit T. Barthelmes, M.Sc. -
+ +
+
+ Berit T. Barthelmes, M.Sc. +
+

Berit T. Barthelmes, M.Sc.

Project Manager

-
+ +
Team

Education & Pedagogy

-
- Dr Madeleine Pownall -
+ +
+
+ Dr Madeleine Pownall +
+

Dr Madeleine Pownall

Director

-
- Lorna Hamilton -
+ +
+
+ Lorna Hamilton +
+

Lorna Hamilton

Deputy-Director

+
@@ -108,176 +136,246 @@ layout: single

Infrastructure, community management, ethical oversight, and support systems powering FORRT.

-
+
Team

Community Governance & Management

-
- Dr. Amanda Kay Montoya -
+ +
+
+ Dr. Amanda Kay Montoya +
+

Dr. Amanda Kay Montoya

Director

-
- Dr Kelly Lloyd -
+ +
+
+ Dr Kelly Lloyd +
+

Dr Kelly Lloyd

Cohesion-Liaison

-
- Giorgia Andreolli -
+ +
+
+ Giorgia Andreolli +
+

Giorgia Andreolli

Cohesion-Liaison

-
+ +
Team

Community Engagement

-
- Dr. Lukas Röseler -
+ +
+
+ Dr. Lukas Röseler +
+

Dr. Lukas Röseler

Infrastructure-Liaison

-
+ +
Team

Sustainability & Strategy

-
- Dr Sarah Ashcroft-Jones -
+ +
+
+ Dr Sarah Ashcroft-Jones +
+

Dr Sarah Ashcroft-Jones

Director

-
- Sara Lil Middleton -
+ +
+
+ Sara Lil Middleton +
+

Sara Lil Middleton

Knowledge Manager

-
+ +
Team

Ethics and Inclusion

-
- Dr John Shaw -
+ +
+
+ Dr John Shaw +
+

Dr John Shaw

Director

-
- Thomas Rhys Evans -
+ +
+
+ Thomas Rhys Evans +
+

Thomas Rhys Evans

Advisor

-
+ +
Team

Fundraising

-
- Dr Max Korbmacher -
+ +
+
+ Dr Max Korbmacher +
+

Dr Max Korbmacher

Director

-
- Karen Matvienko-Sikar -
+ +
+
+ Karen Matvienko-Sikar +
+

Karen Matvienko-Sikar

Co-Director

-
- Fotis Mystakopoulos, PhD Student -
+ +
+
+ Fotis Mystakopoulos, PhD Student +
+

Fotis Mystakopoulos, PhD Student

Officer

-
+ +
Team

Financial Transparency

-
- Alicia Tamara Veersma Barredo -
+ +
+
+ Alicia Tamara Veersma Barredo +
+

Alicia Tamara Veersma Barredo

Officer

-
+ +
Team

Partnerships

-
- Adam Partridge -
+ +
+
+ Adam Partridge +
+

Adam Partridge

Director

-
+ +
Team

Digital Infrastructure Team

-
- Justin Sulik -
+ +
+
+ Justin Sulik +
+

Justin Sulik

Director

-
- Dushime Mudahera Richard -
+ +
+
+ Dushime Mudahera Richard +
+

Dushime Mudahera Richard

Website Lead

-
+ +
Team

Communication, Dissemination & Impact

-
- Charlotte Pennington -
+ +
+
+ Charlotte Pennington +
+

Charlotte Pennington

Director

-
+ +
Team

FORRT Stewards

-
- Stephanie Zellers -
+ +
+
+ Stephanie Zellers +
+

Stephanie Zellers

FORRT Steward

-
- Roksana Sobolak -
+ +
+
+ Roksana Sobolak +
+

Roksana Sobolak

FORRT Steward

-
- Riva Quiroga -
+ +
+
+ Riva Quiroga +
+

Riva Quiroga

FORRT Steward

-
- Meera Chandra -
+ +
+
+ Meera Chandra +
+

Meera Chandra

FORRT Steward

+
@@ -290,15 +388,19 @@ layout: single
- Dr Lukas Wallrich -
+
+ Dr Lukas Wallrich +
+

Dr Lukas Wallrich

Co-Director

- Dr Flavio Azevedo -
+
+ Dr Flavio Azevedo +
+

Dr Flavio Azevedo

Director

@@ -315,15 +417,19 @@ layout: single
- Sam Parsons -
+
+ Sam Parsons +
+

Sam Parsons

Stewardship Panel

- Thomas Rhys Evans -
+
+ Thomas Rhys Evans +
+

Thomas Rhys Evans

Ombudsman

@@ -1189,223 +1295,6 @@ document.addEventListener('DOMContentLoaded', () => { } } }); - - /* --- Team linking + ordering based on source CSV --- */ - const SC_ORDER_CSV = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRCHSY7WBvzDSSWyUyOVPRbsf5QxO7Mc40hGB7yanfT-rjbcNthMbHvUxT0NJ3AAfLKfx4YiOghByZT/pub?output=csv"; - const TEAM_COLORS = ["#2563eb","#c026d3","#ea580c","#22c55e","#0ea5e9","#f59e0b","#ef4444","#8b5cf6","#14b8a6","#f97316"]; - const BACKGROUND_COLORS = ["#0f766e", "#475569"]; - const honorifics = /^(dr|dr\.|prof|prof\.|mr|mrs|ms|miss)\s+/i; - const normalizeBase = (str = "") => str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); - const normalizeText = (str = "") => normalizeBase(str.replace(/&/gi, "&").trim().toLowerCase()); - const normalizeTeam = (str = "") => normalizeText(str).replace(/\s+/g, " "); - const nameTokens = (str = "") => - normalizeText(str.replace(honorifics, "")) - .replace(/[^a-z0-9\s]/g, " ") - .split(/\s+/) - .filter(Boolean); - - const tokenSimilarity = (aTokens, bTokens) => { - if (!aTokens.length || !bTokens.length) return 0; - const aSet = new Set(aTokens); - const bSet = new Set(bTokens); - let intersect = 0; - bSet.forEach((t) => { - if (aSet.has(t)) intersect += 1; - }); - return intersect / Math.max(aSet.size, bSet.size, 1); - }; - - const charDice = (aStr, bStr) => { - const a = aStr.replace(/\s+/g, ""); - const b = bStr.replace(/\s+/g, ""); - if (!a.length || !b.length) return 0; - const count = (s) => { - const m = {}; - for (const ch of s) m[ch] = (m[ch] || 0) + 1; - return m; - }; - const aCount = count(a); - const bCount = count(b); - let overlap = 0; - Object.keys(aCount).forEach((ch) => { - if (bCount[ch]) overlap += Math.min(aCount[ch], bCount[ch]); - }); - return (2 * overlap) / (a.length + b.length); - }; - - const parseCSV = (text) => - text - .trim() - .split(/\\r?\\n/) - .map((line) => line.split(",").map((cell) => cell.replace(/^"+|"+$/g, "").trim())); - - const buildOrders = (rows) => { - const sectionMap = {}; - document.querySelectorAll(".sc-section").forEach((sec) => { - const title = sec.querySelector(".sc-section-title span")?.textContent || sec.id; - sectionMap[normalizeTeam(title)] = sec.id; - }); - - const findSection = (raw) => { - const key = normalizeTeam(raw); - if (sectionMap[key]) return sectionMap[key]; - - const operationsId = Object.entries(sectionMap).find(([k]) => k.includes('operation'))?.[1]; - if (operationsId && (key.includes('ombudsman') || key.includes('steward'))) { - return operationsId; - } - - let best = null; - let bestScore = -1; - Object.entries(sectionMap).forEach(([k, id]) => { - const score = charDice(k, key); - if (score > bestScore) { - bestScore = score; - best = id; - } - }); - return best || Object.values(sectionMap)[0] || null; - }; - - const orders = {}; - rows.slice(1).forEach((row) => { - const sectionRaw = row[1]; - const teamRaw = row[3]; - const nameRaw = row[5]; - if (!sectionRaw || !teamRaw || !nameRaw) return; - const secKey = findSection(sectionRaw); - if (!secKey) return; - if (!orders[secKey]) orders[secKey] = []; - let group = orders[secKey].find((g) => normalizeTeam(g.team) === normalizeTeam(teamRaw)); - if (!group) { - group = { team: teamRaw, members: [] }; - orders[secKey].push(group); - } - group.members.push(nameRaw); - }); - return orders; - }; - - const reorderAndLink = (sectionId, teams) => { - const grid = document.querySelector(`#${sectionId} .sc-grid`); - if (!grid || !teams || !teams.length) return; - - const titleCards = Array.from(grid.querySelectorAll('.sc-title-card')); - const memberCards = Array.from(grid.querySelectorAll('.sc-card')); - - [...titleCards, ...memberCards].forEach((el) => { - el.classList.remove('sc-team-linked', 'sc-team-title'); - el.style.removeProperty('--team-color'); - }); - - const titleMap = new Map(); - titleCards.forEach((card) => { - const text = card.querySelector('.sc-title-text')?.textContent || ""; - titleMap.set(normalizeTeam(text), card); - }); - - const memberMap = []; - memberCards.forEach((card) => { - const name = card.querySelector('.sc-card-name')?.textContent || ""; - const tokens = nameTokens(name); - if (!tokens.length) return; - memberMap.push({ tokens, card }); - }); - - const takeMember = (rawName) => { - const targetTokens = nameTokens(rawName); - if (!targetTokens.length) return null; - const targetJoined = targetTokens.join(""); - - let best = null; - let bestScore = 0.45; - - memberMap.forEach((entry, idx) => { - const tokenScore = tokenSimilarity(entry.tokens, targetTokens); - const joined = entry.tokens.join(""); - const charScore = charDice(joined, targetJoined); - const score = Math.max(tokenScore, charScore); - if (score > bestScore) { - bestScore = score; - best = { idx, card: entry.card }; - } - }); - - if (best) { - memberMap.splice(best.idx, 1); - return best.card; - } - return null; - }; - - let colorIndex = 0; - const parseColor = (hex) => { - const h = hex.replace("#", ""); - const r = parseInt(h.substring(0, 2), 16) / 255; - const g = parseInt(h.substring(2, 4), 16) / 255; - const b = parseInt(h.substring(4, 6), 16) / 255; - const toLin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); - return { r: toLin(r), g: toLin(g), b: toLin(b) }; - }; - const contrast = (hex1, hex2) => { - const a = parseColor(hex1); - const b = parseColor(hex2); - const lum = ({ r, g, b }) => 0.2126 * r + 0.7152 * g + 0.0722 * b; - const l1 = lum(a) + 0.05; - const l2 = lum(b) + 0.05; - return l1 > l2 ? l1 / l2 : l2 / l1; - }; - const nextColor = () => { - let attempts = 0; - while (attempts < TEAM_COLORS.length * 2) { - const color = TEAM_COLORS[colorIndex % TEAM_COLORS.length]; - colorIndex += 1; - const ok = BACKGROUND_COLORS.every((bg) => contrast(color, bg) > 4); - if (ok) return color; - attempts += 1; - } - return TEAM_COLORS[(colorIndex++) % TEAM_COLORS.length]; - }; - - const newChildren = []; - - teams.forEach((team) => { - const color = nextColor(); - const teamKey = normalizeTeam(team.team); - const titleCard = titleMap.get(teamKey); - if (titleCard) { - titleCard.style.setProperty('--team-color', color); - titleCard.classList.add('sc-team-title'); - titleMap.delete(teamKey); - newChildren.push(titleCard); - } - - team.members.forEach((memberName) => { - const card = takeMember(memberName); - if (card) { - card.style.setProperty('--team-color', color); - card.classList.add('sc-team-linked'); - newChildren.push(card); - } - }); - }); - - const leftovers = []; - titleMap.forEach((card) => leftovers.push(card)); - memberMap.forEach((entry) => leftovers.push(entry.card)); - - grid.innerHTML = ''; - [...newChildren, ...leftovers].forEach((el) => grid.appendChild(el)); - }; - - fetch(SC_ORDER_CSV) - .then((res) => res.text()) - .then((text) => { - const rows = parseCSV(text); - const orders = buildOrders(rows); - Object.entries(orders).forEach(([sectionId, teams]) => reorderAndLink(sectionId, teams)); - }) - .catch((err) => console.warn('SC team linking failed', err)); }); diff --git a/scripts/generate_sc_profiles.py b/scripts/generate_sc_profiles.py index 9f30b26b97a..0d22b88566c 100755 --- a/scripts/generate_sc_profiles.py +++ b/scripts/generate_sc_profiles.py @@ -11,19 +11,26 @@ 6. Downloads profile pictures from Google Drive """ -import pandas as pd import os import shutil import re -import requests import unicodedata import json import html from pathlib import Path from difflib import SequenceMatcher from urllib.parse import parse_qs, urlparse -from PIL import Image -from io import BytesIO + +# Heavy, network/data-only dependencies. Kept optional so the page templates and render +# helpers in this module can be imported (e.g. by tooling that re-renders the page from +# existing data) without installing pandas/requests/Pillow. main() requires them. +try: + import pandas as pd + import requests + from PIL import Image + from io import BytesIO +except ImportError: # pragma: no cover - exercised only when deps are absent + pd = requests = Image = BytesIO = None # Configuration SC_CSV_URL = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRCHSY7WBvzDSSWyUyOVPRbsf5QxO7Mc40hGB7yanfT-rjbcNthMbHvUxT0NJ3AAfLKfx4YiOghByZT/pub?output=csv" @@ -131,223 +138,6 @@ } } }); - - /* --- Team linking + ordering based on source CSV --- */ - const SC_ORDER_CSV = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRCHSY7WBvzDSSWyUyOVPRbsf5QxO7Mc40hGB7yanfT-rjbcNthMbHvUxT0NJ3AAfLKfx4YiOghByZT/pub?output=csv"; - const TEAM_COLORS = ["#2563eb","#c026d3","#ea580c","#22c55e","#0ea5e9","#f59e0b","#ef4444","#8b5cf6","#14b8a6","#f97316"]; - const BACKGROUND_COLORS = ["#0f766e", "#475569"]; - const honorifics = /^(dr|dr\.|prof|prof\.|mr|mrs|ms|miss)\s+/i; - const normalizeBase = (str = "") => str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); - const normalizeText = (str = "") => normalizeBase(str.replace(/&/gi, "&").trim().toLowerCase()); - const normalizeTeam = (str = "") => normalizeText(str).replace(/\s+/g, " "); - const nameTokens = (str = "") => - normalizeText(str.replace(honorifics, "")) - .replace(/[^a-z0-9\s]/g, " ") - .split(/\s+/) - .filter(Boolean); - - const tokenSimilarity = (aTokens, bTokens) => { - if (!aTokens.length || !bTokens.length) return 0; - const aSet = new Set(aTokens); - const bSet = new Set(bTokens); - let intersect = 0; - bSet.forEach((t) => { - if (aSet.has(t)) intersect += 1; - }); - return intersect / Math.max(aSet.size, bSet.size, 1); - }; - - const charDice = (aStr, bStr) => { - const a = aStr.replace(/\s+/g, ""); - const b = bStr.replace(/\s+/g, ""); - if (!a.length || !b.length) return 0; - const count = (s) => { - const m = {}; - for (const ch of s) m[ch] = (m[ch] || 0) + 1; - return m; - }; - const aCount = count(a); - const bCount = count(b); - let overlap = 0; - Object.keys(aCount).forEach((ch) => { - if (bCount[ch]) overlap += Math.min(aCount[ch], bCount[ch]); - }); - return (2 * overlap) / (a.length + b.length); - }; - - const parseCSV = (text) => - text - .trim() - .split(/\\r?\\n/) - .map((line) => line.split(",").map((cell) => cell.replace(/^"+|"+$/g, "").trim())); - - const buildOrders = (rows) => { - const sectionMap = {}; - document.querySelectorAll(".sc-section").forEach((sec) => { - const title = sec.querySelector(".sc-section-title span")?.textContent || sec.id; - sectionMap[normalizeTeam(title)] = sec.id; - }); - - const findSection = (raw) => { - const key = normalizeTeam(raw); - if (sectionMap[key]) return sectionMap[key]; - - const operationsId = Object.entries(sectionMap).find(([k]) => k.includes('operation'))?.[1]; - if (operationsId && (key.includes('ombudsman') || key.includes('steward'))) { - return operationsId; - } - - let best = null; - let bestScore = -1; - Object.entries(sectionMap).forEach(([k, id]) => { - const score = charDice(k, key); - if (score > bestScore) { - bestScore = score; - best = id; - } - }); - return best || Object.values(sectionMap)[0] || null; - }; - - const orders = {}; - rows.slice(1).forEach((row) => { - const sectionRaw = row[1]; - const teamRaw = row[3]; - const nameRaw = row[5]; - if (!sectionRaw || !teamRaw || !nameRaw) return; - const secKey = findSection(sectionRaw); - if (!secKey) return; - if (!orders[secKey]) orders[secKey] = []; - let group = orders[secKey].find((g) => normalizeTeam(g.team) === normalizeTeam(teamRaw)); - if (!group) { - group = { team: teamRaw, members: [] }; - orders[secKey].push(group); - } - group.members.push(nameRaw); - }); - return orders; - }; - - const reorderAndLink = (sectionId, teams) => { - const grid = document.querySelector(`#${sectionId} .sc-grid`); - if (!grid || !teams || !teams.length) return; - - const titleCards = Array.from(grid.querySelectorAll('.sc-title-card')); - const memberCards = Array.from(grid.querySelectorAll('.sc-card')); - - [...titleCards, ...memberCards].forEach((el) => { - el.classList.remove('sc-team-linked', 'sc-team-title'); - el.style.removeProperty('--team-color'); - }); - - const titleMap = new Map(); - titleCards.forEach((card) => { - const text = card.querySelector('.sc-title-text')?.textContent || ""; - titleMap.set(normalizeTeam(text), card); - }); - - const memberMap = []; - memberCards.forEach((card) => { - const name = card.querySelector('.sc-card-name')?.textContent || ""; - const tokens = nameTokens(name); - if (!tokens.length) return; - memberMap.push({ tokens, card }); - }); - - const takeMember = (rawName) => { - const targetTokens = nameTokens(rawName); - if (!targetTokens.length) return null; - const targetJoined = targetTokens.join(""); - - let best = null; - let bestScore = 0.45; - - memberMap.forEach((entry, idx) => { - const tokenScore = tokenSimilarity(entry.tokens, targetTokens); - const joined = entry.tokens.join(""); - const charScore = charDice(joined, targetJoined); - const score = Math.max(tokenScore, charScore); - if (score > bestScore) { - bestScore = score; - best = { idx, card: entry.card }; - } - }); - - if (best) { - memberMap.splice(best.idx, 1); - return best.card; - } - return null; - }; - - let colorIndex = 0; - const parseColor = (hex) => { - const h = hex.replace("#", ""); - const r = parseInt(h.substring(0, 2), 16) / 255; - const g = parseInt(h.substring(2, 4), 16) / 255; - const b = parseInt(h.substring(4, 6), 16) / 255; - const toLin = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); - return { r: toLin(r), g: toLin(g), b: toLin(b) }; - }; - const contrast = (hex1, hex2) => { - const a = parseColor(hex1); - const b = parseColor(hex2); - const lum = ({ r, g, b }) => 0.2126 * r + 0.7152 * g + 0.0722 * b; - const l1 = lum(a) + 0.05; - const l2 = lum(b) + 0.05; - return l1 > l2 ? l1 / l2 : l2 / l1; - }; - const nextColor = () => { - let attempts = 0; - while (attempts < TEAM_COLORS.length * 2) { - const color = TEAM_COLORS[colorIndex % TEAM_COLORS.length]; - colorIndex += 1; - const ok = BACKGROUND_COLORS.every((bg) => contrast(color, bg) > 4); - if (ok) return color; - attempts += 1; - } - return TEAM_COLORS[(colorIndex++) % TEAM_COLORS.length]; - }; - - const newChildren = []; - - teams.forEach((team) => { - const color = nextColor(); - const teamKey = normalizeTeam(team.team); - const titleCard = titleMap.get(teamKey); - if (titleCard) { - titleCard.style.setProperty('--team-color', color); - titleCard.classList.add('sc-team-title'); - titleMap.delete(teamKey); - newChildren.push(titleCard); - } - - team.members.forEach((memberName) => { - const card = takeMember(memberName); - if (card) { - card.style.setProperty('--team-color', color); - card.classList.add('sc-team-linked'); - newChildren.push(card); - } - }); - }); - - const leftovers = []; - titleMap.forEach((card) => leftovers.push(card)); - memberMap.forEach((entry) => leftovers.push(entry.card)); - - grid.innerHTML = ''; - [...newChildren, ...leftovers].forEach((el) => grid.appendChild(el)); - }; - - fetch(SC_ORDER_CSV) - .then((res) => res.text()) - .then((text) => { - const rows = parseCSV(text); - const orders = buildOrders(rows); - Object.entries(orders).forEach(([sectionId, teams]) => reorderAndLink(sectionId, teams)); - }) - .catch((err) => console.warn('SC team linking failed', err)); }); """ @@ -367,17 +157,50 @@ """ +# Distinct, white-text-legible colour per team within a section. Operations has the most +# teams (10), so the palette has 10 entries; sections restart the index, so no repeats +# occur within a single section. +TEAM_COLORS = [ + "#0f766e", "#2563eb", "#7c3aed", "#be123c", "#b45309", + "#0891b2", "#4d7c0f", "#db2777", "#475569", "#a16207", +] + +# How far a tile's connecting bar reaches past its right edge to bridge the flex gap to +# the next tile in the same team (the grid gap is 1rem). Last tile in a team gets 0px, so +# the bar stops cleanly; tiles mid-team extend, and at a row break the bar is clipped at +# the row edge and resumes on the next row — a visible "this team continues" cue. +BAR_EXTEND = "calc(1rem + 1px)" + TEAM_TITLE_CARD_TEMPLATE = r""" -
+
Team

{name}

+
""" +# Connected member card (teams with a title card): coloured connecting bar links it to its team. MEMBER_CARD_TEMPLATE = r""" +
+
+ {img_content} +
+
+
+

{name}

+

{role}

+
+ +
+""" + +# Plain member card (sections without team titles, e.g. Steering & Guidance): no team bar. +MEMBER_CARD_PLAIN_TEMPLATE = r"""
- {img_content} -
+
+ {img_content} +
+

{name}

{role}

@@ -514,6 +337,30 @@ def normalize_website_url(url): return f"https://{url}" return url +def render_title_card(name, color, has_members): + """Team title tile (the coloured anchor each team's connecting bar runs from).""" + return TEAM_TITLE_CARD_TEMPLATE.format( + name=name, + color=color, + extend=BAR_EXTEND if has_members else "0px", + ).strip() + +def render_member_card(member_id, name, role, img_content, color=None, is_last=False): + """Member tile. With a colour it carries the team's connecting bar; without, it is plain + (used by sections that have no team titles, e.g. Steering & Guidance).""" + if color is None: + return MEMBER_CARD_PLAIN_TEMPLATE.format( + id=member_id, name=name, role=role, img_content=img_content + ).strip() + return MEMBER_CARD_TEMPLATE.format( + id=member_id, + name=name, + role=role, + img_content=img_content, + color=color, + extend="0px" if is_last else BAR_EXTEND, + ).strip() + def main(): print("=" * 60) print("Generating Steering Committee Page (Static HTML)") @@ -680,24 +527,21 @@ def add_personal_entry(row, target, allow_overwrite=False): cat_details = CATEGORY_DETAILS[cat_key] cards_html = "" - + has_team_titles = cat_key in categories_with_team_titles + # Keep teams in CSV order (insertion order) - for team_name in cat_data["order"]: + for team_index, team_name in enumerate(cat_data["order"]): members = cat_data["teams"][team_name] - - # Only Strategic and Operations display team title cards; Steering and Guidance list members directly. - if cat_key in categories_with_team_titles: - bg_class = "teal" - if cat_key == "operations": - bg_class = "slate" - - cards_html += TEAM_TITLE_CARD_TEMPLATE.format( - name=team_name, - bg_class=bg_class - ).strip() - + + # Only Strategic and Operations display team title cards (and the connecting + # bars that group each team); Steering and Guidance list members directly. + team_color = TEAM_COLORS[team_index % len(TEAM_COLORS)] if has_team_titles else None + + if has_team_titles: + cards_html += render_title_card(team_name, team_color, has_members=bool(members)) + # Member Cards - for member in members: + for member_index, member in enumerate(members): img_content = "" if member["imgUrl"]: img_content = f'{member[' @@ -705,12 +549,11 @@ def add_personal_entry(row, target, allow_overwrite=False): # Placeholder for missing image img_content = f'
{member["initials"]}
' - cards_html += MEMBER_CARD_TEMPLATE.format( - id=member["id"], - name=member["name"], - role=member["role"], - img_content=img_content - ).strip() + cards_html += render_member_card( + member["id"], member["name"], member["role"], img_content, + color=team_color, + is_last=(member_index == len(members) - 1), + ) # Generate Modal for this member img_content_large = "" diff --git a/static/css/steering-committee.css b/static/css/steering-committee.css index e4835ff07a5..07d6ffb1899 100644 --- a/static/css/steering-committee.css +++ b/static/css/steering-committee.css @@ -133,14 +133,18 @@ article.article .article-container .article-title { gap: 1rem; padding: 0; justify-content: flex-start; + /* Clip the connecting team bars at the row/section edges so they break cleanly + at the end of a row and resume on the next one (see .sc-cbar). */ + overflow: hidden; } /* Cards */ .sc-card { position: relative; - background: #e2e8f0; cursor: pointer; - overflow: hidden; + /* overflow stays visible so the connecting bar can reach into the gap; the photo + itself is clipped by .sc-card-photo. */ + overflow: visible; border: 1px solid transparent; /* Portrait aspect ratio 2:3 */ aspect-ratio: 2/3; @@ -152,6 +156,15 @@ article.article .article-container .article-title { flex-shrink: 0; } +/* Clipped photo wrapper: holds the image + overlay so the zoom-on-hover and crop stay + contained, while the connecting bar (a direct child of .sc-card) can overflow. */ +.sc-card-photo { + position: absolute; + inset: 0; + overflow: hidden; + background: #e2e8f0; +} + @media (min-width: 640px) { .sc-card { width: 200px; @@ -293,6 +306,8 @@ article.article .article-container .article-title { /* Title Cards */ .sc-title-card { + position: relative; + overflow: visible; /* let the connecting bar reach into the gap toward the first member */ aspect-ratio: 2/3; /* Match card aspect ratio */ padding: 1rem; @@ -300,6 +315,9 @@ article.article .article-container .article-title { flex-direction: column; justify-content: center; border: 0; + /* Per-team colour, set inline via --team-color */ + background-color: var(--team-color, #0b5d55); + color: #f8fafc; /* Absolute width for consistent sizing */ width: 180px; flex-shrink: 0; @@ -323,16 +341,6 @@ article.article .article-container .article-title { } } -.sc-title-card.teal { - background-color: #0b5d55; /* slightly darker teal */ - color: #f8fafc; -} - -.sc-title-card.slate { - background-color: #364152; /* deeper slate */ - color: #f8fafc; -} - .sc-title-label { font-size: 0.65rem; font-weight: 700; @@ -350,78 +358,21 @@ article.article .article-container .article-title { color: #f8fafc; } -/* Team linkage styling */ -.sc-team-linked { - position: relative; - border-color: transparent; - box-shadow: none; -} - -.sc-team-title { - position: relative; - border-color: transparent; - box-shadow: none; - overflow: hidden; -} - -.sc-team-linked::before, -.sc-team-linked::after, -.sc-team-title::before, -.sc-team-title::after { - content: ""; - position: absolute; - pointer-events: none; - z-index: 30; -} - -/* Member accent: thin line at top */ -.sc-team-linked::before, -.sc-team-linked::after { - top: 0; - left: 0; - width: 100%; - height: 5px; - background: var(--team-color, #0f766e); -} - -.sc-team-linked::after { - right: 12px; - width: 16px; - height: 5px; - background: linear-gradient(90deg, transparent 0%, var(--team-color, #0f766e) 60%, transparent 100%); -} - -/* Team title accent: solid bar + half arrow at top */ -.sc-team-title { - position: relative; - border-color: transparent; - box-shadow: none; - overflow: hidden; -} - -.sc-team-title::before, -.sc-team-title::after { - display: block; - content: ""; +/* Connecting team bar + A coloured rail along the top of every tile in a team (title + members). It reaches + --bar-extend past the tile's right edge to bridge the grid gap to the next tile in the + same team, so a team reads as one connected run. The last tile in a team uses + --bar-extend: 0, ending the run; the .sc-grid clips overflow, so when a team wraps the + bar is cut at the row edge and resumes on the next row. */ +.sc-cbar { position: absolute; top: 0; left: 0; -} - -.sc-team-title::before { - width: 100%; - height: 8px; + right: calc(-1 * var(--bar-extend, 0px)); + height: 7px; background: var(--team-color, #0f766e); -} - -.sc-team-title::after { - left: auto; - right: 0; - width: 0; - height: 0; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - border-left: 8px solid var(--team-color, #0f766e); /* half arrowhead */ + pointer-events: none; + z-index: 31; } /* Modals */ From 2c994c32ea9e4623440eaa4382281ec9069d7ab9 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 24 Jun 2026 22:11:23 +0100 Subject: [PATCH 2/2] Harden steering-committee card alignment against theme CSS (#808) The team grid can render misaligned under the academic theme: its `.article-style img` selector outranks the `.sc-card-img` class, so without !important the theme can drop the avatar into normal flow and push it below the top of the card (title cards then sit higher than the photos, with the connecting bars detached). Force the photo wrapper and image to stay absolutely positioned and flush, top-align the grid explicitly, fill any lightbox wrapper the theme may add around the image, and reset theme heading margins on the title text so title cards keep their aspect-ratio height. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/css/steering-committee.css | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/static/css/steering-committee.css b/static/css/steering-committee.css index 07d6ffb1899..bcc2dc2eb0d 100644 --- a/static/css/steering-committee.css +++ b/static/css/steering-committee.css @@ -133,6 +133,9 @@ article.article .article-container .article-title { gap: 1rem; padding: 0; justify-content: flex-start; + /* Top-align every tile so title cards and member cards (and their connecting bars) + always line up, regardless of any height differences or theme alignment defaults. */ + align-items: flex-start; /* Clip the connecting team bars at the row/section edges so they break cleanly at the end of a row and resume on the next one (see .sc-cbar). */ overflow: hidden; @@ -157,12 +160,22 @@ article.article .article-container .article-title { } /* Clipped photo wrapper: holds the image + overlay so the zoom-on-hover and crop stay - contained, while the connecting bar (a direct child of .sc-card) can overflow. */ + contained, while the connecting bar (a direct child of .sc-card) can overflow. + Positioning is forced so theme rules can never push the photo down inside the card. */ .sc-card-photo { - position: absolute; - inset: 0; + position: absolute !important; + inset: 0 !important; overflow: hidden; background: #e2e8f0; + margin: 0 !important; +} +/* Guard against the theme wrapping the avatar in a lightbox : make any such wrapper + fill the photo box so the image stays flush with the top of the card. */ +.sc-card-photo a { + position: absolute !important; + inset: 0 !important; + display: block !important; + margin: 0 !important; } @media (min-width: 640px) { @@ -210,8 +223,10 @@ article.article .article-container .article-title { } .sc-card-img { - position: absolute; - inset: 0; + /* position/inset forced: the theme's `.article-style img` selector outranks this class, + so without !important it could drop the image into normal flow and misalign it. */ + position: absolute !important; + inset: 0 !important; width: 100% !important; /* Force fill even if global img rules set height:auto */ height: 100% !important; min-width: 100%; @@ -348,7 +363,7 @@ article.article .article-container .article-title { letter-spacing: 0.1em; opacity: 0.9; color: #e2e8f0; - margin-bottom: 0.25rem; + margin: 0 0 0.25rem; /* reset theme heading/paragraph margins */ } .sc-title-text { @@ -356,6 +371,7 @@ article.article .article-container .article-title { font-weight: 700; line-height: 1.2; color: #f8fafc; + margin: 0; /* reset theme h3 margins so the title card can't grow past its aspect ratio */ } /* Connecting team bar