Skip to content

Commit 9b63d0a

Browse files
TMHSDigitalclaude
andcommitted
Entry pages: verdict above the fold on phones, honest thumbnail sizing
- Phones get a one-line verdict under the title (licence, commercial stance, credit, verified age) with Go to source beside it, and the strip is capped at 160 px; at 375x667 the source button now ends at 317 px instead of 980 px. Desktop is unchanged. - Related links written as an entry id now read as the entry's name. - The entry strip image loads eagerly with fetchpriority=high (it is the page's LCP element) and never scales past twice its stored width. - Card thumbnails narrower than 400 px are shown whole on the surface colour instead of cropped and scaled up. Checked in Chromium at 375x667 and 1440x900. Closes #153 Closes #155 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fff2113 commit 9b63d0a

7 files changed

Lines changed: 67 additions & 9 deletions

File tree

‎site/build.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@ function linkResolvers(entries, config, entryBase = "../") {
354354
if (caseMismatch(repoPath, path.relative(realRoot, fs.realpathSync.native(full)))) return null;
355355
return fs.statSync(full).isDirectory() ? "dir" : "file";
356356
};
357-
return (file) => makeLinkResolver({ entryPath: file.path, idByPath, repo: config.site.repo, kindOf, entryBase });
357+
const nameById = new Map(entries.map((e) => [e.id, e.name]));
358+
return (file) => makeLinkResolver({ entryPath: file.path, idByPath, repo: config.site.repo, kindOf, entryBase, nameById });
358359
}
359360

360361
/** Reads stacks/*.md, checks them (V15) and resolves each pick to its entry. */

‎site/lib/cards.mjs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,17 @@ export function tileHtml(entry, label = entry.category) {
2727
}
2828

