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..bcc2dc2eb0d 100644 --- a/static/css/steering-committee.css +++ b/static/css/steering-committee.css @@ -133,14 +133,21 @@ 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; } /* 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 +159,25 @@ 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. + Positioning is forced so theme rules can never push the photo down inside the card. */ +.sc-card-photo { + 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) { .sc-card { width: 200px; @@ -197,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%; @@ -293,6 +321,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 +330,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 +356,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; @@ -340,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 { @@ -348,80 +371,24 @@ 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 */ } -/* 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: ""; +/* 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; - 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; + right: calc(-1 * var(--bar-extend, 0px)); + height: 7px; 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: ""; - position: absolute; - top: 0; - left: 0; -} - -.sc-team-title::before { - width: 100%; - height: 8px; - 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 */