feat(visuals): add generated article visual modules and safe cover support (#328)
Operator-approved. Generated article visuals (signal-noise chart, topic constellation, repo-trend bars, safe cover + fallback card). Non-clickable topic labels, image-policy guard (no hotlinked imgs), heading-level whitelist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 11, 2026 at 14:55 UTC
f9ec9528001fe4ee351e679ef3750ba2add4fa7b
14 files changed
+872
assets/css/extended/article-visuals.css
new
+297
@@ -0,0 +1,297 @@
1
+/* Article visual modules — generated covers, signal/noise, topic & repo trend.
2
+ Issue #328. Locally generated visuals only (no third-party/hotlinked assets).
3
+ Consistent with the Editorial Trend Report tokens + #327 mobile density. */
4
+
5
+/* ---------- Generated cover / fallback ---------- */
6
+.article-cover {
7
+ margin-top: var(--space-4);
8
+}
9
+
10
+.article-cover__panel {
11
+ position: relative;
12
+ display: flex;
13
+ flex-direction: column;
14
+ justify-content: flex-end;
15
+ /* Fixed aspect ratio reserves space up front -> no layout shift (CLS). */
16
+ aspect-ratio: 16 / 7;
17
+ min-height: calc(var(--space-12) + var(--space-3));
18
+ padding: var(--space-4);
19
+ overflow: hidden;
20
+ border: var(--size-border-thin) solid var(--color-border);
21
+ border-radius: var(--radius-md);
22
+ background: var(--color-surface);
23
+}
24
+
25
+.article-cover__panel--fallback {
26
+ aspect-ratio: auto;
27
+ min-height: 0;
28
+ gap: var(--space-1);
29
+}
30
+
31
+/* Decorative generated motif — sits behind the meta, never interactive. */
32
+.article-cover__motif {
33
+ position: absolute;
34
+ inset: 0;
35
+ width: 100%;
36
+ height: 100%;
37
+ color: var(--color-accent);
38
+ opacity: 0.16;
39
+}
40
+
41
+.article-cover__motif rect {
42
+ fill: currentColor;
43
+}
44
+
45
+.article-cover__meta {
46
+ position: relative;
47
+ display: flex;
48
+ flex-direction: column;
49
+ gap: var(--space-1);
50
+}
51
+
52
+.article-cover__kicker {
53
+ margin: 0;
54
+ color: var(--color-text-muted);
55
+ font-size: var(--text-xs);
56
+ font-weight: 700;
57
+ letter-spacing: var(--tracking-nav);
58
+ text-transform: uppercase;
59
+}
60
+
61
+.article-cover__brand {
62
+ margin: 0;
63
+ color: var(--color-text);
64
+ font-size: var(--text-2xl);
65
+ font-weight: 800;
66
+ line-height: var(--leading-tight);
67
+ letter-spacing: var(--tracking-tight);
68
+}
69
+
70
+.article-cover__topics {
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ gap: var(--space-1) var(--space-2);
74
+ margin: var(--space-2) 0 0;
75
+ padding: 0;
76
+ list-style: none;
77
+}
78
+
79
+/* Topic titles are caption metadata, not controls: plain muted labels with a
80
+ subtle separator. No background/border/padding-pill/hover -> never look clickable. */
81
+.article-cover__topic {
82
+ font-size: var(--text-xs);
83
+ font-weight: 600;
84
+ color: var(--color-text-muted);
85
+}
86
+
87
+.article-cover__topic:not(:last-child)::after {
88
+ content: "·";
89
+ margin-inline-start: var(--space-2);
90
+ color: var(--color-border);
91
+}
92
+
93
+.article-cover__topic--more {
94
+ font-weight: 600;
95
+ color: var(--color-text-muted);
96
+}
97
+
98
+.article-cover__fallback-note {
99
+ margin: 0;
100
+ color: var(--color-text-muted);
101
+ font-size: var(--text-base);
102
+ line-height: var(--leading-relaxed);
103
+}
104
+
105
+.article-cover--image {
106
+ margin-block: var(--space-4) 0;
107
+}
108
+
109
+.article-cover__img {
110
+ display: block;
111
+ width: 100%;
112
+ height: auto;
113
+ border-radius: var(--radius-md);
114
+}
115
+
116
+.article-cover__attribution {
117
+ margin-top: var(--space-1);
118
+ color: var(--color-text-muted);
119
+ font-size: var(--text-xs);
120
+}
121
+
122
+/* ---------- Signal & Noise summary ---------- */
123
+.signal-noise {
124
+ margin-top: var(--space-4);
125
+ padding: var(--space-4);
126
+ border: var(--size-border-thin) solid var(--color-border);
127
+ border-radius: var(--radius-md);
128
+ background: var(--color-surface);
129
+}
130
+
131
+.signal-noise__heading {
132
+ margin: 0 0 var(--space-3);
133
+ font-size: var(--text-xl);
134
+ line-height: var(--leading-tight);
135
+}
136
+
137
+.signal-noise__cols {
138
+ display: grid;
139
+ gap: var(--space-3);
140
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--space-15) + var(--space-3))), 1fr));
141
+}
142
+
143
+.signal-noise__label {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: var(--space-2);
147
+ margin: 0 0 var(--space-2);
148
+ font-size: var(--text-sm);
149
+ font-weight: 700;
150
+ text-transform: uppercase;
151
+ letter-spacing: var(--tracking-nav);
152
+}
153
+
154
+.signal-noise__dot {
155
+ width: var(--space-2);
156
+ height: var(--space-2);
157
+ border-radius: var(--radius-full);
158
+ background: var(--color-text-muted);
159
+}
160
+
161
+.signal-noise__col--signal .signal-noise__dot {
162
+ background: var(--color-success);
163
+}
164
+
165
+.signal-noise__col--noise .signal-noise__dot {
166
+ background: var(--color-danger);
167
+}
168
+
169
+.signal-noise__list {
170
+ margin: 0;
171
+ padding-left: var(--space-4);
172
+ color: var(--color-text);
173
+ line-height: var(--leading-relaxed);
174
+}
175
+
176
+.signal-noise__list li + li {
177
+ margin-top: var(--space-1);
178
+}
179
+
180
+.signal-noise__caveat,
181
+.signal-noise__source {
182
+ margin: var(--space-3) 0 0;
183
+ color: var(--color-text-muted);
184
+ font-size: var(--text-sm);
185
+ line-height: var(--leading-normal);
186
+}
187
+
188
+/* ---------- Topic / star visualization ---------- */
189
+.topic-stars {
190
+ margin-top: var(--space-4);
191
+}
192
+
193
+.topic-stars__heading {
194
+ margin: 0 0 var(--space-2);
195
+ font-size: var(--text-lg);
196
+ line-height: var(--leading-tight);
197
+}
198
+
199
+.topic-stars__cloud {
200
+ display: flex;
201
+ flex-wrap: wrap;
202
+ gap: var(--space-2);
203
+ margin: 0;
204
+ padding: 0;
205
+ list-style: none;
206
+}
207
+
208
+/* Topic names are caption metadata, not controls: plain muted labels with a
209
+ subtle separator. No background/border/padding-pill/hover -> never look clickable. */
210
+.topic-stars__chip {
211
+ font-size: var(--text-sm);
212
+ font-weight: 600;
213
+ color: var(--color-text-muted);
214
+}
215
+
216
+.topic-stars__chip:not(:last-child)::after {
217
+ content: "·";
218
+ margin-inline-start: var(--space-3);
219
+ color: var(--color-border);
220
+}
221
+
222
+.topic-stars__figures {
223
+ margin: var(--space-2) 0 0;
224
+ color: var(--color-text-muted);
225
+ font-size: var(--text-sm);
226
+}
227
+
228
+.topic-stars__figures strong {
229
+ color: var(--color-text);
230
+}
231
+
232
+/* ---------- Repo trend chart ---------- */
233
+.repo-trend {
234
+ margin: var(--space-4) 0;
235
+}
236
+
237
+.repo-trend__heading {
238
+ margin: 0 0 var(--space-2);
239
+ font-size: var(--text-base);
240
+ line-height: var(--leading-tight);
241
+}
242
+
243
+.repo-trend__chart {
244
+ display: block;
245
+ width: 100%;
246
+ height: calc(var(--space-11) + var(--space-2));
247
+ color: var(--color-accent);
248
+}
249
+
250
+.repo-trend__chart rect {
251
+ fill: currentColor;
252
+ opacity: 0.85;
253
+}
254
+
255
+.repo-trend__caption {
256
+ margin-top: var(--space-1);
257
+ color: var(--color-text-muted);
258
+ font-size: var(--text-xs);
259
+}
260
+
261
+/* ---------- Responsive (matches #327 mobile density) ---------- */
262
+@media (max-width: 768px) {
263
+ .article-cover {
264
+ margin-top: var(--space-3);
265
+ }
266
+
267
+ .article-cover__panel {
268
+ aspect-ratio: 16 / 9;
269
+ padding: var(--space-3);
270
+ }
271
+
272
+ .article-cover__brand {
273
+ font-size: var(--text-xl);
274
+ }
275
+
276
+ .signal-noise {
277
+ margin-top: var(--space-3);
278
+ padding: var(--space-3);
279
+ }
280
+
281
+ /* Stack signal/noise columns to avoid cramped two-up at narrow widths. */
282
+ .signal-noise__cols {
283
+ grid-template-columns: 1fr;
284
+ gap: var(--space-3);
285
+ }
286
+
287
+ .topic-stars {
288
+ margin-top: var(--space-3);
289
+ }
290
+}
291
+
292
+/* Honor reduced-data/transparency only; no motion is used, but keep motif calm. */
293
+@media (prefers-reduced-motion: reduce) {
294
+ .article-cover__motif {
295
+ opacity: 0.12;
296
+ }
297
+}
content/weekly/2026/W24.md
+11
@@ -7,6 +7,17 @@ categories: ["weekly"]
7
repos_featured: 390
8
stars_tracked: 16480000
9
top_repo: "cpaczek/skylight"
10
+signal_noise:
11
+ signal:
12
+ - "Agent skills are verticalizing into professional, domain-specific packs"
13
+ - "Local-first / self-sovereignty tooling is in active inventory expansion"
14
+ - "Hardware–software crossover hobbyist builds (skylight, ScrollWheel)"
15
+ noise:
16
+ - "Polymarket trading-bot spam with implausibly inflated fork counts"
17
+ - "Coordinated game-cheat / activator repos with uniform star counts"
18
+ - "Crypto-fraud tooling (Flash-USDT-Sender) with self-declared spoofer topics"
19
+ caveat: "Signal and noise here are editorial judgments, not automated classifications; counts are not rankings."
20
+ source: "Derived from this week's new_repos sample and the Signal & Noise section below."
21
summary: "Week 24 deepens two W23 patterns — agent skills verticalization and local-sovereignty tooling — while a high-star hardware-crossover project (skylight) anchors the week's legitimate creativity and a heavier-than-usual noise floor of coordinated spam, activator repos, and crypto fraud tools demands editorial filtering."
22
draft: false
23
---
docs/design/article-visual-modules.md
new
+76
@@ -0,0 +1,76 @@
1
+# Article Visual Modules (#328)
2
+
3
+Reusable, **locally generated** visual modules that give every weekly article a
4
+meaningful visual or an intentional fallback — without third-party/hotlinked
5
+imagery and without enabling Goldmark `unsafe`.
6
+
7
+## Design principles
8
+
9
+- **Generated first.** Visuals are built from the article's own data (frontmatter
10
+ metadata) or author-declared shortcode values. No stock images, no hotlinks,
11
+ no `og:image` reuse.
12
+- **Evidence-first.** Modules help scanning/comprehension; they never overstate
13
+ weak signals, hide caveats, or imply rankings. Decorative motifs are disclosed
14
+ (`aria-hidden`) and never carry the data claim — real figures are always text.
15
+- **Safe by construction.** `unsafe = false` stays. HTML lives in partials /
16
+ shortcodes, never in Markdown bodies.
17
+- **No layout breakage.** Missing data yields an intentional fallback card, not an
18
+ empty or broken region. Fixed aspect ratios reserve space (no CLS).
19
+
20
+## Module taxonomy
21
+
22
+| Module | Source | When to use |
23
+| --- | --- | --- |
24
+| **Generated cover card** (`visuals/cover-card.html`) | frontmatter `week`, `tags`, `repos_featured` | Default header for every weekly article. Bar-field count reflects topic breadth; accessible name reports real week/topic/repo figures. |
25
+| **Topic / star visualization** (`visuals/topic-constellation.html`, shortcode `topic-stars`) | `tags` / author list | Surface the week's topic clusters as equal-weight chips. Honest by construction. |
26
+| **Signal & Noise summary** (`visuals/signal-noise.html`, shortcode `signal-noise`) | frontmatter `signal_noise` or shortcode args | Two-column scannable summary of durable signal vs noise floor, with a required caveat slot. |
27
+| **Repo trend chart** (`visuals/repo-trend.html`, shortcode `repo-trend`) | author/pipeline numeric series | Lightweight bar chart for star/momentum history. Bars are decorative; the series is exposed as a visually-hidden text summary. |
28
+| **Intentional fallback card** (`visuals/fallback-card.html`) | `visual = "none"` | Deliberate "no standalone visual this week" card. |
29
+
30
+## Selection rules (orchestrator `visuals/article-cover.html`)
31
+
32
+1. `visual = "none"` → intentional fallback card.
33
+2. A **compliant local cover image** (resolves to a Hugo page-bundle or global
34
+ resource) → processed, locally-hosted `<img>`. See safe-cover policy below.
35
+3. Otherwise → generated SVG cover card.
36
+
37
+## Declaring modules
38
+
39
+- **Automatic (generated metadata):** the cover card renders from existing
40
+ frontmatter (`week`, `tags`, `repos_featured`); the Signal & Noise card renders
41
+ when `signal_noise` is present.
42
+- **Frontmatter example:**
43
+
44
+ ```yaml
45
+ signal_noise:
46
+ signal:
47
+ - "Agent skills verticalizing into professional packs"
48
+ noise:
49
+ - "Coordinated activator / star-farm repos"
50
+ caveat: "Editorial judgments, not automated classifications; counts are not rankings."
51
+ source: "Derived from this week's new_repos sample."
52
+ ```
53
+
54
+- **Inline shortcodes** (pipe-separated values), for body-level placement:
55
+
56
+ - `topic-stars topics="agent-skills|local-first" stars="16480000" repos="390"`
57
+ - `repo-trend repo="cpaczek/skylight" values="120|340|512|890|2332" labels="W20|W21|W22|W23|W24"`
58
+ - `signal-noise signal="A|B" noise="C|D" caveat="…"`
59
+
60
+## Safe cover policy hook (#329)
61
+
62
+Images are only rendered when `cover.image` resolves to a **locally hosted Hugo
63
+resource** (downloaded + processed). External / hotlinked URLs are intentionally
64
+ignored, and we never rely on fair use or reuse `og:image`. Attribution
65
+(`cover.attribution`) is rendered when present; full CC0/Openverse sourcing,
66
+resizing, attribution, and the image registry are owned by **#329**. Until then,
67
+articles lead with generated visuals + the typographic fallback.
68
+
69
+## Accessibility & responsiveness
70
+
71
+- SVG motifs are `aria-hidden`; data is always available as text (accessible
72
+ names / visually-hidden summaries).
73
+- Responsive at 320/360/390/414/768px: SVGs use `width:100%` + `viewBox`; the
74
+ signal/noise grid collapses to one column on mobile; no horizontal scroll.
75
+- Aspect ratios reserve space to avoid CLS; interactive targets meet the 44×44
76
+ minimum established in #327.
layouts/partials/visuals/article-cover.html
new
+53
@@ -0,0 +1,53 @@
1
+{{- /*
2
+ Article visual orchestrator — guarantees every weekly article gets either a
3
+ meaningful generated visual or an intentional fallback. Lead approach is
4
+ locally-generated SVG; a safe local-image path is wired as a documented hook
5
+ for the image policy (#329).
6
+
7
+ Selection order:
8
+ 1. visual = "none" -> intentional fallback card
9
+ 2. compliant LOCAL cover image -> processed, locally-hosted <img> (#329 hook)
10
+ 3. default -> generated SVG cover card
11
+
12
+ SAFE COVER POLICY (#329): an image is only rendered when `cover.image` resolves
13
+ to a Hugo resource (page bundle or global asset) — i.e. it is downloaded and
14
+ locally hosted. External/hotlinked URLs are intentionally ignored here; do NOT
15
+ rely on fair use or reuse og:image. Attribution + registry handling land in #329.
16
+*/ -}}
17
+{{- $page := . -}}
18
+{{- $visual := $page.Params.visual -}}
19
+{{- if eq (printf "%v" $visual) "none" -}}
20
+ {{ partial "visuals/fallback-card.html" (dict "page" $page) }}
21
+{{- else -}}
22
+ {{- $img := "" -}}
23
+ {{- with $page.Params.cover -}}
24
+ {{- with .image -}}
25
+ {{- /* Only accept locally-hosted resources, matched EXACTLY (not as a
26
+ substring glob) so selection is deterministic and policy-safe.
27
+ External/hotlinked URLs never resolve to a resource and are ignored. */ -}}
28
+ {{- /* Escape glob metacharacters (*?[]\) so a cover.image value containing
29
+ them is matched literally by GetMatch rather than as a glob pattern. */ -}}
30
+ {{- $pattern := replaceRE `([*?\[\]\\])` `\${1}` . -}}
31
+ {{- $bundle := $page.Resources.GetMatch $pattern -}}
32
+ {{- $global := resources.Get . -}}
33
+ {{- $candidate := or $bundle $global -}}
34
+ {{- if and $candidate (eq $candidate.ResourceType "image") -}}
35
+ {{- $img = $candidate -}}
36
+ {{- end -}}
37
+ {{- end -}}
38
+ {{- end -}}
39
+ {{- if $img -}}
40
+ {{- $alt := ($page.Params.cover.alt | default $page.Title | plainify) -}}
41
+ {{- $attribution := $page.Params.cover.attribution -}}
42
+ <figure class="article-cover article-cover--image">
43
+ {{- $sized := $img.Resize "1200x webp q82" -}}
44
+ <img class="article-cover__img" src="{{ $sized.RelPermalink }}" width="{{ $sized.Width }}" height="{{ $sized.Height }}" loading="eager" decoding="async" alt="{{ $alt }}">
45
+ {{- /* Render attribution as a safe inline subset (#329): allow simple
46
+ emphasis/links from Markdown, but strip any <img> the markdown
47
+ image syntax would emit so hotlinked imagery cannot appear here. */ -}}
48
+ {{- with $attribution }}<figcaption class="article-cover__attribution">{{ . | markdownify | replaceRE `(?i)<img[^>]*>` "" | safeHTML }}</figcaption>{{ end }}
49
+ </figure>
50
+ {{- else -}}
51
+ {{ partial "visuals/cover-card.html" (dict "page" $page) }}
52
+ {{- end -}}
53
+{{- end -}}
layouts/partials/visuals/cover-card.html
new
+68
@@ -0,0 +1,68 @@
1
+{{- /*
2
+ Generated cover card — a locally-rendered, deterministic inline-SVG header
3
+ visual for weekly articles. No third-party or hotlinked imagery.
4
+
5
+ Data-anchored: the bar field COUNT reflects the number of topic clusters
6
+ tracked this week (from .Params.tags); the accessible name reports the real
7
+ week, topic count, and repositories-featured figures. Per-bar height variation
8
+ is a deterministic decorative motif (disclosed via aria-hidden) and is NOT a
9
+ claim about per-topic magnitude — preserving the evidence-first editorial stance.
10
+
11
+ Params (dict):
12
+ page : the page context (required)
13
+*/ -}}
14
+{{- $page := .page -}}
15
+{{- $week := $page.Params.week | default "" -}}
16
+{{- $topics := $page.Params.tags | default slice -}}
17
+{{- $topicCount := len $topics -}}
18
+{{- $repos := $page.Params.repos_featured -}}
19
+{{- $kicker := cond (ne $week "") (printf "Weekly signal · %s" $week) "Weekly signal" -}}
20
+
21
+{{- /* Deterministic seed from the week (or title) so each week renders a stable, distinct motif. */ -}}
22
+{{- $seedKey := cond (ne $week "") $week $page.Title -}}
23
+{{- $seed := (hash.FNV32a $seedKey) -}}
24
+
25
+{{- /* Number of bars = topic breadth this week, clamped to a sensible range for layout. */ -}}
26
+{{- $bars := $topicCount -}}
27
+{{- if lt $bars 6 }}{{ $bars = 6 }}{{ end -}}
28
+{{- if gt $bars 18 }}{{ $bars = 18 }}{{ end -}}
29
+
30
+{{- /* Build an accessible summary from REAL data only. */ -}}
31
+{{- $summaryParts := slice -}}
32
+{{- if ne $week "" }}{{ $summaryParts = $summaryParts | append (printf "%s weekly report" $week) }}{{ else }}{{ $summaryParts = $summaryParts | append "Weekly report" }}{{ end -}}
33
+{{- if gt $topicCount 0 }}{{ $summaryParts = $summaryParts | append (printf "tracking %d topic clusters" $topicCount) }}{{ end -}}
34
+{{- if $repos }}{{ $summaryParts = $summaryParts | append (printf "across %s featured repositories" (lang.FormatNumber 0 $repos)) }}{{ end -}}
35
+{{- $summary := printf "Generated cover: %s." (delimit $summaryParts ", ") -}}
36
+
37
+<figure class="article-cover article-cover--generated" aria-label="{{ $summary }}">
38
+ <div class="article-cover__panel">
39
+ <svg class="article-cover__motif" viewBox="0 0 1000 360" preserveAspectRatio="none" aria-hidden="true" focusable="false">
40
+ {{- $slot := div 1000.0 $bars -}}
41
+ {{- $barW := mul $slot 0.46 -}}
42
+ {{- range $i := seq $bars -}}
43
+ {{- $idx := sub $i 1 -}}
44
+ {{- $h := add 56 (mod (mul (add $idx 1) (add $seed 131)) 268) -}}
45
+ {{- $x := add (mul $idx $slot) (mul $slot 0.27) -}}
46
+ {{- $y := sub 360 $h -}}
47
+ <rect x="{{ $x }}" y="{{ $y }}" width="{{ $barW }}" height="{{ $h }}" rx="4" />
48
+ {{- end -}}
49
+ </svg>
50
+ <div class="article-cover__meta">
51
+ <p class="article-cover__kicker">{{ $kicker }}</p>
52
+ <p class="article-cover__brand">Claracle</p>
53
+ {{- if gt $topicCount 0 }}
54
+ {{- /* Topic titles are descriptive caption metadata, NOT controls. Render them
55
+ as plain, non-interactive labels — no link, no button/pill affordance —
56
+ so they never look clickable (operator ask, #328). */ -}}
57
+ <ul class="article-cover__topics" aria-label="Topics tracked this week">
58
+ {{- range first 6 $topics }}
59
+ <li class="article-cover__topic">{{ . }}</li>
60
+ {{- end }}
61
+ {{- if gt $topicCount 6 }}
62
+ <li class="article-cover__topic article-cover__topic--more">+{{ sub $topicCount 6 }} more</li>
63
+ {{- end }}
64
+ </ul>
65
+ {{- end }}
66
+ </div>
67
+ </div>
68
+</figure>
layouts/partials/visuals/fallback-card.html
new
+17
@@ -0,0 +1,17 @@
1
+{{- /*
2
+ Intentional fallback card — shown when an article declares `visual = "none"`
3
+ or has no data to generate a visual from. This is a deliberate, on-brand
4
+ typographic card, NOT a broken or empty layout.
5
+
6
+ Params (dict):
7
+ page : the page context (required)
8
+*/ -}}
9
+{{- $page := .page -}}
10
+{{- $week := $page.Params.week | default "" -}}
11
+<aside class="article-cover article-cover--fallback" aria-label="No generated visual for this report">
12
+ <div class="article-cover__panel article-cover__panel--fallback">
13
+ <p class="article-cover__kicker">{{ cond (ne $week "") (printf "Weekly signal · %s" $week) "Weekly signal" }}</p>
14
+ <p class="article-cover__brand">Claracle</p>
15
+ <p class="article-cover__fallback-note">No standalone visual this week — the signal is in the writing below.</p>
16
+ </div>
17
+</aside>
layouts/partials/visuals/repo-trend.html
new
+58
@@ -0,0 +1,58 @@
1
+{{- /*
2
+ Repo trend visualization — a lightweight, locally-rendered bar chart for a
3
+ numeric series (e.g. star history or weekly momentum). Deterministic and
4
+ dependency-free. Accessible: bars are aria-hidden decoration; the real data is
5
+ exposed as a visually-hidden description and an optional caption.
6
+
7
+ Params (dict):
8
+ values : slice of numbers (required)
9
+ labels : optional slice of strings, aligned to values
10
+ repo : optional repo slug for the heading/link
11
+ heading : optional heading text
12
+ level : optional heading level (default "h3")
13
+ caption : optional caption / source text
14
+*/ -}}
15
+{{- $values := .values | default slice -}}
16
+{{- $labels := .labels | default slice -}}
17
+{{- $repo := .repo | default "" -}}
18
+{{- /* An empty/omitted repo is treated as absent: fall back to the plain
19
+ heading rather than emitting a broken "<empty> — trend" string. */ -}}
20
+{{- $heading := .heading | default (cond (ne $repo "") (printf "%s — trend" $repo) "Trend") -}}
21
+{{- $level := .level | default "h3" | lower -}}
22
+{{- if not (in (slice "h2" "h3" "h4" "h5" "h6") $level) }}{{ $level = "h3" }}{{ end -}}
23
+{{- /* $level is strictly whitelisted above, so the tag name is safe to emit;
24
+ the heading text itself remains contextually auto-escaped. */ -}}
25
+{{- $hOpen := printf `<%s class="repo-trend__heading">` $level | safeHTML -}}
26
+{{- $hClose := printf "</%s>" $level | safeHTML -}}
27
+{{- $caption := .caption -}}
28
+{{- if gt (len $values) 0 -}}
29
+ {{- $max := 0.0 -}}
30
+ {{- range $values }}{{ if gt (float .) $max }}{{ $max = (float .) }}{{ end }}{{ end -}}
31
+ {{- if le $max 0.0 }}{{ $max = 1.0 }}{{ end -}}
32
+ {{- $n := len $values -}}
33
+ {{- $summary := slice -}}
34
+ {{- range $i, $v := $values -}}
35
+ {{- $lab := cond (gt (len $labels) $i) (index $labels $i) (printf "Point %d" (add $i 1)) -}}
36
+ {{- $summary = $summary | append (printf "%s: %s" $lab (lang.FormatNumber 0 $v)) -}}
37
+ {{- end -}}
38
+<figure class="repo-trend" role="group" aria-label="{{ $heading }}">
39
+ {{ $hOpen }}
40
+ {{- if $repo }}<a href="https://github.com/{{ $repo }}" aria-label="Open GitHub repository {{ $repo }}">{{ $heading }}</a>{{ else }}{{ $heading }}{{ end -}}
41
+ {{ $hClose }}
42
+ <svg class="repo-trend__chart" viewBox="0 0 {{ mul $n 100 }} 320" preserveAspectRatio="none" aria-hidden="true" focusable="false">
43
+ {{- $slot := 100 -}}
44
+ {{- range $i, $v := $values -}}
45
+ {{- $h := math.Round (mul (div (float $v) $max) 300.0) -}}
46
+ {{- if lt $h 3.0 }}{{ $h = 3.0 }}{{ end -}}
47
+ {{- $x := add (mul $i $slot) 22 -}}
48
+ {{- $y := sub 320 $h -}}
49
+ <rect x="{{ $x }}" y="{{ $y }}" width="56" height="{{ $h }}" rx="4" />
50
+ {{- end -}}
51
+ </svg>
52
+ <p class="visually-hidden">{{ $heading }}. {{ delimit $summary "; " }}.</p>
53
+ {{- /* Render the caption as a safe inline subset (#329): allow simple
54
+ emphasis/links from Markdown, but strip any <img> the markdown image
55
+ syntax would emit so no third-party/hotlinked imagery can slip in. */ -}}
56
+ {{- with $caption }}<figcaption class="repo-trend__caption">{{ . | markdownify | replaceRE `(?i)<img[^>]*>` "" | safeHTML }}</figcaption>{{ end }}
57
+</figure>
58
+{{- end -}}
layouts/partials/visuals/signal-noise.html
new
+56
@@ -0,0 +1,56 @@
1
+{{- /*
2
+ Signal & Noise summary module — a generated, scannable two-column card built
3
+ from structured frontmatter (or passed data). Surfaces the week's editorial
4
+ Signal/Noise framing without overstating: items are editorial judgments shown
5
+ as plain lists, with an explicit caveat slot. No automated ranking is implied.
6
+
7
+ Params (dict):
8
+ page : page context (used to read .Params.signal_noise if data omitted)
9
+ data : optional explicit dict {signal: [], noise: [], caveat: "", source: ""}
10
+ heading : optional heading text (default "Signal & Noise at a glance")
11
+ level : optional heading level (default "h2")
12
+*/ -}}
13
+{{- $page := .page -}}
14
+{{- $data := .data | default (and $page $page.Params.signal_noise) -}}
15
+{{- $heading := .heading | default "Signal & Noise at a glance" -}}
16
+{{- $level := .level | default "h2" | lower -}}
17
+{{- if not (in (slice "h2" "h3" "h4" "h5" "h6") $level) }}{{ $level = "h2" }}{{ end -}}
18
+{{- /* $level is strictly whitelisted above, so the tag name is safe to emit;
19
+ the heading text itself remains contextually auto-escaped. */ -}}
20
+{{- $hOpen := printf `<%s class="signal-noise__heading">` $level | safeHTML -}}
21
+{{- $hClose := printf "</%s>" $level | safeHTML -}}
22
+{{- with $data -}}
23
+{{- $signal := (index . "signal") | default slice -}}
24
+{{- $noise := (index . "noise") | default slice -}}
25
+{{- $caveat := (index . "caveat") | default "" -}}
26
+{{- $source := (index . "source") | default "" -}}
27
+{{- if or (gt (len $signal) 0) (gt (len $noise) 0) -}}
28
+<section class="signal-noise" aria-label="{{ $heading }}">
29
+ {{ $hOpen }}{{ $heading }}{{ $hClose }}
30
+ <div class="signal-noise__cols">
31
+ {{- if gt (len $signal) 0 }}
32
+ <div class="signal-noise__col signal-noise__col--signal">
33
+ <p class="signal-noise__label">
34
+ <span class="signal-noise__dot" aria-hidden="true"></span>Durable signal
35
+ </p>
36
+ <ul class="signal-noise__list">
37
+ {{- range $signal }}<li>{{ . }}</li>{{ end }}
38
+ </ul>
39
+ </div>
40
+ {{- end }}
41
+ {{- if gt (len $noise) 0 }}
42
+ <div class="signal-noise__col signal-noise__col--noise">
43
+ <p class="signal-noise__label">
44
+ <span class="signal-noise__dot" aria-hidden="true"></span>Noise floor
45
+ </p>
46
+ <ul class="signal-noise__list">
47
+ {{- range $noise }}<li>{{ . }}</li>{{ end }}
48
+ </ul>
49
+ </div>
50
+ {{- end }}
51
+ </div>
52
+ {{- with $caveat }}<p class="signal-noise__caveat">{{ . }}</p>{{ end }}
53
+ {{- with $source }}<p class="signal-noise__source">{{ . }}</p>{{ end }}
54
+</section>
55
+{{- end -}}
56
+{{- end -}}
layouts/partials/visuals/topic-constellation.html
new
+47
@@ -0,0 +1,47 @@
1
+{{- /*
2
+ Topic / star visualization — a reusable, data-driven chip cloud built from a
3
+ list of topics. Honest by construction: chips are equal-weight unless an
4
+ explicit per-topic weight is supplied. Optional star/repo figures are shown as
5
+ plain text. No third-party assets.
6
+
7
+ Params (dict):
8
+ topics : slice of strings, OR slice of dicts {name, weight} (required)
9
+ stars : optional total stars figure (number)
10
+ repos : optional repos-featured figure (number)
11
+ heading : optional heading text
12
+ level : optional heading level (default "h2")
13
+*/ -}}
14
+{{- $topics := .topics | default slice -}}
15
+{{- $heading := .heading | default "Topics tracked this week" -}}
16
+{{- $level := .level | default "h2" | lower -}}
17
+{{- if not (in (slice "h2" "h3" "h4" "h5" "h6") $level) }}{{ $level = "h2" }}{{ end -}}
18
+{{- /* $level is strictly whitelisted above, so the tag name is safe to emit;
19
+ the heading text itself remains contextually auto-escaped. */ -}}
20
+{{- $hOpen := printf `<%s class="topic-stars__heading">` $level | safeHTML -}}
21
+{{- $hClose := printf "</%s>" $level | safeHTML -}}
22
+{{- if gt (len $topics) 0 -}}
23
+<section class="topic-stars" aria-label="{{ $heading }}">
24
+ {{ $hOpen }}{{ $heading }}{{ $hClose }}
25
+ <ul class="topic-stars__cloud">
26
+ {{- range $topics }}
27
+ {{- $name := . -}}
28
+ {{- if reflect.IsMap . }}{{ $name = .name }}{{ end }}
29
+ {{- /* Topic names are plain, non-interactive labels — no link, no button/pill
30
+ affordance — so they never look clickable (operator ask, #328). */ -}}
31
+ <li class="topic-stars__chip">{{ $name }}</li>
32
+ {{- end }}
33
+ </ul>
34
+ {{- /* Coerce repos/stars to integers; .Get from the shortcode yields strings,
35
+ so accept only digit strings (commas/spaces stripped) and ignore any
36
+ non-numeric input rather than passing it to lang.FormatNumber. */ -}}
37
+ {{- $repos := "" -}}{{ with .repos }}{{ $v := printf "%v" . | replaceRE "[, ]" "" }}{{ if findRE "^[0-9]+$" $v }}{{ $repos = $v }}{{ end }}{{ end -}}
38
+ {{- $stars := "" -}}{{ with .stars }}{{ $v := printf "%v" . | replaceRE "[, ]" "" }}{{ if findRE "^[0-9]+$" $v }}{{ $stars = $v }}{{ end }}{{ end -}}
39
+ {{- if or $stars $repos }}
40
+ <p class="topic-stars__figures">
41
+ {{- with $repos }}<span><strong>{{ lang.FormatNumber 0 (int .) }}</strong> repos featured</span>{{ end }}
42
+ {{- if and $stars $repos }}<span aria-hidden="true"> · </span>{{ end }}
43
+ {{- with $stars }}<span><strong>{{ lang.FormatNumber 0 (int .) }}</strong> stars tracked</span>{{ end }}
44
+ </p>
45
+ {{- end }}
46
+</section>
47
+{{- end -}}
layouts/shortcodes/repo-trend.html
new
+20
@@ -0,0 +1,20 @@
1
+{{- /*
2
+ Repo trend shortcode — inline bar chart for a numeric series.
3
+
4
+ Usage (percent-brace shortcode call) with pipe-separated values:
5
+ repo-trend repo="cpaczek/skylight" values="120|340|512|890|2332" labels="W20|W21|W22|W23|W24" caption="Weekly star count."
6
+*/ -}}
7
+{{- $values := slice -}}
8
+{{- /* Coerce only well-formed numeric tokens; skip empty/non-numeric input so a
9
+ stray token (e.g. "N/A") never errors the build. */ -}}
10
+{{- with .Get "values" }}{{ range (split . "|") }}{{ $t := trim . " " }}{{ if findRE `^-?[0-9]+(\.[0-9]+)?$` $t }}{{ $values = $values | append ($t | float) }}{{ end }}{{ end }}{{ end -}}
11
+{{- $labels := slice -}}
12
+{{- with .Get "labels" }}{{ $labels = split . "|" }}{{ end -}}
13
+{{ partial "visuals/repo-trend.html" (dict
14
+ "values" $values
15
+ "labels" $labels
16
+ "repo" (.Get "repo")
17
+ "heading" (.Get "heading")
18
+ "level" (.Get "level")
19
+ "caption" (.Get "caption")
20
+) }}
layouts/shortcodes/signal-noise.html
new
+13
@@ -0,0 +1,13 @@
1
+{{- /*
2
+ Signal & Noise shortcode — lets an article body declare the Signal/Noise
3
+ summary module inline. Works with Goldmark unsafe=false.
4
+
5
+ Usage (percent-brace shortcode call) with pipe-separated items:
6
+ signal-noise signal="Agent skills|Local-first tooling" noise="Bot spam|Activator repos" caveat="Editorial judgments, not automated classifications."
7
+*/ -}}
8
+{{- $signal := slice -}}
9
+{{- with .Get "signal" }}{{ $signal = split . "|" }}{{ end -}}
10
+{{- $noise := slice -}}
11
+{{- with .Get "noise" }}{{ $noise = split . "|" }}{{ end -}}
12
+{{- $data := dict "signal" $signal "noise" $noise "caveat" (.Get "caveat") "source" (.Get "source") -}}
13
+{{ partial "visuals/signal-noise.html" (dict "data" $data "heading" (.Get "heading") "level" (.Get "level")) }}
layouts/shortcodes/topic-stars.html
new
+15
@@ -0,0 +1,15 @@
1
+{{- /*
2
+ Topic / star visualization shortcode — inline topic chip cloud.
3
+
4
+ Usage (percent-brace shortcode call):
5
+ topic-stars topics="agent-skills|local-first|hardware" stars="16480000" repos="390"
6
+*/ -}}
7
+{{- $topics := slice -}}
8
+{{- with .Get "topics" }}{{ $topics = split . "|" }}{{ end -}}
9
+{{ partial "visuals/topic-constellation.html" (dict
10
+ "topics" $topics
11
+ "stars" (.Get "stars")
12
+ "repos" (.Get "repos")
13
+ "heading" (.Get "heading")
14
+ "level" (.Get "level")
15
+) }}
layouts/weekly/single.html
+4
@@ -12,10 +12,14 @@
12
{{ partial "article-meta.html" . }}
13
</header>
14
15
+ {{ partial "visuals/article-cover.html" . }}
16
+
17
{{ partial "report-metrics.html" . }}
18
{{ partial "article-jump-nav.html" . }}
19
{{ partial "article-featured-repos.html" . }}
20
21
+ {{ partial "visuals/signal-noise.html" (dict "page" .) }}
22
+
23
{{ partial "report-shortcuts.html" . }}
24
25
{{- if (and site.Params.ShowShareButtons (ne .Params.disableShare true)) }}
tests/test_article_visuals.py
new
+137
@@ -0,0 +1,137 @@
1
+from pathlib import Path
2
+
3
+ROOT = Path(__file__).resolve().parents[1]
4
+VIS = ROOT / "layouts/partials/visuals"
5
+SC = ROOT / "layouts/shortcodes"
6
+
7
+
8
+def _read(p: Path) -> str:
9
+ return p.read_text(encoding="utf-8")
10
+
11
+
12
+def test_visual_module_partials_exist() -> None:
13
+ for name in (
14
+ "article-cover.html",
15
+ "cover-card.html",
16
+ "topic-constellation.html",
17
+ "signal-noise.html",
18
+ "repo-trend.html",
19
+ "fallback-card.html",
20
+ ):
21
+ assert (VIS / name).is_file(), f"missing visual partial {name}"
22
+
23
+
24
+def test_reusable_shortcodes_exist() -> None:
25
+ for name in ("signal-noise.html", "topic-stars.html", "repo-trend.html"):
26
+ assert (SC / name).is_file(), f"missing shortcode {name}"
27
+
28
+
29
+def test_weekly_single_wires_cover_and_signal_noise() -> None:
30
+ single = _read(ROOT / "layouts/weekly/single.html")
31
+ assert 'partial "visuals/article-cover.html"' in single
32
+ assert 'partial "visuals/signal-noise.html"' in single
33
+
34
+
35
+def test_generated_visuals_have_accessible_names() -> None:
36
+ cover = _read(VIS / "cover-card.html")
37
+ # The figure must NOT use role="img": that collapses the whole container into a
38
+ # single image node and hides its real text (kicker, brand, topics) from AT.
39
+ assert 'role="img"' not in cover
40
+ # It still exposes a real-data accessible name, keeps the topic text in the DOM
41
+ # (not aria-hidden), and hides only the decorative motif.
42
+ assert "aria-label=" in cover
43
+ assert 'aria-hidden="true"' in cover
44
+ assert 'class="article-cover__topics"' in cover # real topic text stays exposed
45
+ trend = _read(VIS / "repo-trend.html")
46
+ assert "visually-hidden" in trend # text summary for the chart data
47
+ assert 'aria-hidden="true"' in trend
48
+
49
+
50
+def test_visuals_are_locally_generated_not_hotlinked() -> None:
51
+ # No external/hotlinked image sources in any visual partial.
52
+ for p in VIS.glob("*.html"):
53
+ text = _read(p)
54
+ assert "http://" not in text, f"{p.name} must not hotlink http assets"
55
+ # GitHub repo deep-links are allowed as anchors, but never as <img src>.
56
+ for marker in ("img src=\"http", "src='http"):
57
+ assert marker not in text.replace(" ", ""), f"{p.name} hotlinks an image"
58
+
59
+
60
+def test_safe_cover_only_accepts_local_resources() -> None:
61
+ orch = _read(VIS / "article-cover.html")
62
+ # Image path resolves Hugo resources (local hosting), matched exactly and
63
+ # guarded to image types, before rendering an <img>.
64
+ assert "Resources.GetMatch" in orch
65
+ assert "resources.Get" in orch
66
+ assert 'eq $candidate.ResourceType "image"' in orch
67
+ assert "#329" in orch # documented image-policy hook
68
+
69
+
70
+def test_heading_levels_are_whitelisted() -> None:
71
+ # Untrusted `level` (shortcode/frontmatter) must be normalized + whitelisted
72
+ # before being used as a raw HTML tag name.
73
+ for name in ("topic-constellation.html", "signal-noise.html", "repo-trend.html"):
74
+ text = _read(VIS / name)
75
+ assert '| lower' in text
76
+ assert 'in (slice "h2" "h3" "h4" "h5" "h6")' in text
77
+ # The tag name is emitted only from the whitelisted value via safeHTML,
78
+ # never by interpolating the raw input as a tag name (`<{{ $level }}>`).
79
+ assert "<{{ $level }}>" not in text
80
+ assert "| safeHTML" in text
81
+
82
+
83
+def test_topic_constellation_coerces_numeric_figures() -> None:
84
+ # `.repos`/`.stars` arrive as strings from the shortcode (`.Get`); only
85
+ # digit strings are passed to lang.FormatNumber, non-numeric input is dropped
86
+ # rather than erroring the build.
87
+ text = _read(VIS / "topic-constellation.html")
88
+ assert 'findRE "^[0-9]+$"' in text
89
+ assert "lang.FormatNumber 0 (int" in text
90
+
91
+
92
+def test_topic_titles_are_non_interactive_labels() -> None:
93
+ # Operator ask (#328): topic titles must NOT look (or be) clickable. They are
94
+ # rendered as plain labels — no link, no button/pill affordance — while the
95
+ # chart itself stays intact.
96
+ cover = _read(VIS / "cover-card.html")
97
+ constellation = _read(VIS / "topic-constellation.html")
98
+ # No anchors around topic chips in either module.
99
+ assert "<a href=" not in cover, "cover topic chips must not be links"
100
+ assert "<a href=" not in constellation, "constellation chips must not be links"
101
+ # The topic list/text is still present (chart intact).
102
+ assert 'class="article-cover__topics"' in cover
103
+ assert 'class="article-cover__topic"' in cover
104
+ # CSS must not give the chips a button/pill affordance (background/border/hover).
105
+ css = _read(ROOT / "assets/css/extended/article-visuals.css")
106
+ for sel in (
107
+ ".article-cover__topic--link",
108
+ ".article-cover__topic--static",
109
+ ".topic-stars__chip--link",
110
+ ".topic-stars__chip--static",
111
+ ):
112
+ assert sel not in css, f"stale clickable-chip rule remains: {sel}"
113
+
114
+
115
+def test_unsafe_markdown_remains_disabled() -> None:
116
+ cfg = _read(ROOT / "hugo.toml")
117
+ assert "unsafe = false" in cfg
118
+
119
+
120
+def test_visuals_keep_claracle_brand() -> None:
121
+ cover = _read(VIS / "cover-card.html")
122
+ assert "Claracle" in cover
123
+ # Do not reintroduce the retired display brand in visible text.
124
+ for p in VIS.glob("*.html"):
125
+ assert "SquadScope" not in _read(p)
126
+
127
+
128
+def test_signal_noise_module_preserves_caveat_slot() -> None:
129
+ sn = _read(VIS / "signal-noise.html")
130
+ assert "signal-noise__caveat" in sn # evidence-first: caveat language supported
131
+ assert "signal-noise__source" in sn
132
+
133
+
134
+def test_article_visuals_css_is_responsive() -> None:
135
+ css = _read(ROOT / "assets/css/extended/article-visuals.css")
136
+ assert "@media (max-width: 768px)" in css
137
+ assert "aspect-ratio" in css # reserve space -> no CLS