close
Skip to content

feat(website): AI search optimization — Search Console harness, structured data, and measurement - #826

Open
blove wants to merge 35 commits into
mainfrom
blove/threadplane-seo-optimization-c2b4f4
Open

feat(website): AI search optimization — Search Console harness, structured data, and measurement#826
blove wants to merge 35 commits into
mainfrom
blove/threadplane-seo-optimization-c2b4f4

Conversation

@blove

@blove blove commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Implements docs/superpowers/plans/2026-08-20-ai-search-optimization.md, built against Google's AI optimization guide.

Summary

  • Search Console API harness (apps/website/scripts/gsc/) — dependency-free service-account JWT auth, snapshot puller, and analysis report. npm run gsc:pull && npm run gsc:report.
  • Technical structure — honest sitemap lastmod, OpenGraph article metadata, schema.org JSON-LD across every page type, per-post OG images, clean heading extraction.
  • E-E-A-T/about page with a Person entity, unified with the blog author across all posts.
  • Measurement — Edge middleware capturing AI crawler visits and AI-engine referrals to PostHog, plus a runbook.

Fixes a live production bug

/blog/<slug>/opengraph-image has been returning 500 on every post since PR #410 — all nine social cards were broken. Root cause was a Satori layout error (a three-child <div> without display: flex), not the font issue first suspected; that turned out to be a second, separate defect silently degrading the typeface. The route is now prerendered, so this class of bug fails the build instead of shipping.

First real Search Console pull (2026-05-19 → 08-17)

  • Impressions +118% (1,149 → 2,506), but only 15 of 121 pages have earned a click; avg position 17.1
  • injectagent: 101 impressions, 2.0% CTR at position 5.6 — ranking well, converting badly
  • 126 of 139 URLs indexed, 0 canonical mismatches
  • 3 of 9 blog posts are not indexed, plus 2 docs pages unknown to Google

Deliberate decisions

  • Organization.logo omitted — no square brand asset exists in the repo (LogoMark.tsx renders an emoji). Pointing it at the 1200×630 marketing card would assert something false. One line to restore once a mark exists.
  • Middleware kept on Edge. Renaming to Next 16's proxy.ts silences a deprecation warning but moves the runtime to Node — a deployment-shape change. Deferred to its own PR with a preview deploy; a DEFERRED, DELIBERATELY comment explains why.
  • Diagrams scroll horizontally on mobile rather than shrinking to an illegible thumbnail, matching the existing wide-table treatment.

What Google says NOT to do — and we didn't

No llms.txt as a Search tactic, no content chunking, no AI-specific keyword rewrites, no inauthentic mentions. /llms.txt is kept only because some non-Google assistants read it. solutions-data.ts now carries a scaled-content-abuse guardrail.

Test plan

  • cd apps/website && npx vitest run --config vite.config.mts307 passed, 5 failed
  • nx run website:e2e59/59 passed
  • nx lint website0 errors
  • nx build website --configuration=production — success
  • nx test posthog-tools — 55/55
  • Verified in real built output: 143/144 pages carry JSON-LD with zero orphaned @id refs; all 9 posts have dateModified == og:modified_time == sitemap <lastmod>; 141 sitemap URLs all with lastmod and no changefreq/priority; 9 prerendered OG images, 9 distinct md5s

The 5 failures pre-date this branch — stale content assertions in thanks/page.spec.tsx, PostCard.spec.tsx, Differentiator.spec.tsx, files this branch never touches. They have never run in CI because apps/website has no Nx test target (tracked separately). This branch takes the suite from 90 passing to 307.

