Files
Nik Richers 8a41a74e36 fix(www): prevent mobile blog layout overflow (#49983)
## I have read the CONTRIBUTING.md file.

YES

## What kind of change does this PR introduce?

This PR fixes a mobile layout bug on blog posts where long unwrapped
URLs made the article column wider than its flex parent. With
`justify-center`, that overflow centered the column and clipped content
on both sides. It also hardens the reading progress bar so it no longer
uses `100vw`, matching the overflow-safe pattern already used on
changelog prose.

## What is the current behavior?

- On mobile Safari, `/blog/are-supabase-docs-agent-friendly` shifts left
with empty space on the right and clipped title/body text
- A raw long URL in a blockquote (`row-level-security.md`) expands the
flex column past the parent width
- The reading progress fill uses `w-screen` (`100vw`) with a negative
`translate3d`, which can widen layout outside the blog `overflow-x-clip`
shell

## What is the new behavior?

- Blog main column uses `min-w-0 w-full` so flex children cannot outgrow
the parent
- Blog prose uses `wrap-break-word` (same as changelog) so long URLs
wrap
- Scroll progress bar uses parent width plus `overflow-hidden` instead
of `w-screen`

## Additional context

- Branch: `fix-docs-agents-blog-post-on-mobile`
- Files: `apps/www/components/Blog/BlogPostRenderer.tsx`,
`apps/www/components/ScrollProgress.tsx`
- Verification:

| Check | Result |
| --- | --- |
| Production at 390px: title `h1` left ≈ -42 (shifted) | pass
(reproduced) |
| Preview at 390px: title `h1` left = 24, right = 366 | pass |
| `scrollWidth === clientWidth` on preview | pass |
| Progress bar still fills on scroll | pass |
| Hero image still `hidden lg:block` (unchanged) | pass |

### Proof: mobile blog no longer shifts left

**Verified:** production before vs PR www preview after, both captured
at CSS viewport 390×844 via `page.setViewport` (not Chrome
`--window-size`)

| [Before
(production)](https://supabase.com/blog/are-supabase-docs-agent-friendly)
| [After (PR
preview)](https://zone-www-dot-com-git-fix-docs-agents-blog-post-4e52df-supabase.vercel.app/blog/are-supabase-docs-agent-friendly)
|
| --- | --- |
|
![Before](https://moijyfpvgnmgoxvwcikq.supabase.co/storage/v1/object/public/pr-proof/supabase/supabase/pr49983/mobile-blog-overflow-before-219e3df5.png)
|
![After](https://moijyfpvgnmgoxvwcikq.supabase.co/storage/v1/object/public/pr-proof/supabase/supabase/pr49983/mobile-blog-overflow-after-85f7751b.png)
|

- **Before:** https://supabase.com/blog/are-supabase-docs-agent-friendly
- **After:**
https://zone-www-dot-com-git-fix-docs-agents-blog-post-4e52df-supabase.vercel.app/blog/are-supabase-docs-agent-friendly

### Test plan

- [ ] Open
[production](https://supabase.com/blog/are-supabase-docs-agent-friendly)
at ~390px and confirm the clipped/shifted layout
- [ ] Open the [PR www
preview](https://zone-www-dot-com-git-fix-docs-agents-blog-post-4e52df-supabase.vercel.app/blog/are-supabase-docs-agent-friendly)
at ~390px
- [ ] Confirm title, authors, and body are fully visible with no empty
strip on the right
- [ ] Confirm `document.documentElement.scrollWidth ===
document.documentElement.clientWidth`
- [ ] Scroll and confirm the green reading progress bar still fills
- [ ] Spot-check another blog post without a raw long URL for no layout
regression

Co-authored-by: Nik Richers <nik@validmind.ai>
2026-09-04 03:13:09 +00:00

316 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dayjs from 'dayjs'
import Image from 'next/image'
import Link from 'next/link'
import type { PostReturnType, ProcessedBlogData, StaticAuthor, Tag } from 'types/post'
import { Badge, cn } from 'ui'
import { CTASection } from '../CTASection'
import SectionContainerWithCn from '../Layouts/SectionContainerWithCn'
import { BlogTableOfContents } from '@/components/Blog/BlogTableOfContents'
import DraftModeBanner from '@/components/Blog/DraftModeBanner'
import ShareArticleActions from '@/components/Blog/ShareArticleActions'
import BlogLinks from '@/components/LaunchWeek/7/BlogLinks'
import LW11Summary from '@/components/LaunchWeek/11/LW11Summary'
import LW12Summary from '@/components/LaunchWeek/12/LWSummary'
import LW13Summary from '@/components/LaunchWeek/13/Releases/LWSummary'
import LW14Summary from '@/components/LaunchWeek/14/Releases/LWSummary'
import LW15Summary from '@/components/LaunchWeek/15/LWSummary'
import LWXSummary from '@/components/LaunchWeek/X/LWXSummary'
import { MarkdownActions } from '@/components/MarkdownActions'
import { getBlogThumbnailImage } from '@/lib/blog-images'
import { compileBlogMdx } from '@/lib/mdx/compileBlogMdx'
import mdxComponents from '@/lib/mdx/mdxComponents'
type NextCardProps = {
post: { path: string; title: string; formattedDate: string }
label: string
className?: string
}
const NextCard = (props: NextCardProps) => {
const { post, label, className } = props
return (
<Link href={`${post.path}`} as={`${post.path}`}>
<div className={className ?? ''}>
<div className="hover:bg-control cursor-pointer rounded-sm border p-6 transition">
<div className="space-y-4">
<div>
<p className="text-foreground-lighter text-sm">{label}</p>
</div>
<div className="flex flex-col gap-2">
{'title' in post && (
<h4 className="text-foreground text-lg text-balance">
{(post as { title?: string }).title}
</h4>
)}
{'formattedDate' in post && (
<p className="small">{(post as { formattedDate?: string }).formattedDate}</p>
)}
</div>
</div>
</div>
</div>
</Link>
)
}
const BlogPostRenderer = async ({
blog,
blogMetaData,
isDraftMode,
prevPost,
nextPost,
authors,
}: {
blog: ProcessedBlogData
blogMetaData: ProcessedBlogData
isDraftMode: boolean
prevPost?: PostReturnType | null
nextPost?: PostReturnType | null
authors: Array<StaticAuthor>
}) => {
const mdxRendered = await compileBlogMdx(blog.content as string, mdxComponents('blog'))
const isLaunchWeek7 = blogMetaData.launchweek === '7'
const isLaunchWeekX = blogMetaData.launchweek?.toString().toLocaleLowerCase() === 'x'
const isGAWeek = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '11'
const isLaunchWeek12 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '12'
const isLaunchWeek13 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '13'
const isLaunchWeek14 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '14'
const isLaunchWeek15 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '15'
type TocJsonItem = { content: string; slug: string; lvl: number }
const rawToc = blogMetaData.toc as unknown as { json?: TocJsonItem[] } | string | undefined
const tocItems =
rawToc && typeof rawToc !== 'string' && Array.isArray(rawToc.json)
? rawToc.json.map((h) => ({ title: h.content, url: `#${h.slug}`, depth: h.lvl }))
: []
const toc = tocItems.length > 0 ? <BlogTableOfContents items={tocItems} /> : null
const imageUrl = getBlogThumbnailImage(blogMetaData, {
fallbackToPlaceholder: false,
})
const MARGIN_BOTTOM_CN = 'mb-12'
return (
<>
{isDraftMode && <DraftModeBanner />}
<div className="overflow-x-clip">
<SectionContainerWithCn height="none">
{/*
* Outer grid: back button | main container
*
* base/md : [back:1–12] stacked above [main:1–12]
* lg : [back:1–12] stacked above [main:2–12]
* xl : [back:1–2] alongside [main:3–12]
*/}
<div className="flex justify-center lg:grid grid-cols-12 gap-x-8">
{/* Main container — indented 1 col at lg, alongside back button at xl */}
<div className="min-w-0 w-full max-w-[65ch] lg:max-w-none col-span-12 lg:col-start-2 lg:col-span-10 xl:col-start-2 xl:col-span-11">
{/* Article header — spans the full main container width */}
<div className="py-8 md:py-12 space-y-4">
<div className="flex items-center gap-1.5 text-foreground-lighter text-sm">
<Link
href="/blog"
className="hover:text-foreground inline-flex items-center transition"
>
Blog
</Link>
{blogMetaData.categories && (
<>
<span className="text-foreground-muted"> / </span>
<Link
href={`/blog/categories/${blogMetaData.categories[0]}`}
className="capitalize hover:text-foreground"
>
{blogMetaData.categories[0]}
</Link>
</>
)}
</div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl max-w-3xl text-pretty">
{blogMetaData.title}
</h1>
<div className="flex items-center gap-2">
<div className="text-foreground-lighter flex space-x-3 text-sm">
<p>{dayjs(blogMetaData.date).format('D MMM YYYY')}</p>
<p>·</p>
<p>{(blogMetaData as any).readingTime}</p>
</div>
</div>
{authors.length > 0 && (
<div className="flex flex-row flex-wrap items-center gap-y-4 gap-x-6 md:gap-x-8 mt-6">
{authors.map((author, i: number) => {
const authorImageUrl = author.author_image_url
const authorId =
(author as any).author_id ||
(author as any).username ||
author.author.toLowerCase().replace(/\s+/g, '_')
return (
<Link
href={`/blog/authors/${authorId}`}
className="cursor-pointer"
key={`author-${i}-${author.author}`}
>
<div className="flex items-center gap-2">
{authorImageUrl && (
<Image
src={authorImageUrl}
className="w-5 h-5 md:w-8 md:h-8 border-default rounded-full border aspect-square object-cover"
alt={`${author.author} avatar`}
width={32}
height={32}
/>
)}
<div className="flex flex-col">
<span className="text-foreground text-sm">{author.author}</span>
<span className="text-foreground-lighter text-xs hidden md:inline-block">
{author.position}
</span>
</div>
</div>
</Link>
)
})}
</div>
)}
</div>
{/*
* Inner grid: article body | sidebar
*
* base/md : single column, sidebar hidden
* lg+ : [body:7fr] [sidebar:3fr]
*/}
<div className="lg:grid lg:grid-cols-12 lg:gap-x-8 xl:gap-x-12">
{/* Article body */}
<div className={cn('col-span-8', MARGIN_BOTTOM_CN)}>
{!blogMetaData.youtubeHero && blogMetaData.imgThumb && imageUrl && (
<div className="hidden lg:block relative w-full aspect-[1.91/1] overflow-hidden rounded-lg border border-foreground/10 mb-6">
<Image
src={imageUrl}
alt={blogMetaData.title}
fill
quality={100}
sizes="(max-width: 768px) 100vw, 58vw"
className="object-cover"
/>
</div>
)}
<article>
<div className="prose prose-docs wrap-break-word">
{blogMetaData.youtubeHero ? (
<iframe
title="YouTube video player"
className="w-full"
width="700"
height="350"
src={blogMetaData.youtubeHero}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
) : null}
{mdxRendered}
</div>
</article>
{isLaunchWeek7 && <BlogLinks />}
{isLaunchWeekX && <LWXSummary />}
{isGAWeek && <LW11Summary />}
{isLaunchWeek12 && <LW12Summary />}
{isLaunchWeek13 && <LW13Summary />}
{isLaunchWeek14 && <LW14Summary />}
{isLaunchWeek15 && <LW15Summary />}
<div className="lg:hidden py-8 flex flex-col gap-6">
<MarkdownActions pagePath={`/blog/${blogMetaData.slug}`} pageType="blog" />
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
<div className="grid gap-8 mt-8 lg:grid-cols-1">
<div>
{prevPost && (
<NextCard
post={
prevPost as unknown as {
path: string
title: string
formattedDate: string
}
}
label="Previous post"
/>
)}
</div>
<div>
{nextPost && (
<NextCard
post={
nextPost as unknown as {
path: string
title: string
formattedDate: string
}
}
label="Next post"
className="text-right"
/>
)}
</div>
</div>
</div>
{/* Sidebar — only above lg */}
<div
className={cn(
'hidden lg:block col-span-4 col-start-9 space-y-8 lg:pl-6',
MARGIN_BOTTOM_CN
)}
>
{(blogMetaData.tags as Tag[])?.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{(blogMetaData.tags as Tag[])?.map((tag) => {
const tagName = typeof tag === 'string' ? tag : tag.name
const tagId = typeof tag === 'string' ? tag : tag.id.toString()
return (
<Link
className="flex"
href={`/blog/tags/${tagName}`}
key={`category-badge-${tagId}`}
>
<Badge>{tagName}</Badge>
</Link>
)
})}
</div>
)}
<div className="sticky top-24 flex flex-col gap-6 max-h-[calc(100vh-7rem)]">
<p className="text-foreground font-mono uppercase tracking-wide text-xs -mb-2">
On this page
</p>
<div className="overflow-y-auto min-h-0 flex-1">{toc}</div>
<div className="shrink-0 flex flex-col gap-6">
<MarkdownActions pagePath={`/blog/${blogMetaData.slug}`} pageType="blog" />
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
</div>
</div>
</div>
</div>
</div>
</SectionContainerWithCn>
<div className="border-t">
<CTASection />
</div>
</div>
</>
)
}
export default BlogPostRenderer