From 574efefbd3958821e00b5f2869d44d9a366e02f1 Mon Sep 17 00:00:00 2001 From: Muskan Hashim Date: Tue, 1 Sep 2026 14:50:46 +0200 Subject: [PATCH 1/5] Rebuild the blog index as a card grid The listing was a wall of date/title/description rows that all carried the same weight, and it gave the posts' own figures nowhere to appear. Each post is now a card whose shape comes from its own cover image, so figures are shown whole rather than letterboxed into a fixed box; the covers here range from 1.46:1 to 7.5:1 and no single ratio serves them. The cover is the first locally hosted image in the post, found at build time. Remote images are skipped on purpose, so a post cannot hotlink a third party from the index. Dimensions come from images.Config, except for SVGs, which that function does not read, so those are taken from the root element instead. A picture only earns a cover slot if it reads at card size. Panoramas and dense multi-panel figures shrink to illegible strips, so those posts and the ones with no image at all get a type card coloured from $eco-hues, the same cycle the ecosystem cards and people monograms already use. That way the grid never shows an empty frame. The newest post spans two columns. Cards stretch to their row so every row ends level, and each one reserves ground beneath its picture so the text never lands on the artwork. Titles and descriptions are clamped to two lines, since the descriptions run from six words to twenty-five. An archive rail lists every post by year alongside the grid, and drops away below 62rem. .page-item-author goes with the old markup; nothing else referenced it. /events and the landing page keep using .page-item, which is untouched. --- assets/main.scss | 260 ++++++++++++++++++++++++++++++++++++++++- layouts/blog/list.html | 105 ++++++++++++++--- 2 files changed, 349 insertions(+), 16 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 5089bc0..d56c4b8 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3149,8 +3149,264 @@ body { } } -.page-item-author { - font-weight: 600; +// The blog index. /events and the landing page still use .page-item above, so the +// two are kept apart. + +.blog-layout { + display: grid; + grid-template-columns: 1fr 15rem; + gap: 2.5rem; + align-items: start; + + // The rail is the first thing to go when width runs short + @media (max-width: 62rem) { + grid-template-columns: 1fr; + } +} + +.blog-rail { + position: sticky; + top: 5.5rem; + + @media (max-width: 62rem) { + display: none; + } + + h2 { + margin: 0 0 0.7rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: $greydesc; + } + + .blog-rail-year { + margin-bottom: 1.1rem; + + > span { + display: block; + padding-bottom: 0.35rem; + font-weight: 700; + color: $greyheader; + border-bottom: 1px solid $overline; + } + + a { + display: block; + padding: 0.4rem 0; + font-size: 0.8rem; + line-height: 1.35; + color: $tiletext2; + border-bottom: 1px solid $overline; + + &:last-child { + border-bottom: 0; + } + + &:hover { + color: $greyheader; + } + } + } + + .blog-rail-when { + display: block; + margin-top: 0.15rem; + font-size: 0.7rem; + color: $greydesc; + } +} + +.blog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.25rem; + margin-top: 1.5rem; + // Stretch, so every row's cards end level with each other + align-items: stretch; + + @media (max-width: 78rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + @media (max-width: 44rem) { + grid-template-columns: minmax(0, 1fr); + } +} + +.blog-card { + position: relative; + // width is load-bearing: with aspect-ratio on the media and an auto width, + // min-height would grow the card sideways out of its column + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 13rem; + scroll-margin-top: 6rem; + overflow: hidden; + border-radius: $card-radius; + background-color: white; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + transition: box-shadow 250ms ease-in-out; + + &:hover { + box-shadow: 0 8px 26px rgba(0, 0, 0, 0.16); + } + + &:focus-visible { + outline: 3px solid $spatialdatablue; + outline-offset: 2px; + } + + // The newest post runs across two columns; its row-mate stretches beside it + &.blog-card--lead { + grid-column: span 2; + + @media (max-width: 44rem) { + grid-column: span 1; + } + } +} + +// The box takes the picture's own shape, so the picture fills it exactly +.blog-card-media { + position: relative; + flex: 0 0 auto; + width: 100%; + aspect-ratio: var(--img-ar); + overflow: hidden; + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.blog-card--contain .blog-card-media { + padding: 0.6rem; + + img { + object-fit: contain; + } +} + +// Reserved ground beneath the picture: the words never land on the artwork +.blog-card-foot { + flex: 1 1 auto; + min-height: 11rem; +} + +.blog-card--lead .blog-card-foot { + min-height: 8.5rem; +} + +.blog-card-text { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 1rem 1.1rem 1.1rem; + color: white; +} + +.blog-card--img .blog-card-text { + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.9) 0%, + rgba(0, 0, 0, 0.72) 22%, + rgba(0, 0, 0, 0.22) 42%, + rgba(0, 0, 0, 0) 60% + ); +} + +// Posts whose picture would not read at card size get a type card instead of +// a placeholder, so the grid never looks like it is missing something +.blog-card--type { + min-height: 14rem; + + @each $hue in $eco-hues { + &[data-hue="#{index($eco-hues, $hue) - 1}"] { + background: linear-gradient( + 160deg, + mix($hue, black, 52%) 0%, + mix($hue, black, 34%) 100% + ); + } + } + + .blog-card-title { + font-size: 1.3rem; + -webkit-line-clamp: 3; + } +} + +.blog-card-mark { + position: absolute; + top: 0.95rem; + left: 1.1rem; + z-index: 2; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.5); +} + +.blog-card-kicker { + margin-bottom: 0.3rem; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: white; + opacity: 0.85; +} + +// Descriptions run from six words to twenty-five. Clamping keeps every card the +// same shape and stops the longest ones climbing over the artwork. +.blog-card-title, +.blog-card-desc { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.blog-card-title { + margin: 0; + font-size: 1.02rem; + font-weight: 700; + line-height: 1.25; + color: white; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); +} + +.blog-card--lead .blog-card-title { + font-size: 1.4rem; +} + +.blog-card-date { + margin-top: 0.3rem; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.78); +} + +.blog-card-desc { + margin: 0.45rem 0 0; + font-size: 0.85rem; + line-height: 1.5; + color: rgba(255, 255, 255, 0.93); +} + +.blog-card-meta { + margin-top: 0.35rem; + font-size: 0.74rem; + color: rgba(255, 255, 255, 0.72); } // Specific to /about/roles — an index of the governance sections, not a summary of them diff --git a/layouts/blog/list.html b/layouts/blog/list.html index 740ec72..d9706c3 100644 --- a/layouts/blog/list.html +++ b/layouts/blog/list.html @@ -1,19 +1,96 @@ {{ define "main" }}

Blog

- {{ range .Pages }} - -
-
- {{ .Date.Format "January 02, 2006" }} -
-
-

{{ .Title }}

- {{ with .Params.author }} - {{ . }} + +
+
+
+ {{ range $i, $p := .Pages }} + {{/* The cover is the first locally hosted image in the post. Remote images + are skipped deliberately: hotlinking a third party from the index is not + something a post should be able to do by accident. */}} + {{ $src := "" }} + {{ with findRE `src="/img/[^"]+"` $p.Content 1 }} + {{ $src = index . 0 | replaceRE `^src="` "" | replaceRE `"$` "" }} + {{ end }} + + {{/* Shape follows the picture, so nothing is letterboxed. images.Config + covers the raster formats; SVGs carry their size in the root element. */}} + {{ $ar := 0.0 }} + {{ with $src }} + {{ $path := printf "static%s" . }} + {{ if strings.HasSuffix . ".svg" }} + {{ $svg := readFile $path }} + {{ $w := 0.0 }} + {{ $h := 0.0 }} + {{ with findRESubmatch `]*?\swidth="([0-9.]+)"` $svg 1 }} + {{ $w = float (index (index . 0) 1) }} + {{ end }} + {{ with findRESubmatch `]*?\sheight="([0-9.]+)"` $svg 1 }} + {{ $h = float (index (index . 0) 1) }} + {{ end }} + {{ if and (gt $w 0.0) (gt $h 0.0) }}{{ $ar = div $w $h }}{{ end }} + {{ else }} + {{ $c := images.Config $path }} + {{ $ar = div (float $c.Width) (float $c.Height) }} + {{ end }} + {{ end }} + + {{/* A picture only earns a cover slot if it reads at card size. Panoramas + and dense multi-panel figures become illegible strips, so those posts get + a type card instead, which at least says something. */}} + {{ $usable := and (ne $src "") (ge $ar 1.2) (le $ar 2.4) }} + {{ $id := anchorize $p.Title }} + + + {{ if $usable }} +
+ +
+
+ {{ else }} + scverse blog + {{ end }} + +
+ {{ if eq $i 0 }}Latest{{ end }} +

{{ $p.Title }}

+ {{ $p.Date.Format "January 02, 2006" }} +

{{ $p.Description }}

+ + {{- with $p.Params.author }}{{ . }} · {{ end -}} + {{ $p.ReadingTime }} min read + +
+
+ {{ end }} +
+
+ +
- - {{ end }} + {{ end }} + + {{ end }} From 69e28b7fde1100f118143f00016df455a9686930 Mon Sep 17 00:00:00 2001 From: Muskan Hashim Date: Tue, 1 Sep 2026 15:00:06 +0200 Subject: [PATCH 2/5] Match the repo's conventions in the blog index Conditional classes go inline on one attribute, the way the ecosystem cards already write them, rather than split across lines. A type card has no picture to give it height, so its text sits in the flow and the card grows to fit a long title instead of clipping it. --- assets/main.scss | 7 +++++++ layouts/blog/list.html | 11 +++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index d56c4b8..9f48c2a 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3343,6 +3343,13 @@ body { font-size: 1.3rem; -webkit-line-clamp: 3; } + + // No picture to give the card height, so the text sits in the flow and the + // card grows to fit it rather than clipping a long title + .blog-card-text { + position: relative; + margin-top: auto; + } } .blog-card-mark { diff --git a/layouts/blog/list.html b/layouts/blog/list.html index d9706c3..5c17987 100644 --- a/layouts/blog/list.html +++ b/layouts/blog/list.html @@ -39,20 +39,19 @@

Blog

and dense multi-panel figures become illegible strips, so those posts get a type card instead, which at least says something. */}} {{ $usable := and (ne $src "") (ge $ar 1.2) (le $ar 2.4) }} - {{ $id := anchorize $p.Title }} {{ if $usable }}
From 366ecb2c2bbf61f54daa347a0d16c67a643710c5 Mon Sep 17 00:00:00 2001 From: Muskan Hashim Date: Tue, 1 Sep 2026 15:17:35 +0200 Subject: [PATCH 3/5] Fix the spacing faults a real build turned up The longest title on a type card slid under the "scverse blog" mark, so the text block now clears it. One column means much narrower cards, so the same words wrap onto more lines than they do on a wide screen and outgrew the ground reserved beneath the picture; on the lead card the title sat across the diagram at 375px. The lead carries a kicker the others do not and was a line short of room for it. Checked at 375, 820 and 1280: no card's text crosses its picture, nothing overflows its column, nothing is clipped, and there is no horizontal scroll. --- assets/main.scss | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 9f48c2a..56e81f1 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3299,8 +3299,22 @@ body { min-height: 11rem; } +// The lead carries a kicker the others do not, so it needs a little more room .blog-card--lead .blog-card-foot { - min-height: 8.5rem; + min-height: 10.25rem; +} + +// One column means much narrower cards, so the same words take more lines and +// need more ground under the picture than they do on a wide screen +@media (max-width: 44rem) { + .blog-card-foot, + .blog-card--lead .blog-card-foot { + min-height: 12.5rem; + } + + .blog-card--lead .blog-card-title { + font-size: 1.25rem; + } } .blog-card-text { @@ -3345,10 +3359,12 @@ body { } // No picture to give the card height, so the text sits in the flow and the - // card grows to fit it rather than clipping a long title + // card grows to fit it rather than clipping a long title. The top padding + // keeps the longest titles from sliding under the mark. .blog-card-text { position: relative; margin-top: auto; + padding-top: 2.6rem; } } From 56758439185ffe58fee20101a80cd0bf5a8580e2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:58:47 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- layouts/blog/list.html | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/layouts/blog/list.html b/layouts/blog/list.html index 5c17987..37060ac 100644 --- a/layouts/blog/list.html +++ b/layouts/blog/list.html @@ -7,14 +7,16 @@

