Conversation
* Restore gallery captions and items the conversion flattened Closes #52. A gallery is a flex row whose column widths come from `.gallery-cols-N figure`, so anything inside it that is not a figure gets no width from that rule and is laid out as one more item in the row. Three variants of that survived the conversion, and no gate could see any of them: every media check reasons about a URL, and misplaced content resolves and is linked exactly as correct content is. Each was verified against the captured live site before being changed rather than inferred from the markup, and the first is why that matters. Eleven captions in the FLAIR post were written as text trailing the last figure shortcode. Reading only the source, the obvious fix is to move the text into that figure's caption parameter, which is what the review suggested. The capture shows all eleven were blocks-gallery-caption elements, captions for the set, so that fix would have attributed a caption describing four images to whichever one sorted last and would have looked right. They move to a caption on the gallery instead. The other two were gallery items the conversion emitted as plain markdown, two bare images and eight linked ones, against li.blocks-gallery-item > figure in the original. Both become figure shortcodes, the linked ones through the parameter the theme already has, which puts the anchor back inside the figure where it was. The container becomes a figure, because figcaption is only valid as a figure's child. The reset already sets figure { margin: 0 } and the theme already styles figure > figcaption, so a set caption needed one rule: a full-width flex basis, without which it packs onto the end of the last row and reads as a caption for whichever image it lands beside. check-url-parity.py gains the gate that would have caught all of this, parsing the built pages rather than matching text, since nested figures defeat a regex. Demonstrated failing before being trusted: against the pre-fix content it reports 27 stray nodes and exits 1, and it distinguishes the shapes rather than counting them, naming bare text and each stray element type. The gate found more than the review did. The linked-image variant is on a post neither the review nor the issue mentioned, and it was found only by running the check across all 19 pages that carry a gallery. Verified through make-release.sh and against the local mirror: PASS - 1245 URLs honored, with assets at 1049 and orphans at 98, both unmoved, so no media was lost or newly orphaned by the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Reject a gallery page by the class token, not by one spelling of it Both from review on #55. The cheap reject read `class="gallery`, which assumes the quotes survive and that the token sorts first. Minification drops the quotes around a value that does not need one and says nothing about class order, so `class=gallery` and `class="foo gallery"` both skipped the parser entirely and the gate passed vacuously on a page whose markup was merely spelled differently. It matches the bare word now, which cannot skip a page the parser would find, since the parser requires the class token and a page carrying it always contains the string. Matching a page that only mentions the word costs one parse. The printed count is now taken from what the parser found rather than from the reject, so the broader test does not turn "pages carrying a gallery" into "pages the word appears on". The shortcode comment claimed a set caption needed no rule of its own, while this branch adds one. It does need it, and for a reason worth keeping: the gallery is a flex container, so without a full-width basis the caption packs onto the end of the last row and reads as a caption for whichever image it lands beside. The comment now says so and says not to remove the rule as redundant. Verified on all three spellings, quoted, unquoted, and reordered, each carrying a known stray node: all three are now detected, and the first two were not before. The site itself is unchanged at 19 pages with galleries and 0 stray nodes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the invariant the gallery check tests, not the shapes it has met From a suppressed finding on #55, which was right: the docstring said "both conversion artifacts" while the README and the description say three. It was written after two were known and never revised when the check itself found the third. Rewritten to say what it actually tests. Three source patterns reached it and they arrive as three different HTML shapes, so an enumeration is a list to extend every time the conversion surprises us again. The invariant is that a gallery holds figures and its own caption, and that is what covers the shape nobody has thought of yet, which is how the third was found after the first two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Record why the gallery scanner has no handle_startendtag From a suppressed finding on #55, which reasoned correctly from the code and reached the wrong conclusion about the base class. HTMLParser forwards a self-closing tag to handle_starttag and then handle_endtag, so `<br/>`, `<br />` and `<img/>` are already reported as stray children and already leave the depth balanced. Verified on those three spellings and on a self-closing non-void `<figure/>`, which enters and exits correctly and lets the `<p>` after it be seen. Adding the override the finding asks for is what would break this, by counting a pair the base class already splits. That is worth a comment rather than a reply nobody reading the file later will find. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say what a gallery failure found, rather than calling it missing From a suppressed finding on #55. The shared summary printed `FAIL gallery: N missing` for a check whose findings are nodes that are present, which is the entire complaint about them. Each label can now name its own noun, defaulting to "missing" so the four older checks are unchanged and a check added later reads the way they do unless it says otherwise. Verified by forcing a failure: `FAIL gallery: 15 stray nodes`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Record operations as recurring work, not as a finished milestone The backlog listed the log review and the off-host log pull among the one-off migration tasks, so a reader finishing that list would read the migration as done while neither duty had ever run on its cadence. Both move to a Recurring operations section that says outright that everything above it ends and this does not, and the State table gains an Operations row so the summary stops implying the pipeline is the whole system. The open decision behind them is that the tooling has no home in git. vps-backup-pull, its systemd units, and the variables naming both ends of the copy exist only on the Proxmox host, which is the host the backup runs from, so losing it loses both the copies and the means of making them. Two candidate homes are recorded without choosing between them. The same question covers the fourteen migration scripts in the capture directory, and one of them already shows the cost of leaving it open: build-redirects.py exists in three places, the two capture copies identical to each other at 115 lines and this repo's maintained copy at 225. The capture is not a git repository, so nothing detects that. Two open questions are closed with measurements rather than assumptions. The production environment's HUGO_BASEURL held the live WordPress address, which the deploy workflow both builds with and points the live check at, and it is now the interim name. And the first production deploy does not fix the robots.txt 404 the VPS agent flagged in its 22.10: hugo.yaml sets no enableRobotsTXT so the site emits none at all, while sitemap.xml is emitted and becomes fetchable at that same deploy. The VPS production row said the resource was deliberately disabled, which its own file contradicted twenty lines later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Record the robots.txt cutover loss and the media gate gap The gallery caption entry under Open decisions is gone: #55 answered the question it posed, against the captured live site, and closed #52 with it. Two findings from the VPS agent's §23.3 and §24.3 replace it. The robots.txt entry described a gap on the interim name and missed that the cutover makes it a loss. The live .com blog serves a robots.txt today carrying a Sitemap: line, and this site emits none, so M7b moves a site that has had crawl directives for years to having none and takes the sitemap pointer with them. That side found it from the outside, will not put a file in this bundle, and has made it block step 1 of the M7b checklist rather than be discovered after it. Recorded as deliberately undecided with the three options, and Open decisions points at it rather than restating it, so the two cannot drift. Nothing checks that media survived the trip to the server. A 3,095-request gate run fetched no image at all, which prompted the question of whether the legacy media list is wired in. It is, at build time, against files on disk. The live check requests pages and redirects and never an image, so a media tree lost between the build and the server is caught by neither, the build having passed before the loss and the live gate never asking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…publish (#37) * Record the transport retest and what #33 still owes * Retire the #33 entry and hold the main publish for the soak The retest issue closes on the prerelease dispatch rather than on a production deploy, so the entry naming a production deploy as its fourth check is answered rather than outstanding. #33 has since closed, on 2026-08-06, which confirms it. The main publish is added as its own item, gated on the soak rather than on the mechanism, which is proven. 1.0.11 is still the newest release from main and still predates every deploy change, so the next release from main is the first that would describe a site serving its public address. Rebased onto develop and reconciled with what landed while this sat open. The hub row this branch added for ProjectTemplate#567 is dropped. That issue closed on 2026-08-05, a day before this branch was last touched, so the row was already wrong when written. Its claim has since been falsified outright: it said an agent cannot trip the production ref gate because the permission classifier refuses a production-labeled dispatch, and a production deploy was dispatched and completed on 2026-08-08. The `Deploy production once` bullet keeps develop's version rather than this branch's. This branch still described that environment as never having run, and it has now served a release. The `Declare what the VPS keeps` bullet goes, which the earlier commit dropped without saying why. It asked for the host's timer and its count to be written beside the ownership line, and `OPERATIONS.md` now names `blog-prune-releases.timer`, that it runs daily, and that it keeps ten bundles per environment. The item is answered rather than abandoned. The main publish item pointed at "the daily 404 watch above", which moved below it into Recurring operations, so it names that section instead. A pointer by position is the same defect a review already flagged on this branch once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table said nothing about whether the conversion lost anything, which is the one question a migration's record has to answer and the one a reader cannot check for themselves. Rebased onto develop and reconciled with two things that landed while this sat open. The volatile counts it originally carried in the Content and media row are gone, because develop deliberately replaced counts that content additions move with what they represent, and this row would have reintroduced 514 pages against that decision. And the claim is narrowed from "closed" to closed for the files themselves: the image loss was 19 and is restored, but #55 later found the conversion had also flattened how galleries present media, in three shapes across three posts. That was not a file loss and no file was missing, which is exactly why nothing saw it for so long, and it is now gated. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Promotes develop to main, carrying a gallery-structure repair (restoring gallery set captions and converting stray gallery children into proper figure shortcodes) and adding a new HTML-parsing gate to detect future gallery-structure regressions, alongside state-file updates that reframe ongoing operational work as recurring rather than “done”.
Changes:
- Update the
galleryshortcode to render a<figure>container and support a gallery-levelcaption, with CSS to force the set caption onto its own full-width row. - Extend
checks/check-url-parity.pywith a new gallery structure check that parses built HTML and fails on non-figure/figcaptiondirect children inside galleries. - Update
TODO.mdstate tracking to add/clarify Media integrity and Operations, and to restructure “Recurring operations”.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Updates state tracking (including a new Operations row and recurring-ops section) as part of the develop→main promotion. |
| layouts/shortcodes/gallery.html | Changes gallery container semantics to <figure> and adds a gallery-level caption output. |
| assets/css/extended/custom.css | Adds styling so gallery-level <figcaption> spans full width in the flex layout. |
| checks/check-url-parity.py | Adds an HTMLParser-based gallery structural gate and adjusts failure nouning for gallery failures. |
| checks/README.md | Documents why/what the new gallery check covers. |
| content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md | Converts trailing gallery captions into gallery caption= parameters and removes stray trailing text. |
| content/posts/2020/06/21/moving-from-unraid-to-proxmox-ve.md | Converts linked markdown images inside a gallery into figure shortcodes using link=. |
| content/posts/2020/02/02/recovering-the-firmware-on-a-supermicro-bpn-sas3-846el1-backplane.md | Converts bare markdown images inside a gallery into figure shortcodes. |
Suppressed comments (1)
TODO.md:32
- This bullet says the interim production hostname is still serving
0000-placeholder, which conflicts with the PR description’s claim that production currently serves20260808-041050. Please confirm which is correct and update this item accordingly so the recorded dependency order matches reality.
- **Deploy production once, to a name that is not the live one.** The host side is done and the interim name is live: `blog.insanegenius.net` answers `200` unauthenticated, on a Let's Encrypt certificate issued 2026-08-07, serving `0000-placeholder`. The VPS agent calls this M7a, and M7b is the `.com` cutover. Three items belong to this repo before the first production deploy, all detailed in the VPS agent's §19 and §20. That file is not in the repository, so pull it first per [`OPERATIONS.md`](./OPERATIONS.md) "The Channel Between the Two Sides":
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
The suppressed finding on this round is the same defect as the resolved thread — |
Found by review on the promotion #56, which caught the file contradicting the description of its own promotion. The State table and the dependency list both still said production served 0000-placeholder and that the first deploy was pending. It deployed on 2026-08-08 and serves 20260808-041050. Three places said it, and all three are now what was measured rather than what was expected. The State row records the release and that the host side verified it independently, 9/9 unauthenticated with the built baseURL read from the deployed bytes rather than from this repo's config, across a 3,095-request gate run. The production item leads with M7a done and reframes its sub-items as where this repo stands against M7b, two owed and one answered, rather than as work before a first deploy that has happened. And the robots decision is now before the cutover rather than before the first deploy, with its 404 measured after the deploy rather than predicted before it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotes
develop(7b1f8ff) tomain(78d36e4). Five pull requests: one code and content change, and four state-file updates, two of which had sat open since 2026-08-05 and 2026-08-07.The one substantive change
#55 found conversion damage nothing could see, and closed the hole that hid it. A gallery is a flex row whose column widths come from
.gallery-cols-N figure, so anything inside it that is not afiguregets no width and is laid out as one more item in the row. Three shapes of that survived the WordPress conversion:figureshortcode's}}<figcaption class="blocks-gallery-caption">— a caption for the setimage<li class="blocks-gallery-item"><figure>[](…)image<a>inside the figureNo media check could reach any of it, and that is the interesting part rather than an excuse: every one reasons about a URL, and content misplaced inside a gallery resolves, is linked, and is reached exactly as correct content is. The defect is purely structural.
Each shape was settled against the captured live site rather than inferred from the markup, which mattered most for the first. Read from the source alone, the obvious fix is to move the trailing text into the last figure's
captionparameter — which is what the review proposed, three times. The capture shows all eleven wereblocks-gallery-captionelements, matching the eleven trailing texts one for one with nothing unaccounted for on either side. That fix would have attributed a caption describing four images to whichever image sorted last, and it would have looked correct.So the
galleryshortcode takes acaptionof its own and renders its container as afigure, sincefigcaptionis only valid as a figure's child.check-url-parity.pygains the gate, which parses the built pages because nested<figure>defeats a regex, and it found the third shape itself, on a post neither the review nor the issue had mentioned.The state file
TODO.mdnow separates work that ends from work that does not. Recurring operations holds the periodic log review and the off-host log pull, both of which had been sitting among the one-off migration tasks where finishing the list would have read as done while neither had ever run on its cadence. TheStatetable gains anOperationsrow reading not started, and aMedia integrityrow.Several entries are closed out against what actually happened rather than what was expected:
HUGO_BASEURLon theproductionenvironment held the live WordPress address. The deploy workflow builds with that value and points the live check at it, so a production deploy would have baked.cominto every canonical tag, feed link andsitemap.xml, then sent 1,245 requests at the live site to verify it. Now the interim.netname; the revert at M7b is recorded as unfinished.robots.txtis a loss at the cutover, not a gap. The live.comblog serves one today carrying aSitemap:line, and this site emits none, so M7b moves a site that has had crawl directives for years to having none. Recorded as deliberately undecided with three options.#33is retired, closed 2026-08-06.Declare what the VPS keepsis retired, answered byOPERATIONS.mdnow namingblog-prune-releases.timer, its cadence, and its count.ProjectTemplate#567row was dropped rather than carried. That issue closed 2026-08-05, before the branch proposing it was last touched, and its claim — that an agent cannot trip the production ref gate because the permission classifier refuses the dispatch — was falsified on 2026-08-08 by a production deploy that dispatched and completed.One correction the review of this promotion produced
The first round on this pull request caught
TODO.mdcontradicting the description of its own promotion: the file still said production served0000-placeholderand that the first deploy was pending, in three places, while it had deployed and was serving20260808-041050. #57 fixes all three with what was measured rather than what was expected, including the host side's independent verification, and is in this promotion.Worth naming how it was caught, since nothing here runs that check: it came from comparing the file against the description written about it.
Verification
The live check was run against the local mirror on this exact tip, which is the half of the URL contract CI cannot reach:
assetsat 1049 andorphansat 98 are both unmoved from before the gallery rewrite, which is the evidence it lost no media and orphaned none. Build clean underhugo --gc --minify --panicOnWarning.Every one of the five pull requests was driven to a clean round with the hub's
scripts/pr_review.py,statusexiting 0 on each.Not in this promotion
No publish.
publish-release.ymlisworkflow_dispatchonly, so merging here ships nothing.1.0.11remains the newest release frommain, and the item holding the next one for the soak is in this promotion rather than being acted on by it.No production deploy. Production currently serves
20260808-041050, which predates every commit here, so it is running the pre-fix galleries. That is a rendering correction rather than a regression, and redeploying is a separate decision.One known staleness deliberately left. The hub findings table says three findings are open and lists
ProjectTemplate#552, which is closed — but its fix is on the hub'sdeveloprather thanmain, and this file's own rule is to readmainas ground truth, so the finding still binds. How the table should represent that deserves its own change and its own reasoning.Merge with a plain merge commit. Do not pass
--delete-branch, which on adevelop -> mainpromotion deletesdevelop.🤖 Generated with Claude Code