diff --git a/CLAUDE.md b/CLAUDE.md index 386e8c4..bea060d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,16 @@ This creates `site/content/posts//index.md` from `site/archetypes/posts/in Set `draft = false` (or remove the field) to publish. Draft posts are excluded from production builds but visible locally via `./preview.sh`. +### Cover images + +Covers are logos on a transparent 1200x1000 canvas. They render `contain`-fit into a fixed box in the post list, so the *artwork inside the canvas* — not the canvas — decides how big a logo looks. After adding a cover, normalise its optical weight so it doesn't out-shout its neighbours: + +```bash +python3 scripts/normalize-cover.py --all # or --check to preview +``` + +The script only touches covers named in post frontmatter. Leave body screenshots and diagrams alone — they're sized by their content. + ## Design tokens The site's colors are OKLCH tokens in `site/assets/css/extended/custom.css` (warm cream light / deep navy dark, steel-blue accent). Three places must stay visually in sync with those tokens: diff --git a/scripts/normalize-cover.py b/scripts/normalize-cover.py new file mode 100755 index 0000000..062979f --- /dev/null +++ b/scripts/normalize-cover.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Normalise the optical size of post cover logos. + +Every cover shares a 1200x1000 canvas, but the artwork inside them arrives at +whatever size the source logo happened to be — VS Code's mark filled 48% of its +canvas while PyTorch's filled 25%, so in the post list one looked twice the +weight of the other. This rescales the artwork so each logo carries a +comparable amount of visual weight, leaving the canvas untouched. + +Sizing is by the geometric mean of the ink bounding box, not by width or +height alone: that treats a square icon, a tall stack and a wide wordmark +fairly, where fitting everything to a common height would blow the wordmark up. +Two caps stop extreme aspect ratios from running away. + +Ink is measured at alpha >= 128 so soft drop shadows don't inflate the box, but +the full artwork (shadows included) is what gets scaled and recentred. + +Idempotent — re-running on an already-normalised file is a no-op. + +This is for cover art only. Screenshots and diagrams inside a post body are +sized by their content, not their optical weight, so don't point it at them — +prefer --all, which reads cover paths out of each post's frontmatter. + +Usage: + python3 scripts/normalize-cover.py --all # every post cover + python3 scripts/normalize-cover.py --all --check # report, write nothing + python3 scripts/normalize-cover.py .png # one specific file +""" + +import argparse +import glob +import math +import os +import re +import sys + +from PIL import Image + +POSTS = "site/content/posts" + +TARGET = 660 # geometric mean of the ink bbox, in canvas pixels +MAX_W = 860 # caps, so a wide wordmark or tall stack can't dominate +MAX_H = 720 + + +def measure(im): + """Return (ink_bbox, full_bbox) — solid artwork, and artwork incl. shadows.""" + alpha = im.getchannel("A") + ink = alpha.point(lambda v: 255 if v >= 128 else 0).getbbox() + full = alpha.point(lambda v: 255 if v > 8 else 0).getbbox() + return ink, full + + +def scale_for(ink): + w, h = ink[2] - ink[0], ink[3] - ink[1] + s = TARGET / math.sqrt(w * h) + if w * s > MAX_W: + s = MAX_W / w + if h * s > MAX_H: + s = MAX_H / h + return s + + +def normalize(path, check_only=False): + im = Image.open(path).convert("RGBA") + W, H = im.size + ink, full = measure(im) + if ink is None or full is None: + print(f"{path}: no artwork found, skipped") + return True + + s = scale_for(ink) + if abs(s - 1) < 0.01: + print(f"{path}: already normalised") + return True + if check_only: + print(f"{path}: needs {s:.3f}x") + return False + + art = im.crop(full).resize( + (max(1, round((full[2] - full[0]) * s)), max(1, round((full[3] - full[1]) * s))), + Image.LANCZOS, + ) + # Centre on the ink, not the full box, so a one-sided shadow doesn't + # push the logo off centre. + cx = ((ink[0] + ink[2]) / 2 - full[0]) * s + cy = ((ink[1] + ink[3]) / 2 - full[1]) * s + + out = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + out.alpha_composite(art, (round(W / 2 - cx), round(H / 2 - cy))) + out.save(path) + print(f"{path}: scaled {s:.3f}x") + return True + + +def discover_covers(root): + """Cover images named in post frontmatter, resolved relative to the post.""" + covers = [] + for index in sorted(glob.glob(os.path.join(root, "*", "index.md"))): + m = re.search(r'^\s*image\s*=\s*"([^"]+)"', open(index).read(), re.M) + if not m: + continue + path = os.path.join(os.path.dirname(index), m.group(1)) + if os.path.exists(path): + covers.append(path) + return covers + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("images", nargs="*", + help="cover images to normalise (omit with --all)") + ap.add_argument("--all", action="store_true", + help=f"normalise every cover named in {POSTS}/*/index.md") + ap.add_argument("--check", action="store_true", + help="report what would change without writing") + args = ap.parse_args() + + images = args.images + if args.all: + images = discover_covers(POSTS) + images + if not images: + ap.error("give one or more images, or --all") + + ok = all([normalize(p, args.check) for p in images]) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/assets/css/extended/custom.css b/site/assets/css/extended/custom.css index bc139c6..91b1d4f 100644 --- a/site/assets/css/extended/custom.css +++ b/site/assets/css/extended/custom.css @@ -150,8 +150,12 @@ a:hover, .post-content a:hover { } .profile img { - /* Show only the left (day) half of the combined logos.png sprite */ - width: 135px; + /* Show only the left (day) half of the combined logos.png sprite. + The 3:4 box is the crop window over the 3:2 sprite — height is + load-bearing, not decoration. Change the two together or the night + half bleeds in from the right. */ + width: 144px; + height: 192px; object-fit: cover; object-position: left; border-radius: 0 !important; @@ -180,7 +184,35 @@ a:hover, .post-content a:hover { carries ~24px of transparent space below the brain, so a negative top margin pulls the title up to close the *visual* gap (not just the element gap). */ .profile-brand h1 { - margin: -0.85rem 0 0; + margin: -0.91rem 0 0; +} + +/* On phones the title wraps to nearly the full viewport width, and PaperMod + additionally shrinks the logo (transform: scale(0.85) under 768px), so the + mark ends up undersized above its own title. Undo the theme's scale and size + the sprite explicitly instead. + + Width and height must grow together: the visible half is a 3:4 crop window + over the 3:2 sprite, so widening alone lets the other half bleed in. Must + stay after .profile-brand h1 above — same specificity, so source order is + what lets the tightened margin win. */ +@media (max-width: 768px) { + .profile img { + transform: none; + width: 153px; + height: 204px; + } + /* Scales with the logo — the sprite's transparent footer grows too. */ + .profile-brand h1 { + margin-top: -0.96rem; + } +} + +/* Reclaim some of the fold the taller masthead takes on small screens. */ +@media (max-width: 550px) { + .main .profile { + padding: 32px 0 28px; + } } /* Subtitle styling kept defensively in case the param is re-added */ @@ -351,9 +383,35 @@ ul.post-tags li a:hover { transform: translateY(-1px); } -/* ── 12. Cover thumbnail grid (post list) ──────────────────────────────── */ +/* ── 12. Cover thumbnails (post list) ──────────────────────────────────── */ +/* Narrow screens keep PaperMod's stacked order — cover above the text, where + there's no horizontal room to spare — but the height cap is what does the + work. Left unstyled the cover renders full-bleed at its natural aspect + ratio, which on a phone is a screen-filling logo above two lines of text. + + Cover art is centred logos on transparent canvases, so `contain` keeps them + whole (where `cover` crops into them) and centres them in the wider box. */ +.post-entry .entry-cover { + margin-bottom: 14px; +} +.post-entry .entry-cover img { + width: 100%; + height: 116px; + object-fit: contain; + border-radius: 8px; + filter: saturate(0.88); + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), filter var(--tr); +} +.post-entry:hover .entry-cover img { + transform: scale(1.05); + filter: saturate(1.05); +} + +/* Wide enough for a side thumbnail: move the cover into its own column. + `.tag-entry` hides its cover (PaperMod), so it stays out of the grid — + otherwise it reserves an empty thumbnail column and squeezes the text. */ @media (min-width: 550px) { - .post-entry:has(.entry-cover) { + .post-entry:not(.tag-entry):has(.entry-cover) { display: grid; grid-template-columns: 1fr 120px; grid-template-rows: auto auto auto; @@ -370,16 +428,7 @@ ul.post-tags li a:hover { .post-entry .entry-footer { grid-area: footer; } .post-entry .entry-cover img { - width: 100%; height: 100px; - object-fit: cover; - border-radius: 8px; - filter: saturate(0.88); - transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), filter var(--tr); - } - .post-entry:hover .entry-cover img { - transform: scale(1.05); - filter: saturate(1.05); } } diff --git a/site/content/posts/applied-software-development-retrospective/images/BCIT_logo2-1.png b/site/content/posts/applied-software-development-retrospective/images/BCIT_logo2-1.png index d4d776a..a45f851 100644 Binary files a/site/content/posts/applied-software-development-retrospective/images/BCIT_logo2-1.png and b/site/content/posts/applied-software-development-retrospective/images/BCIT_logo2-1.png differ diff --git a/site/content/posts/coredata-swift-scalar-optionals/images/swift-icon.png b/site/content/posts/coredata-swift-scalar-optionals/images/swift-icon.png index 9ed6f03..074c8fd 100644 Binary files a/site/content/posts/coredata-swift-scalar-optionals/images/swift-icon.png and b/site/content/posts/coredata-swift-scalar-optionals/images/swift-icon.png differ diff --git a/site/content/posts/demystifying-python-logging-in-maya/images/python-logo.png b/site/content/posts/demystifying-python-logging-in-maya/images/python-logo.png index 6bf57ca..1f38c38 100644 Binary files a/site/content/posts/demystifying-python-logging-in-maya/images/python-logo.png and b/site/content/posts/demystifying-python-logging-in-maya/images/python-logo.png differ diff --git a/site/content/posts/kaggle-courses/images/dataset-card2.png b/site/content/posts/kaggle-courses/images/dataset-card2.png index 62a7736..8e21a35 100644 Binary files a/site/content/posts/kaggle-courses/images/dataset-card2.png and b/site/content/posts/kaggle-courses/images/dataset-card2.png differ diff --git a/site/content/posts/loss-vs-reward-in-reinforcement-learning/images/pytorch-logo.png b/site/content/posts/loss-vs-reward-in-reinforcement-learning/images/pytorch-logo.png index eb68e5f..abe1892 100644 Binary files a/site/content/posts/loss-vs-reward-in-reinforcement-learning/images/pytorch-logo.png and b/site/content/posts/loss-vs-reward-in-reinforcement-learning/images/pytorch-logo.png differ diff --git a/site/content/posts/my-swiftui-currency-field/images/swiftui-icon.png b/site/content/posts/my-swiftui-currency-field/images/swiftui-icon.png index ed46c8a..e57836e 100644 Binary files a/site/content/posts/my-swiftui-currency-field/images/swiftui-icon.png and b/site/content/posts/my-swiftui-currency-field/images/swiftui-icon.png differ diff --git a/site/content/posts/regular-expressions/images/python-logo.png b/site/content/posts/regular-expressions/images/python-logo.png index 6bf57ca..1f38c38 100644 Binary files a/site/content/posts/regular-expressions/images/python-logo.png and b/site/content/posts/regular-expressions/images/python-logo.png differ diff --git a/site/content/posts/switching-from-pycharm-to-vscode-with-uv/images/vscode-icon.png b/site/content/posts/switching-from-pycharm-to-vscode-with-uv/images/vscode-icon.png index 2a7fc0d..82111e6 100644 Binary files a/site/content/posts/switching-from-pycharm-to-vscode-with-uv/images/vscode-icon.png and b/site/content/posts/switching-from-pycharm-to-vscode-with-uv/images/vscode-icon.png differ diff --git a/site/content/posts/sympy-to-solve-a-low-level-ml-training-loop/images/sympy-1.png b/site/content/posts/sympy-to-solve-a-low-level-ml-training-loop/images/sympy-1.png index c506f3e..7eb3e56 100644 Binary files a/site/content/posts/sympy-to-solve-a-low-level-ml-training-loop/images/sympy-1.png and b/site/content/posts/sympy-to-solve-a-low-level-ml-training-loop/images/sympy-1.png differ diff --git a/site/content/posts/tensorflow-to-pytorch/images/pytorch-logo.png b/site/content/posts/tensorflow-to-pytorch/images/pytorch-logo.png index eb68e5f..abe1892 100644 Binary files a/site/content/posts/tensorflow-to-pytorch/images/pytorch-logo.png and b/site/content/posts/tensorflow-to-pytorch/images/pytorch-logo.png differ diff --git a/site/content/posts/the-best-pycharm-mayapy-interpreter-setup/images/pycharm-icon.png b/site/content/posts/the-best-pycharm-mayapy-interpreter-setup/images/pycharm-icon.png index f7e25c5..b8252da 100644 Binary files a/site/content/posts/the-best-pycharm-mayapy-interpreter-setup/images/pycharm-icon.png and b/site/content/posts/the-best-pycharm-mayapy-interpreter-setup/images/pycharm-icon.png differ diff --git a/site/content/posts/using-the-__new__-method-in-python-classes/images/python-logo.png b/site/content/posts/using-the-__new__-method-in-python-classes/images/python-logo.png index 6bf57ca..1f38c38 100644 Binary files a/site/content/posts/using-the-__new__-method-in-python-classes/images/python-logo.png and b/site/content/posts/using-the-__new__-method-in-python-classes/images/python-logo.png differ