Post-merge, needs a human

  1. Rich Results Test on a deployed /blog/* and /docs/* URL.
  2. Request indexing for the 3 unindexed blog posts and 2 unknown docs pages — blog out-clicks the entire docs tree, so this is the highest-value manual action available.
  3. Verify sitemap lastmod survived deployment: curl -s https://threadplane.ai/sitemap.xml | grep -c '<lastmod>'. If it returns ~9 instead of 141, Vercel is shallow-cloning and the git-derived dates degraded to blog-only (honest by design, but the win won't land).
  4. Confirm PostHog delivery — capture was verified against a local sink, never us.i.posthog.com.
  5. Review /about prose and the knowsAbout list — a factual skeleton with no invented credentials, but the connective wording should be yours.
  6. Decide on compliance vs customer-support solutions pages — they overlap more than boilerplate, and none of the three contain the "real code" the new guardrail demands.

The runbook's baseline figures come from a .gsc/ pull that is gitignored, so they are not auditable from the repo.

🤖 Generated with Claude Code

blove and others added 30 commits August 20, 2026 11:36
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d sitemap

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure analysis helpers (striking distance, zero-impression pages,
unindexed, canonical mismatches, weak CTR) plus a markdown report
generator over the .gsc snapshots.

The report reads inspection-errors.json when present so a partial
inspection sweep is stated as partial: failed URLs stay in the sitemap
inventory, the failure count is reported, and the index-health counts
are labelled lower bounds rather than implying a clean bill of health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tests: real ordering assertions for findStrikingDistance and findWeakCtr
(the previous one-row fixture passed with .sort() deleted), table-driven
filter boundaries including the inclusive/exclusive asymmetry between
impressions and ctr, and first coverage for findCanonicalMismatches and
findWeakCtr. The both-canonicals-required policy is now pinned by test
and stated in the doc comment.

Report: InspectionFailure moves to api.ts so the pull/report
serialization contract is declared once; snapshot reads report a missing
or corrupt file by name and point at the pull instead of throwing a raw
ENOENT, with readOptional keeping its own existence check so genuine
absence stays distinguishable; URL comparison normalizes protocol, host
case, www, fragment and query string so a tagged URL is no longer
reported as a page with zero impressions; all four bullet lists are
capped, not just the failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google ignores changefreq/priority and uses lastmod when it is honest, so
the sitemap now emits only lastmod, derived per route from its real source:
blog frontmatter dates, docs .mdx files, and page.tsx templates (plus the
solutions data module for the programmatic /solutions/* pages).

Times come from git commit history rather than file mtimes: a fresh CI
checkout rewrites every mtime to clone time, which would claim the whole
site changed on every deploy. Shallow clones are detected and their grafted
boundary commits discarded, and any route we cannot date honestly simply
omits lastmod rather than fabricating one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `shallow` marker lives in the common git dir, not the per-worktree
gitdir, so `--absolute-git-dir` misses it inside a linked worktree
(.git/worktrees/<name> vs .git) and the sitemap silently dropped lastmod
for every file-derived route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aths

Moves the sitemap date logic out of site-metadata.ts into sitemap-dates.ts
and removes the remaining ways a date could be invented:

- Delete the file-mtime fallback. "Not shallow" never implied "committed" —
  a fresh full clone also rewrites every mtime to checkout time, so a single
  lookup miss could publish a build-time lastmod.
- Run git with core.quotePath=false (octal-escaped non-ASCII paths would
  never match a lookup, silently falling through) and log.showSignature=false
  (a user's config could interleave gpg: lines into the file list).
- Bound the subprocess with timeout/SIGKILL so a stalled git cannot hang the
  build; a kill throws and degrades like any other git failure.
- Require a well-formed "<epoch> <sha1>" remainder before treating a line as
  a commit header, and take paths verbatim so leading whitespace survives.

Blog routes now take the later of the frontmatter date and their .mdx commit
time, since lastmod means last modified rather than published.

Extracts parseGitLog as a pure function and unit-tests the degradation the
design rests on — most importantly that a grafted shallow-boundary commit
yields no entry rather than a clone-time one — plus a test pinning the
absence of changeFrequency/priority. Integration tests now tolerate a
history-less checkout instead of asserting completeness unconditionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Emit article:published_time / modified_time / author / tag on blog posts,
with modified_time derived from real git commit times (sitemap-dates)
rather than defaulting to the publish date. createPageMetadata gains an
optional per-page social image for the Task 9 OG-image routes.

Unify the brand on "Threadplane" (was "ThreadPlane" in blog titles and
prose) and the docs title separator on an em dash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the redundant `lastModified > published` guard in the blog route: it
could never change the value (getRouteLastModified already returns the
max) and its only effect was flipping modifiedTime to undefined, which
made unedited posts emit a date-only article:published_time while edited
ones emitted a full ISO timestamp. The `?? publishedTime` fallback in
createPageMetadata is now the one place that rule lives, and both fields
are ISO timestamps on every post.

Date parsing moves to the shared `publishedDate()` helper, so a malformed
frontmatter date drops the article block instead of shipping NaN-adjacent
garbage as article:published_time.

getRouteLastModified's post index is now a required `postsByRoute`
(renamed: the keys are route paths) rather than an optional argument with
a drafts policy that diverged from the caller's, which could return
different answers for the same route. Callers build it with the new
getPostsByRoute() or pass the post they already hold.

Replaces the tautological SITE_NAME assertion with a real scan of src,
content, scripts, and e2e for the mis-cased brand, and covers the
modifiedTime fallback plus the absence of article keys on non-article
pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure builders for Organization, WebSite, SoftwareSourceCode, BlogPosting,
TechArticle, and BreadcrumbList nodes, plus a JsonLd render component. Not
mounted on any route yet.

Every emitted URL was verified to resolve: the repository is
cacheplane/angular-agent-framework (not blove/...), sameAs links the public
package page rather than the member-gated npm org page, and the author URL
and per-post OG image are omitted until /about and the fixed image route
exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
expectSerializable claimed a round-trip but never compared the parsed result
to the input, so JSON.parse(JSON.stringify(obj)) could not fail and the six
"serializes to JSON" tests were vacuous. Adding toStrictEqual immediately
caught blogPostingJsonLd leaving an undefined-valued `keywords` key when a
post has no tags, where techArticleJsonLd already used the omit pattern for
dateModified; both builders now agree.

Also adds table-driven coverage of @context and the Organization @id
reference across every builder that carries one, a rootJsonLd() @graph that
makes the three root-layout nodes physically inseparable so an @id reference
cannot be orphaned by mounting a subset, and an exported BreadcrumbCrumb type.

Drops Organization.logo: a 1200x630 marketing social card is not a brand
mark, and no square mark exists in the repo. Omitting is honest; the property
should be restored once a real mark ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root layout mounts `rootJsonLd()` as a single `@graph`, so the Organization
node travels with every page and the `@id` references made by BlogPosting and
TechArticle always resolve.

Blog posts emit BlogPosting + BreadcrumbList; docs pages emit TechArticle +
BreadcrumbList. Both reuse the exact sources their `generateMetadata` already
uses, so `dateModified`, `og:modified_time`, and the sitemap `<lastmod>` agree:
`getPostLastModified()` factors the single-post date derivation the blog route
had inline, and `resolveDocDescription()` exposes the description accessor that
`getDocMetadata()` builds the meta description from.

The docs breadcrumb links its library rung at that library's introduction page,
mirroring the visible <DocsBreadcrumb>; `/docs/<library>` has no route and would
have been a crumb pointing at a 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l surfaces

The docs-description test was a tautology: this branch made `getDocMetadata`
call `resolveDocDescription`, so both sides of the comparison had become the
same function and 116 double MDX reads asserted nothing. Replaced with a spec
that renders the docs route component and compares the JSON-LD it actually
emits against the route's own `generateMetadata` — the surface where divergence
can really occur.

`libraryIntroPath()` now lives in docs-config and is called by both the visible
<DocsBreadcrumb> and the page's BreadcrumbList, so the "markup mirrors the
visible trail" claim is structural instead of commented. The new spec asserts
the JSON-LD library rung equals the href the rendered component produces, and
that every non-final rung is a route the sitemap knows.

Also: the sitemap-agreement test no longer passes on two undefineds; the
"unmodified" fallback rule collapses into one `resolveModifiedTime()` shared by
og and JSON-LD; the docs fallback description and the docs last-modified
derivation each get one home; and both page mounts hoist their builder calls
above the return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every blog post's social card returned HTTP 500 in production. Satori
rejects a div with more than one child node unless it carries an explicit
`display`, and the byline rendered three children (author, separator,
date) with none. The root `/opengraph-image` route was unaffected only
because it is prerendered at build time, while `/blog/[slug]/opengraph-image`
is server-rendered on demand.

Separately, the route read the bundled Garamond TTF through a parent
traversal (`join(here, '../../EBGaramond-Bold.ttf')`) that Next's file
tracer cannot statically resolve, so the font was absent from the
deployed function's trace — confirmed by diffing the two routes'
`route.js.nft.json`. Move font loading into `src/app/og-font.ts`,
colocated with the TTF, so the sibling-filename form traces for every
caller. Both routes now share it.

Make the failure mode safe: `satoriFonts` returns `undefined` rather than
`[]` when every font fails, since Satori throws on an empty list but
falls back to its bundled Noto Sans when the option is omitted. A plain
card beats a 500.

With the route verified locally, point `og:image`/`twitter:image` and the
BlogPosting `image` at the per-post card, flipping the two TODOs that
deliberately waited on this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-post card route was `ƒ` (server-rendered on demand), so Satori
markup errors could only surface as a production 500 — which is how the
missing `display: flex` on the byline shipped and broke all nine cards.
The root route escaped the same class of bug purely because it is
prerendered, where such an error fails the build instead.

Add `generateStaticParams`, mirroring this segment's `page.tsx`, so every
published post's card is generated at build time. The route now reports
as `●` and emits nine 1200x630 PNGs into the build output. This turns
render-time Satori failures into build failures, drops two uncached
Google Fonts round-trips and an MDX read per request on a path crawlers
hit, and moves `resolveWebsiteDir()` onto the build's cwd — which is
known to resolve — instead of an unverified serverless cwd.

Also:
- Extract `loadCardFonts` so both routes stop hand-assembling the same
  font descriptors, and widen `OgFont.weight` to the CSS weight domain
  so call sites no longer need `as const`.
- Add a shared `ogImagePath(slug)` used by both `blog/[slug]/page.tsx`
  and `blogPostingJsonLd`; the two built the same URL independently and
  could drift with both suites green.
- Drop the two defensive `display: flex` values on single-child divs and
  scope the comment to the byline, which is the one that was diagnosed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Docs and blog headings rendered a literal `#` text node before the
heading children, so every extracted heading came out as
`#Prerequisites` / `#1. Install the packages` — polluting search
snippets, page outlines, and anything summarizing the page from the DOM.

The `#` is now CSS generated content on `.heading-anchor::before`, and
the anchor is rendered after `{children}`. Extracted text is exactly the
heading text, while the permalink stays a real link with its
`aria-label` and its place in the tab order.

Both MDX heading overrides (MdxRenderer and the choosing-an-adapter
page, which had its own copy of the same bug) now share one
`mdxHeadingComponents` module, covered by a regression spec asserting
heading `textContent` carries no `#`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The heading `#` glyph is now CSS generated content, so the visible
permalink affordance hangs entirely on one declaration that jsdom
cannot see — it does not resolve pseudo-element content. Add a
Playwright assertion in the docs spec that reads
`getComputedStyle(el, '::before').content` off the rendered anchor and
pins it to `"#"`, alongside a check that the heading text carries no
glyph. Verified red by commenting out the rule (received "none").

Also label the permalink with the heading text rather than the slug, so
it announces "Link to At a glance" instead of "Link to at-a-glance".
Children are frequently nested nodes, so the text is flattened
recursively and falls back to the id when nothing can be derived.

Correct the module comment to name the real selectors: the CSS is scoped
to H2/H3 inside `.docs-prose`, not to `.heading-anchor` generally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Establishes an attributable author for the site: an AboutPage JSON-LD
graph whose mainEntity is a Person built from the existing
`blogAuthors['brian']` record, referencing the Organization the root
layout already mounts.

Now that the route exists, blog bylines carry `author.url` pointing at
it — deliberately omitted before, when a 404 author URL was the worse
signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every BlogPosting byline now carries the same `@id` the /about Person
declares, so the two are one entity to a consumer rather than two nodes
that merely share a name — which is the point of the attribution work.

`knowsAbout` moves onto the Author record beside `bio`: it is a claim
about the person, so it belongs with the person rather than with one
page that renders them.

Also: /about reads the author through `getAuthor()` for its missing-key
fallback, and shares the repository URL constant instead of adding a
third copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google Search Console's Generative AI report is UI-only, and AI crawlers
never execute JavaScript, so the client PostHog snippet cannot observe
either signal. Add edge middleware that classifies the request and emits
marketing:ai_crawler_visit / marketing:ai_referral_visit.

- ai-traffic.ts: pure classifiers. Crawler UAs are matched on published
  tokens (plain Googlebot/Applebot deliberately excluded); referrers are
  matched on the parsed hostname exactly or as a subdomain, so lookalikes
  such as evil-chatgpt.com.attacker.net do not classify.
- Capture goes direct to the PostHog ingest host over fetch, not through
  posthog-node (a Node library; middleware runs on the Edge runtime) and
  not through the /ingest/* rewrites, which exist for the browser.
- Registered with FetchEvent#waitUntil so the response is never delayed,
  with a 2s abort: a dropped event is acceptable, a hung request is not.
- Anonymous by construction: $process_person_profile false, $ip null,
  pathname only (never the query string), and no UA on referral events.
- Crawler events are deduped per crawler/path/hour, best-effort and
  per-instance, so a crawler looping one URL cannot become a firehose.
- Matcher excludes api, _next, ingest, og/twitter image routes and any
  path whose last segment has an extension.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…load

Both inputs to this feature are attacker-controlled, and the per-key dedup
was not an abuse ceiling: varying the path defeats it entirely, and the
referral path had no limiter at all. Demonstrated live — 700 requests with
a spoofed chatgpt.com Referer produced 700 events. Anyone reading the
bundle could bill the PostHog account arbitrarily or poison the dataset
this feature exists to produce.

- Add a per-instance token bucket on TOTAL emissions (500/hour, burst 500),
  so the blast radius is bounded regardless of key variety. Chosen well
  above honest volume: 141 sitemap URLs, and a simultaneous full-site sweep
  by three crawlers landing on one instance is ~423. Per-instance and
  best-effort like the dedup: it bounds blast radius, it is not a global
  quota.
- Cover sendToPostHog, where every privacy invariant lives. Asserts the
  exact wire payload for both event types: $process_person_profile false,
  $ip null, no query string, no referrer URL, no UA on referral events,
  and that a caller cannot override the anonymous properties.
- Observe /llms.txt, /llms-full.txt, /sitemap.xml and /robots.txt. These
  are the file written for AI consumers and the strongest crawler-intent
  signals that exist; losing them to the blunt extension rule undercut the
  feature's own premise.
- Anchor the og/twitter-image exclusion to a segment boundary, so a page
  named /my-opengraph-image-guide is no longer silently dropped.
- Rename middleware.ts to proxy.ts (Next 16 deprecates the middleware file
  convention). NextProxy is NextMiddleware, so waitUntil is unchanged. This
  moves the runtime from edge to nodejs, re-verified below.
- Dedup key is now JSON.stringify([bucket, crawler, path]): a path may
  legitimately contain any delimiter, and a collision silently drops an
  event. Drop keepalive (a browser-unload primitive; waitUntil is what
  holds the invocation). Drop the unreachable '[::1]' branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts only the proxy.ts rename from 3413824. Every other change in that
commit — the emission token bucket, the sendToPostHog payload coverage, the
llms.txt/sitemap.xml/robots.txt matchers, the anchored og-image exclusion —
is kept.

The rename silenced a cosmetic deprecation warning at the cost of moving the
runtime from Edge to Node on every request to a public production site.
Next 16's proxy convention always runs on Node, so `git mv` was not a file
rename but a deployment-shape change: different cold-start and pricing
characteristics on Vercel, and unverifiable from a local build. The
deprecation is a warning, not a break, and 16.1.7 builds and runs the
middleware convention fine.

The migration is still worth doing — it additionally makes process.env
runtime-read instead of build-inlined, so token rotation would stop needing
a redeploy — but it belongs in its own change with a preview deploy behind
it, not bundled in as a side effect of a warning fix. A comment on the file
records that the warning is deliberate and that the migration is mechanical,
because the code uses only Web-standard APIs.

Re-verified on Edge after the revert, rather than assumed to carry over from
the Node run: chunks back under server/edge/ with all five matchers; a
5s-hanging PostHog left /contact, /llms.txt and /pricing returning 200 in
9-69ms with all three events still delivered; two 700-request floods
(spoofed chatgpt.com Referer, and GPTBot across 700 distinct paths) each
capped at 501 events; llms.txt, llms-full.txt, sitemap.xml and robots.txt
all captured; ordinary traffic, Googlebot, a google.com referrer and the
evil-chatgpt.com.attacker.net lookalike all emitted nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove and others added 5 commits August 20, 2026 15:52
Every blog post shipped zero images. Add three hand-authored SVG
diagrams, one per architecture-heavy post, each drawn from what the
post actually explains:

- ag-ui-event-flow: the client-tool round trip — the browser ships its
  tool catalog up, the server streams AG-UI events down, and the tool
  result the browser produces starts the next run.
- langgraph-threads-and-runs: the ACTIVE_THREAD signal as the pivot
  between the thread list and the active run.
- agent-contract-boundary: two runtimes reduced into one neutral
  contract of Angular signals, with user intent travelling back.

Each SVG paints its own surface and carries a <title>/<desc>, so it
reads on a light or dark page without depending on the host theme.
Colors come from the design tokens' light palette.

Add an `img` override to MdxRenderer that emits explicit width/height
(so the box is reserved before the file loads, keeping layout shift at
zero) plus lazy loading and async decoding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GSC harness README and every generated report already point at this
file. Write it.

Covers what each source can and cannot answer, the monthly routine over
gsc:pull / gsc:report, the AI crawler and referral events emitted from
Edge middleware, and the baseline from the first real pull
(2026-05-19 → 2026-08-17) so later pulls have something to compare to.

Two things it is deliberately blunt about:

- The Search Console Generative AI performance report is UI-only. It is
  not in searchanalytics.query, not a searchAppearance value, and not in
  the BigQuery export. The only way to read it is by hand, so the doc
  carries the manual procedure and a running log to paste it into.
- Query-dimension totals (528 impressions) are lower than page-dimension
  totals because Google anonymizes rare queries. Page-level is the true
  volume.

Plus a do-not-do list — llms.txt as a Search tactic, content chunking,
AI-specific keyword rewrites, inauthentic mentions — so nobody re-adds
them. We keep /llms.txt because some non-Google assistants read it, not
because it helps Google.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diagrams were authored at 880 wide. `.docs-prose` computes to
706.56px — `max-width: 70ch` wins over Tailwind's `max-w-none` because
it sits later in the built stylesheet — so they rendered at scale 0.803
and put 12.5px labels at 10.0px. On a 375px viewport the column is 323px,
scale 0.372, detail text at 4.6px. Unreadable, and never measured.

Re-author all three at a 700 viewBox so they render 1:1 in the column,
with the type scale raised a step (meta 13px, eyebrow 11.5px). The wider
layouts do not survive 700px, so each one goes more vertical:

- ag-ui-event-flow: one column of six steps, browser/server marked by a
  tinted left rule and a corner tag, with the return rail on the left.
- langgraph-threads-and-runs: two rows pivoting on ACTIVE_THREAD, edge
  labels moved above each exchange instead of into the column gap.
- agent-contract-boundary: four stacked bands instead of four columns.

Below 706px the paragraph scrolls rather than the figure shrinking —
the same treatment `.docs-table-scroll` gives a wide table — so labels
never scale below their authored size. Verified: the page itself does
not gain a horizontal scrollbar at 371px.

Also drop the inline `style` from the img override in favour of a
`.docs-diagram` class. It duplicated `.docs-prose > p > img`, silently
overrode that rule's `margin: 2rem auto`, and being inline would have
clobbered any author-supplied `style` on a future markdown image.
Per-diagram intrinsic sizes replace the single shared constant now that
the three differ in height.

Measured effective font size of the smallest text class, in the built
page: 11.5px at 1280px wide and 11.5px at 375px (scale 1.0 at both).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bullet sat under a heading covering both AI events, but
`shouldEmitCrawlerEvent` is only called on the crawler path in
middleware.ts. The 500/hour token bucket is the one bound both share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeps keys/gcp.json (Search Console service account) out of git.
Also added to .git/info/exclude so it takes effect in every worktree
immediately, not only after this branch merges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
Image threadplane Ready Ready Preview Aug 21, 2026 12:47am

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant