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