Blog

{{ range $i, $p := .Pages }} {{/* The cover is the first locally hosted image in the post. Remote images are skipped deliberately: hotlinking a third party from the index is not - something a post should be able to do by accident. */}} + something a post should be able to do by accident. + */}} {{ $src := "" }} {{ with findRE `src="/img/[^"]+"` $p.Content 1 }} {{ $src = index . 0 | replaceRE `^src="` "" | replaceRE `"$` "" }} {{ end }} {{/* Shape follows the picture, so nothing is letterboxed. images.Config - covers the raster formats; SVGs carry their size in the root element. */}} + covers the raster formats; SVGs carry their size in the root element. + */}} {{ $ar := 0.0 }} {{ with $src }} {{ $path := printf "static%s" . }} @@ -37,15 +39,21 @@

Blog

{{/* A picture only earns a cover slot if it reads at card size. Panoramas and dense multi-panel figures become illegible strips, so those posts get - a type card instead, which at least says something. */}} + a type card instead, which at least says something. + */}} {{ $usable := and (ne $src "") (ge $ar 1.2) (le $ar 2.4) }} +
Blog scverse blog {{ end }} +
{{ if eq $i 0 }}Latest{{ end }}

{{ $p.Title }}

{{ $p.Date.Format "January 02, 2006" }}

