diff --git a/apps/web/src/app/[...slug]/page.tsx b/apps/web/src/app/[...slug]/page.tsx index f67f0d2c..1f543f39 100644 --- a/apps/web/src/app/[...slug]/page.tsx +++ b/apps/web/src/app/[...slug]/page.tsx @@ -1,6 +1,6 @@ import { Logger } from "@workspace/logger"; import { - DRAFT_MODE_ENABLED, + DRAFTS_WITHOUT_SESSION, type DynamicFetchOptions, getDynamicFetchOptions, resolvePageFetchOptions, @@ -10,6 +10,7 @@ import { } from "@workspace/sanity/live"; import { querySlugPageData, querySlugPagePaths } from "@workspace/sanity/query"; import type { Metadata } from "next"; +import { draftMode } from "next/headers"; import { notFound } from "next/navigation"; import { Suspense } from "react"; @@ -75,15 +76,20 @@ export async function generateMetadata({ export default async function SlugPage({ params, }: Readonly<{ params: Promise }>) { - // Dev/preview: draft-aware path so unpublished pages still render. - if (DRAFT_MODE_ENABLED) { + const { isEnabled: isDraftMode } = await draftMode(); + + // A Presentation session (any environment) or local dev takes the draft-aware + // path, so draft edits and unpublished pages render. + if (isDraftMode || DRAFTS_WITHOUT_SESSION) { return ( }> ); } - // Production: static published render with a real 404, no skeleton. + + // Everyone else: published render off the same cached fetch as before, and a + // real 404 — not a soft one streamed inside Suspense. const { slug } = await params; const pageData = await getPublishedSlugPage(slug); if (!pageData) { diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index d6b7b2c5..8a13cc0b 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -1,6 +1,6 @@ import { Logger } from "@workspace/logger"; import { - DRAFT_MODE_ENABLED, + DRAFTS_WITHOUT_SESSION, type DynamicFetchOptions, getDynamicFetchOptions, resolvePageFetchOptions, @@ -15,6 +15,7 @@ import { } from "@workspace/sanity-blocks/internal/rich-text"; import { SanityImage } from "@workspace/sanity-blocks/internal/sanity-image"; import type { Metadata } from "next"; +import { draftMode } from "next/headers"; import { notFound } from "next/navigation"; import { Suspense } from "react"; @@ -82,15 +83,20 @@ export default async function BlogSlugPage({ }: Readonly<{ params: Promise; }>) { - // Dev/preview: draft-aware path so unpublished posts still render. - if (DRAFT_MODE_ENABLED) { + const { isEnabled: isDraftMode } = await draftMode(); + + // A Presentation session (any environment) or local dev takes the draft-aware + // path, so draft edits and unpublished posts render. + if (isDraftMode || DRAFTS_WITHOUT_SESSION) { return ( }> ); } - // Production: static published render with a real 404, no skeleton. + + // Everyone else: published render off the same cached fetch as before, and a + // real 404 — not a soft one streamed inside Suspense. const { slug } = await params; const data = await getPublishedBlogPage(slug); if (!data) { diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 804689c1..bf4c3e16 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -130,8 +130,25 @@ async function fetchBlogIndexPageBlogsCount({ return res.data; } -export async function generateMetadata(): Promise { - const { perspective } = await getDynamicFetchOptions(); +type BlogPageProps = Readonly<{ + searchParams: Promise<{ + page?: string; + category?: string; + }>; +}>; + +export async function generateMetadata({ + searchParams, +}: BlogPageProps): Promise { + const [{ page, category }, { perspective }] = await Promise.all([ + searchParams, + getDynamicFetchOptions(), + ]); + await assertBlogPageInRange({ + page, + category: category ?? "", + perspective, + }); const { data: result } = await sanityFetchMetadata({ query: queryBlogIndexPageData, perspective, @@ -139,12 +156,66 @@ export async function generateMetadata(): Promise { return seoFromDocument(result, { slug: "/blog" }); } -type BlogPageProps = Readonly<{ - searchParams: Promise<{ - page?: string; - category?: string; - }>; -}>; +/** + * `?page=` as a 1-based page number, or `null` when the value is present but + * not a positive integer — a bogus URL that should 404 rather than quietly + * serve page 1 under a different address. + */ +function parseBlogPageParam(page: string | undefined): number | null { + if (page === undefined || page === "") { + return 1; + } + const parsed = Number(page); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +/** + * 404s a `?page=` past the last page. Lives in `generateMetadata` because that + * resolves before the response status is committed; the same `notFound()` + * inside the page's Suspense boundary only ever streams a soft 404, since PPR + * has already flushed the prerendered shell with a 200. + * + * `totalPages` floors at 1, so page 1 always survives: an empty blog, and an + * empty category filter, are legitimate results rather than dead URLs. + */ +async function assertBlogPageInRange({ + page, + category, + perspective, +}: { + page: string | undefined; + category: string; + perspective: DynamicFetchOptions["perspective"]; +}) { + const currentPage = parseBlogPageParam(page); + if (currentPage === null) { + notFound(); + } + if (currentPage === 1) { + return; + } + + const [totalCount] = await handleErrors( + fetchBlogIndexPageBlogsCount({ + category, + excludeFeatured: !category, + perspective, + stega: false, + }) + ); + // A failed count is a server problem, not a missing page — let the page + // render its error state instead of masking it as a 404. + if (totalCount === null || totalCount === undefined) { + return; + } + const { totalPages } = calculateBlogPaginationMetadata( + totalCount, + currentPage + ); + if (currentPage > totalPages) { + notFound(); + } +} export default function BlogIndexPage({ searchParams }: BlogPageProps) { return ( @@ -187,18 +258,27 @@ async function DynamicBlogIndex({ searchParams }: BlogPageProps) { searchParams, getDynamicFetchOptions(), ]); - const parsedPage = Number(page); - const currentPage = - Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1; + const currentPage = parseBlogPageParam(page); + if (currentPage === null) { + notFound(); + } const activeCategory = category ?? ""; + // Keyed so a page/category change mounts a *new* boundary. Without it React + // keeps the previous page's posts on screen for the whole round trip — the + // URL flips to `?page=2` while the grid still shows page 1. return ( - + } + key={`${activeCategory}:${currentPage}`} + > + + ); } @@ -250,6 +330,13 @@ async function BlogIndexView({ currentPage ); + // Past the last page is a dead URL, not an empty list. `totalPages` floors at + // 1, so page 1 still renders when there is nothing to show — including an + // empty category filter, which is a legitimate result rather than a 404. + if (currentPage > paginationMetadata.totalPages) { + notFound(); + } + const { start: blogStart, end: blogEnd } = getBlogPaginationRange(currentPage); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index dc8c77c6..687b4c3c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,7 +1,7 @@ import "@workspace/ui/globals.css"; import { - DRAFT_MODE_ENABLED, + DRAFTS_WITHOUT_SESSION, type DynamicFetchOptions, getDynamicFetchOptions, SanityLive, @@ -46,10 +46,8 @@ export default async function RootLayout({ prefetchDNS("https://cdn.sanity.io"); // In local dev, nav/footer follow drafts too (like page content), so draft // navbar/footer/settings edits are visible without a Presentation session. - // Production stays static published (DRAFT_MODE_ENABLED is false). - const showDrafts = DRAFT_MODE_ENABLED; - // Presentation overlay (preview bar, visual editing) needs a real session. - const isDraftMode = DRAFT_MODE_ENABLED && (await draftMode()).isEnabled; + // Production stays static published. + const showDrafts = DRAFTS_WITHOUT_SESSION; return ( - {showDrafts ? ( - }> - - - ) : ( - - )} -
- {children} +
+ {showDrafts ? ( + }> + + + ) : ( + + )} +
+ {children} +
{showDrafts ? ( @@ -79,22 +76,40 @@ export default async function RootLayout({ )} - + {/* Reads draftMode(), so it must stay behind Suspense — otherwise the + whole layout opts out of prerendering for every visitor. */} + + + - {isDraftMode && ( - <> - - - - )} ); } +/** + * Live updates plus the Presentation overlay. The overlay renders wherever a + * validated draft-mode session exists — production included — which is what + * lets the deployed Studio preview the live site. + */ +async function LivePreviewLayer() { + const { isEnabled: isDraftMode } = await draftMode(); + return ( + <> + + {isDraftMode && ( + <> + + + + )} + + ); +} + async function DynamicNavbar() { const { perspective, stega } = await getDynamicFetchOptions(); return ; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index c556f72f..eb529e7b 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,5 @@ import { - DRAFT_MODE_ENABLED, + DRAFTS_WITHOUT_SESSION, type DynamicFetchOptions, getDynamicFetchOptions, resolvePageFetchOptions, @@ -8,6 +8,7 @@ import { } from "@workspace/sanity/live"; import { queryHomePageData } from "@workspace/sanity/query"; import type { Metadata } from "next"; +import { draftMode } from "next/headers"; import { Suspense } from "react"; import { PageBuilderJsonLd } from "@/components/page-builder-json-ld"; @@ -24,16 +25,20 @@ export async function generateMetadata(): Promise { return seoFromDocument(homePageData, { slug: "/" }); } -export default function Page() { - // Production static-renders published; dev/preview streams drafts below. - if (!DRAFT_MODE_ENABLED) { - return ; +export default async function Page() { + const { isEnabled: isDraftMode } = await draftMode(); + + // A Presentation session (any environment) or local dev streams drafts. + if (isDraftMode || DRAFTS_WITHOUT_SESSION) { + return ( + }> + + + ); } - return ( - }> - - - ); + + // Everyone else: published render off the same cache entry as before. + return ; } async function HomeContent() { diff --git a/apps/web/src/components/elements/menu-link.tsx b/apps/web/src/components/elements/menu-link.tsx index 73c5be60..4c5a58a1 100644 --- a/apps/web/src/components/elements/menu-link.tsx +++ b/apps/web/src/components/elements/menu-link.tsx @@ -14,7 +14,7 @@ export function MenuLink({ return ( diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 16b170fd..fb4c68e2 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -100,13 +100,13 @@ function SocialLinks({ data }: Readonly) {
  • - + {label}
  • diff --git a/apps/web/src/components/mobile-menu.tsx b/apps/web/src/components/mobile-menu.tsx index 60fbffb5..09bad7f2 100644 --- a/apps/web/src/components/mobile-menu.tsx +++ b/apps/web/src/components/mobile-menu.tsx @@ -145,7 +145,7 @@ export function MobileMenu({ aria-current={ column.href === pathname ? "page" : undefined } - className="focus-ring-inset -mx-3 flex items-center rounded-none px-3 py-3 font-light font-mono text-foreground text-sm uppercase tracking-normal hover:bg-zinc-200 dark:hover:bg-zinc-800" + className="hover-surface focus-ring-inset -mx-3 flex items-center rounded-none px-3 py-3 font-light font-mono text-foreground text-sm uppercase tracking-normal" href={column.href} key={column._key} onClick={closeMenu} @@ -162,7 +162,7 @@ export function MobileMenu({ key={column._key} value={column._key} > - + {column.title} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 6af42959..98dd77b0 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -19,21 +19,15 @@ import { Logo } from "@/components/logo"; import { MobileMenu } from "@/components/mobile-menu"; import type { ColumnLink, NavigationData } from "@/types"; -// Shared by the dropdown triggers and the plain links so the two never drift, -// and focus mirrors hover so keyboard and pointer land alike. Over a marked -// section the wash goes translucent: a flat zinc swatch only holds contrast on -// the one background it was picked for. -// -// The wash alone can't carry focus — zinc-200 on white is ~1.2:1, well under -// the 3:1 a focus indicator needs — so keyboard focus also draws the site's -// dotted ring. `currentColor`, not `--foreground`, because the text colour -// already inverts over a marked section and the ring has to follow it. +// Focus-visible mirrors the `hover-surface` wash so keyboard and pointer land +// on the same colour. The `data-nav-on` overrides win over both, keeping the +// translucent treatment where the bar sits on a fixed-ground section. const NAV_LINK_CLASS = - "h-auto rounded-full bg-transparent px-3 py-2 font-light font-mono text-foreground text-sm uppercase tracking-normal outline-none hover:bg-zinc-200 dark:hover:bg-zinc-800 focus-visible:bg-zinc-200 focus-visible:[outline:2px_dotted_currentColor]! focus-visible:outline-offset-2! dark:focus-visible:bg-zinc-800 data-[nav-on=dark]:text-white data-[nav-on=dark]:hover:bg-white/15 data-[nav-on=dark]:focus-visible:bg-white/15 data-[nav-on=light]:text-zinc-900 data-[nav-on=light]:hover:bg-zinc-900/10 data-[nav-on=light]:focus-visible:bg-zinc-900/10"; + "hover-surface h-auto rounded-full bg-transparent px-3 py-2 font-light font-mono text-foreground text-sm uppercase tracking-normal outline-none focus-visible:bg-zinc-100 focus-visible:[outline:2px_dotted_currentColor]! focus-visible:outline-offset-2! dark:focus-visible:bg-zinc-900 data-[nav-on=dark]:text-white data-[nav-on=dark]:hover:bg-white/15 data-[nav-on=dark]:focus-visible:bg-white/15 data-[nav-on=light]:text-zinc-900 data-[nav-on=light]:hover:bg-zinc-900/10 data-[nav-on=light]:focus-visible:bg-zinc-900/10"; const TRIGGER_CLASS = cn( NAV_LINK_CLASS, - "data-popup-open:bg-zinc-100 dark:data-popup-open:bg-zinc-800 data-[nav-on=dark]:data-popup-open:bg-white/15 data-[nav-on=light]:data-popup-open:bg-zinc-900/10" + "data-popup-open:bg-zinc-100 dark:data-popup-open:bg-zinc-900 data-[nav-on=dark]:data-popup-open:bg-white/15 data-[nav-on=light]:data-popup-open:bg-zinc-900/10" ); // The outline pill draws itself in theme ink, which lands white-on-bright over @@ -255,7 +249,7 @@ export function Navbar({ return (
    @@ -293,7 +287,7 @@ export function Navbar({
  • } > diff --git a/apps/web/src/lib/seo.ts b/apps/web/src/lib/seo.ts index b4c2f4ba..833fedf9 100644 --- a/apps/web/src/lib/seo.ts +++ b/apps/web/src/lib/seo.ts @@ -158,10 +158,10 @@ export async function getSEOMetadata( ] : undefined; - const fullTitle = - defaultTitle === siteConfig.title - ? defaultTitle - : `${defaultTitle} / ${siteConfig.title}`; + // Page title only — the site name repeated on every tab pushed the part that + // distinguishes them out of view. It still reaches crawlers via `creator`, + // `authors` and the Open Graph `siteName` below. + const fullTitle = defaultTitle; const markdownUrl = slug && slug !== "/" ? `${pageUrl}.md` : `${baseUrl}/index.md`; @@ -199,6 +199,7 @@ export async function getSEOMetadata( countryName: "UK", description: socialDescription, title: socialTitle, + siteName: siteConfig.title, images: ogImages, url: pageUrl, }, diff --git a/packages/sanity-blocks/src/faq-accordion/index.tsx b/packages/sanity-blocks/src/faq-accordion/index.tsx index e7eabad7..cfded390 100644 --- a/packages/sanity-blocks/src/faq-accordion/index.tsx +++ b/packages/sanity-blocks/src/faq-accordion/index.tsx @@ -40,13 +40,12 @@ export interface FaqAccordionProps { } const DISCLOSURE_BASE_CLASS = - "hover-surface group border border-border bg-background px-4 has-[summary:focus-visible]:[outline:2px_dotted_var(--foreground)] has-[summary:focus-visible]:[outline-offset:-2px]"; -// `transition-none` because `duration-300` is here for `animate-in`, but the -// utility also sets `transition-duration`, and CSS defaults `transition-property` -// to `all` — so the entrance duration was silently fading the hover background -// too, while the code chip inside switched instantly. + "hover-surface group border border-border bg-background px-4 transition-colors duration-150 has-[summary:focus-visible]:[outline:2px_dotted_var(--foreground)] has-[summary:focus-visible]:[outline-offset:-2px] motion-reduce:transition-none"; +// `animation-duration-300`, not `duration-300`: the latter also sets +// `transition-duration`, which stretched the hover fade above to the entrance's +// 300ms while the code chip inside switched instantly. const DISCLOSURE_ANIMATION_CLASS = - "fade-in slide-in-from-bottom-2 animate-in fill-mode-both duration-300 ease-out transition-none motion-reduce:animate-none"; + "fade-in slide-in-from-bottom-2 animate-in fill-mode-both animation-duration-300 ease-out motion-reduce:animate-none"; function FaqDisclosure({ animationDelay, diff --git a/packages/sanity-blocks/src/internal/code-block.tsx b/packages/sanity-blocks/src/internal/code-block.tsx index 4e1817d3..dcd0412b 100644 --- a/packages/sanity-blocks/src/internal/code-block.tsx +++ b/packages/sanity-blocks/src/internal/code-block.tsx @@ -60,7 +60,7 @@ export function CodeBlock({ keyboard-focusable on their own, so arrow keys can still pan a long line into view (WCAG 2.1.1). */}
    -          {code}
    +          {code}
             
  • diff --git a/packages/sanity/src/live.ts b/packages/sanity/src/live.ts index be6a8085..d67c5f23 100644 --- a/packages/sanity/src/live.ts +++ b/packages/sanity/src/live.ts @@ -50,18 +50,30 @@ const DRAFTS_FETCH_OPTIONS: DynamicFetchOptions = { stega: false, }; -export const DRAFT_MODE_ENABLED = process.env.NODE_ENV === "development"; +/** + * Serve drafts to a request that carries no Presentation session. Local dev + * only: anywhere the URL is publicly reachable this would hand unpublished + * content to anyone who can load the page. + */ +export const DRAFTS_WITHOUT_SESSION = process.env.NODE_ENV === "development"; -/** Resolves perspective/stega outside any `'use cache'` boundary (reads draftMode/cookies). */ +/** + * Resolves perspective/stega outside any `'use cache'` boundary (reads + * draftMode/cookies). + * + * Drafts may render in any environment — including production — but only for a + * request holding a draft-mode session. `/api/presentation-draft` is the only + * way to obtain one and it validates a Sanity preview secret, so an anonymous + * request still resolves to published content and renders statically. + * Gating on the session rather than on the environment is what lets the + * deployed Studio's Presentation tool preview the production site. + */ export async function getDynamicFetchOptions(): Promise { - if (!DRAFT_MODE_ENABLED) { - return PUBLISHED_FETCH_OPTIONS; - } const { isEnabled: isDraftMode } = await draftMode(); if (!isDraftMode) { - // Dev without a Presentation session still shows drafts, so draft-only - // pages are visible while developing. Stega stays off here. - return DRAFTS_FETCH_OPTIONS; + return DRAFTS_WITHOUT_SESSION + ? DRAFTS_FETCH_OPTIONS + : PUBLISHED_FETCH_OPTIONS; } const jar = await cookies(); @@ -70,14 +82,10 @@ export async function getDynamicFetchOptions(): Promise { } /** - * Perspective/stega for a page route's inner (post-Suspense) component: - * published in production (stays static), drafts in dev. Must be called outside - * any `'use cache'` boundary (reads draftMode). + * Perspective/stega for a page route's inner (post-Suspense) component. Must be + * called outside any `'use cache'` boundary (reads draftMode). */ export async function resolvePageFetchOptions(): Promise { - if (!DRAFT_MODE_ENABLED) { - return PUBLISHED_FETCH_OPTIONS; - } return getDynamicFetchOptions(); } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 8f37b5ae..7705bbf7 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -213,8 +213,11 @@ margin-inline: calc(var(--container-px, 0.5rem) * -1); } +/* The one hover wash, shared by nav, the FAQ accordion and blog cards. Any + * surface using it must rest on `--background` (white/black), or the wash and + * the rest state collapse into each other. */ @utility hover-surface { - @apply hover:bg-zinc-100 dark:hover:bg-zinc-800; + @apply hover:bg-zinc-100 dark:hover:bg-zinc-900; } @utility body-text { @@ -271,6 +274,33 @@ } } +/* Slides the navbar out as the footer finishes taking over the screen. Sticky + cannot do this on its own while the footer is pinned: a fixed footer adds + only `--footer-height` of scroll past the content, so the header's container + comes to rest `100vh - --footer-height` below the top and never runs out. + Anchored to the end of the document, so it works pinned or in flow. */ +@keyframes nav-exit { + to { + transform: translateY(-100%); + } +} + +@utility nav-exit { + animation-name: nav-exit; + /* Ignored on a scroll timeline, which drives progress from the range below. */ + animation-duration: 1ms; + animation-timing-function: linear; + animation-fill-mode: both; + animation-timeline: scroll(root block); + animation-range: calc(100% - 180px) calc(100% - 20px); +} + +@supports not (animation-timeline: scroll()) { + .nav-exit { + animation-name: none; + } +} + /* No `to`, so settled is the element's own state and anything ignoring the animation still shows the stroke. User units: SVG % needs `transform-box`. */ @keyframes mark-frame { @@ -474,9 +504,19 @@ /* Code blocks (rich-text). Plain, unhighlighted monospace with a line-number gutter — no syntax coloring, tabs preserved. */ +.rich-code { + /* One inset for both columns: the block padding has to match or number N and + line N start on different rows, and the inline inset matches the header's + px-3 so the badge, the numbers and the code share one 0.75rem rhythm. */ + --rich-code-inset-block: 1rem; + --rich-code-inset-inline: 0.75rem; +} + .rich-code-pre { margin: 0; - padding: 1rem 1.25rem; + padding: var(--rich-code-inset-block) var(--rich-code-inset-inline); + /* Inherited from .rich-code, so the gutter and the code cannot drift apart. */ + line-height: inherit; /* max-content + min-width keeps the background full-width when lines overflow. */ width: max-content; min-width: 100%; @@ -488,7 +528,11 @@ /* Line-number gutter — a fixed column beside the horizontally-scrolling code. */ .rich-code-gutter { flex: none; - padding: 1rem 0.75rem; + padding: var(--rich-code-inset-block) var(--rich-code-inset-inline); + /* Reserve two digits: the column is otherwise content-sized, so a 9-line and + a 90-line block would indent their code by different amounts. */ + min-width: calc(2.5ch + var(--rich-code-inset-inline) * 2); + line-height: inherit; text-align: right; font-variant-numeric: tabular-nums; color: color-mix(in oklab, var(--muted-foreground) 60%, transparent);