From a28191dcf7ebbc2a2b6424b8d307fd7426ca52d5 Mon Sep 17 00:00:00 2001
From: Lukas Wallrich
Date: Thu, 1 Oct 2026 23:05:07 +0200
Subject: [PATCH 1/7] Clusters: show teaching materials (lesson plans and
slides) per sub-cluster
- data/lesson_plans.json (sample entries) holds sets keyed by cluster number
and sub-cluster slug.
- Sub-clusters with a set get a teaching-materials strip at the top of the
accordion body and a green dot on the header. Cluster stats show the
count, and the sidebar shows the dot.
- A "Lesson plans" filter chip shows only sub-clusters and clusters that
have sets. It combines with the existing search and tag filters.
- The build fails when a lesson_plans.json key matches no cluster or
sub-cluster.
- The sub-cluster slug now comes from one shared partial (sc_slug.html).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
assets/scss/custom.scss | 112 ++++++++++++++++++
data/lesson_plans.json | 51 ++++++++
layouts/clusters/cluster.html | 4 +
.../partials/clusters/cluster_section.html | 43 ++++++-
.../partials/clusters/clusters_controls.html | 9 ++
layouts/partials/clusters/lesson_plans.html | 22 ++++
layouts/partials/clusters/sc_slug.html | 2 +
layouts/partials/clusters/sidebar_nav.html | 14 ++-
static/js/featured-resources.js | 19 ++-
9 files changed, 263 insertions(+), 13 deletions(-)
create mode 100644 data/lesson_plans.json
create mode 100644 layouts/partials/clusters/lesson_plans.html
create mode 100644 layouts/partials/clusters/sc_slug.html
diff --git a/assets/scss/custom.scss b/assets/scss/custom.scss
index ae9864bbf0a..60822dc67e1 100644
--- a/assets/scss/custom.scss
+++ b/assets/scss/custom.scss
@@ -623,6 +623,118 @@
color: #1264ab;
}
+/* ---- Lesson plans (data/lesson_plans.json) ----
+ One green marks lesson-plan sets everywhere: the dot on sub-cluster headers,
+ cluster stats and sidebar entries, the filter chip, and the teaching-materials strip. */
+$lp-green: #2f6b4f;
+
+.lp-dot {
+ display: inline-block;
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ background: $lp-green;
+ margin-left: 0.35rem;
+ vertical-align: 0.05em;
+ flex-shrink: 0;
+}
+.cluster-stats .lp-dot { margin-left: 0; width: 0.45rem; height: 0.45rem; }
+/* Sidebar sub-cluster: dot sits beside the reference count so it never wraps onto its own line */
+.cluster-nav-subs .badge .lp-dot { margin: 0 0.35rem 0 0; vertical-align: 0; }
+
+.lp-strip {
+ background: #fff;
+ border: 1px solid rgba(0,0,0,0.08);
+ border-left: 4px solid $lp-green;
+ border-radius: 4px;
+ padding: 0.45rem 0.7rem 0.4rem;
+ margin: 0.15rem 0 0.55rem;
+}
+.lp-strip__label {
+ display: block;
+ font-size: 0.58rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: $lp-green;
+ margin-bottom: 0.25rem;
+}
+.lp-strip__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin-bottom: 0.3rem;
+}
+.lp-chip {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: #333;
+ background: rgba(47,107,79,0.07);
+ border: 1px solid rgba(47,107,79,0.2);
+ border-radius: 3px;
+ padding: 0.12rem 0.45rem;
+ white-space: nowrap;
+}
+.lp-chip__formats {
+ font-weight: 400;
+ color: #777;
+}
+.lp-chip a {
+ color: $lp-green;
+ font-weight: 600;
+ text-decoration: underline;
+ text-decoration-color: rgba(47,107,79,0.35);
+ text-underline-offset: 2px;
+}
+.lp-chip a:hover { color: #1f4a36; text-decoration-color: currentColor; }
+.lp-strip__meta {
+ font-size: 0.62rem;
+ line-height: 1.45;
+ color: #777;
+ margin: 0;
+}
+.lp-strip__meta a { color: #555; text-decoration: underline; text-decoration-color: rgba(0,0,0,0.25); }
+.lp-strip__meta a:hover { color: $lp-green; }
+
+/* Filter chip: same footprint as .fr-tag-btn; pressed state fills green */
+.lp-filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-family: inherit;
+ font-size: 0.62rem;
+ font-weight: 600;
+ padding: 0.18rem 0.55rem;
+ border-radius: 3px;
+ border: 1px solid rgba(47,107,79,0.35);
+ background: transparent;
+ color: $lp-green;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+.lp-filter-btn .lp-dot { margin-left: 0; }
+.lp-filter-btn:hover { background: rgba(47,107,79,0.08); }
+.lp-filter-btn[aria-pressed="true"] { background: $lp-green; border-color: $lp-green; color: #fff; }
+.lp-filter-btn[aria-pressed="true"] .lp-dot { background: #fff; }
+
+/* Lesson-plan filter on (.lp-filter-on set by featured-resources.js): hide everything without a set */
+.lp-filter-on .cluster-section:not([data-lp]),
+.lp-filter-on .acc-section:not([data-lp]),
+.lp-filter-on .cluster-nav-group:not([data-lp]),
+.lp-filter-on .cluster-nav-subs .nav-item:not([data-lp]) {
+ display: none;
+}
+/* Remaining sub-clusters lose their visible neighbour, so drop the divider on the first one shown */
+.lp-filter-on .acc-section[data-lp] { border-top: 0; }
+.lp-filter-on .acc-section[data-lp] ~ .acc-section[data-lp] { border-top: 1px solid rgba(0,0,0,0.06); }
+.lp-filter-empty { display: none; }
+.lp-filter-on .lp-filter-empty {
+ display: block;
+ font-size: 0.8rem;
+ color: #666;
+ padding: 1rem 0;
+}
+
/* ---- Publication list ---- */
.publication-list {
padding-left: 1.5rem;
diff --git a/data/lesson_plans.json b/data/lesson_plans.json
new file mode 100644
index 00000000000..31f0d7a4169
--- /dev/null
+++ b/data/lesson_plans.json
@@ -0,0 +1,51 @@
+{
+ "sets": {
+ "2": {
+ "effect-sizes-statistical-power-simulations-confidence-intervals": {
+ "title": "Effect sizes, power and confidence intervals",
+ "record": "https://zenodo.org/records/100002",
+ "doi": "10.5281/zenodo.100002",
+ "creators": ["Jane Doe", "Sam Rivera"],
+ "reviewers": ["Alex Brown", "Chris Dang"],
+ "files": [
+ {"label": "Slides", "url": "https://zenodo.org/records/100002/files/effect-sizes-slides.pptx?download=1", "format": "PPTX"},
+ {"label": "Slides", "url": "https://zenodo.org/records/100002/files/effect-sizes-slides.pdf?download=1", "format": "PDF"},
+ {"label": "Lesson plan L1", "url": "https://zenodo.org/records/100002/files/effect-sizes-lesson-plan-L1.docx?download=1", "format": "DOCX"},
+ {"label": "Lesson plan L1", "url": "https://zenodo.org/records/100002/files/effect-sizes-lesson-plan-L1.pdf?download=1", "format": "PDF"},
+ {"label": "Lesson plan L2", "url": "https://zenodo.org/records/100002/files/effect-sizes-lesson-plan-L2.docx?download=1", "format": "DOCX"},
+ {"label": "Lesson plan L2", "url": "https://zenodo.org/records/100002/files/effect-sizes-lesson-plan-L2.pdf?download=1", "format": "PDF"}
+ ]
+ }
+ },
+ "7": {
+ "open-peer-review": {
+ "title": "Open peer review",
+ "record": "https://zenodo.org/records/100007",
+ "doi": "10.5281/zenodo.100007",
+ "creators": ["Priya Natarajan"],
+ "reviewers": ["Alex Brown", "Morgan Lee"],
+ "files": [
+ {"label": "Slides", "url": "https://zenodo.org/records/100007/files/open-peer-review-slides.pptx?download=1", "format": "PPTX"},
+ {"label": "Slides", "url": "https://zenodo.org/records/100007/files/open-peer-review-slides.pdf?download=1", "format": "PDF"},
+ {"label": "Lesson plan L1", "url": "https://zenodo.org/records/100007/files/open-peer-review-lesson-plan-L1.docx?download=1", "format": "DOCX"},
+ {"label": "Lesson plan L1", "url": "https://zenodo.org/records/100007/files/open-peer-review-lesson-plan-L1.pdf?download=1", "format": "PDF"}
+ ]
+ }
+ },
+ "8": {
+ "conducting-replication-studies-challenges-limitations-and-comparisons-with-the-original-study": {
+ "title": "Conducting replication studies",
+ "record": "https://zenodo.org/records/100008",
+ "doi": "10.5281/zenodo.100008",
+ "creators": ["Jane Doe"],
+ "reviewers": ["Chris Dang", "Morgan Lee"],
+ "files": [
+ {"label": "Slides", "url": "https://zenodo.org/records/100008/files/replication-slides.pptx?download=1", "format": "PPTX"},
+ {"label": "Slides", "url": "https://zenodo.org/records/100008/files/replication-slides.pdf?download=1", "format": "PDF"},
+ {"label": "Lesson plan L1", "url": "https://zenodo.org/records/100008/files/replication-lesson-plan-L1.docx?download=1", "format": "DOCX"},
+ {"label": "Lesson plan L1", "url": "https://zenodo.org/records/100008/files/replication-lesson-plan-L1.pdf?download=1", "format": "PDF"}
+ ]
+ }
+ }
+ }
+}
diff --git a/layouts/clusters/cluster.html b/layouts/clusters/cluster.html
index da2e3fd01d5..55a1bea58e7 100644
--- a/layouts/clusters/cluster.html
+++ b/layouts/clusters/cluster.html
@@ -71,6 +71,10 @@
This cluster has no lesson plans yet. The clusters navigation lists the clusters that have them.
+ {{ end }}
diff --git a/layouts/partials/clusters/cluster_section.html b/layouts/partials/clusters/cluster_section.html
index dbeb5eb956a..28fdc60add6 100644
--- a/layouts/partials/clusters/cluster_section.html
+++ b/layouts/partials/clusters/cluster_section.html
@@ -19,13 +19,17 @@
{{ end }}
{{ $hasFeatured := and $featuredEnabled $featuredData }}
-
+{{/* Lesson-plan sets for this cluster, keyed by sub-cluster slug (keys validated in lesson_plans.html) */}}
+{{ $lpSets := index (partialCached "clusters/lesson_plans.html" "lesson-plans") (printf "%d" (int $cluster.number)) | default dict }}
+{{ $lpLabel := "Has lesson plans and slides" }}
+
+
{{ (int $cluster.number) }}
{{ $cluster.name }}
- {{ len $cluster.sub_clusters }} sub-clusters · {{ $totalPubs }} references
+ {{ len $cluster.sub_clusters }} sub-clusters · {{ $totalPubs }} references{{ with len $lpSets }} · {{ . }} with lesson plans{{ end }}
@@ -52,13 +56,14 @@
{{ end }}
{{ range $si, $sc := $cluster.sub_clusters }}
- {{/* Stable, human-readable anchor slug derived from the name (not the array index), so shared links survive reordering. Must match sidebar_nav.html. */}}
- {{ $slug := $sc.name | lower | replaceRE "[^a-z0-9]+" "-" | replaceRE "^-+|-+$" "" }}
+ {{/* Stable, human-readable anchor slug derived from the name (not the array index), so shared links survive reordering. */}}
+ {{ $slug := partial "clusters/sc_slug.html" $sc.name }}
{{ $scid := printf "c%d-%s" (int $cluster.number) $slug }}
-
+ {{ $lp := index $lpSets $slug }}
+
▸
-
{{ $sc.name }}
+
{{ $sc.name }}{{ if $lp }}{{ end }}
{{ len $sc.publications }} / {{ len $sc.publications }}
+ {{ with $lp }}
+ {{/* Teaching materials: one chip per file label, one link per format */}}
+ {{ $files := .files }}
+
+ {{ end }}
+
{{ if $sc.description }}
{{ $sc.description }}
{{ end }}
diff --git a/layouts/partials/clusters/clusters_controls.html b/layouts/partials/clusters/clusters_controls.html
index 673037fa735..d51b55272a2 100644
--- a/layouts/partials/clusters/clusters_controls.html
+++ b/layouts/partials/clusters/clusters_controls.html
@@ -32,6 +32,15 @@
Show specialist resources
+ {{/* Lesson-plan filter; also the legend for the green dot used on headers and in the sidebar */}}
+ {{ if partialCached "clusters/lesson_plans.html" "lesson-plans" }}
+
+
+
+ {{ end }}
diff --git a/layouts/partials/clusters/lesson_plans.html b/layouts/partials/clusters/lesson_plans.html
new file mode 100644
index 00000000000..7272c431a8e
--- /dev/null
+++ b/layouts/partials/clusters/lesson_plans.html
@@ -0,0 +1,22 @@
+{{- /* Returns the lesson-plan sets from data/lesson_plans.json (cluster number -> sub-cluster slug -> set).
+ Fails the build when a key matches no cluster or sub-cluster in clusters_v4.json, so a renamed
+ sub-cluster cannot silently drop its materials. Call via partialCached so the check runs once. */ -}}
+{{- $sets := dict -}}
+{{- with hugo.Data.lesson_plans -}}{{- $sets = .sets | default dict -}}{{- end -}}
+{{- $clusters := hugo.Data.clusters_v4.clusters -}}
+{{- range $ck, $bySlug := $sets -}}
+ {{- $match := false -}}
+ {{- range $clusters -}}{{- if eq (printf "%d" (int .number)) $ck -}}{{- $match = . -}}{{- end -}}{{- end -}}
+ {{- if not $match -}}
+ {{- errorf "data/lesson_plans.json: cluster key %q matches no cluster in clusters_v4.json" $ck -}}
+ {{- else -}}
+ {{- $slugs := slice -}}
+ {{- range $match.sub_clusters -}}{{- $slugs = $slugs | append (partial "clusters/sc_slug.html" .name) -}}{{- end -}}
+ {{- range $sk, $_ := $bySlug -}}
+ {{- if not (in $slugs $sk) -}}
+ {{- errorf "data/lesson_plans.json: key %q/%q matches no sub-cluster of cluster %s in clusters_v4.json" $ck $sk $ck -}}
+ {{- end -}}
+ {{- end -}}
+ {{- end -}}
+{{- end -}}
+{{- return $sets -}}
diff --git a/layouts/partials/clusters/sc_slug.html b/layouts/partials/clusters/sc_slug.html
new file mode 100644
index 00000000000..68ab8b0850a
--- /dev/null
+++ b/layouts/partials/clusters/sc_slug.html
@@ -0,0 +1,2 @@
+{{- /* Returns the anchor slug for a sub-cluster name. Sub-cluster ids (c-), sidebar links and the keys of data/lesson_plans.json all use it. */ -}}
+{{- return (. | lower | replaceRE "[^a-z0-9]+" "-" | replaceRE "^-+|-+$" "") -}}
diff --git a/layouts/partials/clusters/sidebar_nav.html b/layouts/partials/clusters/sidebar_nav.html
index c39c0cef17c..5b4e7343f61 100644
--- a/layouts/partials/clusters/sidebar_nav.html
+++ b/layouts/partials/clusters/sidebar_nav.html
@@ -3,6 +3,7 @@
{{ $arrowColors := .arrowColors }}
{{ $link_mode := .link_mode | default "anchor" }}
{{ $activeN := int (.active_cluster_number | default -1) }}
+{{ $lpLabel := "Has lesson plans and slides" }}
+ {{ end }}
+
{{ with $lp }}
{{/* Teaching materials: one chip per file label, one link per format */}}
{{ $files := .files }}
@@ -97,10 +101,6 @@
{{ $sc.name }}{{ if $lp }}
{{ end }}
- {{ if $sc.description }}
-
{{ $sc.description }}
- {{ end }}
-
{{ if $sc.publications }}
{{ $pubCards := hugo.Data.pub_cards }}
From a1cc827906151341814c577a584c690f1153ee77 Mon Sep 17 00:00:00 2001
From: Lukas Wallrich
Date: Fri, 2 Oct 2026 10:50:51 +0200
Subject: [PATCH 5/7] Lesson plans: filter as a sub-cluster checkbox;
centre-align the filter bar
Co-Authored-By: Claude Opus 5.5 (1M context)
---
assets/scss/custom.scss | 23 +++----------------
.../partials/clusters/clusters_controls.html | 8 +++----
static/js/featured-resources.js | 17 +++++---------
3 files changed, 13 insertions(+), 35 deletions(-)
diff --git a/assets/scss/custom.scss b/assets/scss/custom.scss
index 2964f3c7ce0..4c3bfc6fb91 100644
--- a/assets/scss/custom.scss
+++ b/assets/scss/custom.scss
@@ -699,25 +699,8 @@ $lp-green: #2f6b4f;
.lp-strip__meta a:hover { color: $lp-green; }
/* Filter chip: same footprint as .fr-tag-btn; pressed state fills green */
-.lp-filter-btn {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- font-family: inherit;
- font-size: 0.62rem;
- font-weight: 600;
- padding: 0.18rem 0.55rem;
- border-radius: 3px;
- border: 1px solid rgba(47,107,79,0.35);
- background: transparent;
- color: $lp-green;
- cursor: pointer;
- transition: background 0.15s, color 0.15s, border-color 0.15s;
-}
-.lp-filter-btn .lp-dot { margin-left: 0; }
-.lp-filter-btn:hover { background: rgba(47,107,79,0.08); }
-.lp-filter-btn[aria-pressed="true"] { background: $lp-green; border-color: $lp-green; color: #fff; }
-.lp-filter-btn[aria-pressed="true"] .lp-dot { background: #fff; }
+.lp-filter-toggle { color: $lp-green; }
+.lp-filter-toggle .lp-dot { margin: 0 0.2rem 0 0; }
/* Lesson-plan filter on (.lp-filter-on set by featured-resources.js): hide everything without a set */
.lp-filter-on .cluster-section:not([data-lp]),
@@ -2275,7 +2258,7 @@ h1.clusters-site-heading.clusters-hub-page__title {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
- align-items: flex-start;
+ align-items: center;
margin-bottom: 0.75rem;
padding: 0.65rem 0.75rem;
background: rgba(255,255,255,0.5);
diff --git a/layouts/partials/clusters/clusters_controls.html b/layouts/partials/clusters/clusters_controls.html
index d51b55272a2..9b8d217bf5c 100644
--- a/layouts/partials/clusters/clusters_controls.html
+++ b/layouts/partials/clusters/clusters_controls.html
@@ -35,10 +35,10 @@
{{/* Lesson-plan filter; also the legend for the green dot used on headers and in the sidebar */}}
{{ if partialCached "clusters/lesson_plans.html" "lesson-plans" }}
-
+
{{ end }}
diff --git a/static/js/featured-resources.js b/static/js/featured-resources.js
index dd5cfd094d3..628c3c4c903 100644
--- a/static/js/featured-resources.js
+++ b/static/js/featured-resources.js
@@ -667,6 +667,7 @@
body: body,
chevron: header ? header.querySelector('.acc-chevron') : null,
ownText: ownText,
+ hasLp: section.hasAttribute('data-lp'),
cards: cards
});
});
@@ -768,6 +769,10 @@
}
}
+ /* With the lesson-plan filter on, a sub-cluster with a set stays open even if no card
+ matches the other filters: its teaching-materials strip is what the user asked for. */
+ if (lpOnly && sec.hasLp) ownTextMatches = true;
+
sectionResults.push({ matchCount: matchCount, cardVis: cardVis, ownTextMatches: ownTextMatches });
}