{{ $p.Description }}

- {{- with $p.Params.author }}{{ . }} · {{ end -}} + {{- with $p.Params.author }}{{ . }} ·{{ end -}} {{ $p.ReadingTime }} min read
From eb7b0383e11f22d6208dae547aa0b22acd2fc93a Mon Sep 17 00:00:00 2001 From: Muskan Hashim Date: Thu, 3 Sep 2026 16:35:30 +0200 Subject: [PATCH 5/5] Confine the card gradient to the text zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Philipp and José both found the black-to-white gradient too harsh on the posts whose cover is a diagram, and they were right: the stops were percentages of the card, so on a tall card the dark washed most of the way up a white figure. The stops are in rem now, measured from the bottom, so the dark covers the ground already reserved for the text and feathers out over the 2.5rem above it. How far it reaches no longer depends on how tall the picture is. It holds flat across the text rather than fading through it. Fading looked softer but put white titles on a pale wash over a white figure: measured against white, contrast was 2.1:1 on the BioContextAI card and 4.2:1 on the lead, both under the 4.5:1 AA threshold. Flat brings the worst case to 13.8:1 on wide screens and 7.1:1 at 375px, where the taller text zone needs the flat part to reach further. --- assets/main.scss | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/assets/main.scss b/assets/main.scss index 56e81f1..9b86536 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -3315,6 +3315,18 @@ body { .blog-card--lead .blog-card-title { font-size: 1.25rem; } + + // The text zone is taller here, so the flat part of the scrim has to reach + // further up with it or the title loses its contrast + .blog-card--img .blog-card-text { + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.9) 0, + rgba(0, 0, 0, 0.86) 12rem, + rgba(0, 0, 0, 0.5) 13.5rem, + rgba(0, 0, 0, 0) 14.5rem + ); + } } .blog-card-text { @@ -3328,13 +3340,17 @@ body { color: white; } +// Anchored to the bottom in rem, not in percentages of the card: as a percentage +// the dark washed all the way up a tall white figure. It holds flat across the +// reserved text zone, so white text keeps its contrast even over a white figure, +// then feathers out over 2.5rem just above it. .blog-card--img .blog-card-text { background: linear-gradient( to top, - rgba(0, 0, 0, 0.9) 0%, - rgba(0, 0, 0, 0.72) 22%, - rgba(0, 0, 0, 0.22) 42%, - rgba(0, 0, 0, 0) 60% + rgba(0, 0, 0, 0.9) 0, + rgba(0, 0, 0, 0.86) 10.5rem, + rgba(0, 0, 0, 0.5) 12rem, + rgba(0, 0, 0, 0) 13rem ); }