Skip to main content

max / makenotwork

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>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 23:46 UTC
Signed with PGP, not checked
Commit: 3708175c9940f5d6dceea43a73ffe1140a636115
Parent: 77ff213
7 files changed, +68 insertions, -8 deletions
@@ -5159,7 +5159,7 @@
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,6 +1,6 @@
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,7 +6,7 @@
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,11 +1795,21 @@
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,9 +2896,19 @@
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,6 +109,22 @@
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,3 +284,21 @@
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,7 +127,13 @@
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 %} &middot; {{ item.release_date }}</div>
@@ -13,7 +13,7 @@
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">