Skip to content

Commit 558f224

Browse files
FinnaXxxclaude
andcommitted
Let the screenshot follow the reader's theme, and lead with the banner
The diagonal light/dark split showed both appearances in one image, but that is a convention from theme repositories, where the appearance is the product. Here the banner does the visual work, so the screenshot's job is to be legible: each README now picks between a light and a dark capture with <picture>, and the window matches whichever theme the reader is browsing GitHub in. capture.mjs draws four single-appearance windows instead of two composites, and re-deflates what canvas encodes. Canvas optimises its PNGs for speed, which cost about half the file again; four images now weigh what the two used to. The header is the banner alone — it already carries the icon and the name, so the icon and the H1 that sat above it were saying everything twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1EPFBFbu8mdkiYZjoGgLc
1 parent 2613580 commit 558f224

10 files changed

Lines changed: 134 additions & 108 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<div align="center">
22

3-
<img src="Assets/AppIcon.png" width="112" alt="Clean My Codex" />
4-
5-
# Clean My Codex
3+
<img src="docs/images/banner.png" alt="Clean My Codex — Scan. Understand. Reclaim." />
64

75
**Find out what Codex has left on your disk, and remove only the parts that are safe to remove.**
86

@@ -13,7 +11,11 @@ English · [简体中文](README_CN.md)
1311
![Platform](https://img.shields.io/badge/platform-macOS-lightgrey)
1412
![License](https://img.shields.io/badge/license-MIT-blue)
1513

16-
<img src="docs/images/overview-en.png" width="900" alt="Clean My Codex overview, shown in both light and dark appearance" />
14+
<picture>
15+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/overview-en-dark.png">
16+
<source media="(prefers-color-scheme: light)" srcset="docs/images/overview-en-light.png">
17+
<img src="docs/images/overview-en-light.png" width="900" alt="The Clean My Codex overview: current usage and reclaimable space, a breakdown of where the space went, and the caches, logs and databases found by the scan" />
18+
</picture>
1719

1820
</div>
1921

README_CN.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<div align="center">
22

3-
<img src="Assets/AppIcon.png" width="112" alt="Clean My Codex" />
4-
5-
# Clean My Codex
3+
<img src="docs/images/banner.png" alt="Clean My Codex — Scan. Understand. Reclaim." />
64

75
**看清 Codex 在磁盘上留下了什么,只删掉确实可以删的那部分。**
86

@@ -13,7 +11,11 @@
1311
![Platform](https://img.shields.io/badge/platform-macOS-lightgrey)
1412
![License](https://img.shields.io/badge/license-MIT-blue)
1513

16-
<img src="docs/images/overview-zh.png" width="900" alt="Clean My Codex 总览界面,浅色与深色两种外观" />
14+
<picture>
15+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/overview-zh-dark.png">
16+
<source media="(prefers-color-scheme: light)" srcset="docs/images/overview-zh-light.png">
17+
<img src="docs/images/overview-zh-light.png" width="900" alt="Clean My Codex 总览界面:当前占用与本次可释放、空间分布,以及扫描出的缓存、日志与数据库" />
18+
</picture>
1719

1820
</div>
1921

docs/images/overview-en-dark.png

559 KB
Loading

docs/images/overview-en-light.png

566 KB
Loading

docs/images/overview-en.png

-1.04 MB
Binary file not shown.

docs/images/overview-zh-dark.png

533 KB
Loading

docs/images/overview-zh-light.png

540 KB
Loading

docs/images/overview-zh.png

-1 MB
Binary file not shown.

scripts/screenshots/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# README screenshots
22

3-
`capture.mjs` regenerates the two hero images the READMEs show:
3+
`capture.mjs` regenerates the four screenshots the READMEs show:
44

5-
- `docs/images/overview-en.png` — English interface
6-
- `docs/images/overview-zh.png` — Chinese interface
5+
docs/images/overview-{en,zh}-{light,dark}.png
76

8-
Both are the same window split along the diagonal, light above it and dark below it.
7+
Each README picks between the two appearances with `<picture>` and
8+
`prefers-color-scheme`, so the screenshot matches whichever theme the reader is browsing
9+
GitHub in. `docs/images/banner.png` is not generated here.
910

1011
The renderer is bundled and run for real; only the preload API is replaced, by the demo
1112
scan in `mock.js`. So the screenshots follow the interface automatically, and the only

scripts/screenshots/capture.mjs

Lines changed: 117 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
/**
2-
* Regenerates the two README hero images.
2+
* Regenerates the four README screenshots: one per language, per appearance.
33
*
4-
* The renderer is bundled and run for real against `mock.js`, once per language and
5-
* theme, then the light and dark shots of the same language are composited into one
6-
* window split along the diagonal. Nothing here mocks the interface itself, so an image
7-
* that no longer matches the app means the app changed, not the screenshot script.
4+
* The renderer is bundled and run for real against `mock.js`, then each shot is drawn
5+
* into a macOS-shaped window with its own shadow and border. Nothing here mocks the
6+
* interface itself, so an image that no longer matches the app means the app changed,
7+
* not the screenshot script.
8+
*
9+
* The READMEs pick between light and dark with `<picture>`, so the screenshot follows
10+
* whichever appearance the reader is browsing GitHub in.
811
*
912
* Usage: `node scripts/screenshots/capture.mjs` (see ./README.md for the prerequisites).
1013
*/
@@ -14,6 +17,7 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'
1417
import { createServer } from 'node:http'
1518
import { dirname, extname, join, normalize, resolve } from 'node:path'
1619
import { fileURLToPath } from 'node:url'
20+
import { deflateSync, inflateSync } from 'node:zlib'
1721
import * as esbuild from 'esbuild'
1822

1923
const HERE = dirname(fileURLToPath(import.meta.url))
@@ -26,6 +30,50 @@ const WIDTH = 1180
2630
const HEIGHT = 800
2731
const SCALE = 2
2832

33+
/**
34+
* Canvas encodes its PNGs for speed, which costs about half the file again on an image
35+
* this size. Re-deflating the pixels it produced is lossless and gets that back.
36+
*/
37+
function recompress(png) {
38+
const chunks = []
39+
const parts = []
40+
for (let at = 8; at < png.length; ) {
41+
const length = png.readUInt32BE(at)
42+
const type = png.toString('ascii', at + 4, at + 8)
43+
const body = png.subarray(at + 8, at + 8 + length)
44+
if (type === 'IDAT') parts.push(body)
45+
else if (type !== 'IEND') chunks.push({ type, body })
46+
at += length + 12
47+
}
48+
49+
const deflated = deflateSync(inflateSync(Buffer.concat(parts)), { level: 9 })
50+
const framed = ({ type, body }) => {
51+
const chunk = Buffer.alloc(body.length + 12)
52+
chunk.writeUInt32BE(body.length, 0)
53+
chunk.write(type, 4, 'ascii')
54+
body.copy(chunk, 8)
55+
chunk.writeInt32BE(crc(chunk.subarray(4, body.length + 8)), body.length + 8)
56+
return chunk
57+
}
58+
return Buffer.concat([
59+
png.subarray(0, 8),
60+
...chunks.map(framed),
61+
framed({ type: 'IDAT', body: deflated }),
62+
framed({ type: 'IEND', body: Buffer.alloc(0) })
63+
])
64+
}
65+
66+
const CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
67+
let value = index
68+
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
69+
return value >>> 0
70+
})
71+
const crc = (bytes) => {
72+
let value = 0xffffffff
73+
for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8)
74+
return (value ^ 0xffffffff) | 0
75+
}
76+
2977
const playwright = await import('playwright').catch(() => {
3078
throw new Error('playwright is not installed — see scripts/screenshots/README.md')
3179
})
@@ -76,103 +124,76 @@ for (const language of ['zh', 'en']) {
76124
}
77125
}
78126