2929
/** The entry's thumbnail image, or its tile. `root` is the page's path to the site root. */
30-
export function thumbHtml(entry, root = "", label) {
30+
/** Stored previews narrower than this are shown whole on the surface colour, not cropped and scaled up. */
31+
export const SMALL_THUMB_WIDTH = 400;
32+
33+
export function thumbHtml(entry, root = "", label, { eager = false } = {}) {
3134
const t = entry.thumb;
3235
if (t && t.path) {
3336
const credit = t.credit ? ` title="${esc(t.credit)}"` : "";
34-
return `<img class="entry-thumb-img" src="${esc(root)}${esc(t.path)}" width="${Number(t.width) || 640}" height="${Number(t.height) || 400}" alt=""${credit} loading="lazy" decoding="async">`;
37+
const small = Number(t.width) > 0 && Number(t.width) < SMALL_THUMB_WIDTH ? " is-small" : "";
38+
// The entry strip is the page's largest image: fetch it first, not lazily.
39+
const load = eager ? 'loading="eager" fetchpriority="high"' : 'loading="lazy"';
40+
return `<img class="entry-thumb-img${small}" src="${esc(root)}${esc(t.path)}" width="${Number(t.width) || 640}" height="${Number(t.height) || 400}" alt=""${credit} ${load} decoding="async">`;
3541
}
3642
return tileHtml(entry, label);
3743
}

‎site/lib/entry-page.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
107107
: "";
108108
// The publisher's preview, when the licence gate allowed one, with its credit.
109109
const strip = entry.thumb
110-
? `<figure class="entry-strip">${thumbHtml(entry, "../../")}<figcaption class="entry-credit">${esc(previewCredit(entry))}</figcaption></figure>`
110+
? `<figure class="entry-strip" style="--thumb-max:${2 * (Number(entry.thumb.width) || 640)}px">${thumbHtml(entry, "../../", undefined, { eager: true })}<figcaption class="entry-credit">${esc(previewCredit(entry))}</figcaption></figure>`
111111
: "";
112112
const usedIn = stacks.length
113113
? `<p class="used-in">Used in: ${stacks.map((s) => `<a href="../../stack/${esc(s.id)}/">${esc(s.title)}</a>`).join(", ")}</p>`
@@ -144,6 +144,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
144144
</nav>
145145
${banner}
146146
<h1>${esc(entry.name)}</h1>
147+
<p class="entry-verdict"><span>${esc(licence)}</span> <span>${esc(commercialLabel(entry.commercial))}</span> <span>Credit: ${esc(c.value)}</span> <span class="verified is-${age.bucket}">${esc(age.days === null ? "verified date unknown" : `verified ${age.days}d ago`)}</span> <a class="btn" href="${esc(entry.url)}" rel="noopener noreferrer">Go to source</a></p>
147148
${strip}
148149
<div class="entry-lead">${leadHtml}</div>
149150
<section class="facts-panel" aria-labelledby="facts-title">

‎site/lib/lib.test.mjs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,9 @@ eq("filmstrip: empty when nothing to show", filmstripHtml([], { root: "" }), "")
10381038
const siteR = { title: "T", tagline: "t", siteUrl: "https://s/", repo: "https://r" };
10391039
const common = { leadHtml: "<p>l</p>", restHtml: "<p>r</p>", deprecatedReasonHtml: null, prev: null, next: null, site: siteR, categoryLabel: "3D", stamp: "2026-10-23", total: 1, hasCard: false, now: 0 };
10401040
const html = entryPageHtml({ ...common, entry: { ...cardBase, thumb: { path: "thumbs/x.webp", width: 640, height: 400 }, publisher: "Kenney" } });
1041-
has("entry page: strip image", html, '<figure class="entry-strip"><img class="entry-thumb-img" src="../../thumbs/x.webp"');
1041+
has("entry page: strip image", html, '<img class="entry-thumb-img" src="../../thumbs/x.webp"');
1042+
has("entry page: strip capped at twice its width", html, '<figure class="entry-strip" style="--thumb-max:');
1043+
has("entry page: strip image loads first", html, 'loading="eager" fetchpriority="high"');
10421044
has("entry page: credit line", html, '<figcaption class="entry-credit">Preview image from Kenney, shown under CC0</figcaption>');
10431045
has("entry page: one primary action", html, '<p class="page-actions"><a class="btn" href="https://x.itch.io/p"');
10441046
has("entry page: secondary links row", html, '<p class="page-links">');
@@ -1096,6 +1098,17 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
10961098
has("modulepreload: head emits the links", baseHeadHtml({ site: { siteUrl: "https://s/", title: "T", tagline: "x" }, title: "T", description: "d", url: "https://s/", root: "../../", modules: ["a.js"] }), '<link rel="modulepreload" href="../../a.js" />');
10971099
}
10981100

