main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
Show the cover art on a storefront card
A storefront's item grid rendered the item type, "Plugin" or "Digital" or
"Template", in a 300px block where the artwork belongs, and the checkout page
did the same at 120px. Not for want of artwork: `cover_image_url` is already on
the view model, already scan-gated, and on testnot all 25 seeded items carry a
cover. The templates simply rendered `thumbnail`, which conversions.rs sets to
`item_type.label()` and never to an image, so no storefront card has ever shown
a cover. This is the page a creator sends people to.
Render the cover when there is one and keep the type label as the placeholder
when there is not, which is what the slot was always drawing.
The dimming moves with it. `opacity: 0.3` sat on the slot rather than on the
placeholder, so the first cover to render there would have rendered at 30% and
looked like a bug in the artwork.
Covers arrive through `Item::cover`, one accessor, because the field is
`Option<String>` and a creator who clears it saves `Some("")`. Taking the
Option at face value reserves the slot and fills it with a broken image;
library_downloads.html already worked around that inline, which is the sort of
thing that gets copied to three templates and forgotten in the fourth.
Found shooting the landing carousel, whose storefront frame promises "listed
items with prices and cover art" and could not be taken.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+68 insertions,
-8 deletions
5159
5159
5160
5160
[[package]]
5161
5161
name = "makenotwork"
5162
-
version = "0.11.10"
5162
+
version = "0.11.11"
5163
5163
dependencies = [
5164
5164
"ammonia",
5165
5165
"anyhow",
1
1
[package]
2
2
name = "makenotwork"
3
-
version = "0.11.10"
3
+
version = "0.11.11"
4
4
edition = "2024"
5
5
license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6
6
# Server binary: never published to a registry. Marks the crate private so
6
6
"license": {
7
7
"name": "PolyForm Noncommercial 1.0.0"
8
8
},
9
-
"version": "0.11.10"
9
+
"version": "0.11.11"
10
10
},
11
11
"paths": {
12
12
"/api/v1/items/{item_id}/license.txt": {
1795
1795
align-items: center;
1796
1796
justify-content: center;
1797
1797
font-size: var(--text-hero);
1798
-
opacity: 0.3;
1799
1798
text-decoration: none;
1799
+
overflow: hidden;
1800
1800
}
1801
1801
1802
-
.project-page a.item-thumbnail:hover { opacity: 0.5; }
1802
+
.project-page a.item-thumbnail img {
1803
+
width: 100%;
1804
+
height: 100%;
1805
+
object-fit: cover;
1806
+
display: block;
1807
+
}
1808
+
1809
+
/* The dimming belongs to the placeholder, not to the slot. It was on the slot,
1810
+
so the day covers started rendering here they would have rendered at 30%. */
1811
+
.project-page a.item-thumbnail-empty { opacity: 0.3; }
1812
+
.project-page a.item-thumbnail-empty:hover { opacity: 0.5; }
1803
1813
1804
1814
.project-page .item-content {
1805
1815
padding: var(--gap-pane);
2886
2896
align-items: center;
2887
2897
justify-content: center;
2888
2898
font-size: var(--text-hero);
2889
-
opacity: 0.3;
2899
+
overflow: hidden;
2890
2900
}
2891
2901
2902
+
.purchase-page .item-thumbnail img {
2903
+
width: 100%;
2904
+
height: 100%;
2905
+
object-fit: cover;
2906
+
display: block;
2907
+
}
2908
+
2909
+
/* See the storefront card: the dimming is the placeholder's, not the slot's. */
2910
+
.purchase-page .item-thumbnail-empty { opacity: 0.3; }
2911
+
2892
2912
.purchase-page .item-details { flex: 1; }
2893
2913
2894
2914
.purchase-page .item-title {
109
109
}
110
110
111
111
impl Item {
112
+
/// The cover to render, or `None` when there is nothing worth rendering.
113
+
///
114
+
/// `cover_image_url` is already scan-gated by `visible_cover_url`, but a
115
+
/// creator can save an empty string, which is `Some("")` and not `None`.
116
+
/// Templates that took the `Option` at face value reserved a cover slot and
117
+
/// filled it with a broken image; `library_downloads.html` worked around it
118
+
/// inline with `.as_deref().unwrap_or("").is_empty()`, which is the sort of
119
+
/// thing that gets copied to three templates and forgotten in the fourth.
120
+
/// One accessor, so `{% if let Some(img) = item.cover() %}` is always right.
121
+
pub fn cover(&self) -> Option<&str> {
122
+
self.cover_image_url
123
+
.as_deref()
124
+
.map(str::trim)
125
+
.filter(|url| !url.is_empty())
126
+
}
127
+
112
128
/// Price formatted as a decimal string for JSON-LD (e.g. "9.99").
113
129
pub fn price_decimal(&self) -> String {
114
130
let abs = self.price_cents.unsigned_abs();
268
284
pub project_title: String,
269
285
pub position: i32,
270
286
}
287
+
288
+
#[cfg(test)]
289
+
mod tests {
290
+
/// `Item::cover` exists for the values that are `Some` and still not a
291
+
/// cover. A creator who clears the field saves `Some("")`, and a template
292
+
/// that trusted the `Option` reserved a 300px slot for a broken image.
293
+
#[test]
294
+
fn an_empty_cover_url_is_not_a_cover() {
295
+
fn cover(raw: Option<&str>) -> Option<&str> {
296
+
raw.map(str::trim).filter(|url| !url.is_empty())
297
+
}
298
+
299
+
assert_eq!(cover(None), None);
300
+
assert_eq!(cover(Some("")), None, "an empty string is not a cover");
301
+
assert_eq!(cover(Some(" ")), None, "nor is whitespace");
302
+
assert_eq!(cover(Some("https://cdn/x.jpg")), Some("https://cdn/x.jpg"));
303
+
}
304
+
}
127
127
<div class="items-grid">
128
128
{% for item in items %}
129
129
<div class="item-card">
130
-
<a href="{% if item.can_access %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}" class="item-thumbnail">{{ item.thumbnail }}</a>
130
+
{# The cover if there is one, the item type if there is not.
131
+
The type label is a placeholder, styled at 0.3 opacity,
132
+
and it used to be all this slot ever showed: `thumbnail`
133
+
is the type by construction in conversions.rs, so a
134
+
storefront full of uploaded artwork rendered a grid of
135
+
grey boxes reading "Plugin". #}
136
+
<a href="{% if item.can_access %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}" class="item-thumbnail{% if item.cover().is_none() %} item-thumbnail-empty{% endif %}">{% if let Some(img) = item.cover() %}<img src="{{ img }}" alt="{{ item.title }}" loading="lazy">{% else %}{{ item.thumbnail }}{% endif %}</a>
131
137
<div class="item-content">
132
138
<h3 class="item-title"><a class="unstyled-link" href="{% if item.can_access %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}">{{ item.title }}</a></h3>
133
139
<div class="item-meta">{{ item.item_type }}{% if item.bundle_item_count > 0 %} ({{ item.bundle_item_count }} items){% endif %} · {{ item.release_date }}</div>
13
13
<h2 class="section-header">Confirm Purchase</h2>
14
14
15
15
<div class="item-summary">
16
-
<div class="item-thumbnail">{{ item.thumbnail }}</div>
16
+
<div class="item-thumbnail{% if item.cover().is_none() %} item-thumbnail-empty{% endif %}">{% if let Some(img) = item.cover() %}<img src="{{ img }}" alt="{{ item.title }}">{% else %}{{ item.thumbnail }}{% endif %}</div>
17
17
<div class="item-details">
18
18
<div class="item-title">{{ item.title }}</div>
19
19
<div class="item-creator">