79-
// Compositing happens in the page so the shots stay same-origin and the canvas untainted.
127+
// Framing happens in the page so the shots stay same-origin and the canvas untainted.
80128
const compositor = await context.newPage()
81129
await compositor.goto(`${origin}${page_}`)
82130

83131
await mkdir(OUT, { recursive: true })
84132
for (const language of ['zh', 'en']) {
85-
const dataURL = await compositor.evaluate(async ({ light, dark, width, height, scale }) => {
86-
const load = (src) => new Promise((done, fail) => {
87-
const image = new Image()
88-
image.onload = () => done(image)
89-
image.onerror = fail
90-
image.src = src
91-
})
92-
const [lightShot, darkShot] = await Promise.all([load(light), load(dark)])
93-
94-
const pad = 56 * scale
95-
const w = width * scale
96-
const h = height * scale
97-
const radius = 12 * scale
98-
const canvas = document.createElement('canvas')
99-
canvas.width = w + pad * 2
100-
canvas.height = h + pad * 2
101-
const ctx = canvas.getContext('2d')
102-
const window_ = (inset = 0) => {
103-
const [x, y, width_, height_, r] = [pad + inset, pad + inset, w - inset * 2, h - inset * 2, radius]
104-
ctx.beginPath()
105-
ctx.moveTo(x + r, y)
106-
ctx.arcTo(x + width_, y, x + width_, y + height_, r)
107-
ctx.arcTo(x + width_, y + height_, x, y + height_, r)
108-
ctx.arcTo(x, y + height_, x, y, r)
109-
ctx.arcTo(x, y, x + width_, y, r)
110-
ctx.closePath()
111-
}
112-
113-
// The page behind the image is transparent, so the drop shadow has to read on both
114-
// GitHub themes rather than sit on an assumed white background.
115-
ctx.save()
116-
ctx.shadowColor = 'rgba(12, 14, 28, 0.34)'
117-
ctx.shadowBlur = 44 * scale
118-
ctx.shadowOffsetY = 16 * scale
119-
window_()
120-
ctx.fill()
121-
ctx.restore()
122-
123-
ctx.save()
124-
window_()
125-
ctx.clip()
126-
ctx.drawImage(lightShot, pad, pad, w, h)
127-
// Dark takes the half below the diagonal running top-left → bottom-right.
128-
ctx.save()
129-
ctx.beginPath()
130-
ctx.moveTo(pad + w, pad)
131-
ctx.lineTo(pad + w, pad + h)
132-
ctx.lineTo(pad, pad + h)
133-
ctx.closePath()
134-
ctx.clip()
135-
ctx.drawImage(darkShot, pad, pad, w, h)
136-
ctx.restore()
137-
138-
const seam = ctx.createLinearGradient(pad + w, pad, pad, pad + h)
139-
seam.addColorStop(0, 'rgba(255, 255, 255, 0.16)')
140-
seam.addColorStop(0.5, 'rgba(255, 255, 255, 0.62)')
141-
seam.addColorStop(1, 'rgba(255, 255, 255, 0.16)')
142-
ctx.strokeStyle = seam
143-
ctx.lineWidth = 1.6 * scale
144-
ctx.beginPath()
145-
ctx.moveTo(pad + w, pad)
146-
ctx.lineTo(pad, pad + h)
147-
ctx.stroke()
148-
149-
// The window keeps its traffic lights: the sidebar already reserves the room.
150-
;['#ff5f57', '#febc2e', '#28c840'].forEach((color, index) => {
151-
ctx.beginPath()
152-
ctx.arc(pad + (25 + index * 20) * scale, pad + 27 * scale, 6 * scale, 0, Math.PI * 2)
153-
ctx.fillStyle = color
154-
ctx.fill()
133+
for (const theme of ['light', 'dark']) {
134+
const dataURL = await compositor.evaluate(async ({ shot, dark, width, height, scale }) => {
135+
const image = await new Promise((done, fail) => {
136+
const element = new Image()
137+
element.onload = () => done(element)
138+
element.onerror = fail
139+
element.src = shot
140+
})
141+
142+
const pad = 12 * scale
143+
const w = width * scale
144+
const h = height * scale
145+
const radius = 12 * scale
146+
const canvas = document.createElement('canvas')
147+
canvas.width = w + pad * 2
148+
canvas.height = h + pad * 2
149+
const ctx = canvas.getContext('2d')
150+
const window_ = (inset = 0) => {
151+
const [x, y, width_, height_, r] = [pad + inset, pad + inset, w - inset * 2, h - inset * 2, radius]
152+
ctx.beginPath()
153+
ctx.moveTo(x + r, y)
154+
ctx.arcTo(x + width_, y, x + width_, y + height_, r)
155+
ctx.arcTo(x + width_, y + height_, x, y + height_, r)
156+
ctx.arcTo(x, y + height_, x, y, r)
157+
ctx.arcTo(x, y, x + width_, y, r)
158+
ctx.closePath()
159+
}
160+
161+
// No drop shadow: a soft gradient across a transparent canvas is the one thing a
162+
// PNG cannot compress, and it doubled the file for an effect GitHub's own page
163+
// never shows around anything else.
164+
ctx.save()
165+
window_()
166+
ctx.clip()
167+
ctx.drawImage(image, pad, pad, w, h)
168+
169+
// The window keeps its traffic lights: the sidebar already reserves the room.
170+
;['#ff5f57', '#febc2e', '#28c840'].forEach((color, index) => {
171+
ctx.beginPath()
172+
ctx.arc(pad + (25 + index * 20) * scale, pad + 27 * scale, 6 * scale, 0, Math.PI * 2)
173+
ctx.fillStyle = color
174+
ctx.fill()
175+
})
176+
ctx.restore()
177+
178+
ctx.strokeStyle = dark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(20, 22, 40, 0.16)'
179+
ctx.lineWidth = scale
180+
window_(0.5)
181+
ctx.stroke()
182+
183+
return canvas.toDataURL('image/png')
184+
}, {
185+
shot: `${origin}/scripts/screenshots/build/shots/${language}-${theme}.png`,
186+
dark: theme === 'dark',
187+
width: WIDTH,
188+
height: HEIGHT,
189+
scale: SCALE
155190
})
156-
ctx.restore()
157-
158-
ctx.strokeStyle = 'rgba(20, 22, 40, 0.16)'
159-
ctx.lineWidth = scale
160-
window_(0.5)
161-
ctx.stroke()
162-
163-
return canvas.toDataURL('image/png')
164-
}, {
165-
light: `${origin}/scripts/screenshots/build/shots/${language}-light.png`,
166-
dark: `${origin}/scripts/screenshots/build/shots/${language}-dark.png`,
167-
width: WIDTH,
168-
height: HEIGHT,
169-
scale: SCALE
170-
})
171-
172-
const file = join(OUT, `overview-${language}.png`)
173-
const bytes = Buffer.from(dataURL.slice(dataURL.indexOf(',') + 1), 'base64')
174-
await writeFile(file, bytes)
175-
console.log(`wrote ${file} (${Math.round(bytes.length / 1024)} KiB)`)
191+
192+
const file = join(OUT, `overview-${language}-${theme}.png`)
193+
const bytes = recompress(Buffer.from(dataURL.slice(dataURL.indexOf(',') + 1), 'base64'))
194+
await writeFile(file, bytes)
195+
console.log(`wrote ${file} (${Math.round(bytes.length / 1024)} KiB)`)
196+
}
176197
}
177198

178199
await browser.close()

0 commit comments

Comments
 (0)