1101+
/* entry links read as names; thumbnails size honestly ------------------- */
1102+
{
1103+
const r = makeLinkResolver({ entryPath: "catalog/2d/a.md", idByPath: new Map([["catalog/2d/b.md", "b"]]), repo: "https://g/r", kindOf: () => null, nameById: new Map([["b", "Bee Pack"]]) });
1104+
has("links: an id label becomes the entry name", renderInline("[b](b.md)", r), ">Bee Pack</a>");
1105+
has("links: a written label is kept", renderInline("[the bee one](b.md)", r), ">the bee one</a>");
1106+
const { thumbHtml: th } = await import("./cards.mjs");
1107+
has("thumbs: small preview contained", th({ thumb: { path: "t/a.webp", width: 160, height: 120 } }), 'class="entry-thumb-img is-small"');
1108+
lacks("thumbs: normal preview not marked small", th({ thumb: { path: "t/a.webp", width: 640, height: 400 } }), "is-small");
1109+
has("thumbs: eager strip image", th({ thumb: { path: "t/a.webp", width: 640, height: 400 } }, "", undefined, { eager: true }), 'loading="eager" fetchpriority="high"');
1110+
}
1111+
10991112
/* report (keep last) ------------------------------------------------------ */
11001113
if (failures.length) {
11011114
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/links.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function caseMismatch(linked, onDisk) {
2525
return null;
2626
}
2727

28-
export function makeLinkResolver({ entryPath, idByPath, repo, kindOf, entryBase = "../" }) {
28+
export function makeLinkResolver({ entryPath, idByPath, repo, kindOf, entryBase = "../", nameById }) {
2929
const dir = path.posix.dirname(entryPath);
3030
return (raw) => {
3131
if (EXTERNAL_SCHEME_RE.test(raw)) return { href: raw, external: true };
@@ -44,7 +44,7 @@ export function makeLinkResolver({ entryPath, idByPath, repo, kindOf, entryBase
4444
}
4545
const repoPath = path.posix.normalize(path.posix.join(dir, decoded)).replace(/\/$/, "");
4646
const id = idByPath.get(repoPath);
47-
if (id) return { href: `${entryBase}${id}/${hash}`, external: false };
47+
if (id) return { href: `${entryBase}${id}/${hash}`, external: false, entryId: id, entryName: nameById ? nameById.get(id) : undefined };
4848
const kind = repoPath.startsWith("..") ? null : kindOf(repoPath);
4949
if (!kind) throw new LinkError(`${entryPath}: link target "${raw}" does not exist`);
5050
const mode = kind === "dir" ? "tree" : "blob";

‎site/lib/markdown.mjs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ export function renderInline(text, resolveLink) {
6363
// Links. The label keeps bold, italic and code; the href goes through the
6464
// resolver.
6565
s = s.replace(LINK_RE, (_, label, href) => {
66-
const { href: out, external } = resolveLink(href);
66+
const { href: out, external, entryId, entryName } = resolveLink(href);
6767
const rel = external ? ' rel="noopener noreferrer"' : "";
68-
return keep(`<a href="${esc(out)}"${rel}>${marks(esc(label))}</a>`);
68+
// Related lists are written as [kenney-ui-pack](kenney-ui-pack.md); a
69+
// reader should see the entry's name, not its file name.
70+
const text = entryName && label.trim() === entryId ? esc(entryName) : marks(esc(label));
71+
return keep(`<a href="${esc(out)}"${rel}>${text}</a>`);
6972
});
7073
// Autolinks in angle brackets.
7174
s = s.replace(/<(https?:\/\/[^\s<>]+)>/g, (_, url) => keep(`<a href="${esc(url)}" rel="noopener noreferrer">${esc(url)}</a>`));

‎site/public/styles.css‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,12 @@ input:focus-visible,
17851785
object-fit: cover;
17861786
}
17871787

1788+
/* A small stored preview is shown whole rather than cropped and scaled up. */
1789+
.entry-thumb-img.is-small {
1790+
object-fit: contain;
1791+
background: var(--surface);
1792+
}
1793+
17881794
/* No permitted image: the entry's name on the checker an editor shows behind
17891795
a sprite, so the tile still reads as "asset", not "missing". */
17901796
.entry-tile {
@@ -2104,11 +2110,39 @@ body.card-focus .to-top {
21042110
.entry-strip .entry-thumb-img {
21052111
display: block;
21062112
width: 100%;
2113+
max-width: var(--thumb-max, 100%);
21072114
height: auto;
21082115
max-height: 360px;
2116+
margin: 0 auto;
21092117
object-fit: contain;
21102118
}
21112119

2120+
/* The licence verdict and the source link, repeated under the title on a
2121+
phone, where the facts panel starts below the fold. */
2122+
.entry-verdict {
2123+
display: none;
2124+
}
2125+
2126+
@media (max-width: 640px) {
2127+
.entry-verdict {
2128+
display: flex;
2129+
flex-wrap: wrap;
2130+
align-items: center;
2131+
gap: 0.35rem 0.75rem;
2132+
margin: 0.25rem 0 0.75rem;
2133+
font-size: 0.9rem;
2134+
}
2135+
2136+
.entry-verdict .btn {
2137+
flex-basis: 100%;
2138+
text-align: center;
2139+
}
2140+
2141+
.entry-strip .entry-thumb-img {
2142+
max-height: 160px;
2143+
}
2144+
}
2145+
21122146
.entry-credit {
21132147
padding: 0.4rem 0.75rem;
21142148
border-top: 1px solid var(--line);

0 commit comments

Comments
 (0)