docs: enhance README and homepage with two screenshot plates

- Add assets/before-after.png — Plate i: five real renames laid out as a
  typeset table; mulberry highlight on the description segment; visual
  callouts for the U+202F gap and the user-keyword preservation case
- Add assets/session.png — Plate ii: editorial paper frame around an
  ink-dark Claude Code session card showing user prompt, claude
  orchestration, parallel Haiku fan-out across ten batches, and the run
  receipt
- Keep the source HTMLs (assets/{before-after,session}.html) so the
  plates can be regenerated via headless Brave at 1600x1100
- README.md rewritten: centered hero with embedded plate i, dedicated
  "A session, end to end" section embedding plate ii, new highlights
  (multi-prefix, idempotent), new "What the parser accepts" table,
  gotchas extended to 13, real-world impact promoted to a 3-row table
- docs/index.html surgically extended (existing editorial CSS preserved):
  new .plate and .additions components, nav gets what's-new and session
  links, new section 04 "What's new" with three additions cards plus a
  run-iii receipt, original receipt relabeled run-ii, gotchas list
  extended with #11-13, new section 07 "The session" embedding plate ii,
  install renumbered to 08
- Image refs in docs/index.html use absolute gitea raw URLs so they
  resolve when served from gitea pages or viewed locally

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Cardinale
2026-05-04 11:14:12 -04:00
parent 3a9997e990
commit 0728ae6592
6 changed files with 969 additions and 21 deletions
+52 -13
View File
@@ -1,8 +1,20 @@
<div align="center">
# screenshot-rename # screenshot-rename
> A Claude Code skill that turns a folder of timestamp-named screenshots into a folder of human-readable, searchable filenames — using parallel Haiku vision agents. *A Claude Code skill that turns a folder of timestamp-named screenshots*<br>
*into a folder of human-readable, searchable filenames —*<br>
*using parallel Haiku vision agents.*
**Documentation:** [pages.tojo.team/cardinale/screenshot-rename](https://pages.tojo.team/cardinale/screenshot-rename/) — full homepage with the workflow, gotchas, and use-case worked examples. [**Homepage**](https://pages.tojo.team/cardinale/screenshot-rename/) &nbsp;·&nbsp; [SKILL.md](SKILL.md) &nbsp;·&nbsp; [pipeline.py](pipeline.py) &nbsp;·&nbsp; [MIT](LICENSE)
<br>
<img src="assets/before-after.png" alt="Before / after — five real renames, including U+202F handling and user-keyword preservation" width="900">
</div>
<br>
``` ```
CleanShot 2026-04-15 at 09.14.07.png CleanShot 2026-04-15 at 09.14.07.png
@@ -10,28 +22,41 @@ CleanShot 2026-04-15 at 09.14.07.png
CleanShot - Shamel Studio Affiliate Referral Code Modal - 2026-04-15 at 09.14.07.png CleanShot - Shamel Studio Affiliate Referral Code Modal - 2026-04-15 at 09.14.07.png
``` ```
Built for [CleanShot](https://cleanshot.com)-style screenshot folders, but works on any directory of `.png` / `.gif` / `.mp4` / `.pdf` files named only by timestamp. Built for [CleanShot](https://cleanshot.com)-style screenshot folders, but works on any directory of `.png` / `.gif` / `.mp4` / `.pdf` files named only by timestamp. Recognizes both **`CleanShot ...`** and Apple **`Screenshot ...`** filenames in the same pass, preserves any leading user-typed keyword prefix, and is safe to re-run on a folder that's already partially renamed.
## Highlights ## Highlights
- **Parallel** — describes ~200 files in 3 minutes using 10 concurrent Haiku subagents. - **Parallel** — describes ~200 files in 3 minutes using 10 concurrent Haiku subagents.
- **Safe** — pre-builds the full rename plan in memory, validates uniqueness and target collisions, then renames atomically with file-count audit. Designed after losing 4 files to a `mv` overwrite during prototyping. - **Safe** — pre-builds the full rename plan in memory, validates uniqueness and target collisions, then renames atomically with a file-count audit. Designed after losing 4 files to a `mv` overwrite during prototyping.
- **Multi-prefix** — same pipeline handles `CleanShot ...`, Apple `Screenshot ...`, and files with hand-typed leading keywords (e.g. `jojo travel CleanShot ...`).
- **Idempotent** — re-running on a folder skips files already in the renamed `App - Description - timestamp.ext` form. No description-stacking.
- **Handles video / PDF** — extracts the first frame so vision agents can describe them. - **Handles video / PDF** — extracts the first frame so vision agents can describe them.
- **Resizes for the vision tool** — Retina screenshots exceed Read's image cap; pipeline downsamples to 1568 px max. - **Resizes for the vision tool** — Retina screenshots exceed Read's image cap; pipeline downsamples to 1568 px max.
## A session, end to end
<div align="center">
<img src="assets/session.png" alt="Claude Code session — user prompt, parallel Haiku fan-out across ten batches, receipt of the run" width="900">
</div>
The skill activates when you ask Claude conversationally. Behind the scenes it preps the folder, fans out ten Haiku agents in a single round-trip, validates the resulting plan, then applies the renames in a single Python pass with a file-count audit at the end.
## Installation ## Installation
This is a Claude Code skill. Drop the `screenshot-rename/` directory into `~/.claude/skills/`: This is a Claude Code skill. Drop the repo into `~/.claude/skills/`:
```bash ```bash
git clone https://gitea.tojo.team/cardinale/screenshot-rename.git ~/.claude/skills/screenshot-rename git clone https://gitea.tojo.team/cardinale/screenshot-rename.git \
~/.claude/skills/screenshot-rename
``` ```
In your next Claude Code session, ask: In your next Claude Code session, ask:
> rename all the cleanshot files in `~/Documents/Screenshots/` based on their content > rename all the cleanshot files in `~/Documents/Screenshots/` based on their content
The skill will activate automatically. The skill will activate automatically from its description.
## Usage from the command line ## Usage from the command line
@@ -52,19 +77,22 @@ python3 pipeline.py plan --src "/path/to/folder"
python3 pipeline.py execute --src "/path/to/folder" python3 pipeline.py execute --src "/path/to/folder"
``` ```
The dispatch step (#2) currently requires a Claude Code session. See [Roadmap](#roadmap). The dispatch step (#2) currently requires a Claude Code session.
## Documentation ## What the parser accepts
- **Homepage with worked examples:** [docs/index.html](docs/index.html) | Form | Recognized | Becomes |
- **Full skill spec:** [SKILL.md](SKILL.md) |---|---|---|
- **Pipeline source:** [pipeline.py](pipeline.py) | `CleanShot 2026-MM-DD at HH.MM.SS.png` | yes | `CleanShot - <description> - 2026-MM-DD at HH.MM.SS.png` |
| `Screenshot 2026-MM-DD at H.MM.SS PM.png` (with U+202F) | yes — U+202F normalized to ASCII space | `Screenshot - <description> - 2026-MM-DD at H.MM.SS PM.png` |
| `<keywords> CleanShot 2026-MM-DD at HH.MM.SS.png` | yes — keywords title-cased and prepended to the AI description | `CleanShot - <Keywords + description> - 2026-MM-DD at HH.MM.SS.png` |
| `App - <description> - 2026-MM-DD at HH.MM.SS.png` | already renamed → **skipped** | (unchanged) |
## The gotchas this skill encodes ## The gotchas this skill encodes
This skill exists because every one of these caused real damage during development: This skill exists because every one of these caused real damage during development:
1. The macOS `Read` tool has an image-size cap. Resize first. 1. The `Read` tool has an image-size cap. Resize first.
2. Vision can't read `.mp4` or multi-page `.pdf` directly. Extract a frame. 2. Vision can't read `.mp4` or multi-page `.pdf` directly. Extract a frame.
3. **Bash regex `[[ =~ ]]` does NOT populate `BASH_REMATCH` in zsh.** Targets become empty. Loops collide on the same filename. Files vanish. Use Python for any filename mutation. 3. **Bash regex `[[ =~ ]]` does NOT populate `BASH_REMATCH` in zsh.** Targets become empty. Loops collide on the same filename. Files vanish. Use Python for any filename mutation.
4. `mv` silently overwrites. Use `mv -n` or `os.rename` with explicit pre-existence check. 4. `mv` silently overwrites. Use `mv -n` or `os.rename` with explicit pre-existence check.
@@ -74,9 +102,20 @@ This skill exists because every one of these caused real damage during developme
8. `Bash run_in_background` may exit early on `while read` loops. Run renames foreground via Python. 8. `Bash run_in_background` may exit early on `while read` loops. Run renames foreground via Python.
9. Haiku occasionally returns the resized `.jpg` filename instead of the original `.png`. Validator must try alt extensions. 9. Haiku occasionally returns the resized `.jpg` filename instead of the original `.png`. Validator must try alt extensions.
10. Always preserve the original `.mp4` / `.pdf` extension — describe via the extracted frame, rename the source. 10. Always preserve the original `.mp4` / `.pdf` extension — describe via the extracted frame, rename the source.
11. **macOS `Screenshot` filenames contain U+202F (NARROW NO-BREAK SPACE)** before AM/PM. Haiku echoes it as ASCII space, so a verbatim filename lookup misses every Screenshot file. Normalize on both sides of the lookup; emit ASCII space in the new name.
12. **Re-running is only safe if the parser skips already-renamed files.** Detect `^App - .+ - timestamp.ext$` and exclude.
13. **Leading user-typed keyword prefix is signal, not noise.** Title-case the keywords and prepend them to the AI description before assembling the new name.
The full discussion is in [SKILL.md](SKILL.md#the-critical-gotchas-every-one-of-these-caused-real-pain). The full discussion is in [SKILL.md](SKILL.md#the-critical-gotchas-every-one-of-these-caused-real-pain).
## Real-world impact
| Run | Files | What happened |
|---|---|---|
| 1 | 196 CleanShot | Lost 4 to the bash-regex-in-zsh gotcha (#3). |
| 2 | 196 CleanShot | Rebuilt with Python and `mv -n` — 189 renamed cleanly, zero loss. |
| 3 | 20 mixed (CleanShot + Apple Screenshot + one user-prefixed) | First plan attempt dropped every Screenshot file with a misleading `NO_DESC` error. Diagnosed the U+202F gotcha (#11) via `repr()` of the live filename. After adding U+202F normalization, multi-prefix support, and keyword preservation — all 20 renamed in one pass. |
## Roadmap ## Roadmap
- Direct Anthropic API mode (no Claude Code session required) — needs `ANTHROPIC_API_KEY` - Direct Anthropic API mode (no Claude Code session required) — needs `ANTHROPIC_API_KEY`
+332
View File
@@ -0,0 +1,332 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>screenshot-rename · before / after</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500;1,9..144,700&family=JetBrains+Mono:wght@400;500;600&display=block">
<style>
:root {
--paper: #f7f3ed;
--paper-2: #efe7d8;
--paper-3: #e8dfcd;
--ink: #1c1916;
--ink-soft: #3c3530;
--ink-mute: #8d8478;
--accent: #7a1f3d;
--accent-soft: rgba(122,31,61,.10);
--accent-deep: #5d1730;
--rule: #d8cebf;
--rule-soft: #e6dfd2;
--serif: "Fraunces", Georgia, serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1600px;
height: 1100px;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
font-feature-settings: "ss01", "ss02", "kern";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-image:
radial-gradient(1100px 380px at 92% -10%, rgba(122,31,61,.06), transparent 60%),
radial-gradient(900px 320px at 0% 22%, rgba(0,0,0,.03), transparent 70%);
position: relative;
overflow: hidden;
}
/* paper grain */
body::before {
content: "";
position: absolute; inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
pointer-events: none;
mix-blend-mode: multiply;
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
padding: 70px 88px 64px;
height: 100%;
}
/* ───── Header ────────────────────────────────────── */
.head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
gap: 36px;
padding-bottom: 28px;
border-bottom: 1px solid var(--rule);
}
.eyebrow {
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
display: flex; align-items: center; gap: 14px;
}
.eyebrow .bar { display: inline-block; width: 44px; height: 1px; background: var(--accent); }
.title {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-variation-settings: "opsz" 144;
font-size: 76px;
line-height: 0.96;
letter-spacing: -0.022em;
margin: 0;
color: var(--ink);
}
.title .ampersand { font-weight: 300; color: var(--accent); padding: 0 4px; }
.runtag {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
color: var(--ink-mute);
text-transform: uppercase;
text-align: right;
}
.runtag .v { color: var(--ink); display: block; font-size: 13px; margin-top: 4px; letter-spacing: 0.06em; }
/* ───── Column legend ─────────────────────────────── */
.legend {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) minmax(0, 1.55fr);
gap: 32px;
margin-top: 32px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
}
/* ───── Rows ──────────────────────────────────────── */
.rows { margin-top: 14px; }
.row {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) minmax(0, 1.55fr);
gap: 32px;
padding: 18px 0 18px;
border-top: 1px dashed var(--rule);
align-items: center;
position: relative;
}
.row:first-child { border-top: 1px solid var(--rule); }
.glyph {
font-family: var(--mono);
font-size: 13px;
color: var(--accent);
text-align: center;
letter-spacing: 0;
font-weight: 500;
}
.before {
font-family: var(--mono);
font-size: 17px;
color: var(--ink-mute);
letter-spacing: -0.005em;
}
.before .nbsp {
display: inline-block;
background: rgba(122,31,61,.16);
border-bottom: 1px dashed var(--accent);
width: 0.4em;
height: 1.05em;
vertical-align: -0.18em;
margin: 0 -0.04em;
}
.after {
font-family: var(--mono);
font-size: 17.5px;
color: var(--ink);
letter-spacing: -0.005em;
line-height: 1.45;
}
.after .desc {
color: var(--accent-deep);
font-weight: 500;
background: linear-gradient(180deg, transparent 56%, var(--accent-soft) 56%, var(--accent-soft) 100%);
padding: 0 1px;
}
.after .keep {
color: var(--accent);
font-weight: 600;
}
/* annotation callout to the right of certain rows */
.row .note {
position: absolute;
right: -76px;
top: 50%;
transform: translateY(-50%);
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
display: none;
}
.row.callout .note {
display: block;
position: static;
transform: none;
grid-column: 2 / 4;
margin-top: 6px;
display: flex;
align-items: center;
gap: 14px;
color: var(--ink-mute);
}
.row.callout .note::before {
content: "↳";
color: var(--accent);
font-family: var(--serif);
font-style: italic;
font-size: 14px;
letter-spacing: 0;
}
.row.callout .note b {
color: var(--accent);
font-weight: 600;
text-transform: uppercase;
}
/* ───── Footer strip ─────────────────────────────── */
.foot {
margin-top: 48px;
padding-top: 26px;
border-top: 1px solid var(--rule);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 36px;
}
.foot .stat {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
line-height: 1.3;
}
.foot .stat b {
display: block;
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-variation-settings: "opsz" 96;
font-size: 38px;
letter-spacing: -0.02em;
text-transform: none;
color: var(--accent);
margin-top: 6px;
line-height: 1.05;
}
.foot .stat b .small { font-style: normal; font-weight: 500; font-size: 16px; color: var(--ink); }
.colophon {
position: absolute;
bottom: 28px; right: 88px;
font-family: var(--serif);
font-style: italic;
font-size: 13px;
color: var(--ink-mute);
}
/* sheet number */
.sheet {
position: absolute;
top: 70px; right: 88px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.22em;
color: var(--ink-mute);
text-transform: uppercase;
}
.sheet b { color: var(--accent); }
::selection { background: var(--accent); color: var(--paper); }
</style>
</head>
<body>
<div class="frame">
<div class="sheet">Sheet · <b>01 / 02</b></div>
<div class="head">
<div class="eyebrow"><span class="bar"></span>screenshot-rename · plate i</div>
<h1 class="title">Before <span class="ampersand">&amp;</span> after</h1>
<div class="runtag">A real run, twenty files<span class="v">2026·05·04</span></div>
</div>
<div class="legend">
<span></span>
<span>Filename · before</span>
<span>Filename · after</span>
</div>
<div class="rows">
<div class="row">
<span class="glyph"></span>
<span class="before">CleanShot 2026-04-15 at 09.14.07.png</span>
<span class="after">CleanShot - <span class="desc">Shamel Studio Affiliate Referral Code Modal</span> - 2026-04-15 at 09.14.07.png</span>
</div>
<div class="row callout">
<span class="glyph"></span>
<span class="before">Screenshot 2026-03-12 at 11.42.18<span class="nbsp" title="U+202F"></span>PM.png</span>
<span class="after">Screenshot - <span class="desc">Synqora Modal Architecture Decisions Diagram</span> - 2026-03-12 at 11.42.18 PM.png</span>
<span class="note"><b>U+202F</b> · Apple's narrow no-break space, normalized in plan + execute</span>
</div>
<div class="row callout">
<span class="glyph"></span>
<span class="before"><span class="keep" style="color: var(--accent); font-weight: 600;">jojo travel</span> CleanShot 2026-03-31 at 10.52.34.png</span>
<span class="after">CleanShot - <span class="desc"><span class="keep">Jojo Travel</span> Flight Australia Melbourne Flightaware Map Route</span> - 2026-03-31 at 10.52.34.png</span>
<span class="note"><b>User keyword</b> · hand-typed prefix is preserved and title-cased into the new name</span>
</div>
<div class="row">
<span class="glyph"></span>
<span class="before">CleanShot 2026-01-26 at 17.38.30.png</span>
<span class="after">CleanShot - <span class="desc">Claude Code Hitting Limits Conversation Transcript</span> - 2026-01-26 at 17.38.30.png</span>
</div>
<div class="row">
<span class="glyph"></span>
<span class="before">CleanShot 2026-02-09 at 07.29.35.png</span>
<span class="after">CleanShot - <span class="desc">Claude Code Skill Rust Refactor Diff View</span> - 2026-02-09 at 07.29.35.png</span>
</div>
</div>
<div class="foot">
<div class="stat">Files in&nbsp;run<b>20<span class="small"> &nbsp;files</span></b></div>
<div class="stat">Renamed<b>20<span class="small"> &nbsp;</span></b></div>
<div class="stat">Prefix variants<b>3<span class="small"> &nbsp;CleanShot · Screenshot · keyword</span></b></div>
<div class="stat">Idempotent rerun<b>safe<span class="small"> &nbsp;already-renamed skipped</span></b></div>
</div>
<div class="colophon">Set in Fraunces &amp; JetBrains Mono. Plate i of ii.</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

+340
View File
@@ -0,0 +1,340 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>screenshot-rename · session</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400;1,9..144,500;1,9..144,700&family=JetBrains+Mono:wght@400;500;600&display=block">
<style>
:root {
--paper: #f7f3ed;
--paper-2: #efe7d8;
--paper-3: #e8dfcd;
--ink: #1c1916;
--ink-2: #221e1a;
--ink-soft: #3c3530;
--ink-mute: #8d8478;
--accent: #7a1f3d;
--accent-soft: rgba(122,31,61,.10);
--accent-deep: #5d1730;
--rule: #d8cebf;
--rule-soft: #e6dfd2;
--term-paper: #ece4d2;
--term-mute: #a8a094;
--serif: "Fraunces", Georgia, serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
width: 1600px;
height: 1100px;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
font-feature-settings: "ss01", "ss02", "kern";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-image:
radial-gradient(1100px 380px at 92% -10%, rgba(122,31,61,.06), transparent 60%),
radial-gradient(900px 320px at 0% 22%, rgba(0,0,0,.03), transparent 70%);
position: relative;
overflow: hidden;
}
body::before {
content: "";
position: absolute; inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
pointer-events: none;
mix-blend-mode: multiply;
z-index: 0;
}
.frame {
position: relative;
z-index: 1;
padding: 70px 88px 64px;
height: 100%;
}
/* ───── Header ────────────────────────────────────── */
.head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
gap: 36px;
padding-bottom: 28px;
border-bottom: 1px solid var(--rule);
}
.eyebrow {
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
display: flex; align-items: center; gap: 14px;
}
.eyebrow .bar { display: inline-block; width: 44px; height: 1px; background: var(--accent); }
.title {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-variation-settings: "opsz" 144;
font-size: 76px;
line-height: 0.96;
letter-spacing: -0.022em;
margin: 0;
}
.title .ampersand { color: var(--accent); padding: 0 4px; font-weight: 300; }
.runtag {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
color: var(--ink-mute);
text-transform: uppercase;
text-align: right;
}
.runtag .v { color: var(--ink); display: block; font-size: 13px; margin-top: 4px; letter-spacing: 0.06em; }
.sheet {
position: absolute;
top: 70px; right: 88px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.22em;
color: var(--ink-mute);
text-transform: uppercase;
}
.sheet b { color: var(--accent); }
/* ───── Terminal card ─────────────────────────────── */
.terminal {
margin-top: 38px;
background: var(--ink);
color: var(--term-paper);
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
padding: 30px 38px 28px;
border-left: 3px solid var(--accent);
position: relative;
display: grid;
grid-template-columns: 80px 1fr;
gap: 22px 24px;
align-items: baseline;
}
.terminal::after {
content: "claude code · session 0a3f";
position: absolute;
top: 10px; right: 18px;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
}
.tag {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
text-align: right;
padding-top: 3px;
}
.tag.you { color: var(--accent); }
.tag.bash { color: #c8d3a3; }
.tag.cc { color: #d8a4b3; }
.tag.fan { color: var(--accent); }
.line { font-size: 15px; }
.line.you { color: var(--term-paper); font-style: italic; font-family: var(--serif); font-size: 18px; line-height: 1.45; font-weight: 400; }
.line.cc { color: var(--term-paper); font-size: 15px; line-height: 1.55; }
.line.cc em { color: #d8a4b3; font-style: normal; font-weight: 500; }
.line.bash { color: var(--term-mute); font-size: 13px; line-height: 1.65; }
.line.bash b { color: var(--term-paper); font-weight: 500; }
.line.bash .num { color: #e9c98c; }
.line.bash .com { color: #6f685d; font-style: italic; }
/* ───── Agent fan ─────────────────────────────────── */
.fan-wrap { padding: 4px 0 6px; }
.agents {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.agent {
background: var(--ink-2);
border: 1px solid #2c2722;
padding: 14px 16px 13px;
display: grid;
grid-template-columns: 22px 1fr auto;
gap: 12px;
align-items: center;
position: relative;
}
.agent .dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(122,31,61,.18);
justify-self: center;
}
.agent .id {
font-size: 13px;
color: var(--term-paper);
letter-spacing: 0.04em;
}
.agent .id b { color: #d8a4b3; font-weight: 500; }
.agent .ct {
font-size: 11px;
color: var(--term-mute);
letter-spacing: 0.06em;
}
.agent .ct .ok { color: var(--accent); font-weight: 600; padding-left: 2px; }
.agent .haiku-tag {
position: absolute;
top: -8px; left: 12px;
background: var(--ink);
font-size: 9px;
letter-spacing: 0.2em;
color: var(--accent);
padding: 0 6px;
text-transform: uppercase;
}
/* ───── Receipt strip beneath ─────────────────────── */
.receipt {
margin-top: 38px;
background: var(--paper-2);
border: 1px solid var(--rule);
padding: 28px 36px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 36px;
position: relative;
}
.receipt::before, .receipt::after {
content: ""; position: absolute; left: 0; right: 0; height: 8px;
background-image: radial-gradient(circle at 6px 4px, var(--paper) 4px, transparent 4px);
background-size: 14px 8px;
background-repeat: repeat-x;
}
.receipt::before { top: -1px; }
.receipt::after { bottom: -1px; transform: scaleY(-1); }
.receipt .cell .k {
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
display: block;
margin-bottom: 6px;
}
.receipt .cell .v {
font-family: var(--serif);
font-style: italic;
font-variation-settings: "opsz" 96;
font-size: 32px;
line-height: 1.05;
color: var(--ink);
letter-spacing: -0.012em;
}
.receipt .cell .v .ok { color: var(--accent); font-weight: 500; }
.receipt .cell .v .small {
font-style: normal;
font-family: var(--mono);
font-size: 13px;
color: var(--ink-soft);
letter-spacing: 0.04em;
margin-left: 4px;
}
.colophon {
position: absolute;
bottom: 28px; right: 88px;
font-family: var(--serif);
font-style: italic;
font-size: 13px;
color: var(--ink-mute);
}
::selection { background: var(--accent); color: var(--paper); }
</style>
</head>
<body>
<div class="frame">
<div class="sheet">Sheet · <b>02 / 02</b></div>
<div class="head">
<div class="eyebrow"><span class="bar"></span>screenshot-rename · plate ii</div>
<h1 class="title">A&nbsp;session, <span class="ampersand">end&nbsp;to&nbsp;end</span></h1>
<div class="runtag">196 files · 10 agents · ~3 min<span class="v">claude code · 2026·05·04</span></div>
</div>
<div class="terminal">
<span class="tag you">you</span>
<div class="line you">rename all the cleanshots in <code style="font-family:var(--mono); font-size:15px; color:#e9c98c;">~/Documents/Screenshots/</code> based on their content.</div>
<span class="tag cc">claude</span>
<div class="line cc">I'll prep the folder, batch into ten files of 19, dispatch <em>parallel Haiku agents</em>, then plan and execute. Resizing Retinas first; extracting frames from <em>.mp4</em> and <em>.pdf</em>.</div>
<span class="tag bash">bash</span>
<div class="line bash"><span class="com"># 1 · prep</span><br>
python3 pipeline.py prep --src <span style="color:#e9c98c">~/Documents/Screenshots/</span><br>
<b><span class="num">196</span></b> source files · <b><span class="num">9</span></b> mp4/pdf frames · resized to <b>1568 px</b> · <b><span class="num">10</span></b> batches written
</div>
<span class="tag fan">fan-out</span>
<div class="line fan-wrap">
<div class="agents">
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>01</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>02</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>03</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>04</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>05</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>06</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>07</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>08</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>09</b></span><span class="ct">19/19 <span class="ok"></span></span></div>
<div class="agent"><span class="haiku-tag">haiku</span><span class="dot"></span><span class="id">batch-<b>10</b></span><span class="ct">10/10 <span class="ok"></span></span></div>
</div>
</div>
<span class="tag bash">bash</span>
<div class="line bash"><span class="com"># 2 · plan, then execute</span><br>
python3 pipeline.py plan --src <span style="color:#e9c98c">~/Documents/Screenshots/</span> &nbsp;<b><span class="num">189</span></b> renames · <b><span class="num">0</span></b> errors · <b><span class="num">0</span></b> collisions<br>
python3 pipeline.py execute --src <span style="color:#e9c98c">~/Documents/Screenshots/</span> &nbsp;→ audit <b><span class="num">195</span></b> = <b><span class="num">195</span></b> · <span style="color:#d8a4b3;">189&nbsp;</span>
</div>
</div>
<div class="receipt">
<div class="cell">
<span class="k">Agents in flight</span>
<span class="v">10<span class="small">parallel</span></span>
</div>
<div class="cell">
<span class="k">Renames committed</span>
<span class="v"><span class="ok">189</span><span class="small">/&nbsp;189 planned</span></span>
</div>
<div class="cell">
<span class="k">File-count audit</span>
<span class="v">195 <span style="color:var(--ink-mute);">=</span> 195 <span class="ok"></span></span>
</div>
<div class="cell">
<span class="k">Files lost</span>
<span class="v"><span class="ok">0</span><span class="small">zero overwrites</span></span>
</div>
</div>
<div class="colophon">Set in Fraunces &amp; JetBrains Mono. Plate ii of ii.</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

+243 -6
View File
@@ -657,6 +657,132 @@ footer .colophon {
max-width: 50ch; max-width: 50ch;
} }
/* ───── Plate (image + caption) ───────────────── */
.plate {
margin: clamp(56px, 7vw, 96px) 0 0;
text-align: center;
}
.plate figure {
margin: 0;
display: inline-block;
max-width: 100%;
position: relative;
}
.plate figure::before, .plate figure::after {
content: "";
position: absolute;
width: 56px; height: 1px;
background: var(--accent);
top: 50%;
}
.plate figure::before { left: -68px; }
.plate figure::after { right: -68px; }
@media (max-width: 1100px) {
.plate figure::before, .plate figure::after { display: none; }
}
.plate img {
display: block;
width: 100%;
max-width: 1180px;
height: auto;
border: 1px solid var(--rule);
background: var(--paper);
box-shadow:
0 28px 56px -34px rgba(28,25,22,.30),
0 6px 14px -8px rgba(28,25,22,.18);
}
.plate figcaption {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
margin-top: 22px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
}
.plate figcaption .dot { color: var(--accent); }
.plate figcaption .b { color: var(--ink); }
/* ───── Additions (what's new this revision) ──── */
.additions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--rule);
background: var(--paper-2);
margin-bottom: 32px;
}
@media (max-width: 920px) { .additions { grid-template-columns: 1fr; } }
.add {
padding: 32px 28px 28px;
border-right: 1px solid var(--rule);
position: relative;
}
.add:last-child { border-right: none; }
@media (max-width: 920px) {
.add { border-right: none; border-bottom: 1px solid var(--rule); }
.add:last-child { border-bottom: none; }
}
.add .badge {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.20em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.add .badge .num {
display: inline-block;
border: 1px solid var(--accent);
border-radius: 50%;
width: 22px; height: 22px;
text-align: center; line-height: 20px;
font-size: 10px;
}
.add h3 {
font-family: var(--serif);
font-style: italic;
font-weight: 500;
font-variation-settings: "opsz" 60;
font-size: 26px;
line-height: 1.15;
margin: 0 0 12px;
letter-spacing: -0.012em;
color: var(--ink);
}
.add p {
font-size: 15px;
color: var(--ink-soft);
line-height: 1.55;
margin: 0 0 14px;
}
.add code {
font-family: var(--mono);
font-size: 12px;
color: var(--accent-deep);
background: rgba(122,31,61,.08);
padding: 1px 6px;
}
.add .glyph {
position: absolute;
top: 24px; right: 22px;
font-family: var(--serif);
font-style: italic;
font-size: 42px;
color: var(--accent-soft);
line-height: 1;
pointer-events: none;
}
/* ───── Reveal animation ─────────────────────── */ /* ───── Reveal animation ─────────────────────── */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
@@ -686,8 +812,9 @@ footer .colophon {
<nav class="top"> <nav class="top">
<a href="#problem">problem</a> <a href="#problem">problem</a>
<a href="#pipeline">pipeline</a> <a href="#pipeline">pipeline</a>
<a href="#whats-new">what's&nbsp;new</a>
<a href="#gotchas">gotchas</a> <a href="#gotchas">gotchas</a>
<a href="#cases">use cases</a> <a href="#session">session</a>
<a href="#install">install</a> <a href="#install">install</a>
<a href="https://gitea.tojo.team/cardinale/screenshot-rename">repo&nbsp;</a> <a href="https://gitea.tojo.team/cardinale/screenshot-rename">repo&nbsp;</a>
</nav> </nav>
@@ -837,14 +964,29 @@ footer .colophon {
<p style="font-family:var(--mono); font-size:12px; color:var(--ink-mute); letter-spacing:0.1em; text-transform:uppercase; margin-top:20px;"> <p style="font-family:var(--mono); font-size:12px; color:var(--ink-mute); letter-spacing:0.1em; text-transform:uppercase; margin-top:20px;">
The original timestamp survives unchanged. Sorting still works. The description sits between, set off by em-dashes. The original timestamp survives unchanged. Sorting still works. The description sits between, set off by em-dashes.
</p> </p>
<div class="plate reveal">
<figure>
<img src="https://gitea.tojo.team/cardinale/screenshot-rename/raw/branch/main/assets/before-after.png" alt="Plate i — five real renames, including U+202F handling and user-keyword preservation">
<figcaption>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
<span>Plate i</span>
<span class="dot">·</span>
<span class="b">Five real renames</span>
<span class="dot">·</span>
<span>CleanShot · Apple Screenshot · user-keyword</span>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
</figcaption>
</figure>
</div>
</div> </div>
</section> </section>
<!-- ───── Receipt ──────────────────────────────────── --> <!-- ───── Receipt (run ii) ─────────────────────────── -->
<section> <section>
<div class="wrap"> <div class="wrap">
<div class="receipt reveal"> <div class="receipt reveal">
<div class="head">screenshot-rename · run log · 2026-05-04</div> <div class="head">screenshot-rename · run ii · 2026-05-04 · 196 files</div>
<div class="line"><span>source files</span><span class="v">196</span></div> <div class="line"><span>source files</span><span class="v">196</span></div>
<div class="line"><span>resized to 1568px</span><span class="v">196</span></div> <div class="line"><span>resized to 1568px</span><span class="v">196</span></div>
<div class="line"><span>frames extracted (mp4 / pdf)</span><span class="v">9</span></div> <div class="line"><span>frames extracted (mp4 / pdf)</span><span class="v">9</span></div>
@@ -860,10 +1002,74 @@ footer .colophon {
</div> </div>
</section> </section>
<!-- ───── What's new ───────────────────────────────── -->
<section id="whats-new">
<div class="wrap">
<div class="section-label"><span class="num">04</span>What's new this revision</div>
<h2><em>Three</em> additions, one mixed run.</h2>
<p class="lede-2">
A second batch of files surfaced cases the first run never hit: Apple's own
<em>Screenshot</em> filenames, user-typed keyword prefixes, and folders where some
files were already renamed. The pipeline now handles all three in a single pass
&mdash; and the parser learned a new gotcha along the way.
</p>
<div class="additions reveal">
<div class="add">
<span class="glyph">a.</span>
<div class="badge"><span class="num">i</span>multi-prefix</div>
<h3>CleanShot <em>and</em> Screenshot, in one run.</h3>
<p>
The parser now accepts both <code>CleanShot 2026-MM-DD at HH.MM.SS.png</code> and
Apple's <code>Screenshot 2026-MM-DD at H.MM.SS PM.png</code>. Mixed folders no
longer need two passes &mdash; the manifest builder picks up either prefix.
</p>
</div>
<div class="add">
<span class="glyph">b.</span>
<div class="badge"><span class="num">ii</span>keyword preservation</div>
<h3>Hand-typed prefixes survive.</h3>
<p>
A file named <code>jojo travel CleanShot 2026-...png</code> carries user knowledge
the AI doesn't have. The parser strips the keyword phrase, title-cases it, and
prepends it to the AI description &mdash; so the new filename reads
<em>Jojo Travel Flight Australia Melbourne Flightaware Map Route</em>.
</p>
</div>
<div class="add">
<span class="glyph">c.</span>
<div class="badge"><span class="num">iii</span>idempotent re-runs</div>
<h3>Re-running won't stack descriptions.</h3>
<p>
The parser now detects names already in the
<code>App - Description - timestamp.ext</code> form and excludes them from the
manifest. You can re-run the skill on a partially-renamed folder without the
name growing on every pass.
</p>
</div>
</div>
<div class="receipt reveal" style="max-width: 820px;">
<div class="head">screenshot-rename · run iii · 2026-05-04 · mixed prefixes</div>
<div class="line"><span>source files</span><span class="v">20</span></div>
<div class="line"><span>CleanShot</span><span class="v">14</span></div>
<div class="line"><span>Apple Screenshot (with U+202F)</span><span class="v">5</span></div>
<div class="line"><span>user-prefixed</span><span class="v">1</span></div>
<div class="line"><span>already-renamed (skipped)</span><span class="v">0</span></div>
<div class="line"><span>plan validated</span><span class="v">20 renames · 0 errors</span></div>
<div class="line"><span>file count before / after</span><span class="v">20 = 20</span></div>
<div class="line total"><span>renames committed</span><span class="ok">20 ✓</span></div>
<div class="line total"><span>files lost</span><span class="ok">0 ✓</span></div>
</div>
</div>
</section>
<!-- ───── Gotchas ──────────────────────────────────── --> <!-- ───── Gotchas ──────────────────────────────────── -->
<section id="gotchas"> <section id="gotchas">
<div class="wrap"> <div class="wrap">
<div class="section-label"><span class="num">04</span>The rules that prevent data loss</div> <div class="section-label"><span class="num">05</span>The rules that prevent data loss</div>
<h2>Every rule below was <em>paid for</em>.</h2> <h2>Every rule below was <em>paid for</em>.</h2>
<p class="lede-2"> <p class="lede-2">
During development, four files were destroyed by a one-line bash mistake. During development, four files were destroyed by a one-line bash mistake.
@@ -880,6 +1086,9 @@ footer .colophon {
<li><b>Run renames foreground.</b><code>Bash run_in_background</code> with <code>while read</code> may exit early with no progress. Run via Python in the same shell — <code>os.rename</code> is just a syscall.</li> <li><b>Run renames foreground.</b><code>Bash run_in_background</code> with <code>while read</code> may exit early with no progress. Run via Python in the same shell — <code>os.rename</code> is just a syscall.</li>
<li><b>Validate the filename column.</b>Haiku occasionally returns the resized <code>.jpg</code> name instead of the original <code>.png</code>. The plan-builder must try alternate extensions when the claimed source isn't found.</li> <li><b>Validate the filename column.</b>Haiku occasionally returns the resized <code>.jpg</code> name instead of the original <code>.png</code>. The plan-builder must try alternate extensions when the claimed source isn't found.</li>
<li><b>Preserve the original extension.</b>The pipeline reads from a resized JPEG but renames the original <code>.mp4</code> / <code>.pdf</code>. Write the source extension back into the new name.</li> <li><b>Preserve the original extension.</b>The pipeline reads from a resized JPEG but renames the original <code>.mp4</code> / <code>.pdf</code>. Write the source extension back into the new name.</li>
<li><b>Apple Screenshot files use <code>U+202F</code>.</b>The narrow no-break space sits between the seconds and AM/PM. Haiku echoes it as ASCII space, the lookup misses, and every Screenshot file is dropped from the plan with a misleading <code>NO_DESC</code> error. Normalize on both sides; emit ASCII space in the new name.</li>
<li><b>Re-runs must skip already-renamed files.</b>Without an <code>^App - .+ - timestamp.ext$</code> exclusion rule the parser will pile a second AI description into every name on every run. The pipeline detects and excludes them.</li>
<li><b>User-typed keyword prefix is signal.</b>A name like <code>jojo travel CleanShot ...</code> carries knowledge the AI doesn't have. Strip the keyword phrase, title-case it, and prepend it to the description before assembly. Don't drop it.</li>
</ol> </ol>
</div> </div>
</section> </section>
@@ -887,7 +1096,7 @@ footer .colophon {
<!-- ───── Use cases ────────────────────────────────── --> <!-- ───── Use cases ────────────────────────────────── -->
<section id="cases"> <section id="cases">
<div class="wrap"> <div class="wrap">
<div class="section-label"><span class="num">05</span>Use cases</div> <div class="section-label"><span class="num">06</span>Use cases</div>
<h2>What this looks like in <em>practice</em>.</h2> <h2>What this looks like in <em>practice</em>.</h2>
<p class="lede-2"> <p class="lede-2">
The skill earns its keep when "Spotlight will find it" stops being true. Four scenarios where it has. The skill earns its keep when "Spotlight will find it" stops being true. Four scenarios where it has.
@@ -929,10 +1138,38 @@ footer .colophon {
</div> </div>
</section> </section>
<!-- ───── Session (plate ii) ───────────────────────── -->
<section id="session">
<div class="wrap">
<div class="section-label"><span class="num">07</span>A session, end to end</div>
<h2>What it <em>looks like</em> when you ask.</h2>
<p class="lede-2">
The skill is conversational at the top, mechanical underneath. You ask in plain
English; ten Haiku agents fan out in a single round-trip; Python validates the
plan and applies it under the file-count audit. The whole thing fits on one page.
</p>
<div class="plate reveal">
<figure>
<img src="https://gitea.tojo.team/cardinale/screenshot-rename/raw/branch/main/assets/session.png" alt="Plate ii — Claude Code session showing user prompt, parallel Haiku fan-out across ten batches, and the run receipt">
<figcaption>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
<span>Plate ii</span>
<span class="dot">·</span>
<span class="b">A session, end to end</span>
<span class="dot">·</span>
<span>10 agents · ~3 min · zero loss</span>
<span class="bar" style="display:inline-block;width:28px;height:1px;background:var(--ink-mute);"></span>
</figcaption>
</figure>
</div>
</div>
</section>
<!-- ───── Install ──────────────────────────────────── --> <!-- ───── Install ──────────────────────────────────── -->
<section id="install"> <section id="install">
<div class="wrap"> <div class="wrap">
<div class="section-label"><span class="num">06</span>Install &amp; run</div> <div class="section-label"><span class="num">08</span>Install &amp; run</div>
<h2>Three commands, <em>one folder</em>.</h2> <h2>Three commands, <em>one folder</em>.</h2>
<p class="lede-2"> <p class="lede-2">
The skill installs as a Claude Code skill. Once cloned into <code>~/.claude/skills/</code>, it The skill installs as a Claude Code skill. Once cloned into <code>~/.claude/skills/</code>, it