From 4052529fd732792af0f7072ffa655b0abb851edb Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:12:38 +0200 Subject: [PATCH 01/33] Fix hero badge mobile overflow, densify the UMAP demo, and redesign the homepage highlight sections The conference-announcement chip lacked min-width: 0 on its flex items, so it couldn't shrink below its unwrapped text width and overflowed the viewport on narrow screens. The interactive UMAP demo used too few, too-tightly-packed points and generic chart-library colors, so it read as a sparse placeholder rather than real data. It now uses more points and the same brand hues as the package tiles below it. The Ecosystem/Mission/Team/References sections were four identical stacked text blocks. They're now a single 2x2 card grid with an icon per topic, consistent with the card styling used elsewhere on the site. --- assets/main.scss | 89 ++++++++++++++++++++++++++++++++++------------ layouts/index.html | 84 +++++++++++++++++++++---------------------- static/main.js | 24 ++++++------- 3 files changed, 120 insertions(+), 77 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 5089bc06..04d9af9c 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -414,6 +414,9 @@ body { .event-chip { width: auto; + max-width: 100%; + // Flex items default to min-width: auto, which pins them to their content's intrinsic width instead of letting them shrink. + min-width: 0; display: inline-flex; align-items: center; background-color: transparent; @@ -429,6 +432,10 @@ body { border-radius: 1.5rem; padding: 0.05rem 0.6rem 0.05rem 0; font-size: 0.7rem; + // Lets the date and message stack instead of forcing one nowrap line into horizontal scroll on narrow viewports. + flex-wrap: wrap; + justify-content: center; + row-gap: 0.35rem; } .event-date { @@ -448,6 +455,9 @@ body { .event-text { position: relative; + // Same min-width: auto fix as .event-chip, this time so the text itself can wrap. + min-width: 0; + white-space: normal; .arrow { display: inline-block; @@ -579,33 +589,66 @@ body { } } } - #mission { - .section-content { - p { - font-size: 1.1rem; + #about-highlights { + .about-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.5rem; + + @media (max-width: 50rem) { + grid-template-columns: 1fr; + gap: 1rem; } } - } - #team { - .section-content { - .section-paragraph { - #team-list { - display: inline; - } - a { - border-bottom: 1px solid $linkunderline; - transition: - border 200ms, - color 200ms; - - &:hover, - &:active { - color: black; - border-bottom: 1px solid black; - } - } + + .about-card { + background-color: $tilebg4; + border: 1px solid $overline; + border-radius: $card-radius; + padding: 1.75rem; + transition: all 200ms ease-in-out; + + &:hover { + border-color: $tilebg2; + box-shadow: 0 0.6rem 1.5rem rgba(0, 0, 0, 0.06); + transform: translateY(-2px); + } + } + + .about-card-icon { + width: 2.75rem; + height: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + margin-bottom: 1rem; + font-size: 1.3rem; + background-color: $tilebg; + color: $tiletext; + } + + // Written as a descendant rather than nested under .about-card-icon: `&` there would carry the whole ancestor chain and put .about-card in front of #about-highlights. + @each $hue in $eco-hues { + .about-card[data-hue="#{index($eco-hues, $hue) - 1}"] + .about-card-icon { + background-color: rgba($hue, 0.16); + color: darken($hue, 12%); } } + + .about-card-title { + font-size: 1.2rem; + font-weight: 700; + margin: 0 0 0.5rem; + color: $tiletext; + } + + .about-card-text { + font-size: 1rem; + color: $tiletext2; + margin-bottom: 0.75rem; + } } } } diff --git a/layouts/index.html b/layouts/index.html index c5be35d8..a04c539e 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -90,51 +90,51 @@

{{ .Title }}

{{ partial "main/packages" . }} -
-
Ecosystem
-
-

- {{ .Params.ecosystem.text | markdownify }} -

- -
Continue to scverse ecosystem
-
-
-
+
+
+
+
+

Ecosystem

+

+ {{ .Params.ecosystem.text | markdownify }} +

+ +
Continue to scverse ecosystem
+
+
-
-
Mission
-
-

- {{ .Params.mission.text | markdownify }} -

- -
Read more about scverse
-
-
-
+
+
+

Mission

+

+ {{ .Params.mission.text | markdownify }} +

+ +
Read more about scverse
+
+
-
-
Team
-
-

- {{ .Params.team.text | markdownify }} -

- -
Learn more about scverse community
-
-
-
+
+
+

Team

+

+ {{ .Params.team.text | markdownify }} +

+ +
Learn more about scverse community
+
+
-
-
References
-
-

- {{ .Params.references.text | markdownify }} -

- -
Find each package's publication
-
+
+
+

References

+

+ {{ .Params.references.text | markdownify }} +

+ +
Find each package's publication
+
+
diff --git a/static/main.js b/static/main.js index f2cc3a27..a2321d59 100644 --- a/static/main.js +++ b/static/main.js @@ -267,16 +267,16 @@ const initInteractiveViz = () => { // Exit early if visualization elements don't exist on this page if (!visualization || !card) return; - // Color clusters for UMAP visualization + // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#4285F4', count: 14, name: 'Cluster A' }, - { color: '#34A853', count: 12, name: 'Cluster B' }, - { color: '#FBBC05', count: 10, name: 'Cluster C' }, - { color: '#EA4335', count: 8, name: 'Cluster D' }, - { color: '#FF00FF', count: 11, name: 'Cluster E' }, - { color: '#00BCD4', count: 9, name: 'Cluster F' }, - { color: '#9C27B0', count: 7, name: 'Cluster G' }, - { color: '#FF9800', count: 13, name: 'Cluster H' } + { color: '#40a9ff', count: 34, name: 'Cluster A' }, + { color: '#4ab274', count: 29, name: 'Cluster B' }, + { color: '#fbb822', count: 26, name: 'Cluster C' }, + { color: '#e5864b', count: 22, name: 'Cluster D' }, + { color: '#da347f', count: 27, name: 'Cluster E' }, + { color: '#969dea', count: 24, name: 'Cluster F' }, + { color: '#de367b', count: 19, name: 'Cluster G' }, + { color: '#6cf1a1', count: 31, name: 'Cluster H' } ]; // 3D tilt effect @@ -329,7 +329,7 @@ const initInteractiveViz = () => { dot.dataset.cluster = cluster.name; dot.dataset.color = cluster.color; - const size = Math.floor(Math.random() * 12) + 8; + const size = Math.floor(Math.random() * 8) + 5; dot.style.width = `${size}px`; dot.style.height = `${size}px`; @@ -341,7 +341,7 @@ const initInteractiveViz = () => { u = u / 6 - 0.5; v = v / 6 - 0.5; - const distance = Math.random() * 70 + 10; + const distance = Math.random() * 130 + 20; const dx = u * distance * 2; const dy = v * distance * 2; const x = centerX + dx; @@ -362,7 +362,7 @@ const initInteractiveViz = () => { setTimeout(() => { dot.style.transform = 'scale(1)'; dot.style.opacity = '1'; - }, i * 50 + Math.random() * 200); + }, i * 18 + Math.random() * 150); } }); From daf36fe828552b6b2269c947a17fe84074cca5a5 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:19:21 +0200 Subject: [PATCH 02/33] Add a CellRank-style trajectory graph to the UMAP demo and bottom-align highlight card links The demo previously only showed a static point cloud. It now draws a curved, arrowed trajectory graph over the cluster centroids that forks into two fates near the end, evoking a CellRank/PAGA fate map instead of a bare scatter plot. The "more" links in the homepage highlight cards followed the paragraph text, so cards with shorter text had their link sitting higher than cards with longer text. They're now pinned to the bottom of each card. --- assets/main.scss | 38 +++++++++++++++++++++++++++ static/main.js | 67 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/assets/main.scss b/assets/main.scss index 04d9af9c..34ee088b 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -602,6 +602,8 @@ body { } .about-card { + display: flex; + flex-direction: column; background-color: $tilebg4; border: 1px solid $overline; border-radius: $card-radius; @@ -613,6 +615,11 @@ body { box-shadow: 0 0.6rem 1.5rem rgba(0, 0, 0, 0.06); transform: translateY(-2px); } + + // Pins the "more" link to the card's bottom edge regardless of how many lines the paragraph above it takes. + > a { + margin-top: auto; + } } .about-card-icon { @@ -3090,6 +3097,37 @@ body { cursor: pointer; } +.trajectory-svg { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; + z-index: 2; + opacity: 0; + animation: trajectory-appear 0.6s ease forwards; +} + +.trajectory-edge { + fill: none; + stroke-width: 2.5px; + stroke-linecap: round; + stroke-dasharray: 7 6; + opacity: 0.6; + animation: trajectory-flow 1.2s linear infinite; +} + +@keyframes trajectory-appear { + to { + opacity: 1; + } +} + +@keyframes trajectory-flow { + to { + stroke-dashoffset: -26; + } +} + .animation-pulse { animation: pulse 1.5s infinite; } diff --git a/static/main.js b/static/main.js index a2321d59..a9b0f72a 100644 --- a/static/main.js +++ b/static/main.js @@ -315,13 +315,17 @@ const initInteractiveViz = () => { function generateUMAP() { visualization.querySelectorAll('.dot').forEach(dot => dot.remove()); + const oldTrajectory = visualization.querySelector('.trajectory-svg'); + if (oldTrajectory) oldTrajectory.remove(); const width = visualization.clientWidth; const height = visualization.clientHeight; + const centers = []; colorClusters.forEach(cluster => { const centerX = Math.random() * 0.6 * width + 0.2 * width; const centerY = Math.random() * 0.6 * height + 0.2 * height; + centers.push({ x: centerX, y: centerY, color: cluster.color }); for (let i = 0; i < cluster.count; i++) { const dot = document.createElement('div'); @@ -367,6 +371,69 @@ const initInteractiveViz = () => { }); setupDotInteractions(); + setTimeout(() => drawTrajectory(centers), 850); + } + + // Draws a CellRank/PAGA-style trajectory graph over the clusters: a chain of curved, arrowed edges that forks into two fates at the end, standing in for a real pseudotime computation. + function drawTrajectory(centers) { + const svgNS = 'http://www.w3.org/2000/svg'; + const old = visualization.querySelector('.trajectory-svg'); + if (old) old.remove(); + if (centers.length < 4) return; + + const ordered = [...centers].sort((a, b) => a.x - b.x); + const edges = []; + for (let i = 0; i < ordered.length - 3; i++) { + edges.push([ordered[i], ordered[i + 1]]); + } + const branchFrom = ordered[ordered.length - 3]; + edges.push([branchFrom, ordered[ordered.length - 2]]); + edges.push([branchFrom, ordered[ordered.length - 1]]); + + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('class', 'trajectory-svg'); + svg.setAttribute('width', visualization.clientWidth); + svg.setAttribute('height', visualization.clientHeight); + + const defs = document.createElementNS(svgNS, 'defs'); + edges.forEach((edge, i) => { + const marker = document.createElementNS(svgNS, 'marker'); + marker.setAttribute('id', `traj-arrow-${i}`); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('refX', '8'); + marker.setAttribute('refY', '5'); + marker.setAttribute('markerWidth', '7'); + marker.setAttribute('markerHeight', '7'); + marker.setAttribute('orient', 'auto-start-reverse'); + const arrowhead = document.createElementNS(svgNS, 'path'); + arrowhead.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z'); + arrowhead.setAttribute('fill', edge[1].color); + marker.appendChild(arrowhead); + defs.appendChild(marker); + }); + svg.appendChild(defs); + + edges.forEach(([from, to], i) => { + const mx = (from.x + to.x) / 2; + const my = (from.y + to.y) / 2; + const dx = to.x - from.x; + const dy = to.y - from.y; + const dist = Math.hypot(dx, dy) || 1; + // Bows the edge outward, like a PAGA/CellRank transition graph, instead of a straight line. + const bow = Math.min(dist * 0.22, 60) * (i % 2 === 0 ? 1 : -1); + const cx = mx - (dy / dist) * bow; + const cy = my + (dx / dist) * bow; + + const path = document.createElementNS(svgNS, 'path'); + path.setAttribute('d', `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`); + path.setAttribute('class', 'trajectory-edge'); + path.setAttribute('stroke', to.color); + path.setAttribute('marker-end', `url(#traj-arrow-${i})`); + path.style.animationDelay = `${i * 0.15}s`; + svg.appendChild(path); + }); + + visualization.appendChild(svg); } function setupDotInteractions() { From 4c35ab7b9e1aebc0bfcc278516ac32eea966dc46 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:28:06 +0200 Subject: [PATCH 03/33] Redo the demo trajectory as a scVelo-style streamline bundle and tighten spacing under it The previous trajectory graph drew one curved edge per cluster transition, which looked like a PAGA abstraction rather than a real velocity field. It's now a bundle of parallel arrowed streamlines per transition, fanned out at the source cluster and converging at the target, matching the many-small-arrows look of scVelo/CellRank plots. #interactive-demo-section's margin-bottom was 15%, stacking on top of the next section's own top margin and leaving a large gap before "Next event". Reduced to a fixed 3rem. --- assets/main.scss | 20 +++++------- static/main.js | 85 ++++++++++++++++++++++++++++-------------------- 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 34ee088b..0a719111 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -523,7 +523,7 @@ body { // The wide gap under the hero used to live on #packages, which put it // between whatever section came after the demo and the packages. #interactive-demo-section { - margin-bottom: 15%; + margin-bottom: 3rem; } #packages { #packages-list { @@ -3107,24 +3107,20 @@ body { animation: trajectory-appear 0.6s ease forwards; } -.trajectory-edge { +.trajectory-line { fill: none; - stroke-width: 2.5px; + stroke-width: 1px; stroke-linecap: round; - stroke-dasharray: 7 6; - opacity: 0.6; - animation: trajectory-flow 1.2s linear infinite; + opacity: 0.55; } -@keyframes trajectory-appear { - to { - opacity: 1; - } +.trajectory-arrow { + opacity: 0.75; } -@keyframes trajectory-flow { +@keyframes trajectory-appear { to { - stroke-dashoffset: -26; + opacity: 1; } } diff --git a/static/main.js b/static/main.js index a9b0f72a..b4609e0c 100644 --- a/static/main.js +++ b/static/main.js @@ -325,7 +325,7 @@ const initInteractiveViz = () => { colorClusters.forEach(cluster => { const centerX = Math.random() * 0.6 * width + 0.2 * width; const centerY = Math.random() * 0.6 * height + 0.2 * height; - centers.push({ x: centerX, y: centerY, color: cluster.color }); + centers.push({ x: centerX, y: centerY, color: cluster.color, name: cluster.name }); for (let i = 0; i < cluster.count; i++) { const dot = document.createElement('div'); @@ -374,7 +374,7 @@ const initInteractiveViz = () => { setTimeout(() => drawTrajectory(centers), 850); } - // Draws a CellRank/PAGA-style trajectory graph over the clusters: a chain of curved, arrowed edges that forks into two fates at the end, standing in for a real pseudotime computation. + // Draws a scVelo/CellRank-style velocity field: bundles of parallel arrowed streamlines that fan out from a cluster and converge into the next one, rather than one graph edge between cluster centroids. function drawTrajectory(centers) { const svgNS = 'http://www.w3.org/2000/svg'; const old = visualization.querySelector('.trajectory-svg'); @@ -394,46 +394,59 @@ const initInteractiveViz = () => { svg.setAttribute('class', 'trajectory-svg'); svg.setAttribute('width', visualization.clientWidth); svg.setAttribute('height', visualization.clientHeight); + visualization.appendChild(svg); - const defs = document.createElementNS(svgNS, 'defs'); - edges.forEach((edge, i) => { - const marker = document.createElementNS(svgNS, 'marker'); - marker.setAttribute('id', `traj-arrow-${i}`); - marker.setAttribute('viewBox', '0 0 10 10'); - marker.setAttribute('refX', '8'); - marker.setAttribute('refY', '5'); - marker.setAttribute('markerWidth', '7'); - marker.setAttribute('markerHeight', '7'); - marker.setAttribute('orient', 'auto-start-reverse'); - const arrowhead = document.createElementNS(svgNS, 'path'); - arrowhead.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z'); - arrowhead.setAttribute('fill', edge[1].color); - marker.appendChild(arrowhead); - defs.appendChild(marker); - }); - svg.appendChild(defs); + const LANES = 9; + const ARROWS_PER_LANE = 2; - edges.forEach(([from, to], i) => { - const mx = (from.x + to.x) / 2; - const my = (from.y + to.y) / 2; + edges.forEach(([from, to]) => { const dx = to.x - from.x; const dy = to.y - from.y; const dist = Math.hypot(dx, dy) || 1; - // Bows the edge outward, like a PAGA/CellRank transition graph, instead of a straight line. - const bow = Math.min(dist * 0.22, 60) * (i % 2 === 0 ? 1 : -1); - const cx = mx - (dy / dist) * bow; - const cy = my + (dx / dist) * bow; - - const path = document.createElementNS(svgNS, 'path'); - path.setAttribute('d', `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`); - path.setAttribute('class', 'trajectory-edge'); - path.setAttribute('stroke', to.color); - path.setAttribute('marker-end', `url(#traj-arrow-${i})`); - path.style.animationDelay = `${i * 0.15}s`; - svg.appendChild(path); + const ux = dx / dist; + const uy = dy / dist; + // Perpendicular unit vector: lanes are offset along this so the bundle fans sideways instead of along the direction of travel. + const px = -uy; + const py = ux; + const bow = Math.min(dist * 0.16, 45); + const spread = Math.min(dist * 0.24, 48); + + for (let lane = 0; lane < LANES; lane++) { + // -1 at one edge of the bundle, +1 at the other, 0 down the middle. + const t = LANES === 1 ? 0 : (lane / (LANES - 1)) * 2 - 1; + const jitter = (Math.random() - 0.5) * 6; + const startOffset = t * spread + jitter; + // Streamlines start spread across the source cluster and narrow as they approach the target, like real velocity fields converging on a cell state. + const endOffset = t * spread * 0.2 + jitter; + const midOffset = t * spread * 0.55 + jitter; + + const start = { x: from.x + px * startOffset, y: from.y + py * startOffset }; + const end = { x: to.x + px * endOffset, y: to.y + py * endOffset }; + const cx = (start.x + end.x) / 2 + px * (midOffset + bow); + const cy = (start.y + end.y) / 2 + py * (midOffset + bow); + + const path = document.createElementNS(svgNS, 'path'); + path.setAttribute('d', `M ${start.x} ${start.y} Q ${cx} ${cy} ${end.x} ${end.y}`); + path.setAttribute('class', 'trajectory-line'); + path.setAttribute('stroke', to.color); + svg.appendChild(path); + + const length = path.getTotalLength(); + for (let a = 1; a <= ARROWS_PER_LANE; a++) { + const at = (a / (ARROWS_PER_LANE + 1)) * length; + const p = path.getPointAtLength(at); + const ahead = path.getPointAtLength(Math.min(at + 3, length)); + const angle = (Math.atan2(ahead.y - p.y, ahead.x - p.x) * 180) / Math.PI; + + const arrow = document.createElementNS(svgNS, 'polygon'); + arrow.setAttribute('points', '0,-2.5 5,0 0,2.5'); + arrow.setAttribute('transform', `translate(${p.x}, ${p.y}) rotate(${angle})`); + arrow.setAttribute('class', 'trajectory-arrow'); + arrow.setAttribute('fill', to.color); + svg.appendChild(arrow); + } + } }); - - visualization.appendChild(svg); } function setupDotInteractions() { From bf96b353ee7bd948575fcc849ecabfade26569ff Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:38:16 +0200 Subject: [PATCH 04/33] Color the demo trajectory streamlines black, like a real CellRank/scVelo plot They were tinted per-cluster, which read as decorative rather than data. Real velocity/fate plots draw the flow field in black over the colored cell scatter, so the lines and arrowheads are now black, with more arrows per line for the same dense look. --- assets/main.scss | 8 +++++--- static/main.js | 6 ++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 0a719111..792ebbec 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3109,13 +3109,15 @@ body { .trajectory-line { fill: none; - stroke-width: 1px; + stroke: black; + stroke-width: 0.75px; stroke-linecap: round; - opacity: 0.55; + opacity: 0.65; } .trajectory-arrow { - opacity: 0.75; + fill: black; + opacity: 0.8; } @keyframes trajectory-appear { diff --git a/static/main.js b/static/main.js index b4609e0c..b8305b21 100644 --- a/static/main.js +++ b/static/main.js @@ -325,7 +325,7 @@ const initInteractiveViz = () => { colorClusters.forEach(cluster => { const centerX = Math.random() * 0.6 * width + 0.2 * width; const centerY = Math.random() * 0.6 * height + 0.2 * height; - centers.push({ x: centerX, y: centerY, color: cluster.color, name: cluster.name }); + centers.push({ x: centerX, y: centerY }); for (let i = 0; i < cluster.count; i++) { const dot = document.createElement('div'); @@ -397,7 +397,7 @@ const initInteractiveViz = () => { visualization.appendChild(svg); const LANES = 9; - const ARROWS_PER_LANE = 2; + const ARROWS_PER_LANE = 4; edges.forEach(([from, to]) => { const dx = to.x - from.x; @@ -428,7 +428,6 @@ const initInteractiveViz = () => { const path = document.createElementNS(svgNS, 'path'); path.setAttribute('d', `M ${start.x} ${start.y} Q ${cx} ${cy} ${end.x} ${end.y}`); path.setAttribute('class', 'trajectory-line'); - path.setAttribute('stroke', to.color); svg.appendChild(path); const length = path.getTotalLength(); @@ -442,7 +441,6 @@ const initInteractiveViz = () => { arrow.setAttribute('points', '0,-2.5 5,0 0,2.5'); arrow.setAttribute('transform', `translate(${p.x}, ${p.y}) rotate(${angle})`); arrow.setAttribute('class', 'trajectory-arrow'); - arrow.setAttribute('fill', to.color); svg.appendChild(arrow); } } From 3b2f62a987ef9c5561d64fb26b323f4c47b8a3d9 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:42:03 +0200 Subject: [PATCH 05/33] Make the demo clusters denser and elongated instead of sparse round scatters Real UMAP clusters are dense, overlapping, tapered blobs, not evenly spread circles. Roughly tripled the point count per cluster and replaced the isotropic Gaussian placement with an elongated axis per cluster (with a narrower cross-axis), so clusters read as solid shapes closer to the CellRank/scVelo reference instead of a loose scatter. --- static/main.js | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/static/main.js b/static/main.js index b8305b21..a1409f1b 100644 --- a/static/main.js +++ b/static/main.js @@ -269,14 +269,14 @@ const initInteractiveViz = () => { // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#40a9ff', count: 34, name: 'Cluster A' }, - { color: '#4ab274', count: 29, name: 'Cluster B' }, - { color: '#fbb822', count: 26, name: 'Cluster C' }, - { color: '#e5864b', count: 22, name: 'Cluster D' }, - { color: '#da347f', count: 27, name: 'Cluster E' }, - { color: '#969dea', count: 24, name: 'Cluster F' }, - { color: '#de367b', count: 19, name: 'Cluster G' }, - { color: '#6cf1a1', count: 31, name: 'Cluster H' } + { color: '#40a9ff', count: 90, name: 'Cluster A' }, + { color: '#4ab274', count: 78, name: 'Cluster B' }, + { color: '#fbb822', count: 70, name: 'Cluster C' }, + { color: '#e5864b', count: 58, name: 'Cluster D' }, + { color: '#da347f', count: 72, name: 'Cluster E' }, + { color: '#969dea', count: 64, name: 'Cluster F' }, + { color: '#de367b', count: 50, name: 'Cluster G' }, + { color: '#6cf1a1', count: 82, name: 'Cluster H' } ]; // 3D tilt effect @@ -327,13 +327,22 @@ const initInteractiveViz = () => { const centerY = Math.random() * 0.6 * height + 0.2 * height; centers.push({ x: centerX, y: centerY }); + // Real UMAP clusters are elongated, tapered blobs rather than round scatters, so each cluster gets its own axis to stretch along and a perpendicular axis to stay narrow on. + const axisAngle = Math.random() * Math.PI * 2; + const axisX = Math.cos(axisAngle); + const axisY = Math.sin(axisAngle); + const perpX = -axisY; + const perpY = axisX; + const axisLength = Math.random() * 40 + 55; + const perpWidth = axisLength * (Math.random() * 0.2 + 0.28); + for (let i = 0; i < cluster.count; i++) { const dot = document.createElement('div'); dot.className = 'dot'; dot.dataset.cluster = cluster.name; dot.dataset.color = cluster.color; - const size = Math.floor(Math.random() * 8) + 5; + const size = Math.floor(Math.random() * 5) + 4; dot.style.width = `${size}px`; dot.style.height = `${size}px`; @@ -345,11 +354,10 @@ const initInteractiveViz = () => { u = u / 6 - 0.5; v = v / 6 - 0.5; - const distance = Math.random() * 130 + 20; - const dx = u * distance * 2; - const dy = v * distance * 2; - const x = centerX + dx; - const y = centerY + dy; + const along = u * axisLength; + const across = v * perpWidth; + const x = centerX + axisX * along + perpX * across; + const y = centerY + axisY * along + perpY * across; const safeX = Math.min(Math.max(size, x), width - size); const safeY = Math.min(Math.max(size, y), height - size); @@ -366,12 +374,12 @@ const initInteractiveViz = () => { setTimeout(() => { dot.style.transform = 'scale(1)'; dot.style.opacity = '1'; - }, i * 18 + Math.random() * 150); + }, i * 8 + Math.random() * 150); } }); setupDotInteractions(); - setTimeout(() => drawTrajectory(centers), 850); + setTimeout(() => drawTrajectory(centers), 950); } // Draws a scVelo/CellRank-style velocity field: bundles of parallel arrowed streamlines that fan out from a cluster and converge into the next one, rather than one graph edge between cluster centroids. @@ -408,8 +416,8 @@ const initInteractiveViz = () => { // Perpendicular unit vector: lanes are offset along this so the bundle fans sideways instead of along the direction of travel. const px = -uy; const py = ux; - const bow = Math.min(dist * 0.16, 45); - const spread = Math.min(dist * 0.24, 48); + const bow = Math.min(dist * 0.16, 36); + const spread = Math.min(dist * 0.22, 36); for (let lane = 0; lane < LANES; lane++) { // -1 at one edge of the bundle, +1 at the other, 0 down the middle. From ead563bb4c61e77f7b2f65c35bdb92f6bfb894a0 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:46:37 +0200 Subject: [PATCH 06/33] Lay demo cluster centers out along a connected chain instead of scattering them randomly Clusters were placed independently across the box, so most pairs ended up far apart relative to their own size, with long thin streamline bridges between distant islands. Centers are now placed along a winding, rightward-biased walk (with the last two branching off the end), so neighbouring clusters touch and the whole thing reads as one connected embedding, closer to a real CellRank plot. Bumped counts and blob size to match the larger spacing. --- static/main.js | 60 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/static/main.js b/static/main.js index a1409f1b..a2a8650f 100644 --- a/static/main.js +++ b/static/main.js @@ -269,14 +269,14 @@ const initInteractiveViz = () => { // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#40a9ff', count: 90, name: 'Cluster A' }, - { color: '#4ab274', count: 78, name: 'Cluster B' }, - { color: '#fbb822', count: 70, name: 'Cluster C' }, - { color: '#e5864b', count: 58, name: 'Cluster D' }, - { color: '#da347f', count: 72, name: 'Cluster E' }, - { color: '#969dea', count: 64, name: 'Cluster F' }, - { color: '#de367b', count: 50, name: 'Cluster G' }, - { color: '#6cf1a1', count: 82, name: 'Cluster H' } + { color: '#40a9ff', count: 130, name: 'Cluster A' }, + { color: '#4ab274', count: 112, name: 'Cluster B' }, + { color: '#fbb822', count: 100, name: 'Cluster C' }, + { color: '#e5864b', count: 84, name: 'Cluster D' }, + { color: '#da347f', count: 104, name: 'Cluster E' }, + { color: '#969dea', count: 92, name: 'Cluster F' }, + { color: '#de367b', count: 72, name: 'Cluster G' }, + { color: '#6cf1a1', count: 118, name: 'Cluster H' } ]; // 3D tilt effect @@ -320,12 +320,33 @@ const initInteractiveViz = () => { const width = visualization.clientWidth; const height = visualization.clientHeight; + + // Lays cluster centers out along a winding chain instead of scattering them independently, so neighbouring clusters sit close enough to touch, like a real connected UMAP embedding, and the last two branch off from the chain's end. const centers = []; + // Biased toward rightward travel (angle 0) so the chain fans out across this wide, short box instead of a random walk drifting into a corner; it can still wiggle up and down along the way. + let angle = (Math.random() - 0.5) * 0.6; + let cx = width * (0.05 + Math.random() * 0.05); + let cy = height * (0.35 + Math.random() * 0.3); + const chainCount = colorClusters.length - 2; + for (let i = 0; i < chainCount; i++) { + if (i > 0) { + angle = angle * 0.6 + (Math.random() - 0.5) * 1.1; + const step = 110 + Math.random() * 40; + cx = Math.min(Math.max(cx + Math.cos(angle) * step, width * 0.04), width * 0.96); + cy = Math.min(Math.max(cy + Math.sin(angle) * step, height * 0.12), height * 0.88); + } + centers.push({ x: cx, y: cy }); + } + [angle + 0.7 + Math.random() * 0.3, angle - 0.7 - Math.random() * 0.3].forEach(branchAngle => { + const step = 105 + Math.random() * 40; + centers.push({ + x: Math.min(Math.max(cx + Math.cos(branchAngle) * step, width * 0.03), width * 0.97), + y: Math.min(Math.max(cy + Math.sin(branchAngle) * step, height * 0.05), height * 0.95) + }); + }); - colorClusters.forEach(cluster => { - const centerX = Math.random() * 0.6 * width + 0.2 * width; - const centerY = Math.random() * 0.6 * height + 0.2 * height; - centers.push({ x: centerX, y: centerY }); + colorClusters.forEach((cluster, clusterIndex) => { + const { x: centerX, y: centerY } = centers[clusterIndex]; // Real UMAP clusters are elongated, tapered blobs rather than round scatters, so each cluster gets its own axis to stretch along and a perpendicular axis to stay narrow on. const axisAngle = Math.random() * Math.PI * 2; @@ -333,7 +354,7 @@ const initInteractiveViz = () => { const axisY = Math.sin(axisAngle); const perpX = -axisY; const perpY = axisX; - const axisLength = Math.random() * 40 + 55; + const axisLength = Math.random() * 55 + 95; const perpWidth = axisLength * (Math.random() * 0.2 + 0.28); for (let i = 0; i < cluster.count; i++) { @@ -389,14 +410,15 @@ const initInteractiveViz = () => { if (old) old.remove(); if (centers.length < 4) return; - const ordered = [...centers].sort((a, b) => a.x - b.x); + // centers is already laid out as a chain with the last two entries branching off its end, matching how generateUMAP placed the cluster centers, so the edges just follow that same order. + const chainCount = centers.length - 2; const edges = []; - for (let i = 0; i < ordered.length - 3; i++) { - edges.push([ordered[i], ordered[i + 1]]); + for (let i = 0; i < chainCount - 1; i++) { + edges.push([centers[i], centers[i + 1]]); } - const branchFrom = ordered[ordered.length - 3]; - edges.push([branchFrom, ordered[ordered.length - 2]]); - edges.push([branchFrom, ordered[ordered.length - 1]]); + const branchFrom = centers[chainCount - 1]; + edges.push([branchFrom, centers[chainCount]]); + edges.push([branchFrom, centers[chainCount + 1]]); const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('class', 'trajectory-svg'); From af8a5b6e250b12258f600d036830ca9986fb1742 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:52:12 +0200 Subject: [PATCH 07/33] Roughly double the demo point count and overlap adjacent clusters more Clusters were touching but not overlapping, and still looked thin compared to real scRNA-seq embeddings where neighbouring clusters often blend into each other. Point counts per cluster are up significantly, and the spacing between chain steps is now smaller than the cluster blob radius, so neighbours visibly overlap instead of just meeting at an edge. --- static/main.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/static/main.js b/static/main.js index a2a8650f..47da5004 100644 --- a/static/main.js +++ b/static/main.js @@ -269,14 +269,14 @@ const initInteractiveViz = () => { // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#40a9ff', count: 130, name: 'Cluster A' }, - { color: '#4ab274', count: 112, name: 'Cluster B' }, - { color: '#fbb822', count: 100, name: 'Cluster C' }, - { color: '#e5864b', count: 84, name: 'Cluster D' }, - { color: '#da347f', count: 104, name: 'Cluster E' }, - { color: '#969dea', count: 92, name: 'Cluster F' }, - { color: '#de367b', count: 72, name: 'Cluster G' }, - { color: '#6cf1a1', count: 118, name: 'Cluster H' } + { color: '#40a9ff', count: 240, name: 'Cluster A' }, + { color: '#4ab274', count: 205, name: 'Cluster B' }, + { color: '#fbb822', count: 185, name: 'Cluster C' }, + { color: '#e5864b', count: 155, name: 'Cluster D' }, + { color: '#da347f', count: 190, name: 'Cluster E' }, + { color: '#969dea', count: 170, name: 'Cluster F' }, + { color: '#de367b', count: 135, name: 'Cluster G' }, + { color: '#6cf1a1', count: 215, name: 'Cluster H' } ]; // 3D tilt effect @@ -331,14 +331,14 @@ const initInteractiveViz = () => { for (let i = 0; i < chainCount; i++) { if (i > 0) { angle = angle * 0.6 + (Math.random() - 0.5) * 1.1; - const step = 110 + Math.random() * 40; + const step = 85 + Math.random() * 30; cx = Math.min(Math.max(cx + Math.cos(angle) * step, width * 0.04), width * 0.96); cy = Math.min(Math.max(cy + Math.sin(angle) * step, height * 0.12), height * 0.88); } centers.push({ x: cx, y: cy }); } [angle + 0.7 + Math.random() * 0.3, angle - 0.7 - Math.random() * 0.3].forEach(branchAngle => { - const step = 105 + Math.random() * 40; + const step = 70 + Math.random() * 25; centers.push({ x: Math.min(Math.max(cx + Math.cos(branchAngle) * step, width * 0.03), width * 0.97), y: Math.min(Math.max(cy + Math.sin(branchAngle) * step, height * 0.05), height * 0.95) @@ -354,7 +354,7 @@ const initInteractiveViz = () => { const axisY = Math.sin(axisAngle); const perpX = -axisY; const perpY = axisX; - const axisLength = Math.random() * 55 + 95; + const axisLength = Math.random() * 65 + 110; const perpWidth = axisLength * (Math.random() * 0.2 + 0.28); for (let i = 0; i < cluster.count; i++) { @@ -395,7 +395,7 @@ const initInteractiveViz = () => { setTimeout(() => { dot.style.transform = 'scale(1)'; dot.style.opacity = '1'; - }, i * 8 + Math.random() * 150); + }, i * 3 + Math.random() * 150); } }); From d1f2c093556be857ab4c6bfbf80512e42b99f880 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:54:45 +0200 Subject: [PATCH 08/33] Center the demo trajectory and double the point count again The biased random walk building the cluster chain tended to drift toward one side of the box, leaving the middle empty and the shape crammed into a corner. The chain's bounding box is now re-centered on the container after layout. Also doubled point counts again (now ~3,000 total) for a denser, more solid-looking scatter. --- static/main.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/static/main.js b/static/main.js index 47da5004..d627542b 100644 --- a/static/main.js +++ b/static/main.js @@ -269,14 +269,14 @@ const initInteractiveViz = () => { // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#40a9ff', count: 240, name: 'Cluster A' }, - { color: '#4ab274', count: 205, name: 'Cluster B' }, - { color: '#fbb822', count: 185, name: 'Cluster C' }, - { color: '#e5864b', count: 155, name: 'Cluster D' }, - { color: '#da347f', count: 190, name: 'Cluster E' }, - { color: '#969dea', count: 170, name: 'Cluster F' }, - { color: '#de367b', count: 135, name: 'Cluster G' }, - { color: '#6cf1a1', count: 215, name: 'Cluster H' } + { color: '#40a9ff', count: 480, name: 'Cluster A' }, + { color: '#4ab274', count: 410, name: 'Cluster B' }, + { color: '#fbb822', count: 370, name: 'Cluster C' }, + { color: '#e5864b', count: 310, name: 'Cluster D' }, + { color: '#da347f', count: 380, name: 'Cluster E' }, + { color: '#969dea', count: 340, name: 'Cluster F' }, + { color: '#de367b', count: 270, name: 'Cluster G' }, + { color: '#6cf1a1', count: 430, name: 'Cluster H' } ]; // 3D tilt effect @@ -345,6 +345,16 @@ const initInteractiveViz = () => { }); }); + // The walk above tends to drift toward one side, so re-center its bounding box on the container instead of leaving the whole shape off to one edge. + const xs = centers.map(c => c.x); + const ys = centers.map(c => c.y); + const shiftX = width / 2 - (Math.min(...xs) + Math.max(...xs)) / 2; + const shiftY = height / 2 - (Math.min(...ys) + Math.max(...ys)) / 2; + centers.forEach(c => { + c.x += shiftX; + c.y += shiftY; + }); + colorClusters.forEach((cluster, clusterIndex) => { const { x: centerX, y: centerY } = centers[clusterIndex]; From 36dbda783838c2c59cf1cb6a089d24ccfb94cbe6 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 11:57:37 +0200 Subject: [PATCH 09/33] Double the demo point count again and grow the cluster blobs to fill the box Point counts per cluster are doubled again (~6,000 total). Also grew the cluster blob size and per-dot size, since the box still had a lot of empty space with clusters that stayed small even as they got denser. --- static/main.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/static/main.js b/static/main.js index d627542b..ecf4b3e0 100644 --- a/static/main.js +++ b/static/main.js @@ -269,14 +269,14 @@ const initInteractiveViz = () => { // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#40a9ff', count: 480, name: 'Cluster A' }, - { color: '#4ab274', count: 410, name: 'Cluster B' }, - { color: '#fbb822', count: 370, name: 'Cluster C' }, - { color: '#e5864b', count: 310, name: 'Cluster D' }, - { color: '#da347f', count: 380, name: 'Cluster E' }, - { color: '#969dea', count: 340, name: 'Cluster F' }, - { color: '#de367b', count: 270, name: 'Cluster G' }, - { color: '#6cf1a1', count: 430, name: 'Cluster H' } + { color: '#40a9ff', count: 960, name: 'Cluster A' }, + { color: '#4ab274', count: 820, name: 'Cluster B' }, + { color: '#fbb822', count: 740, name: 'Cluster C' }, + { color: '#e5864b', count: 620, name: 'Cluster D' }, + { color: '#da347f', count: 760, name: 'Cluster E' }, + { color: '#969dea', count: 680, name: 'Cluster F' }, + { color: '#de367b', count: 540, name: 'Cluster G' }, + { color: '#6cf1a1', count: 860, name: 'Cluster H' } ]; // 3D tilt effect @@ -364,8 +364,8 @@ const initInteractiveViz = () => { const axisY = Math.sin(axisAngle); const perpX = -axisY; const perpY = axisX; - const axisLength = Math.random() * 65 + 110; - const perpWidth = axisLength * (Math.random() * 0.2 + 0.28); + const axisLength = Math.random() * 90 + 150; + const perpWidth = axisLength * (Math.random() * 0.25 + 0.4); for (let i = 0; i < cluster.count; i++) { const dot = document.createElement('div'); @@ -373,7 +373,7 @@ const initInteractiveViz = () => { dot.dataset.cluster = cluster.name; dot.dataset.color = cluster.color; - const size = Math.floor(Math.random() * 5) + 4; + const size = Math.floor(Math.random() * 6) + 5; dot.style.width = `${size}px`; dot.style.height = `${size}px`; From 6cfef9443136e7cd2bf23e863a66b77e9e19d75f Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 12:09:02 +0200 Subject: [PATCH 10/33] Add a third demo command for the velocity streamlines The trajectory overlay was tacked onto sc.pl.umap(), but a UMAP call doesn't produce a velocity field. Added a third fake command line, scv.pl.velocity_embedding_stream(data, basis='umap'), matching the real scVelo function that plots this. sc.pl.umap() now only (re)plots the scatter; the streamlines are drawn by the new command, independently clickable, and still auto-play once on load and after resize so a visitor who never clicks anything still sees all three steps. --- layouts/index.html | 18 ++++++++++++++++++ static/main.js | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/layouts/index.html b/layouts/index.html index a04c539e..2ff374d0 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -56,6 +56,24 @@
+
+ +
scv.pl.velocity_embedding_stream(data, basis='umap')
+
+
+
diff --git a/static/main.js b/static/main.js index ecf4b3e0..e10c6e0a 100644 --- a/static/main.js +++ b/static/main.js @@ -259,14 +259,20 @@ const initInteractiveViz = () => { const visualization = document.getElementById('visualization'); const runCmd1 = document.getElementById('run-cmd1'); const runCmd2 = document.getElementById('run-cmd2'); + const runCmd3 = document.getElementById('run-cmd3'); const statusCmd1 = document.getElementById('status-cmd1'); const statusCmd2 = document.getElementById('status-cmd2'); + const statusCmd3 = document.getElementById('status-cmd3'); const execAnim1 = document.getElementById('exec-anim-1'); const execAnim2 = document.getElementById('exec-anim-2'); + const execAnim3 = document.getElementById('exec-anim-3'); // Exit early if visualization elements don't exist on this page if (!visualization || !card) return; + // Cluster centers from the most recent sc.pl.umap() run, so scv.pl.velocity_embedding_stream() can draw its streamlines over the same layout without recomputing it. + let lastCenters = null; + // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ { color: '#40a9ff', count: 960, name: 'Cluster A' }, @@ -410,7 +416,7 @@ const initInteractiveViz = () => { }); setupDotInteractions(); - setTimeout(() => drawTrajectory(centers), 950); + lastCenters = centers; } // Draws a scVelo/CellRank-style velocity field: bundles of parallel arrowed streamlines that fan out from a cluster and converge into the next one, rather than one graph edge between cluster centroids. @@ -536,7 +542,7 @@ const initInteractiveViz = () => { } // These are divs, not buttons, so the keyboard behaviour has to be added by hand. - [runCmd1, runCmd2].forEach(function(control) { + [runCmd1, runCmd2, runCmd3].forEach(function(control) { control.addEventListener('keydown', function(event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); @@ -588,14 +594,38 @@ const initInteractiveViz = () => { }, 1200); }); - // Initial generation + runCmd3.addEventListener('click', function() { + statusCmd3.style.width = '0'; + execAnim3.style.width = '0'; + + setTimeout(() => { + execAnim3.style.width = '100%'; + }, 50); + + setTimeout(() => { + if (lastCenters) drawTrajectory(lastCenters); + + statusCmd3.style.width = '100%'; + runCmd3.style.backgroundColor = '#34A853'; + + setTimeout(() => { + runCmd3.style.backgroundColor = ''; + }, 2000); + }, 900); + }); + + // Initial generation, followed by the velocity streamlines once the scatter has settled, so a visitor who never clicks anything still sees the full three-command story play out once. generateUMAP(); + setTimeout(() => runCmd3.click(), 1200); // Responsive regeneration let resizeTimer; window.addEventListener('resize', function() { clearTimeout(resizeTimer); - resizeTimer = setTimeout(generateUMAP, 250); + resizeTimer = setTimeout(function() { + generateUMAP(); + setTimeout(() => runCmd3.click(), 1200); + }, 250); }); } From d9195879afab588a49466bc337b814759df5a140 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 12:34:17 +0200 Subject: [PATCH 11/33] Make the third demo command generic instead of naming scVelo scv.pl.velocity_embedding_stream() calls out a specific ecosystem package that isn't part of scverse core, which reads oddly on the homepage hero. Renamed to the package-agnostic pl.trajectory(), keeping the same three-step demo structure without endorsing one tool. --- layouts/index.html | 4 ++-- static/main.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/layouts/index.html b/layouts/index.html index 2ff374d0..3242267b 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -62,7 +62,7 @@ id="run-cmd3" type="button" tabindex="0" - aria-label="Run scv.pl.velocity_embedding_stream(data, basis='umap')" + aria-label="Run pl.trajectory(data, basis='umap')" > -
scv.pl.velocity_embedding_stream(data, basis='umap')
+
pl.trajectory(data, basis='umap')
diff --git a/static/main.js b/static/main.js index e10c6e0a..5d324fcd 100644 --- a/static/main.js +++ b/static/main.js @@ -270,7 +270,7 @@ const initInteractiveViz = () => { // Exit early if visualization elements don't exist on this page if (!visualization || !card) return; - // Cluster centers from the most recent sc.pl.umap() run, so scv.pl.velocity_embedding_stream() can draw its streamlines over the same layout without recomputing it. + // Cluster centers from the most recent sc.pl.umap() run, so pl.trajectory() can draw its streamlines over the same layout without recomputing it. let lastCenters = null; // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. From 3ce82269bb2da70d3bbe7e116ce2b44ca03232be Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 13:55:10 +0200 Subject: [PATCH 12/33] Explain what scverse is in the hero, expand the highlight grid to 6 cards, and clean up the hero chrome The hero previously only had a one-line tagline with no explanation of what scverse actually is or who it's for. Added an nf-core-style explanatory paragraph plus three audience value props (researchers, developers, institutions) right under the title. Added Core packages and Events cards to the highlight grid, growing it from a 2x2 to an auto-fitting 3-column grid (2 rows of 3 on desktop). Removed the row of social platform buttons from the hero: they duplicate the same links already in the footer and were adding clutter with no unique content. Expanded the conference announcement chip with the event's location and reduced its margin, so it carries more information without floating in as much empty space. --- assets/main.scss | 55 ++++++++++++++++++++------------ content/_index.md | 29 +++++++++++++++++ layouts/index.html | 22 +++++++++++++ layouts/partials/main/chip.html | 7 ++-- layouts/partials/main/cover.html | 27 +++++++--------- 5 files changed, 101 insertions(+), 39 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 792ebbec..fdc8d23c 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -135,12 +135,6 @@ $eco-hues: } } -// Cover buttons -#badges .btn { - background-color: $tilebg; - border: none; -} - #join-content .card { h5 { font-weight: 700; @@ -153,32 +147,26 @@ $eco-hues: } } -#badges .btn-github:hover, #join-content .card#github:hover { background-color: $github-black !important; color: white !important; } -#badges .btn-discourse:hover, #join-content .card#discourse:hover { background-color: $discourse-yellow !important; color: black !important; } -#badges .btn-zulip:hover, #join-content .card#zulip:hover { background-color: $zulip-blue !important; color: white !important; } -#badges .btn-twitter:hover, #join-content .card#twitter:hover { background-color: $twitter-blue !important; color: white !important; } -#badges .btn-bluesky:hover, #join-content .card#bluesky:hover { background-color: $bluesky-blue !important; color: white !important; } -#badges .btn-youtube:hover, #join-content .card#youtube:hover { background-color: $youtube-red !important; color: white !important; @@ -385,21 +373,46 @@ body { } } } - #badges { + + #hero-lede { + max-width: 42rem; + margin: 1.5rem 0 0; + font-size: 1.1rem; + line-height: 1.6; + color: $tiletext2; + @media (max-width: 50rem) { + font-size: 0.95rem; + } + } + + #hero-points { display: flex; flex-wrap: wrap; - gap: 1rem; - margin: 2rem 0; + gap: 2rem; + margin: 2rem 0 0; @media (max-width: 50rem) { - margin: 1rem 0; - padding: 0; - width: 100%; + gap: 1.25rem; + margin-top: 1.5rem; + } + .hero-point { + flex: 1 1 14rem; + max-width: 18rem; + } + .hero-point-title { + font-weight: 700; + color: $tiletext; + margin-bottom: 0.3rem; + } + .hero-point-text { + margin: 0; + font-size: 0.9rem; + color: $tiletext2; } } } #chip-announcement { - margin: 0 auto 2rem; + margin: 0 auto 1.5rem; max-width: $maxwidthcover; width: 100%; @media (max-width: 50rem) { @@ -592,11 +605,11 @@ body { #about-highlights { .about-card-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + // auto-fit rather than a fixed column count: the container is capped around 960px, so this settles at 3 columns on desktop and folds down on its own as the viewport narrows, without a hand-tuned breakpoint. + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1.5rem; @media (max-width: 50rem) { - grid-template-columns: 1fr; gap: 1rem; } } diff --git a/content/_index.md b/content/_index.md index 996bc1ed..0ea04d31 100644 --- a/content/_index.md +++ b/content/_index.md @@ -2,6 +2,35 @@ title = "scverse" description = "Foundational tools for single-cell omics data analysis" +# Hero +[hero] + +text = """\ +scverse is an open-source, community-governed ecosystem of interoperable Python packages for single-cell and spatial omics data analysis — from foundational data structures like AnnData to analysis frameworks like Scanpy, scvi-tools, and Squidpy. The tools share common data formats, so you can move between them in a single analysis instead of gluing together incompatible pipelines.""" + +[[hero.points]] +title = "For researchers" +text = "Well-documented, actively maintained tools that interoperate out of the box." + +[[hero.points]] +title = "For developers" +text = "Shared data structures and conventions, so your package fits the ecosystem from day one." + +[[hero.points]] +title = "For institutions" +text = "A community-governed foundation ensuring the long-term maintenance of critical infrastructure." + +# Core packages +[packages] + +text = """\ +scverse maintains a set of interoperable core packages spanning every step of single-cell and spatial omics analysis, from data structures to modeling.""" + +# Events +[events] + +text = """\ +scverse hosts hackathons, workshops, and an annual conference to bring the community together throughout the year.""" # Mission [mission] diff --git a/layouts/index.html b/layouts/index.html index 3242267b..1c5edbb2 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -110,6 +110,17 @@

{{ .Title }}

+
+
+

Core packages

+

+ {{ .Params.packages.text | markdownify }} +

+ +
Browse the core packages
+
+
+

Ecosystem

@@ -121,6 +132,17 @@

Ecosystem

+
+
+

Events

+

+ {{ .Params.events.text | markdownify }} +

+ +
See all scverse events
+
+
+

Mission

diff --git a/layouts/partials/main/chip.html b/layouts/partials/main/chip.html index 947bbf7c..db2b35f4 100644 --- a/layouts/partials/main/chip.html +++ b/layouts/partials/main/chip.html @@ -1,7 +1,10 @@
-
October 12 – 14
-
scverse conference 2026: Register now!
+
Oct 12–14, 2026
+
+ scverse conference 2026 · Copenhagen, Denmark — registration is open + +
diff --git a/layouts/partials/main/cover.html b/layouts/partials/main/cover.html index d18900c0..40b7635e 100644 --- a/layouts/partials/main/cover.html +++ b/layouts/partials/main/cover.html @@ -5,20 +5,15 @@

{{ .Title }}

{{ .Description }}
- + {{ with .Params.hero }} +

{{ .text | markdownify }}

+
+ {{ range .points }} +
+
{{ .title }}
+

{{ .text }}

+
+ {{ end }} +
+ {{ end }} From a9a08ece67eb49ab9ff43c17d6d158bfae0d3760 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 14:06:12 +0200 Subject: [PATCH 13/33] Widen the homepage layout, remove now-redundant sections, and fix the trajectory command name The homepage capped content at 1200px/80% while other pages already had a "wide" variant (1400px/92%); switched the hero, chip, and section widths to that wide variant so the page uses more of a large monitor instead of leaving big unused margins on either side. Removed the "Next event" article and the full core-packages tile grid from the homepage now that the highlight grid has its own Core packages and Events cards linking to the same destinations. Deleted the now-unused main/packages.html partial and its dead CSS. Pinned the highlight grid to a fixed 3-column layout instead of auto-fit, which was squeezing in a 4th column on wide viewports and breaking the intended 3+3 layout of the 6 cards. Renamed the third demo command from pl.trajectory(...) to sc.pl.trajectory(...): the bare pl. prefix didn't correspond to anything import scanpy as sc actually imports. --- assets/main.scss | 86 +++++------------------------ layouts/index.html | 31 +---------- layouts/partials/main/packages.html | 38 ------------- static/main.js | 2 +- 4 files changed, 16 insertions(+), 141 deletions(-) delete mode 100644 layouts/partials/main/packages.html diff --git a/assets/main.scss b/assets/main.scss index fdc8d23c..a5d55437 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -46,7 +46,6 @@ $footertextmuted: #aaaaaa; $postlinktext: #333333; $maxwidth: 1200px; -$maxwidthcover: 1200px; $maxwidthwide: 1400px; $contentwidth: 80%; $contentwidthwide: 92%; @@ -339,7 +338,7 @@ body { align-items: center; align-self: center; #cover { - max-width: $maxwidthcover; + max-width: $maxwidthwide; width: 100%; padding: 0 1rem; #cover-top-section { @@ -349,7 +348,7 @@ body { #cover-heading { margin: 0; padding: 0; - max-width: $maxwidthcover; + max-width: $maxwidthwide; #title { #title-text { #title-name { @@ -413,7 +412,7 @@ body { #chip-announcement { margin: 0 auto 1.5rem; - max-width: $maxwidthcover; + max-width: $maxwidthwide; width: 100%; @media (max-width: 50rem) { width: 100%; @@ -505,8 +504,8 @@ body { } section { - max-width: $maxwidth; - width: $contentwidth; + max-width: $maxwidthwide; + width: $contentwidthwide; margin: 0 auto; margin-top: 5%; @media (max-width: 50rem) { @@ -538,78 +537,19 @@ body { #interactive-demo-section { margin-bottom: 3rem; } - #packages { - #packages-list { - .card { - display: flex; - flex-direction: row; - align-items: center; - background-color: $tilebg; - border-radius: 0.5rem; - transition: all 200ms ease-in-out; - @media (max-width: 50rem) { - min-height: 6rem; - } - .package-icon { - transition: all 200ms ease-in-out; - align-items: center; - justify-content: center; - padding: auto; - display: flex; - min-width: 6rem; - max-width: 6rem; - // Matches the img height below, so that a package - // without an icon still lines up with the others. - min-height: 4rem; - @media (max-width: 50rem) { - display: none; - } - - img { - max-width: 4rem; - height: 4rem; - } - } - .package-text { - .package-name { - color: $tiletext; - } - .package-desc { - font-size: 0.9rem; - color: $tiletext2; - } - } - &:hover { - cursor: pointer; - background-color: $tilebg2; - .package-icon { - transform: scale(1.1); - } - transform: scale(1.05); - } - &#anndata-tile:hover { - background-color: rgba($anndataorange, 0.2); - } - &#scanpy-tile:hover { - background-color: rgba($scanpymandy, 0.2); - } - &#mudata-tile:hover { - background-color: rgba($mudatagreen, 0.2); - } - &#muon-tile:hover { - background-color: rgba($muonaquamarine, 0.2); - } - } - } - } #about-highlights { .about-card-grid { display: grid; - // auto-fit rather than a fixed column count: the container is capped around 960px, so this settles at 3 columns on desktop and folds down on its own as the viewport narrows, without a hand-tuned breakpoint. - grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + // A fixed 3-column count rather than auto-fit: on a wide viewport auto-fit would squeeze in a 4th column and break the 3+3 layout of 6 cards. + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.5rem; + @media (max-width: 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + @media (max-width: 50rem) { + grid-template-columns: 1fr; gap: 1rem; } } @@ -2984,7 +2924,7 @@ body { .interactive-container { width: 100%; - max-width: 1200px; + max-width: $maxwidthwide; margin: 0 auto; transform-style: preserve-3d; perspective: 1500px; diff --git a/layouts/index.html b/layouts/index.html index 1c5edbb2..5fca686c 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -62,7 +62,7 @@ id="run-cmd3" type="button" tabindex="0" - aria-label="Run pl.trajectory(data, basis='umap')" + aria-label="Run sc.pl.trajectory(data, basis='umap')" > -
pl.trajectory(data, basis='umap')
+
sc.pl.trajectory(data, basis='umap')
@@ -81,33 +81,6 @@
- {{ with first 1 (where (where .Site.RegularPages "Section" "events") "Date.Unix" ">" now.Unix).ByDate }} -
-
Next event
- -
- {{ end }} - - {{ partial "main/packages" . }} - -
diff --git a/layouts/partials/main/packages.html b/layouts/partials/main/packages.html deleted file mode 100644 index fc1794fb..00000000 --- a/layouts/partials/main/packages.html +++ /dev/null @@ -1,38 +0,0 @@ -
-
Core packages
-
- {{ $registry := partialCached "registry.html" "registry" }} - {{ with $registry.core }} -
- {{ range $p := . }} -
-
-
- {{ with .logo }} - Logo for {{ $p.name }} - {{ end }} -
-
- -
- {{ .name }} -
-

- {{ .blurb }} -

-
-
-
- {{ end }} -
- {{ end }} - -
View all scverse packages
-
-
-
diff --git a/static/main.js b/static/main.js index 5d324fcd..1fc6bbc7 100644 --- a/static/main.js +++ b/static/main.js @@ -270,7 +270,7 @@ const initInteractiveViz = () => { // Exit early if visualization elements don't exist on this page if (!visualization || !card) return; - // Cluster centers from the most recent sc.pl.umap() run, so pl.trajectory() can draw its streamlines over the same layout without recomputing it. + // Cluster centers from the most recent sc.pl.umap() run, so sc.pl.trajectory() can draw its streamlines over the same layout without recomputing it. let lastCenters = null; // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. From d76aae67d7a1557a94d1c181ca34343bae359afc Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 14:13:02 +0200 Subject: [PATCH 14/33] Widen the hero lede, add icons to the audience value props The lede paragraph was capped at 42rem and the value props at 18rem each, so both looked stranded in a narrow column on the left of the now-wide hero with a lot of unused space beside them, and the paragraph wrapped into more lines than it needed to. Widened both to 58rem, and gave each "For X" value prop an icon to match the visual weight of the rest of the page instead of being bare text. --- assets/main.scss | 16 ++++++++++++++-- content/_index.md | 3 +++ layouts/partials/main/cover.html | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index a5d55437..a9bcad80 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -374,7 +374,7 @@ body { } #hero-lede { - max-width: 42rem; + max-width: 58rem; margin: 1.5rem 0 0; font-size: 1.1rem; line-height: 1.6; @@ -388,6 +388,7 @@ body { display: flex; flex-wrap: wrap; gap: 2rem; + max-width: 58rem; margin: 2rem 0 0; @media (max-width: 50rem) { gap: 1.25rem; @@ -395,7 +396,18 @@ body { } .hero-point { flex: 1 1 14rem; - max-width: 18rem; + } + .hero-point-icon { + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + margin-bottom: 0.6rem; + font-size: 1.1rem; + background-color: $tilebg; + color: $tiletext; } .hero-point-title { font-weight: 700; diff --git a/content/_index.md b/content/_index.md index 0ea04d31..63815fb3 100644 --- a/content/_index.md +++ b/content/_index.md @@ -9,14 +9,17 @@ text = """\ scverse is an open-source, community-governed ecosystem of interoperable Python packages for single-cell and spatial omics data analysis — from foundational data structures like AnnData to analysis frameworks like Scanpy, scvi-tools, and Squidpy. The tools share common data formats, so you can move between them in a single analysis instead of gluing together incompatible pipelines.""" [[hero.points]] +icon = "bi-mortarboard" title = "For researchers" text = "Well-documented, actively maintained tools that interoperate out of the box." [[hero.points]] +icon = "bi-code-slash" title = "For developers" text = "Shared data structures and conventions, so your package fits the ecosystem from day one." [[hero.points]] +icon = "bi-bank" title = "For institutions" text = "A community-governed foundation ensuring the long-term maintenance of critical infrastructure." diff --git a/layouts/partials/main/cover.html b/layouts/partials/main/cover.html index 40b7635e..cd0b2569 100644 --- a/layouts/partials/main/cover.html +++ b/layouts/partials/main/cover.html @@ -10,6 +10,7 @@

{{ .Title }}

{{ range .points }}
+
{{ .title }}

{{ .text }}

From 26f03990b2a1c4f340b68d87e5855f0f9ced97e2 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 14:19:45 +0200 Subject: [PATCH 15/33] Center the hero, redesign the conference banner as a bold card, and add real hero CTAs Centered the whole hero block (title, tagline, lede, value props) instead of leaving it left-aligned while the rest of the page reads as centered. Replaced the thin conference-announcement pill with a full-width dark banner card: a real date block, title, one-line description, and a proper "Register now" button, instead of a single line of text with an arrow. Added "Get started" and "View on GitHub" buttons to the hero, giving visitors an actual next action instead of only prose. --- assets/main.scss | 212 ++++++++++++++++++++----------- layouts/partials/main/chip.html | 23 ++-- layouts/partials/main/cover.html | 6 + 3 files changed, 159 insertions(+), 82 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index a9bcad80..9eb6c3cb 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -346,9 +346,10 @@ body { padding: 2rem 0; } #cover-heading { - margin: 0; + margin: 0 auto; padding: 0; max-width: $maxwidthwide; + text-align: center; #title { #title-text { #title-name { @@ -375,7 +376,7 @@ body { #hero-lede { max-width: 58rem; - margin: 1.5rem 0 0; + margin: 1.5rem auto 0; font-size: 1.1rem; line-height: 1.6; color: $tiletext2; @@ -384,18 +385,58 @@ body { } } + #hero-cta { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 1rem; + margin: 2rem 0 0; + } + + .hero-button { + padding: 0.8rem 1.8rem; + border-radius: 2rem; + font-weight: 700; + text-decoration: none; + transition: all 200ms ease-in-out; + } + + .hero-button-primary { + background-color: black; + color: white; + + &:hover { + background-color: $greyheader; + transform: translateY(-2px); + } + } + + .hero-button-secondary { + background-color: $tilebg; + color: $tiletext; + + &:hover { + background-color: $tilebg2; + transform: translateY(-2px); + } + } + #hero-points { display: flex; flex-wrap: wrap; + justify-content: center; gap: 2rem; max-width: 58rem; - margin: 2rem 0 0; + margin: 2rem auto 0; @media (max-width: 50rem) { gap: 1.25rem; margin-top: 1.5rem; } .hero-point { flex: 1 1 14rem; + display: flex; + flex-direction: column; + align-items: center; } .hero-point-icon { width: 2.25rem; @@ -423,95 +464,118 @@ body { } #chip-announcement { - margin: 0 auto 1.5rem; + margin: 0 auto 2rem; max-width: $maxwidthwide; width: 100%; @media (max-width: 50rem) { - width: 100%; - margin: 0 auto 1.25rem; + margin: 0 auto 1.5rem; } + } + + .conference-banner { display: flex; - justify-content: center; + align-items: center; + gap: 1.75rem; + width: 100%; + padding: 1.5rem 2rem; + border-radius: 1.25rem; + background: linear-gradient(120deg, #0b0b12 0%, #22223a 100%); + color: white; + text-decoration: none; + transition: all 200ms ease-in-out; + &:hover { - cursor: pointer; - } + transform: translateY(-2px); + box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.25); - .event-chip { - width: auto; - max-width: 100%; - // Flex items default to min-width: auto, which pins them to their content's intrinsic width instead of letting them shrink. - min-width: 0; - display: inline-flex; - align-items: center; - background-color: transparent; - border-radius: 2.2rem; - padding: 0.1rem 1rem 0.1rem 0; - font-size: 0.8rem; - font-weight: 500; - color: black; - transition: all 0.3s ease; - outline: 1px solid black; + .conference-banner-cta { + background-color: white; + color: black; - @media (max-width: 50rem) { - border-radius: 1.5rem; - padding: 0.05rem 0.6rem 0.05rem 0; - font-size: 0.7rem; - // Lets the date and message stack instead of forcing one nowrap line into horizontal scroll on narrow viewports. - flex-wrap: wrap; - justify-content: center; - row-gap: 0.35rem; + .arrow { + transform: translateX(0.2rem); + } } + } - .event-date { - background-color: black; - color: white; - padding: 0.5rem 1rem; - border-radius: 2rem; - margin: 0.1rem 1rem 0.1rem 0.2rem; - align-self: start; + @media (max-width: 50rem) { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + padding: 1.25rem 1.5rem; + text-align: left; + } + } - @media (max-width: 50rem) { - padding: 0.35rem 0.7rem; - border-radius: 1.5rem; - margin: 0.05rem 0.7rem 0.05rem 0.1rem; - } - } + .conference-banner-date { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + line-height: 1.1; + padding-right: 1.75rem; + border-right: 1px solid rgba(255, 255, 255, 0.2); - .event-text { - position: relative; - // Same min-width: auto fix as .event-chip, this time so the text itself can wrap. - min-width: 0; - white-space: normal; + .conference-banner-month { + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.6); + } - .arrow { - display: inline-block; - transition: transform 0.3s ease; - } - } + .conference-banner-day { + font-size: 1.6rem; + font-weight: 800; + } - &:hover { - color: #4557c4; - transform: scale(1.05); + .conference-banner-year { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); + } - .event-date { - background: linear-gradient(135deg, #262fb5, #74c8fa); - } + @media (max-width: 50rem) { + flex-direction: row; + align-items: baseline; + gap: 0.4rem; + padding-right: 0; + border-right: none; + } + } - .event-text .arrow { - transform: translateX(0.2rem); - } - } + .conference-banner-body { + flex: 1; + min-width: 0; + + .conference-banner-title { + font-size: 1.15rem; + font-weight: 800; + } - position: relative; + .conference-banner-desc { + margin: 0.2rem 0 0; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.75); + max-width: 34rem; + } + } - a { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; - } + .conference-banner-cta { + flex-shrink: 0; + padding: 0.7rem 1.4rem; + border-radius: 2rem; + background-color: rgba(255, 255, 255, 0.12); + font-weight: 700; + white-space: nowrap; + transition: all 200ms ease-in-out; + + .arrow { + display: inline-block; + transition: transform 200ms ease; + } + + @media (max-width: 50rem) { + align-self: flex-start; } } diff --git a/layouts/partials/main/chip.html b/layouts/partials/main/chip.html index db2b35f4..d3113a7c 100644 --- a/layouts/partials/main/chip.html +++ b/layouts/partials/main/chip.html @@ -1,10 +1,17 @@ -
-
-
Oct 12–14, 2026
-
- scverse conference 2026 · Copenhagen, Denmark — registration is open - + +
+
scverse conference 2026
+

+ Three days of talks, posters, and workshops for the single-cell and spatial omics community — Copenhagen, + Denmark. +

+
+
Register now
+
diff --git a/layouts/partials/main/cover.html b/layouts/partials/main/cover.html index cd0b2569..08482859 100644 --- a/layouts/partials/main/cover.html +++ b/layouts/partials/main/cover.html @@ -7,6 +7,12 @@

{{ .Title }}

{{ with .Params.hero }}

{{ .text | markdownify }}

+
{{ range .points }}
From 577ac83060b3cc252e40c9ef28da1566e2eeda1e Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 14:33:28 +0200 Subject: [PATCH 16/33] Let the hero lede and value props use the full container width They were still capped at 58rem while the conference banner and demo box above and below them spanned the full width, so the hero read as narrower than the rest of the page. Removed the cap so all three match. --- assets/main.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 9eb6c3cb..8613b6ae 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -375,7 +375,6 @@ body { } #hero-lede { - max-width: 58rem; margin: 1.5rem auto 0; font-size: 1.1rem; line-height: 1.6; @@ -426,7 +425,6 @@ body { flex-wrap: wrap; justify-content: center; gap: 2rem; - max-width: 58rem; margin: 2rem auto 0; @media (max-width: 50rem) { gap: 1.25rem; From e1a76edcbd9e95e3e4fd9490b88581f7eb472c4e Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 14:47:22 +0200 Subject: [PATCH 17/33] Trim the banner and hero lede, remove hero CTA buttons, link the value props The conference banner was taller and wider than it needed to be, creating an abrupt jump against the narrower centered title right below it. Reduced its padding and inset it to 92% width. Shortened the hero lede paragraph to one tighter sentence. Removed the "Get started"/"View on GitHub" buttons, which felt arbitrary next to the rest of the hero. Made each "For X" value prop a link instead (researchers -> /learn, developers -> /join, institutions -> /about/mission), so they carry the same navigational purpose without extra buttons competing for attention. --- assets/main.scss | 56 ++++++++++---------------------- content/_index.md | 5 ++- layouts/partials/main/cover.html | 10 ++---- 3 files changed, 23 insertions(+), 48 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 8613b6ae..dbce8144 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -384,42 +384,6 @@ body { } } - #hero-cta { - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 1rem; - margin: 2rem 0 0; - } - - .hero-button { - padding: 0.8rem 1.8rem; - border-radius: 2rem; - font-weight: 700; - text-decoration: none; - transition: all 200ms ease-in-out; - } - - .hero-button-primary { - background-color: black; - color: white; - - &:hover { - background-color: $greyheader; - transform: translateY(-2px); - } - } - - .hero-button-secondary { - background-color: $tilebg; - color: $tiletext; - - &:hover { - background-color: $tilebg2; - transform: translateY(-2px); - } - } - #hero-points { display: flex; flex-wrap: wrap; @@ -435,6 +399,18 @@ body { display: flex; flex-direction: column; align-items: center; + text-decoration: none; + border-radius: 1rem; + padding: 0.75rem; + transition: background-color 200ms ease-in-out; + + &:hover { + background-color: $tilebg4; + + .hero-point-icon { + transform: scale(1.08); + } + } } .hero-point-icon { width: 2.25rem; @@ -447,6 +423,7 @@ body { font-size: 1.1rem; background-color: $tilebg; color: $tiletext; + transition: transform 200ms ease-in-out; } .hero-point-title { font-weight: 700; @@ -474,9 +451,10 @@ body { display: flex; align-items: center; gap: 1.75rem; - width: 100%; - padding: 1.5rem 2rem; - border-radius: 1.25rem; + width: 92%; + margin: 0 auto; + padding: 1rem 1.75rem; + border-radius: 1rem; background: linear-gradient(120deg, #0b0b12 0%, #22223a 100%); color: white; text-decoration: none; diff --git a/content/_index.md b/content/_index.md index 63815fb3..97e77a7a 100644 --- a/content/_index.md +++ b/content/_index.md @@ -6,22 +6,25 @@ description = "Foundational tools for single-cell omics data analysis" [hero] text = """\ -scverse is an open-source, community-governed ecosystem of interoperable Python packages for single-cell and spatial omics data analysis — from foundational data structures like AnnData to analysis frameworks like Scanpy, scvi-tools, and Squidpy. The tools share common data formats, so you can move between them in a single analysis instead of gluing together incompatible pipelines.""" +scverse is an open-source, community-governed ecosystem of interoperable Python packages for single-cell and spatial omics analysis — from data structures like AnnData to analysis frameworks like Scanpy, scvi-tools, and Squidpy, all sharing common data formats so you can move between them in one analysis.""" [[hero.points]] icon = "bi-mortarboard" title = "For researchers" text = "Well-documented, actively maintained tools that interoperate out of the box." +link = "/learn" [[hero.points]] icon = "bi-code-slash" title = "For developers" text = "Shared data structures and conventions, so your package fits the ecosystem from day one." +link = "/join" [[hero.points]] icon = "bi-bank" title = "For institutions" text = "A community-governed foundation ensuring the long-term maintenance of critical infrastructure." +link = "/about/mission" # Core packages [packages] diff --git a/layouts/partials/main/cover.html b/layouts/partials/main/cover.html index 08482859..de851f3d 100644 --- a/layouts/partials/main/cover.html +++ b/layouts/partials/main/cover.html @@ -7,19 +7,13 @@

{{ .Title }}

{{ with .Params.hero }}

{{ .text | markdownify }}

-
{{ range .points }} - + {{ end }}
{{ end }} From e76f744ab78f370797eb57ad15381c526256cbeb Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Thu, 3 Sep 2026 14:52:24 +0200 Subject: [PATCH 18/33] Add a compact social icon row to the navbar GitHub, Discourse, Zulip, Bluesky, X, and YouTube as small icon-only links, reusing the existing bootstrap-icons and custom .i-* icon assets (no new SVGs needed) rather than duplicating the old hero button row we removed earlier. Hidden between 992px and ~1180px: Bootstrap's own collapse breakpoint is 992px, which isn't wide enough to also fit this row, and it was overflowing the nav and clipping the Join button in that range. The footer still lists all of these regardless of width. --- assets/main.scss | 35 +++++++++++++++++++++++++++++++++++ layouts/partials/header.html | 30 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/assets/main.scss b/assets/main.scss index dbce8144..cdc3d5b2 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -263,6 +263,41 @@ body { } } +.nav-social { + display: flex; + align-items: center; + gap: 0.15rem; + padding: 0.5rem 0.25rem !important; + + // Bootstrap's own collapse breakpoint (992px) isn't wide enough to also fit this row, so it overflows the nav between 992px and roughly 1180px; hide it there and rely on the footer. + @media (max-width: 1199px) { + display: none; + } + + a { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + color: $navtext; + font-size: 1rem; + transition: all 200ms ease-in-out; + + &:hover { + background-color: $navbghover; + color: $navtexthover; + + .i-discourse::before, + .i-zulip::before, + .i-bluesky::before { + opacity: 1; + } + } + } +} + #join-button { color: white; background-color: #4557c4; diff --git a/layouts/partials/header.html b/layouts/partials/header.html index fbfe6463..6bd82be7 100644 --- a/layouts/partials/header.html +++ b/layouts/partials/header.html @@ -136,6 +136,36 @@ +
-
- -
sc.pl.trajectory(data, basis='umap')
-
-
-
diff --git a/static/main.js b/static/main.js index ecf5bfc5..1ed2153c 100644 --- a/static/main.js +++ b/static/main.js @@ -259,30 +259,24 @@ const initInteractiveViz = () => { const visualization = document.getElementById('visualization'); const runCmd1 = document.getElementById('run-cmd1'); const runCmd2 = document.getElementById('run-cmd2'); - const runCmd3 = document.getElementById('run-cmd3'); const statusCmd1 = document.getElementById('status-cmd1'); const statusCmd2 = document.getElementById('status-cmd2'); - const statusCmd3 = document.getElementById('status-cmd3'); const execAnim1 = document.getElementById('exec-anim-1'); const execAnim2 = document.getElementById('exec-anim-2'); - const execAnim3 = document.getElementById('exec-anim-3'); // Exit early if visualization elements don't exist on this page if (!visualization || !card) return; - // Cluster centers from the most recent sc.pl.umap() run, so sc.pl.trajectory() can draw its streamlines over the same layout without recomputing it. - let lastCenters = null; - // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette. const colorClusters = [ - { color: '#40a9ff', count: 960, name: 'Cluster A' }, - { color: '#4ab274', count: 820, name: 'Cluster B' }, - { color: '#fbb822', count: 740, name: 'Cluster C' }, - { color: '#e5864b', count: 620, name: 'Cluster D' }, - { color: '#da347f', count: 760, name: 'Cluster E' }, - { color: '#969dea', count: 680, name: 'Cluster F' }, - { color: '#de367b', count: 540, name: 'Cluster G' }, - { color: '#6cf1a1', count: 860, name: 'Cluster H' } + { color: '#40a9ff', count: 68, name: 'Cluster A' }, + { color: '#4ab274', count: 58, name: 'Cluster B' }, + { color: '#fbb822', count: 52, name: 'Cluster C' }, + { color: '#e5864b', count: 44, name: 'Cluster D' }, + { color: '#da347f', count: 54, name: 'Cluster E' }, + { color: '#969dea', count: 48, name: 'Cluster F' }, + { color: '#de367b', count: 38, name: 'Cluster G' }, + { color: '#6cf1a1', count: 62, name: 'Cluster H' } ]; // 3D tilt effect @@ -321,70 +315,21 @@ const initInteractiveViz = () => { function generateUMAP() { visualization.querySelectorAll('.dot').forEach(dot => dot.remove()); - const oldTrajectory = visualization.querySelector('.trajectory-svg'); - if (oldTrajectory) oldTrajectory.remove(); const width = visualization.clientWidth; const height = visualization.clientHeight; - // All the pixel constants below were tuned against the desktop-sized box; scale them down for the much narrower mobile box instead of letting them clip against its edges. - const scale = Math.max(0.45, Math.min(1, width / 700)); - // Squared, so mobile sheds DOM nodes much faster than the layout shrinks: a smaller box needs proportionally far fewer points to still look dense, not just fewer to avoid clipping. - const countScale = scale * scale; - - // Lays cluster centers out along a winding chain instead of scattering them independently, so neighbouring clusters sit close enough to touch, like a real connected UMAP embedding, and the last two branch off from the chain's end. - const centers = []; - // Biased toward rightward travel (angle 0) so the chain fans out across this wide, short box instead of a random walk drifting into a corner; it can still wiggle up and down along the way. - let angle = (Math.random() - 0.5) * 0.6; - let cx = width * (0.05 + Math.random() * 0.05); - let cy = height * (0.35 + Math.random() * 0.3); - const chainCount = colorClusters.length - 2; - for (let i = 0; i < chainCount; i++) { - if (i > 0) { - angle = angle * 0.6 + (Math.random() - 0.5) * 1.1; - const step = (85 + Math.random() * 30) * scale; - cx = Math.min(Math.max(cx + Math.cos(angle) * step, width * 0.04), width * 0.96); - cy = Math.min(Math.max(cy + Math.sin(angle) * step, height * 0.12), height * 0.88); - } - centers.push({ x: cx, y: cy }); - } - [angle + 0.7 + Math.random() * 0.3, angle - 0.7 - Math.random() * 0.3].forEach(branchAngle => { - const step = (70 + Math.random() * 25) * scale; - centers.push({ - x: Math.min(Math.max(cx + Math.cos(branchAngle) * step, width * 0.03), width * 0.97), - y: Math.min(Math.max(cy + Math.sin(branchAngle) * step, height * 0.05), height * 0.95) - }); - }); - - // The walk above tends to drift toward one side, so re-center its bounding box on the container instead of leaving the whole shape off to one edge. - const xs = centers.map(c => c.x); - const ys = centers.map(c => c.y); - const shiftX = width / 2 - (Math.min(...xs) + Math.max(...xs)) / 2; - const shiftY = height / 2 - (Math.min(...ys) + Math.max(...ys)) / 2; - centers.forEach(c => { - c.x += shiftX; - c.y += shiftY; - }); - colorClusters.forEach((cluster, clusterIndex) => { - const { x: centerX, y: centerY } = centers[clusterIndex]; + colorClusters.forEach(cluster => { + const centerX = Math.random() * 0.6 * width + 0.2 * width; + const centerY = Math.random() * 0.6 * height + 0.2 * height; - // Real UMAP clusters are elongated, tapered blobs rather than round scatters, so each cluster gets its own axis to stretch along and a perpendicular axis to stay narrow on. - const axisAngle = Math.random() * Math.PI * 2; - const axisX = Math.cos(axisAngle); - const axisY = Math.sin(axisAngle); - const perpX = -axisY; - const perpY = axisX; - const axisLength = (Math.random() * 90 + 150) * scale; - const perpWidth = axisLength * (Math.random() * 0.25 + 0.4); - const count = Math.round(cluster.count * countScale); - - for (let i = 0; i < count; i++) { + for (let i = 0; i < cluster.count; i++) { const dot = document.createElement('div'); dot.className = 'dot'; dot.dataset.cluster = cluster.name; dot.dataset.color = cluster.color; - const size = Math.floor(Math.random() * 6) + 5; + const size = Math.floor(Math.random() * 8) + 5; dot.style.width = `${size}px`; dot.style.height = `${size}px`; @@ -396,10 +341,11 @@ const initInteractiveViz = () => { u = u / 6 - 0.5; v = v / 6 - 0.5; - const along = u * axisLength; - const across = v * perpWidth; - const x = centerX + axisX * along + perpX * across; - const y = centerY + axisY * along + perpY * across; + const distance = Math.random() * 130 + 20; + const dx = u * distance * 2; + const dy = v * distance * 2; + const x = centerX + dx; + const y = centerY + dy; const safeX = Math.min(Math.max(size, x), width - size); const safeY = Math.min(Math.max(size, y), height - size); @@ -416,86 +362,11 @@ const initInteractiveViz = () => { setTimeout(() => { dot.style.transform = 'scale(1)'; dot.style.opacity = '1'; - }, i * 3 + Math.random() * 150); + }, i * 18 + Math.random() * 150); } }); setupDotInteractions(); - lastCenters = centers; - } - - // Draws a scVelo/CellRank-style velocity field: bundles of parallel arrowed streamlines that fan out from a cluster and converge into the next one, rather than one graph edge between cluster centroids. - function drawTrajectory(centers) { - const svgNS = 'http://www.w3.org/2000/svg'; - const old = visualization.querySelector('.trajectory-svg'); - if (old) old.remove(); - if (centers.length < 4) return; - - // centers is already laid out as a chain with the last two entries branching off its end, matching how generateUMAP placed the cluster centers, so the edges just follow that same order. - const chainCount = centers.length - 2; - const edges = []; - for (let i = 0; i < chainCount - 1; i++) { - edges.push([centers[i], centers[i + 1]]); - } - const branchFrom = centers[chainCount - 1]; - edges.push([branchFrom, centers[chainCount]]); - edges.push([branchFrom, centers[chainCount + 1]]); - - const svg = document.createElementNS(svgNS, 'svg'); - svg.setAttribute('class', 'trajectory-svg'); - svg.setAttribute('width', visualization.clientWidth); - svg.setAttribute('height', visualization.clientHeight); - visualization.appendChild(svg); - - const LANES = 9; - const ARROWS_PER_LANE = 4; - - edges.forEach(([from, to]) => { - const dx = to.x - from.x; - const dy = to.y - from.y; - const dist = Math.hypot(dx, dy) || 1; - const ux = dx / dist; - const uy = dy / dist; - // Perpendicular unit vector: lanes are offset along this so the bundle fans sideways instead of along the direction of travel. - const px = -uy; - const py = ux; - const bow = Math.min(dist * 0.16, 36); - const spread = Math.min(dist * 0.22, 36); - - for (let lane = 0; lane < LANES; lane++) { - // -1 at one edge of the bundle, +1 at the other, 0 down the middle. - const t = LANES === 1 ? 0 : (lane / (LANES - 1)) * 2 - 1; - const jitter = (Math.random() - 0.5) * 6; - const startOffset = t * spread + jitter; - // Streamlines start spread across the source cluster and narrow as they approach the target, like real velocity fields converging on a cell state. - const endOffset = t * spread * 0.2 + jitter; - const midOffset = t * spread * 0.55 + jitter; - - const start = { x: from.x + px * startOffset, y: from.y + py * startOffset }; - const end = { x: to.x + px * endOffset, y: to.y + py * endOffset }; - const cx = (start.x + end.x) / 2 + px * (midOffset + bow); - const cy = (start.y + end.y) / 2 + py * (midOffset + bow); - - const path = document.createElementNS(svgNS, 'path'); - path.setAttribute('d', `M ${start.x} ${start.y} Q ${cx} ${cy} ${end.x} ${end.y}`); - path.setAttribute('class', 'trajectory-line'); - svg.appendChild(path); - - const length = path.getTotalLength(); - for (let a = 1; a <= ARROWS_PER_LANE; a++) { - const at = (a / (ARROWS_PER_LANE + 1)) * length; - const p = path.getPointAtLength(at); - const ahead = path.getPointAtLength(Math.min(at + 3, length)); - const angle = (Math.atan2(ahead.y - p.y, ahead.x - p.x) * 180) / Math.PI; - - const arrow = document.createElementNS(svgNS, 'polygon'); - arrow.setAttribute('points', '0,-2.5 5,0 0,2.5'); - arrow.setAttribute('transform', `translate(${p.x}, ${p.y}) rotate(${angle})`); - arrow.setAttribute('class', 'trajectory-arrow'); - svg.appendChild(arrow); - } - } - }); } function setupDotInteractions() { @@ -547,7 +418,7 @@ const initInteractiveViz = () => { } // These are divs, not buttons, so the keyboard behaviour has to be added by hand. - [runCmd1, runCmd2, runCmd3].forEach(function(control) { + [runCmd1, runCmd2].forEach(function(control) { control.addEventListener('keydown', function(event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); @@ -599,38 +470,14 @@ const initInteractiveViz = () => { }, 1200); }); - runCmd3.addEventListener('click', function() { - statusCmd3.style.width = '0'; - execAnim3.style.width = '0'; - - setTimeout(() => { - execAnim3.style.width = '100%'; - }, 50); - - setTimeout(() => { - if (lastCenters) drawTrajectory(lastCenters); - - statusCmd3.style.width = '100%'; - runCmd3.style.backgroundColor = '#34A853'; - - setTimeout(() => { - runCmd3.style.backgroundColor = ''; - }, 2000); - }, 900); - }); - - // Initial generation, followed by the velocity streamlines once the scatter has settled, so a visitor who never clicks anything still sees the full three-command story play out once. + // Initial generation generateUMAP(); - setTimeout(() => runCmd3.click(), 1200); // Responsive regeneration let resizeTimer; window.addEventListener('resize', function() { clearTimeout(resizeTimer); - resizeTimer = setTimeout(function() { - generateUMAP(); - setTimeout(() => runCmd3.click(), 1200); - }, 250); + resizeTimer = setTimeout(generateUMAP, 250); }); } From c66b89c95103182915c251f54acf527b20be9629 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 20:34:52 +0200 Subject: [PATCH 23/33] Tighten the gap between the hero value props and the demo box #interactive-demo-section is a
, so it inherited the generic section rule's margin-top: 5%, which stacked on top of #cover-top-section's own bottom padding and left a much bigger gap here than between any other two sections on the page. --- assets/main.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/main.scss b/assets/main.scss index d6c69302..7b945642 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -628,6 +628,8 @@ body { // The wide gap under the hero used to live on #packages, which put it // between whatever section came after the demo and the packages. #interactive-demo-section { + // Overrides the generic `section` rule's margin-top: 5%, which stacked with #cover-top-section's padding into an oversized gap. + margin-top: 2rem; margin-bottom: 3rem; } #about-highlights { From 28e9c204b0c7ced6644b52f40be395b7502916ae Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 20:48:15 +0200 Subject: [PATCH 24/33] Improve wording of boxes Signed-off-by: Lukas Heumos --- content/_index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/_index.md b/content/_index.md index 97e77a7a..ff994ed1 100644 --- a/content/_index.md +++ b/content/_index.md @@ -6,7 +6,7 @@ description = "Foundational tools for single-cell omics data analysis" [hero] text = """\ -scverse is an open-source, community-governed ecosystem of interoperable Python packages for single-cell and spatial omics analysis — from data structures like AnnData to analysis frameworks like Scanpy, scvi-tools, and Squidpy, all sharing common data formats so you can move between them in one analysis.""" +Scverse is an open-source, community-governed ecosystem of packages for single-cell and spatial omics. Shared data structures like AnnData, MuData, and SpatialData let tools such as Scanpy, scvi-tools, and Squidpy compose into a single powerful analysis.""" [[hero.points]] icon = "bi-mortarboard" @@ -30,7 +30,7 @@ link = "/about/mission" [packages] text = """\ -scverse maintains a set of interoperable core packages spanning every step of single-cell and spatial omics analysis, from data structures to modeling.""" +scverse develops a set of interoperable packages spanning every step of single-cell and spatial omics analysis, from data structures to modeling.""" # Events [events] @@ -42,19 +42,19 @@ scverse hosts hackathons, workshops, and an annual conference to bring the commu [mission] text = """\ -Scverse is a consortium of foundational tools (mostly in Python) for omics data in life sciences. It has been founded to ensure the long-term maintenance of these core tools.""" +Scverse builds the community that sustains foundational tools for omics data in the life sciences.""" # Ecosystem [ecosystem] text = """\ -A broader ecosystem of packages builds on the scverse core packages. [These tools](/packages/#ecosystem) implement models and analytical approaches to tackle challenges in spatial omics, regulatory genomics, trajectory inference, visualization, and more.""" +A broader [ecosystem](/packages/#ecosystem) of packages builds on the scverse core, extending it with domain-specific models and analysis methods.""" # Team [team] text = """\ -Scverse is a community project currently governed by the developers of the core packages. Please [reach out](/join) if you'd like to be involved! +Scverse is a community project currently governed by a core team. Please [reach out](/join) if you'd like to be involved! """ # People information is recorded in the people/_index.md file From f59c9e8f9fd5ec809470d538247e9907dfe580e7 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 20:49:23 +0200 Subject: [PATCH 25/33] Improve wording of boxes Signed-off-by: Lukas Heumos --- content/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/_index.md b/content/_index.md index ff994ed1..c1645270 100644 --- a/content/_index.md +++ b/content/_index.md @@ -64,7 +64,7 @@ Scverse is a community project currently governed by a core team. Please [reach [references] text = """\ -scverse tools are used in research and industry projects across the globe and are cited in [thousands](https://scholar.google.ru/scholar?cites=14568046068402025757) of academic publications. +scverse tools are used in research and industry across the globe. If they are useful in your work, please cite [the scverse paper](https://www.nature.com/articles/s41587-023-01733-8) along with the individual packages you used. """ From 703b89254366f60643a98429ef745447c6530617 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 20:54:24 +0200 Subject: [PATCH 26/33] Make the hero value props stand out, nf-core-style, with color and bigger type Took the same "bigger font + a strong color" idea nf-core uses for its three hero boxes, but with scverse's own per-package hue palette instead of copying nf-core's green: each point gets a tinted icon circle and a colored, larger, bolder title (using the same $eco-hues already used for the highlight-card icons further down the page), rather than plain black text under a gray icon. --- assets/main.scss | 40 ++++++++++++++++++++------------ layouts/partials/main/cover.html | 10 ++++---- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 7b945642..04a4b670 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -431,45 +431,55 @@ body { } .hero-point { flex: 1 1 14rem; + max-width: 18rem; display: flex; flex-direction: column; align-items: center; text-decoration: none; border-radius: 1rem; - padding: 0.75rem; - transition: background-color 200ms ease-in-out; + padding: 1.25rem 1rem; + transition: all 200ms ease-in-out; &:hover { background-color: $tilebg4; + transform: translateY(-2px); .hero-point-icon { transform: scale(1.08); } - - .hero-point-title { - border-bottom-color: black; - } } } .hero-point-icon { - width: 2.25rem; - height: 2.25rem; + width: 3rem; + height: 3rem; display: flex; align-items: center; justify-content: center; border-radius: 50%; - margin-bottom: 0.6rem; - font-size: 1.1rem; + margin-bottom: 0.85rem; + font-size: 1.35rem; background-color: $tilebg; color: $tiletext; transition: transform 200ms ease-in-out; } + // Written as a descendant rather than nested under .hero-point-icon/.hero-point-title: `&` there would carry the whole ancestor chain and put .hero-point in front of #hero-points. + @each $hue in $eco-hues { + .hero-point[data-hue="#{index($eco-hues, $hue) - 1}"] { + .hero-point-icon { + background-color: rgba($hue, 0.16); + color: darken($hue, 12%); + } + .hero-point-title { + color: darken($hue, 12%); + } + } + } .hero-point-title { - font-weight: 700; - color: $tiletext; - margin-bottom: 0.3rem; - // Same underline convention as every other inline text link on this page (see the global `p > a` rule), so this reads as a link without a hover. - border-bottom: 1px solid $linkunderline; + font-size: 1.15rem; + font-weight: 800; + margin-bottom: 0.4rem; + // Underlined at rest (not just on hover) so it still reads as a link without hovering; colored to match the icon above it via currentColor. + border-bottom: 1px solid currentColor; } .hero-point-text { margin: 0; diff --git a/layouts/partials/main/cover.html b/layouts/partials/main/cover.html index de851f3d..98b4c537 100644 --- a/layouts/partials/main/cover.html +++ b/layouts/partials/main/cover.html @@ -8,11 +8,11 @@

{{ .Title }}

{{ with .Params.hero }}

{{ .text | markdownify }}

From 5c919aa0bec9618ad3dbabcd2b49058301209245 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 21:07:25 +0200 Subject: [PATCH 27/33] Add an nf-core-style feature grid, use one accent color for the hero value props, tighten header spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lightweight section between the demo and the highlight card grid: a short lede plus a 2x3 grid of icon + title + one-line description (Documentation, Stable releases, Open source, Continuously tested, Interoperable, Community governed) — no card borders or shadows, so it stays visually distinct from the boxed cards below it. The hero value props used a different eco-hue per point, which read as arbitrary; all three now use the same blue accent. Reduced #cover-top-section's top margin so the gap between the nav and the conference banner isn't as large. --- assets/main.scss | 71 ++++++++++++++++++++++++++++++++++++---------- content/_index.md | 36 +++++++++++++++++++++++ layouts/index.html | 18 ++++++++++++ 3 files changed, 110 insertions(+), 15 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 04a4b670..c524b730 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -377,7 +377,7 @@ body { width: 100%; padding: 0 1rem; #cover-top-section { - margin-top: 3rem; + margin-top: 1.5rem; padding: 2rem 0; } #cover-heading { @@ -458,26 +458,15 @@ body { border-radius: 50%; margin-bottom: 0.85rem; font-size: 1.35rem; - background-color: $tilebg; - color: $tiletext; + background-color: rgba($spatialdatablue, 0.16); + color: darken($spatialdatablue, 12%); transition: transform 200ms ease-in-out; } - // Written as a descendant rather than nested under .hero-point-icon/.hero-point-title: `&` there would carry the whole ancestor chain and put .hero-point in front of #hero-points. - @each $hue in $eco-hues { - .hero-point[data-hue="#{index($eco-hues, $hue) - 1}"] { - .hero-point-icon { - background-color: rgba($hue, 0.16); - color: darken($hue, 12%); - } - .hero-point-title { - color: darken($hue, 12%); - } - } - } .hero-point-title { font-size: 1.15rem; font-weight: 800; margin-bottom: 0.4rem; + color: darken($spatialdatablue, 12%); // Underlined at rest (not just on hover) so it still reads as a link without hovering; colored to match the icon above it via currentColor. border-bottom: 1px solid currentColor; } @@ -642,6 +631,58 @@ body { margin-top: 2rem; margin-bottom: 3rem; } + #feature-grid { + #feature-grid-lede { + max-width: 42rem; + margin: 0 auto 2.5rem; + text-align: center; + font-size: 1.05rem; + color: $tiletext2; + } + + #feature-grid-items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 3rem; + row-gap: 2rem; + + @media (max-width: 50rem) { + grid-template-columns: 1fr; + row-gap: 1.5rem; + } + } + + .feature-item { + display: flex; + gap: 1rem; + align-items: flex-start; + } + + .feature-item-icon { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 1.1rem; + background-color: rgba($spatialdatablue, 0.16); + color: darken($spatialdatablue, 12%); + } + + .feature-item-title { + font-weight: 700; + color: $tiletext; + margin-bottom: 0.2rem; + } + + .feature-item-text { + margin: 0; + font-size: 0.9rem; + color: $tiletext2; + } + } #about-highlights { .about-card-grid { display: grid; diff --git a/content/_index.md b/content/_index.md index c1645270..d6c139f5 100644 --- a/content/_index.md +++ b/content/_index.md @@ -26,6 +26,42 @@ title = "For institutions" text = "A community-governed foundation ensuring the long-term maintenance of critical infrastructure." link = "/about/mission" +# Features +[features] + +text = """\ +scverse packages share data structures and conventions, so what works for one works for the rest.""" + +[[features.items]] +icon = "bi-book" +title = "Documentation" +text = "Extensive documentation: installation, usage, and API references for every core package." + +[[features.items]] +icon = "bi-tags" +title = "Stable releases" +text = "Semantic versioning and changelogs for every release." + +[[features.items]] +icon = "bi-unlock" +title = "Open source" +text = "Freely licensed and hosted on GitHub — use it, modify it, fork it." + +[[features.items]] +icon = "bi-check2-circle" +title = "Continuously tested" +text = "Every change runs through CI before release." + +[[features.items]] +icon = "bi-plug" +title = "Interoperable" +text = "Shared data structures mean output from one tool works as input to the next." + +[[features.items]] +icon = "bi-people" +title = "Community governed" +text = "Roadmap and priorities are set by the community and core developers, not a single company." + # Core packages [packages] diff --git a/layouts/index.html b/layouts/index.html index 36775700..06ab3280 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -63,6 +63,24 @@
+ {{ with .Params.features }} +
+

{{ .text | markdownify }}

+
+ {{ range .items }} +
+
+
+
{{ .title }}
+

{{ .text }}

+
+
+ {{ end }} +
+
+ {{ end }} + +
From 588857c237188ee011699aa6813a31ecd0d7dff6 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 21:08:47 +0200 Subject: [PATCH 28/33] Rewrite the documentation feature item as one flowing sentence instead of a colon-led fragment --- content/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/_index.md b/content/_index.md index d6c139f5..a02dcdb1 100644 --- a/content/_index.md +++ b/content/_index.md @@ -35,7 +35,7 @@ scverse packages share data structures and conventions, so what works for one wo [[features.items]] icon = "bi-book" title = "Documentation" -text = "Extensive documentation: installation, usage, and API references for every core package." +text = "Extensive documentation covering installation, usage, and API references for every core package." [[features.items]] icon = "bi-tags" From e5dff832be176773c2e0141765e7a2d9211c13a2 Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 21:15:49 +0200 Subject: [PATCH 29/33] Drop the feature grid's lede, give it a tinted band and a plain heading instead of getting lost as whitespace Removed the redundant intro paragraph (already said in the hero lede). Replaced it with an uppercase section heading matching the site's existing heading style, and gave the section a tinted background band so it reads as its own section between the demo box and the highlight card grid instead of disappearing as plain whitespace. --- assets/main.scss | 16 ++++++++++------ content/_index.md | 3 --- layouts/index.html | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index c524b730..066fb857 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -632,12 +632,16 @@ body { margin-bottom: 3rem; } #feature-grid { - #feature-grid-lede { - max-width: 42rem; - margin: 0 auto 2.5rem; - text-align: center; - font-size: 1.05rem; - color: $tiletext2; + background-color: $tilebg4; + border-radius: 1.25rem; + padding: 2.5rem; + + @media (max-width: 50rem) { + padding: 1.5rem; + } + + .section-heading { + margin-bottom: 1.5rem; } #feature-grid-items { diff --git a/content/_index.md b/content/_index.md index a02dcdb1..74e40d0c 100644 --- a/content/_index.md +++ b/content/_index.md @@ -29,9 +29,6 @@ link = "/about/mission" # Features [features] -text = """\ -scverse packages share data structures and conventions, so what works for one works for the rest.""" - [[features.items]] icon = "bi-book" title = "Documentation" diff --git a/layouts/index.html b/layouts/index.html index 06ab3280..c9e95bca 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -65,7 +65,7 @@ {{ with .Params.features }}
-

{{ .text | markdownify }}

+
What every core package provides
{{ range .items }}
From 1fb381a830617e06ca7706f2316110645db334da Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 21:19:16 +0200 Subject: [PATCH 30/33] Drop the hero title underline and tighten the nav-to-banner gap further --- assets/main.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 066fb857..8cec8b70 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -377,7 +377,7 @@ body { width: 100%; padding: 0 1rem; #cover-top-section { - margin-top: 1.5rem; + margin-top: 0.5rem; padding: 2rem 0; } #cover-heading { @@ -467,8 +467,6 @@ body { font-weight: 800; margin-bottom: 0.4rem; color: darken($spatialdatablue, 12%); - // Underlined at rest (not just on hover) so it still reads as a link without hovering; colored to match the icon above it via currentColor. - border-bottom: 1px solid currentColor; } .hero-point-text { margin: 0; From e487b4ae4495fa5ca56fc5b3135ec9ce4336c86e Mon Sep 17 00:00:00 2001 From: Lukas Heumos Date: Mon, 7 Sep 2026 21:22:11 +0200 Subject: [PATCH 31/33] Make the hero value prop titles plain dark text instead of blue --- assets/main.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/main.scss b/assets/main.scss index 8cec8b70..8721c2bb 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -466,7 +466,7 @@ body { font-size: 1.15rem; font-weight: 800; margin-bottom: 0.4rem; - color: darken($spatialdatablue, 12%); + color: $tiletext; } .hero-point-text { margin: 0; From 9af26ce50a0e92dc426c1dadc0ee55629be6f9a5 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 8 Sep 2026 12:32:30 +0200 Subject: [PATCH 32/33] align icons --- assets/main.scss | 28 +++++++++++++++------------- static/img/icons/xcom.svg | 1 + 2 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 static/img/icons/xcom.svg diff --git a/assets/main.scss b/assets/main.scss index ad824eca..02b31ceb 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -100,7 +100,7 @@ $eco-hues: } } -@each $icon in zulip, discourse, bluesky { +@each $icon in zulip, discourse, bluesky, xcom { .i-#{$icon}::before { display: inline-block; height: 1rem; @@ -119,18 +119,11 @@ $eco-hues: } } -.i-xcom::before { - content: "𝕏"; - font-weight: 900; - transition: - background 200ms ease-out, - filter 200ms ease-out; -} - .card:hover { // Not discord, since it’s light-colored .i-zulip::before, - .i-bluesky::before { + .i-bluesky::before, + .i-xcom::before { filter: invert(100%); } } @@ -284,18 +277,27 @@ body { border-radius: 50%; color: $navtext; font-size: 1rem; + text-decoration: none; transition: all 200ms ease-in-out; &:hover { background-color: $navbghover; color: $navtexthover; - .i-discourse::before, - .i-zulip::before, - .i-bluesky::before { + i::before { opacity: 1; } } + + // Both icon kinds are 1rem boxes, but the Bootstrap glyphs are baseline-aligned + // (`vertical-align: -.125em`) while our `::before` images aren't. Making the `` + // a 1rem flex box takes the baseline out of it so all six line up. + i { + display: flex; + width: 1rem; + height: 1rem; + line-height: 1; + } } } diff --git a/static/img/icons/xcom.svg b/static/img/icons/xcom.svg new file mode 100644 index 00000000..ed2d7645 --- /dev/null +++ b/static/img/icons/xcom.svg @@ -0,0 +1 @@ + From eb20e1cd4be81ef7de26f11eb8eedca276f5c574 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Tue, 8 Sep 2026 13:47:39 +0200 Subject: [PATCH 33/33] remove X icon from frontpage --- layouts/partials/header.html | 3 --- 1 file changed, 3 deletions(-) diff --git a/layouts/partials/header.html b/layouts/partials/header.html index dbbb00d6..138e8b38 100644 --- a/layouts/partials/header.html +++ b/layouts/partials/header.html @@ -160,9 +160,6 @@ > - - -