diff --git a/.okf/architecture/index.md b/.okf/architecture/index.md index cb38f26b7..2ea979dbf 100644 --- a/.okf/architecture/index.md +++ b/.okf/architecture/index.md @@ -4,7 +4,7 @@ * [CSS Build Pipeline](css-pipeline.md) - PostCSS + per-bundle PurgeCSS, the site-wide token layer and its zero-delta promotion pattern, FL-Builder legacy CSS * [Blog Index / Listing Page](blog-list-page.md) - index AND tag-page templates, the shared row/filter/CTA partials, blog-list CSS bundle, and the term-kind / date-fallback traps * [Blog Cover Image Pipeline](cover-image-pipeline.md) - JetVelocity cover generation, og:image vs thumbnail rendering, and the responsive mobileWidth/mobileSizes params list covers need -* [Enhanced SEO Meta Tags](seo-meta-tags.md) - per-section title/description generation partial +* [Enhanced SEO Meta Tags](seo-meta-tags.md) - per-section title/description generation partial, its own og:image path, and the site-default social fallback * [ICP Journey SIPOC](icp-journey-sipoc.md) - pointer: Mermaid flowchart of Sam's full journey through every lesson, gate, branch, and artifact * [Operational Dependency Map](operational-dependency-map.md) - pointer: per-lesson prerequisites, template blank sources, wait times, Plan B paths diff --git a/.okf/architecture/seo-meta-tags.md b/.okf/architecture/seo-meta-tags.md index 00259d99f..5bea34ecd 100644 --- a/.okf/architecture/seo-meta-tags.md +++ b/.okf/architecture/seo-meta-tags.md @@ -2,11 +2,14 @@ type: Service title: Enhanced SEO Meta Tags description: Hugo partial that generates per-section page titles, meta descriptions, robots/canonical/OpenGraph/Twitter tags, and its own resized og:image. -resource: themes/beaver/layouts/partials/seo/enhanced-meta-tags.html +resource: layouts/partials/seo/enhanced-meta-tags.html tags: [seo, hugo, meta-tags] generated: by: process:okf-migrate at: 2026-07-12T00:00:00Z +verified: + - { by: claude/opus-5, at: 2026-08-20T23:50:00Z } +timestamp: 2026-08-20T23:50:00Z --- # Overview @@ -29,11 +32,51 @@ no minimum-length enforcement. The partial also emits robots, canonical (with a `/tags/` → `/blog/tags/` rewrite for the tag taxonomy), keywords, OpenGraph (`og:type = article` for single blog pages), Twitter cards, and its -**own** `og:image` resized to 1200×630 (CDN `w=1200&h=630` or a local -`.Resize "1200x630 webp q85"`), with a site-default fallback. This is a -separate og:image path from the one in +**own** `og:image` resized to 1200×630 (CDN `w=1200&h=630&output=jpg&q=85`, +or a local `.Resize "1200x630 jpg q90"`), with a site-default fallback. This +is a separate og:image path from the one in [cover-image-pipeline](/architecture/cover-image-pipeline.md). +**JPEG is deliberate, not incidental**: LinkedIn's crawler will not render +WebP, so an og:image emitted as WebP unfurls blank there even though most +other clients handle it. + +# The site-default fallback + +When no frontmatter image field resolves, the partial falls back to a +hardcoded `https://jetthoughts.com/assets/images/og-default.jpg`. **This +asset went missing for an unknown period and was only added 2026-08-20** - +553 pages, overwhelmingly blog posts without cover art, unfurled blank on +LinkedIn and Slack the whole time. `og:image` is the one asset class no +crawler, link-checker, or reader ever exercises, so nothing surfaced it. + +It is a brand plate (wordmark + category line + canon proof chips), NOT a +campaign pitch: a fallback stands in under arbitrary technical posts, where +sales copy over a Puma-config article reads as an ad. Any figure on it is +bound by [claims-canon](/content/claims-canon.md), and because those figures +live inside a binary, **no text ratchet can see them** - the generator source +is kept in-repo so they stay greppable: + +``` +# .stitch/og-default.svg (NOT .stitch/designs/, which is gitignored) +rsvg-convert -w 1200 -h 630 -o /tmp/og.png .stitch/og-default.svg +magick /tmp/og.png -quality 88 static/assets/images/og-default.jpg +``` + +Verified byte-identical to the committed jpg (`md5 +ecd26681b0c591a68d1315d5d578d05c`), so the SVG is the source of record rather +than an approximation of it - a canon change is edited there and re-rendered, +never painted onto the jpg. + +Guarded since 2026-08-20 by `test/unit/og_image_resolves_test.rb`, which +resolves every same-origin `og:image`/`twitter:image` in RENDERED output +against the build. Known gap: the two theme partials +(`blog/list.html`, `page/cover_image.html`) guard their tags with +`{{ if . }}` / `{{ with }}`, so a missing resource DROPS the tag rather than +dangling it - a page that loses its social image entirely is invisible to +that gate. + # Citations -[1] `themes/beaver/layouts/partials/seo/enhanced-meta-tags.html` +[1] `layouts/partials/seo/enhanced-meta-tags.html` +[2] `static/assets/images/og-default.jpg` - the site-default fallback asset diff --git a/.okf/build/ci-gates.md b/.okf/build/ci-gates.md index e27506bb7..d2802ec1f 100644 --- a/.okf/build/ci-gates.md +++ b/.okf/build/ci-gates.md @@ -133,9 +133,21 @@ Consequences for how to react: stall anyway (run 32414801788, 10 min on `--depth=1`). The one job that cannot go shallow is the one that must not: `enableGitInfo` in `config/_default/hugo.toml` needs commit history to resolve `.Lastmod`. + +* **Do not copy `filter: blob:none` into the other workflows.** `_hugo.yml` + is the ONLY job that sets `fetch-depth: 0`, and the filter's whole win is + skipping *historical* blobs. Every other checkout — `test.yml:66`, + `publish.yml:45` and `:82`, `link-check.yml`, `sync-and-publish.yml`, + `process-issue-zip.yml` — runs at the default depth 1, where there is no + history to skip and git must still materialise every blob at HEAD to + populate the working tree. Adding the filter there buys nothing and risks + a slower lazy per-blob fetch. Their 7-minute checkouts are the content + weight below, not a missing flag (established 2026-08-20 while triaging a + checkout-stalled Unit Tests job on PR #511). * **The deeper fix is the content weight**, not the checkout flags: 625 MB of images in git is the floor every job pays. Moving them to LFS or - CDN-only would be a separate, larger decision. + CDN-only would be a separate, larger decision — and it is the only lever + left for the depth-1 jobs. [actions/checkout#2441]: https://github.com/actions/checkout/issues/2441 diff --git a/.okf/build/index.md b/.okf/build/index.md index 6ab47ece1..ea9850472 100644 --- a/.okf/build/index.md +++ b/.okf/build/index.md @@ -1,6 +1,6 @@ # Build & Test * [Hugo build pipeline](hugo-build.md) - bin/hugo-build with the 8 course validators; also the PurgeCSS cold-start race and the minified-unquoted-attribute audit-tool trap -* [Test gates](test-gates.md) - the local suites, when each is a commit blocker, and bin/record-baselines for accepting only the baselines you meant to move +* [Test gates](test-gates.md) - the local suites, when each is a commit blocker, bin/record-baselines for accepting only the baselines you meant to move, and why a deleted source file still serves from every local _dest/ tree * [CI gates](ci-gates.md) - what GitHub Actions enforces: build, unit, path-scoped link check (visual regression is report-only), and what gates a PR never sees * [Template PDFs](pdf-templates.md) - regenerating the downloadable course PDFs diff --git a/.okf/build/test-gates.md b/.okf/build/test-gates.md index 6629281d7..167cde6ba 100644 --- a/.okf/build/test-gates.md +++ b/.okf/build/test-gates.md @@ -8,6 +8,7 @@ generated: { by: claude/opus-4-8, at: 2026-08-12T20:20:00Z } verified: - { by: claude/fable-5, at: 2026-08-01T11:30:00Z } - { by: claude/sonnet-5, at: 2026-08-20T00:00:00Z } + - { by: claude/opus-5, at: 2026-08-20T23:45:00Z } - { by: claude/opus-5, at: 2026-08-21T00:00:00Z } timestamp: 2026-08-21T00:00:00Z --- @@ -216,6 +217,46 @@ Minitest under `test/`, driven by `Rakefile` (`Rake::TestTask`). a build-recipe change invalidates the tree. Diagnosis tell: `grep localhost:1314 _dest//404.html` returns hits; escape hatch is `FORCE_BUILD=1` or `rm -rf` the tree. + +- **No local build path passes `--cleanDestinationDir`, so DELETED sources + keep serving from the dest tree forever** (2026-08-20). `bin/hugo-build:47` + builds with `hugo build --noBuildLock --environment --destination + ` and `Hugo#precompile` (`test/support/hugo_helpers.rb:24-38`) with a + bare `hugo --destination `; only the CI Pages build + (`.github/workflows/_hugo.yml:82`) cleans. Hugo does not remove outputs + whose source is gone, so an orphaned page or asset persists in every local + `_dest/` tree until someone `rm -rf`s it. Note this is NOT the staleness + probe failing - `bin/build-if-stale` handles deletions correctly (it + probes DIRECTORIES, whose mtime a delete bumps). The tree is genuinely + rebuilt and STILL serves the deleted file, which is why the usual + "did it rebuild?" reflex diagnoses it wrong. + + Consequence for any rendered-output test: **deleting a source file to + prove a test goes RED proves nothing.** On 2026-08-20 a new og:image test + stayed green through three delete-and-rerun attempts against a stale + `_dest/public-test-local`. Clear the dest dir before trusting a RED, the + same way a screenshot baseline must be COMMITTED before trusting a + re-record (both are "the assertion is right, the input is stale"). + +- **A rendered-output sweep can look thorough and check almost nothing** + (2026-08-20). Count the DISTINCT values a sweep actually resolves before + trusting its breadth - a glob over 1757 files is not coverage. The first + `og_image_resolves_test.rb` matched `property="..." content="..."` as a + FIXED attribute sequence. Two facts collapsed 1297 tag matches to ONE url + checked: most values are off-origin CDN urls that fell through the + same-origin filter, and the theme partials + (`themes/beaver/layouts/blog/list.html:23`, + `themes/beaver/layouts/partials/page/cover_image.html:3,:11`) write + `content=` BEFORE `property=`, so the content-first tag on 480 pages was + dropped. Those pages still matched via their property-first tag, which is + why nothing looked wrong - what was lost was one whole distinct image. + Match whole tags and extract the attribute separately. + + The trap underneath: **a reproduction test can be honestly RED for the + right reason and still cover nothing else.** RED->GREEN passed cleanly + because the single path the sweep reached WAS the defect under repair. + Coverage and correctness are independent; proving the second says + nothing about the first. - The snap_diff HTML report at `test/fixtures/screenshots/snap_diff_report.html` (gitignored) is written automatically on any RED run - the gem auto-registers the reporter on @@ -271,6 +312,12 @@ Minitest under `test/`, driven by `Rakefile` (`Rake::TestTask`). the repo - fresh clones had a hooksPath pointing at nothing. Adding any root dotfile/dot-dir? Check `git check-ignore -v ` before assuming it's tracked. + Same rule bites SUBdirectories of a tracked dot-dir: `.stitch/*.md` is + tracked but `.stitch/designs/` is ignored, so a source file placed there + is invisible (2026-08-20, the og:image plate source). **`git add ` + ERRORS on an ignored path; `git add -A` just silently omits it** - which + is the case for staging by explicit path when a commit's value depends on + a specific file actually landing. - REPORT-ONLY build gates (each flips to blocking once its backlog hits zero via an env flag): `bin/check-svg-floor` (`SVG_FLOOR_BLOCK=1`) catches course SVGs whose smallest text renders <9px@390; diff --git a/.okf/design/site-palette.md b/.okf/design/site-palette.md index 1c6918d67..603980ceb 100644 --- a/.okf/design/site-palette.md +++ b/.okf/design/site-palette.md @@ -86,7 +86,10 @@ anything else going dark is a defect: # Deprecations in progress `--color-primary` (`#1a8cff`) is named "primary" and dies in 2608 Phase 1a.2, -along with the late-cascade `#0066d6` anchor rule in 1a.3. +along with the late-cascade `#0066d6` anchor rule in 1a.3. WHEN those phases +run relative to everything else is a separate decision - see the +[rollout sequence](/workflows/site-redesign-rollout.md); the blog bundles ship +before the site-wide chrome, so tokens land in blog CSS first. **It is the logo's colour, and that is the point, not a reason to keep it.** `themes/beaver/assets/img/icons/logo-dark.svg` contains exactly one hex value: diff --git a/.okf/index.md b/.okf/index.md index 31aab78b1..8dbdcc2b1 100644 --- a/.okf/index.md +++ b/.okf/index.md @@ -16,7 +16,11 @@ frontmatter: `generated: { by: , at: }`, `verified: [{ by, at }]`, human-reviewed trust tier. These are OPTIONAL and added honestly by whoever touches a concept; absence never invalidates it (§11). Legacy concepts still carrying only `timestamp` remain conformant and migrate as they're edited — do -NOT back-stamp `generated`/`verified` you didn't actually perform. +NOT back-stamp `generated`/`verified` you didn't actually perform. When two +sessions verify the same concept concurrently and a rebase conflicts on the +`verified:` array, keep BOTH entries and take the later `timestamp` - each +verification really happened, and dropping one falsifies the provenance the +field exists to carry (2026-08-20, `build/test-gates.md`). # Sections diff --git a/.okf/log.md b/.okf/log.md index 398c8f0c7..cb231ef5e 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -2,6 +2,37 @@ Newest first. Entries before 2026-08-19 are squashed to one line each (compacted 2026-08-20); their full text is in this file's git history. + +## 2026-08-21 - new concept: the rollout SEQUENCE was undiscoverable + +`design/site-palette.md` carried the palette decision, but nothing in the +bundle carried the ORDER it ships in. A session consuming `.okf/` after +Phase 1a.3 would reasonably do 1a.4 next - and that is the wrong order: +Paul re-sequenced 2026-08-20 to run the BLOG before the site-wide chrome. + +New [workflows/site-redesign-rollout.md](workflows/site-redesign-rollout.md) +distils the three rules that decide sequence (order by whether LAYOUT MOVES +not by size; no A/B exists at this traffic so reversibility substitutes for +statistical confidence; blog first because that is where the humans are), the +cost blog-first accepts on purpose (token definitions move twice - duplicate +tokens mid-rollout are the plan working, not a mistake), and the gates that +block regardless (2.4's GSC keyword gate; 2.2 coupling the course page). + +Live phase status deliberately NOT copied - it rots; the plan doc under +`resource:` owns it. Cross-linked from site-palette. + +## 2026-08-21 - maintain pass 3: three rules the og:image PR taught, lifted + +* `build/test-gates.md` - the `.gitignore` bullet now covers SUBdirectories + of a tracked dot-dir (`.stitch/*.md` tracked, `.stitch/designs/` ignored), + and records that `git add ` ERRORS on an ignored path while + `git add -A` silently omits it. Staging by explicit path is the safer + default whenever a commit's value depends on a specific file landing. +* `architecture/seo-meta-tags.md` - added the og-plate regeneration recipe + and the md5 proving the SVG is the source of record, not an approximation. +* `index.md` - when two sessions verify one concept concurrently, a rebase + conflict on `verified:` is resolved by keeping BOTH entries. Dropping one + falsifies the provenance the field exists to carry. ## 2026-08-21 - Two gate lessons from merging six PRs: the stall masks, the local red lies Both are sharpenings of rules the bundle already had, and both came from the @@ -42,6 +73,102 @@ root. Every other screenshot in this repo lives under `docs/projects//` Removed in #514. Binary at the repo root is the kind of thing the next session copies because it looks sanctioned. +## 2026-08-20 - maintain pass 2: a claim I wrote was wrong in three files + +Review found "a fixed attribute sequence silently skipped 480 tag pages" +false - measured, 0 pages were missed entirely; what was dropped was the +content-first SECOND tag on those pages, losing one distinct image. The +wrong number had already propagated from the test comment into +`build/test-gates.md` and this log. All three corrected. Lesson lifted to +[workflows/review-swarm.md](workflows/review-swarm.md): brief critics to +return measurements, and scrutinise the prose describing a fix as hard as +the fix. Also dropped a `CGI.unescape` that was a no-op on every url in the +build and could only misfire (it decodes `+` as a space, a form rule that is +wrong for paths). + +## 2026-08-20 - maintain pass: two concepts corrected against the code + +* `architecture/seo-meta-tags.md` `resource:` and its citation pointed at + `themes/beaver/layouts/partials/seo/enhanced-meta-tags.html` - a path that + DOES NOT EXIST (that dir holds only schema partials). Repointed at the + live root-level override `layouts/partials/seo/enhanced-meta-tags.html`. + Also corrected `.Resize "1200x630 webp q85"` -> `jpg q90` (WebP unfurls + blank on LinkedIn), and documented the site-default fallback + its test. +* `build/test-gates.md` gained the `--cleanDestinationDir` caveat lifted out + of the entry below, per this bundle's own rule that log.md records what + changed and concepts record what is true. +## 2026-08-20 - 553 pages previewed blank on social; the tests that should have caught it asserted the wrong thing + +**The site-wide `og:image` fallback pointed at a file nobody ever uploaded.** +`layouts/partials/seo/enhanced-meta-tags.html:129` defaults to +`https://jetthoughts.com/assets/images/og-default.jpg`; `static/assets/` +did not exist. 553 pages - overwhelmingly blog posts without cover art - +rendered a social card that 404'd, so every LinkedIn and Slack share of them +previewed blank. Nothing on the site links an `og:image`, which is why it +survived: it is the one asset class no crawler, no link-checker, and no +reader ever exercises. + +**Why the existing gate could not see it.** +`test/unit/meta_tags/meta_tags_test.rb:136-149` asserts the tag EXISTS and +that width/height are `1200`/`630`. Both passed throughout. That is an +existence-plus-config assertion of exactly the kind CLAUDE.md rejects: it +describes the markup, never the thing the markup points at. New +`test/unit/og_image_resolves_test.rb` sweeps RENDERED HTML for every +`og:image`/`twitter:image` and resolves each SAME-ORIGIN path against the +build. Same principle as the rendered-output rule for text ratchets. +Off-origin CDN URLs (`wsrv.nl?url=raw.githubusercontent.com/...`) are +deliberately not followed: `enhanced-meta-tags.html` only emits them inside +`{{- if $resource -}}`, so the build already guarantees the source resource +exists, and what is left is a publish-time network question. + +**A rendered-output sweep can look thorough and check almost nothing.** +The first version matched `property="..." content="..."` as a FIXED +attribute sequence. Two facts collapsed that to a single URL checked out of +1297 tag matches: 682 of 683 distinct values are off-origin CDN URLs that +fell through the same-origin filter, and the two theme partials +(`themes/beaver/layouts/blog/list.html:23`, +`themes/beaver/layouts/partials/page/cover_image.html:3,:11`) write +`content=` BEFORE `property=`, dropping the content-first tag on 480 pages +(they still matched via their property-first tag - what was lost was one whole +distinct image, not the pages). The +test was `File.exist?` on one path wearing a 1757-file glob. It passed RED +and GREEN honestly, because the one path it checked was the defect under +repair - a reproduction test can be genuinely red for the right reason and +still cover nothing else. Match whole `` tags and extract `content` +separately; count the DISTINCT URLs a sweep actually resolves before +trusting its breadth. + +**What this test structurally cannot catch.** If a page resource is +missing, Hugo does not emit a dangling `og:image` - the `{{ if . }}` / +`{{ with }}` guards drop the tag entirely, so the page silently ships with +NO social image. Deleting `content/blog/og-blog.jpg` (480 tag pages) proved +this: the sweep stayed green because the reference vanished rather than +broke. Dangling-reference and missing-tag are two different defects; this +gate covers only the first. + +**The false GREEN worth remembering.** After writing the test I deleted the +JPG to confirm RED - and it stayed green through three attempts. First +diagnosis (warm tree, build skipped) was WRONG: `bin/build-if-stale` detects +deletions correctly. The tree rebuilt every time and still served the file, +because no local build path passes `--cleanDestinationDir`. Rule lifted to +[build/test-gates.md](build/test-gates.md) - it is not a log fact, it +governs every rendered-output test from here on. + +**Fix:** a branded 1200x630 brand plate at `static/assets/images/og-default.jpg` +(JetVelocity obsidian/ruby per `.stitch/design.md`, canon figures only). It is +deliberately NOT the campaign pitch the first draft carried - a fallback +stands in under arbitrary technical posts, where "Your dev shop stopped +delivering" over a Puma-config article reads as an ad rather than an article. + +**CI, adjacent:** clarified in `build/ci-gates.md` that `filter: blob:none` +must NOT be copied into the other five workflows. `_hugo.yml` is the only job +setting `fetch-depth: 0`, and the filter's entire win is skipping historical +blobs; the rest run at depth 1, where there is no history to skip and git +must still materialise every blob at HEAD. Their slow checkouts are the +625 MB of images in the tree, not a missing flag. + +Touched: `build/ci-gates.md`. + ## 2026-08-21 - The course's "good positions" were an artifact; GA4 UI setup closed out **Course discovery diagnosed, and the inherited premise is retracted.** Two diff --git a/.okf/workflows/index.md b/.okf/workflows/index.md index 6a34d87ad..3839dd339 100644 --- a/.okf/workflows/index.md +++ b/.okf/workflows/index.md @@ -6,6 +6,7 @@ * [Blog Post Pipeline](blog-pipeline.md) - mandatory end-to-end workflow for writing/publishing blog posts, plus the execute-don't-read claims gate, the frontmatter claims check, and the `## Sources` citation convention * [LinkedIn Post Pipeline](linkedin-post-pipeline.md) - Paul Keen voice rules and posting workflow * [CSS Maintainability Redesign](css-maintainability-plan.md) - approved plan for hand-editable CSS + FL-Builder retirement +* [Rescue Room rollout sequence](site-redesign-rollout.md) - why phases are ordered by whether layout moves, why blog runs before site-wide chrome, and the gates that block regardless * [Visual Scroll Gate](visual-scroll-gate.md) - blocking pre-handback visual walk for content/visual changes * Test suites, rake tasks, and the visual-regression gate live in [test-gates](/build/test-gates.md) (`workflows/testing.md` was squashed into it 2026-08-20 - it duplicated the gate rules and still carried the superseded "run both suites per commit" instruction) * [Analytics Access](analytics-access.md) - live GA4 + Search Console MCP servers, correct property IDs, credential split, data-lag traps, and why GA4 sessions must be reconciled against GSC clicks before being quoted diff --git a/.okf/workflows/review-swarm.md b/.okf/workflows/review-swarm.md index 394479685..07fdb55be 100644 --- a/.okf/workflows/review-swarm.md +++ b/.okf/workflows/review-swarm.md @@ -6,6 +6,9 @@ tags: [swarm, review, process] generated: by: process:okf-migrate at: 2026-07-24T00:00:00Z +verified: + - { by: claude/opus-5, at: 2026-08-21T00:10:00Z } +timestamp: 2026-08-21T00:10:00Z --- # The loop @@ -101,3 +104,17 @@ verdict format, and the two or three specific things to attack. - Fixer geometry claims (SVG sizes, clipping fixed) must be re-verified by your own re-render - one wave shipped a wording truncation nobody saw. - Parallel sessions contend on .git/index.lock - wait-loop before git ops. +- **Brief critics to return MEASUREMENTS, not verdicts** (2026-08-20). Two + consecutive reviewers on one small test each found a claim that passed + self-review twice, and in both cases the decisive artifact was a COUNT: + "this sweep resolves 1 distinct url, not 683" killed a test that looked + thorough, and "0 pages were missed entirely" killed the comment written + to describe the fix. Ask for the number that would be different if the + claim were false. A critic who returns an opinion can be argued with; a + critic who returns a count cannot. +- **Claims ABOUT a fix need the same scrutiny as the fix** (2026-08-20). A + corrected defect ships with prose describing it - a comment, a concept, a + log entry - and that prose is written at the moment of least skepticism. + Both overstatements this session were in comments, not code, and one had + already propagated into two `.okf/` files before review caught it. Grep + your own explanation for numbers you did not measure. diff --git a/.okf/workflows/site-redesign-rollout.md b/.okf/workflows/site-redesign-rollout.md new file mode 100644 index 000000000..6e49c3ccd --- /dev/null +++ b/.okf/workflows/site-redesign-rollout.md @@ -0,0 +1,80 @@ +--- +type: Decision +title: Rescue Room rollout sequence (2608) +description: Phases are ordered by whether LAYOUT MOVES, not by size; blog runs before site-wide chrome; no A/B is available at this traffic, so reversibility substitutes for statistical confidence. +resource: docs/projects/2608-site-design-system/20-29-strategy/20.01-rollout-plan.md +tags: [design, rollout, sequencing, decision, adr] +status: stable +generated: + by: claude/opus-5 + at: 2026-08-21T00:30:00Z +timestamp: 2026-08-21T00:30:00Z +--- + +# Why this concept exists + +The palette DECISION is in [site-palette](/design/site-palette.md). This is the +ORDER the decision ships in, which is a separate call and the one a session +gets wrong. Reading only the palette concept, the obvious next move after +Phase 1a.3 is 1a.4 - and that is the wrong order (see blog-first below). + +Live phase status lives in the plan doc under `resource:`, not here; a state +snapshot in a concept rots within days. What is recorded here is the reasoning +that outlives any particular phase. + +# Three rules that decide sequence + +**1. Phases are ordered by whether LAYOUT MOVES, not by size.** A recolour +leaves every element in place: a fault is a wrong colour, caught in a +screenshot diff, revertable alone. A spatial change shifts every page +vertically: a fault is a layout break and baselines churn wholesale (143 macOS ++ 135 Linux). Hence Phase 1 splits - 1a recolours, 1b moves things - costing +one extra baseline re-record and buying a rollback point in between. + +**2. No A/B test is available anywhere in this rollout** (ADR-0004). Real human +traffic is ~9.7 sessions/day; the cheapest viable engagement test needs 192 +days to reach power, lead conversion ~3.6 years. GA4's ~300/day is 85-90% bots. +**Reversibility is the substitute for statistical confidence** - which is what +justifies the 1a/1b split paying an extra re-record for a rollback point. Gates +are A (qualitative), B (guardrails with declared rollback thresholds), C +(reversibility). Never attach a performance or conversion CLAIM to a phase +whose case is coherence. + +**3. Blog before site-wide chrome** (Paul, 2026-08-20). That is where the +humans already are - the ~145 GSC clicks/28d land overwhelmingly on posts - and +the blog bundles do not touch the money pages. It also has the clearest +engagement problem to move: blog pages sit at **25.2% average scroll depth / +26.3s** against a site average of 32.9-40.3% / 28-34s (Clarity, bot-filtered, +2026-08-20 baseline). + +# The cost blog-first accepts, and why it is not a bug + +Because the blog phases run first, new tokens are defined INSIDE the blog +bundles' own CSS (`pages/blog-list.css`, `pages/blog-single.css`) and promoted +to `foundations/css-variables.css` in Phase 1a later. **The token definitions +move twice.** A reviewer seeing duplicate token definitions mid-rollout is +looking at the plan working, not a mistake. It is slower overall, and it buys +learning whether the design engages anyone before the whole site commits to it. + +# Gates that block regardless of sequence + +- **2.4 (homepage) carries an extra, blocking GSC gate.** The cut removes the + twelve service tiles and the framework logo grid; both carry keywords, and + the site has just been through a GSC re-baseline and a fractional-CTO + cluster cannibalisation. Pull GSC query/page data for `/` BEFORE removing any + section and keep whatever earns impressions - collapsed below the FAQ, not + deleted. A conversion gain paid for in rankings is a loss. +- **Per-phase measurement reads are RETIRED as gates** (Paul, 2026-08-20 + evening): "avoid measures, rebuild the whole blog, and we will use measure + based on the whole blog." Nothing blog-scoped waits on a reading. The read + still happens - once, over the whole rebuilt blog - and is informational. +- **2.2 couples the course page.** `blog-single` shares + `pages/blog-single.css` with `course-single`; sequence 2.3 immediately after + or screenshot both. + +# Citations + +[1] `docs/projects/2608-site-design-system/20-29-strategy/20.01-rollout-plan.md` +[2] `docs/projects/2608-site-design-system/20-29-strategy/20.02-phase-1a-plan.md` +[3] `docs/adr/0003-site-design-system.md`, `docs/adr/0004-static-site-experimentation.md` +[4] `docs/projects/2608-site-design-system/40-49-measurement/40.01-blog-engagement-baseline.md` diff --git a/.stitch/og-default.svg b/.stitch/og-default.svg new file mode 100644 index 000000000..2479fb0ed --- /dev/null +++ b/.stitch/og-default.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JetThoughts + RUBY ON RAILS · REACT · FRACTIONAL CTO + + + + + We build, rescue, and stabilize + Rails and React products. + + + + + + RATED ON CLUTCH + 4.8 / 5 + + + + AVERAGE CLIENT STAYS + 5 years + + + + SHIPPING RAILS + since 2008 + + diff --git a/static/assets/images/og-default.jpg b/static/assets/images/og-default.jpg new file mode 100644 index 000000000..8b017e1b6 Binary files /dev/null and b/static/assets/images/og-default.jpg differ diff --git a/test/unit/og_image_resolves_test.rb b/test/unit/og_image_resolves_test.rb new file mode 100644 index 000000000..e4e8c779e --- /dev/null +++ b/test/unit/og_image_resolves_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "base_page_test_case" + +# Social-preview images are the one asset nothing on the site links to, so a +# missing file is invisible until someone shares the page and gets a blank +# card. On 2026-08-20 the site-wide fallback +# (/assets/images/og-default.jpg) had never been uploaded - 553 pages, mostly +# blog posts, previewed blank on LinkedIn and Slack. +# +# The pre-existing meta-tag tests could not catch it: they assert the tag +# EXISTS and that width/height are 1200x630. Neither reads the file the tag +# points at. This one sweeps RENDERED output and resolves every same-origin +# social image against the build. +# +# Honest about its own reach: the build emits 684 distinct social-image +# values, of which exactly 2 are same-origin (this fallback and +# /blog/og-blog.jpg). The rest are off-origin CDN urls - see same_origin_url. +class OgImageResolvesTest < BasePageTestCase + # Attribute order and attribute name both vary by emitter: + # enhanced-meta-tags.html writes property=/name= first, the two theme + # partials write content= first. Match the whole tag, then pull content out + # of it - a fixed attribute sequence dropped the content-first tag on 480 + # pages, losing one whole distinct image (/blog/og-blog.jpg). Those pages + # still matched via their property-first tag, so nothing looked wrong. + SOCIAL_META = /]*(?:property|name)="(?:og:image|twitter:image)"[^>]*>/ + CONTENT_ATTR = /content="([^"]*)"/ + SITE_ORIGIN = "https://jetthoughts.com" + + def test_every_social_image_resolves_to_a_file_in_the_build + refs = collect_social_image_refs + refute_empty refs, "Build should emit social images - sweep found none" + + missing = refs.reject { |url, _| File.exist?(build_path_for(url)) } + + assert_empty missing.map { |url, page| "#{url} (referenced by #{page})" }, + "Social images referenced by rendered pages must exist in the build" + end + + private + + # => { url => first page that referenced it } + def collect_social_image_refs + Dir.glob("#{root_path}/**/*.html").each_with_object({}) do |page, refs| + File.read(page).scan(SOCIAL_META).each do |tag| + url = same_origin_url(tag[CONTENT_ATTR, 1].to_s.gsub("&", "&")) + refs[url] ||= page.delete_prefix("#{root_path}/") if url + end + end + end + + # Same-origin URL, or nil for anything this build does not produce. + # + # Off-origin images are deliberately NOT followed. Most og:image values are + # CDN-proxied (wsrv.nl?url=raw.githubusercontent.com/...), and behind that + # proxy sits a Hugo page resource that enhanced-meta-tags.html only emits + # inside `if $resource` - so the build itself already guarantees the source + # exists. What is left is a publish-time question (is it pushed to master + # yet), which needs the network, not the filesystem. + def same_origin_url(raw) + raw = raw.delete_prefix(SITE_ORIGIN) + raw.start_with?("/") ? raw.split("?").first : nil + end + + def build_path_for(url) + File.join(root_path, url) + end +end