Writing / Build Log

The Studio showed eight projects. The website saw none.

The Studio listed eight projects and the website could see none of them. Every bug in this portfolio's CMS migration that mattered passed lint, type-check, and build. Here is what each one looked like, and the check that would have caught it.

This site renders from Sanity and falls back to local JSON when the CMS is unreachable. That fallback is a feature — a Sanity outage degrades the site instead of breaking it — and it is also why the bugs below were quiet. When the CMS path fails, the page still renders something reasonable, the build still passes, and nothing tells you which of the two sources you are looking at.

Every bug in this post passed npm run check and npm run build. Each one was found by looking at what a visitor actually receives.

Zero, from outside

The migration script gave every document a deterministic id so a re-run would replace documents rather than duplicate them. The ids used a period as a separator:

project.bup-bus-tracker
technology.esp32
projectCategory.embedded-iot

The import succeeded. The Studio showed all eight projects, correctly filled in. The website kept rendering the local fallback, and an unauthenticated query explained why:

count(*[_type == "project"])   → 0    (as the public website)
count(*[_type == "project"])   → 8    (logged in, in the Studio)

In Sanity, a document id that contains a period is private: only authenticated requests can read it. The website reads anonymously, so 43 imported records did not exist for it. The one document that did show up was the homepage singleton — its id is homepage, with no period.

The fix was a hyphen (project-bup-bus-tracker). The lesson was about where to look: the Studio is an authenticated client, so it is the wrong place to confirm that content is public. Verify with the same kind of request the site makes.

A string that was an object

With the documents visible, project pages rendered from the CMS — and lost detail. The "Build scope" section came up empty, and the flow diagram fell back to its "documentation pending" text.

Each CMS project is merged field by field with its local record, matched by slug. The record query opened with a spread:

*[_type == "project" && slug.current == $slug][0]{
  ...,
  "category": projectCategory->{title, "slug": slug.current}
}

A spread returns every field as stored, and Sanity stores a slug as an object — { "_type": "slug", "current": "bup-bus-tracker" } — not a string. The merge compared that object to a string, the comparison never matched, and every CMS-backed record silently dropped its local enrichment. It had been wrong since the query was written; it only became visible once the dataset had content. One line fixed it:

  ...,
  "slug": slug.current,

null + array

Blog posts point at their project with a relatedProject reference. The first version of the project query read only the project's own relatedPosts list, which nobody fills, so project pages showed no writing. Adding the reverse lookup — every post whose relatedProject is this project — still showed nothing.

Most projects never set relatedPosts, so it is undefined — and in GROQ, null plus an array is null, not the array. The reverse results were computed and then thrown away by the concatenation:

"relatedPosts": coalesce(relatedPosts[]->{title, "slug": slug.current}, [])
  + *[_type == "blogPost" && relatedProject._ref == ^._id]{title, "slug": slug.current}

coalesce(…, []) makes the left side an empty array when the field is missing.

What the reader sees

The CMS was not the only source of silent failures. The posts had rendering bugs of their own, none of which a build can see. The first two were live on published posts until they were noticed:

  • Every paragraph ran together. Tailwind's preflight resets paragraph margins to zero and removes list markers. Nothing in the post styles put them back, so a post was one continuous block of text and its bullet lists had no bullets.
  • Every code block was double-spaced. The syntax highlighter separates its line spans with newline characters inside the <pre>. The stylesheet also made each line a block element, so every line break happened twice — which hurts most in the one block where alignment is the point, an elimination matrix.
  • Posts scrolled sideways on a phone by 399 px. A grid child defaults to min-width: auto, so one long line inside a code block held the column open far past the viewport, and the block's own overflow-x never engaged. The fix is min-width: 0 on the grid children.

A fourth was accessibility rather than layout: the dark highlighting theme's comment colour measured 4.11:1 against this site's code background, below the 4.5:1 minimum, because that theme was designed for a lighter background of its own. Its sibling theme's worst foreground is 6.44:1 on the same surface.

What I check now

None of these needed a debugger. Each needed someone to look at the output instead of the exit code.

  • Query as the public does. An anonymous request, not the Studio, decides whether content is published.
  • Know which source rendered the page. A fallback that is indistinguishable from the real thing hides the failure it exists to survive. Failures are now logged before falling back, so "no content yet" and "CORS rejected the request" no longer look the same.
  • Look at real content, at real sizes. A page with placeholder data cannot show a code block overflowing a phone. Every page template is now checked for sideways scrolling at 320 px, in both themes.
  • Turn each bug into a test. Paragraph spacing in a rendered post, no sideways scrolling at 320 px, related writing on a project page, and an accessibility run in both themes are all end-to-end tests now. A build proves the code compiles. It does not prove anyone can read the page.