●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 32 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 32 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●API — In API version 2026-10-21, passing temperature, top_p or top_k returns a 400 error●NEW — I Put a Ten-Line Promise File at the To…
When a Share Card Lost Its Bottom Edge: Checking OG Images by Pixels, Not by Declaration, Before the Build
An OG image I had already fixed went back to 1200×1200, and the site name and tagline at the bottom were cut off in wide social cards. These are the checks I lined up before the build: declared size versus real file, the band lost to cropping, and a crawler-style request.
When I shared one of my own articles on social media, the bottom of the card was missing. The band where I had placed the site name and tagline was simply gone.
The image itself was fine. Opened in a browser, it showed everything down to the last row. The meta tags said 1200×630, too. Yet the card was cut. What explained the gap was that the numbers in the meta tags are only a declaration, and have nothing to do with the real dimensions of the file.
Here is the conclusion up front. Check an og:image by the numbers you get from opening the image file, not the numbers written in the HTML. And check before the build, because after sharing is too late.
What had happened
I had already fixed this site's OG images once, to a wide 1200×630. Twitter/X, Facebook, Slack and note.com embeds all assume a wide card, and I had set twitter:card to summary_large_image. I believed it was settled.
Then the real size went back to 1200×1200. I have no record of how it went back. All I know is the result: in a wide card the middle gets cropped, and the site name and tagline at the bottom disappeared. By October 23 I had rebuilt them as separate Japanese and English 1200×630 files.
I can't pin down the cause now. But there are other places that want a square image: a Stripe product image, a LINE thumbnail. As long as those exist, there will be occasions to export the same artwork as a square. If one of those lands in the OG folder, the same thing happens again.
So before the fix, what I needed was a way to notice when it goes back.
How much does cropping take away?
A wide card is shown at roughly 1.91 : 1 (width to height). Here is what a center crop at that ratio does to a 1200×1200 image.
Source image
Rows kept
Rows lost
Where
1200×1200
628
572
286 at the top and 286 at the bottom
1200×630
628
2
1 at the top and 1 at the bottom
Hand over a square and almost half the height (47.7%) disappears. The bottom 286 rows are right in the middle of the band where a site name usually sits. Platforms may crop in slightly different ways, but I treat "the bottom edge is not safe" as true everywhere.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can add a pre-build check that reads the real pixels of an og:image file instead of trusting the width and height written in the HTML
✦You can tell, before opening the image, which rows a wide-card crop will throw away and whether any text sits in them
✦You can stop an image from silently drifting back to its old size by catching it in the build, before anyone shares a broken card
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first guard is simple. Walk every image under public/og and list any whose real size is not 1200×630.
#!/usr/bin/env python3"""Check images under public/og by real size. Reads pixels, not declarations."""import sysfrom pathlib import Pathfrom PIL import ImageTARGET = (1200, 630) # real size for share-card imagesEXEMPT = {"stripe.png"} # names of square files for other uses, if kept under ogdef check(root: Path): bad, total = [], 0 for p in sorted(root.rglob("*")): if p.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp"}: continue total += 1 if p.name in EXEMPT: continue with Image.open(p) as im: # reads only the header, so it is fast size = im.size if size != TARGET: bad.append((p, size)) return total, badif __name__ == "__main__": root = Path(sys.argv[1] if len(sys.argv) > 1 else "public/og") if not root.is_dir(): sys.exit(f"NG: {root} does not exist") # never pass silently total, bad = check(root) if total == 0: sys.exit(f"NG: no images at all under {root}") for p, (w, h) in bad: print(f"NG {p} {w}x{h} (expected {TARGET[0]}x{TARGET[1]})") print(f"checked {total} / violations {len(bad)}") sys.exit(1 if bad else 0)
I made one 1200×1200 and one 1200×630 test image and ran it. The output was:
NG public/og/site-ja.png 1200x1200 (expected 1200x630)
checked 2 / violations 1
The exit code is 1, so placed in front of the build, it stops right here.
Two details deserve attention. First, a missing folder and zero images are both failures. If a path changes and the target becomes empty, that looks identical to "no violations". A check that examined nothing and still reports green is the scariest state a check can be in. Second, Image.open reads only the header, so even a site that writes one image per article finishes the scan in a moment.
Check 2: is there text in the band that will be lost?
The size check is the first line of defense. Separately, an image of an unexpected size sometimes turns out to be intentional. What I then want to know is whether the band lost to cropping carries text I want readers to see.
To work with gradient backgrounds, I look at the spread of brightness along each row. A row of pure background barely changes color as you move across it. A row with text alternates between the background and the letters, so the spread is large.
#!/usr/bin/env python3"""Check whether the band lost to a wide center crop contains text-like variation."""import sysfrom PIL import ImageCARD_RATIO = 1.91 # rough wide-card ratio (width / height)ROW_STD_THRESHOLD = 12 # std dev of one row's brightness; a plain gradient stays below itdef lost_rows(h: int, w: int, ratio: float = CARD_RATIO): keep = round(w / ratio) if keep >= h: return [] top = (h - keep) // 2 return list(range(0, top)) + list(range(top + keep, h))def rows_with_content(im: Image.Image, rows): g = im.convert("L") w = g.width hit = [] for y in rows: line = list(g.crop((0, y, w, y + 1)).tobytes()) mean = sum(line) / w std = (sum((v - mean) ** 2 for v in line) / w) ** 0.5 if std > ROW_STD_THRESHOLD: hit.append(y) return hitif __name__ == "__main__": with Image.open(sys.argv[1]) as im: im.load() lost = lost_rows(im.height, im.width) hit = rows_with_content(im, lost) print(f"{im.width}x{im.height}: {len(lost)} rows lost by cropping / text-like rows among them {len(hit)}") if hit: print(f" first y={hit[0]} last y={hit[-1]}") sys.exit(1)
Here are the results on the same test images, a 1200×1200 with a text band at the bottom and a 1200×630 with the same layout.
1200x1200: 572 rows lost by cropping / text-like rows among them 24
first y=1099 last y=1124
1200x630: 2 rows lost by cropping / text-like rows among them 0
The square one reports text in the lost area, rows 1099 to 1124. The 24 is simply the height of the text I drew for the test; on a real image it depends on your design. What matters is less the count than the fact that you learn the text is on the lost side without opening the image.
The threshold of 12 was tuned on these test images. Re-tune it by hand for your own background. A finely patterned background will produce false positives; feeding in a few pattern-only images and finding the smallest value that stays quiet was the quickest way for me.
Check 3: match the meta tag declaration against the real file
As I said at the start, og:image:width and og:image:height in the HTML are declarations. If you rebuild the image and forget the meta tags, they disagree. And if only the image goes back, the meta tags stay "correct". That is why this incident was hard to notice.
#!/usr/bin/env python3"""Compare the og:image declaration in HTML against the real dimensions of the file it points to."""import sysfrom html.parser import HTMLParserfrom pathlib import Pathfrom urllib.parse import urlparsefrom PIL import Imageclass Meta(HTMLParser): def __init__(self): super().__init__() self.m = {} def handle_starttag(self, tag, attrs): if tag != "meta": return a = dict(attrs) key = a.get("property") or a.get("name") if key and "content" in a: self.m.setdefault(key, a["content"])def check(html_path: Path, public_dir: Path): p = Meta() p.feed(html_path.read_text(encoding="utf-8")) m, errs = p.m, [] if m.get("twitter:card") != "summary_large_image": errs.append(f"twitter:card={m.get('twitter:card')!r}") url = m.get("og:image") if not url: return errs + ["og:image is missing"] f = public_dir / urlparse(url).path.lstrip("/") if not f.is_file(): return errs + [f"file not found: {f}"] with Image.open(f) as im: real = im.size decl = (int(m.get("og:image:width", 0)), int(m.get("og:image:height", 0))) if decl != real: errs.append(f"declared {decl} differs from real {real}") if real != (1200, 630): errs.append(f"real size {real} is not 1200x630") return errsif __name__ == "__main__": html, public = Path(sys.argv[1]), Path(sys.argv[2]) errs = check(html, public) for e in errs: print("NG", e) print("OK" if not errs else f"{len(errs)} violation(s)") sys.exit(1 if errs else 0)
I prepared an HTML file that still declared 1200×630 but pointed at the 1200×1200 image, and ran it:
NG declared (1200, 630) differs from real (1200, 1200)
NG real size (1200, 1200) is not 1200x630
2 violation(s)
Pointing the same HTML at the 1200×630 image prints OK with exit code 0. Run it against the folder where the built HTML lands and you can check every article's og:image in one pass.
Line the three up in front of the build
A check means nothing if it is not run. Put them in the prebuild of package.json, and let any failure stop the whole build.
Because of &&, a failure in the first stops the rest. The meta tag comparison (Check 3) needs the built HTML, so it goes after the build. I wrote about a build that stopped because of synced-folder artifacts in when conflicted copies pile up in a Dropbox-synced build output. The lesson there was the same: put the check where it can stop the thing that should stop.
One more hole to check on the same day
Even with a correct image, no card appears if the crawler cannot reach it. Around OG tags I have stepped on three different causes.
Symptom
Cause
Fix
og:image is never fetched
The URL of a dynamically generated image, combined with the locale-prefix setting, was redirected with a 307, and crawlers did not follow it
Dropped the dynamic generation and named a static image URL explicitly
The image vanishes on child pages
The openGraph a child page returns replaced the parent's values entirely instead of merging
Whenever returning openGraph, also set images and twitter explicitly
Only crawlers with another language fail
Crawlers sending an English Accept-Language were sent a 307 to a different-language page by automatic detection
Turned off automatic locale detection and kept manual switching only
The third one is invisible in a browser, because if your browser is set to Japanese you are never redirected. Imitate the crawler by sending the language header.
# Imitate an English-language crawler and print only the response code of the top pagecurl -s -o /dev/null -I -w "%{http_code}\n" -H "Accept-Language: en-US,en;q=0.9" https://example.com/
To test the idea, I ran a tiny local server that returns a 307 only when the English header is present. With the header it printed 307, without it 200, so the difference does show up. In production, a 200 means the path is clear; a 307 means it is not fixed yet.
When you hand an image swap to Claude Code
Sometimes I hand the image swap itself to Claude Code. One line in CLAUDE.md reduces misses at that point.
- After touching OG images, run npm run check:og before reporting completion, and paste the result lines as they are
The words "I checked" are easy to put in a report. Make it paste the output lines and you can see whether it ran at all. I have found out about a mismatch after trusting only a completion report, so I prefer reports that carry the output.
Pitfalls
The target set becomes empty: renaming a folder can make the scan cover zero files. Treat zero as a failure.
Trusting the threshold: the per-row spread depends on the background pattern. Find the smallest quiet value on your own images.
Keeping squares under og: put the Stripe or LINE square elsewhere. If they must live together, list them by name in the exemptions.
Checking only in a browser: language headers and crawler behavior don't reproduce in a browser.
One thing to do next
Count the real sizes under public/og once. python3 scripts/og_size_check.py public/og is all it takes. If there are no violations, that is today's reward. If there are, someone's shared card may be cut right now.
With the check in prebuild, even if the same image comes back, it will stop before anyone shares it.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.