Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ This creates `site/content/posts/<slug>/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:
Expand Down
130 changes: 130 additions & 0 deletions scripts/normalize-cover.py
Original file line number Diff line number Diff line change
@@ -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 <cover>.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())
77 changes: 63 additions & 14 deletions site/assets/css/extended/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/content/posts/kaggle-courses/images/dataset-card2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified site/content/posts/regular-expressions/images/python-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading