Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,32 @@
--whisper: #0f9d6a;
/* Intentionally 0: anchor jumps land flush at viewport top (under
the fixed nav). Section headers don't get clipped because every
Frame's --frame-pad-y floor (5rem = 80px) sits comfortably above
the current fixed-nav height (py-5 + 30px wordmark ≈ 70px),
Frame's --spacing-frame-pad-y floor (5rem = 80px) sits comfortably
above the current fixed-nav height (py-5 + 30px wordmark ≈ 70px),
leaving ~10px of breathing room below the nav. If the nav grows
past 5rem, set --nav-h to the measured nav height — Frame already
applies scroll-mt-[var(--nav-h)] on every section, so anchor
offsets pick it up automatically. (Bumping the 5rem floor on
--frame-pad-y only helps below ~889px — above that the clamp's
9vw term already exceeds 5rem, so a bigger floor does nothing on
desktop.) */
--spacing-frame-pad-y only helps below ~889px — above that the
clamp's 9vw term already exceeds 5rem, so a bigger floor does
nothing on desktop.) */
--nav-h: 0;
}

@theme inline {
--color-ink: var(--ink);
--color-paper: var(--paper);
--color-whisper: var(--whisper);
--font-sans: var(--font-geist-sans);

/* Shared content rail: every Frame centers within --frame-max. */
--frame-max: 1280px;
--frame-gutter: clamp(1.5rem, 4vw, 4rem);
--frame-pad-y: clamp(
/* Shared content rail: every Frame centers within --container-frame. */
--container-frame: 1280px;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In Tailwind CSS v4, the --container-* namespace is specifically reserved for configuring the container utility (e.g., centering, padding, or breakpoint-specific max-widths). It does not automatically generate a max-w-* utility for the given name. To enable the max-w-frame utility used in Chrome.tsx, Footer.tsx, and Frame.tsx, you should use the --width-* namespace (or --spacing-* if you want it available for padding/margin as well).

  --width-frame: 1280px;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified — --container-* does generate max-w-* in Tailwind v4. After pnpm build on this branch (39ac7c8), the static export contains .max-w-frame{max-width:1280px} — exactly the utility Chrome.tsx / Footer.tsx / Frame.tsx rely on. Tailwind v4's own defaults follow the same pattern: node_modules/tailwindcss/theme.css defines --container-3xs--container-7xl, which back the framework's max-w-3xsmax-w-7xl utilities, and the official v4 docs implement every max-w-* as max-width: var(--container-<name>) (e.g. max-w-2xs is max-width: var(--container-2xs)).

The @frame: container-query variant the comment alludes to is an additional output of --container-*, not a substitute — both happen. --width-* isn't a max-width-generating namespace in v4, so renaming to --width-frame would actually break max-w-frame. No change needed.

--spacing-frame-gutter: clamp(1.5rem, 4vw, 4rem);
--spacing-frame-pad-y: clamp(
5rem,
9vw,
7rem
); /* floor coupled to nav height — see --nav-h note above */
--frame-min-h-cap: 54rem;
--spacing-frame-min-h: min(100svh, 54rem);

/* Type scale: micro and CTA are fixed; body and headlines scale with viewport.
Named under Tailwind v4's --text-* namespace so `text-body`, `text-h1`, …
Expand Down Expand Up @@ -1009,7 +1008,7 @@ a.underline-brutal:hover .arrow {
.mm-toggle {
position: fixed;
top: 24px;
right: var(--frame-gutter);
right: var(--spacing-frame-gutter);
z-index: 65;
width: 22px;
height: 22px;
Expand Down
17 changes: 9 additions & 8 deletions components/site/Chrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { links } from "@/lib/links";
import { homeHashSectionId, shouldInterceptNavClick } from "@/lib/scroll";
import { MobileMenu } from "@/components/site/MobileMenu";
import { resolveActiveSection } from "@/components/site/chrome-active";

const SECTION_IDS = ["intro", "compare", "method", "faq", "contact"] as const;
// Hash anchors are written `/#section`. Required because this nav also
Expand Down Expand Up @@ -169,12 +170,12 @@ export function Chrome() {
return () => window.removeEventListener("scroll", onScroll);
}, [pathname]);

// Guard against a stale `active` landing in `DARK_SECTIONS` and painting
// `text-paper` over a paper-toned page: off-home the IO observes nothing,
// and on the first render after a client nav `active` still holds the
// previous route's last value until the [pathname] effects re-sync.
// `scrolled` doubles as a proxy for "past the intro hero".
const effectiveActive = pathname === "/" && scrolled ? active : "intro";
const effectiveActive = resolveActiveSection(
pathname,
scrolled,
active,
"intro",
);
const onDark = DARK_SECTIONS.has(effectiveActive);
// The toggle is portalled to <body> (see MobileMenu.tsx) and lives
// on top of the paper panel while the drawer is open — force it to
Expand All @@ -188,11 +189,11 @@ export function Chrome() {
data-scrolled={scrolled ? "true" : undefined}
data-tone={onDark ? "ink" : "paper"}
data-mobile-open={mobileOpen ? "true" : undefined}
className={`chrome-nav fixed inset-x-0 top-0 z-50 px-[var(--frame-gutter)] py-5 ${
className={`chrome-nav fixed inset-x-0 top-0 z-50 px-frame-gutter py-5 ${
onDark ? "text-paper" : "text-ink"
}`}
>
<div className="mx-auto grid w-full max-w-[var(--frame-max)] grid-cols-[1fr_auto_1fr] items-center gap-4">
<div className="mx-auto grid w-full max-w-frame grid-cols-[1fr_auto_1fr] items-center gap-4">
<Link
href="/#intro"
onClick={handleNavClick}
Expand Down
4 changes: 2 additions & 2 deletions components/site/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { links } from "@/lib/links";

export function Footer() {
return (
<footer className="bg-paper px-[var(--frame-gutter)] pb-10 text-ink">
<div className="@container mx-auto flex max-w-[var(--frame-max)] flex-col gap-3">
<footer className="bg-paper px-frame-gutter pb-10 text-ink">
<div className="@container mx-auto flex max-w-frame flex-col gap-3">
<span aria-hidden className="rule opacity-40" />
<div className="grid grid-cols-1 gap-2 text-micro tracking-[0.2em] uppercase opacity-80 @md:grid-cols-3 @md:items-center">
<span>© decdn labs · open source</span>
Expand Down
53 changes: 53 additions & 0 deletions components/site/chrome-active.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveActiveSection } from "./chrome-active";

describe("resolveActiveSection", () => {
it.each([
{
name: "off-home + scrolled falls back",
pathname: "/blog/foo/",
scrolled: true,
observed: "compare",
expected: "intro",
},
{
name: "home but not yet scrolled falls back (back-nav flash guard)",
pathname: "/",
scrolled: false,
observed: "compare",
expected: "intro",
},
{
name: "home + scrolled returns the observed dark section",
pathname: "/",
scrolled: true,
observed: "compare",
expected: "compare",
},
{
name: "home + scrolled passes other DARK_SECTIONS members through",
pathname: "/",
scrolled: true,
observed: "faq",
expected: "faq",
},
{
name: "off-home dominates regardless of scrolled state",
pathname: "/blog/",
scrolled: false,
observed: "faq",
expected: "intro",
},
{
name: "home with query string falls back (exact match)",
pathname: "/?utm=x",
scrolled: true,
observed: "compare",
expected: "intro",
},
] as const)("$name", ({ pathname, scrolled, observed, expected }) => {
expect(resolveActiveSection(pathname, scrolled, observed, "intro")).toBe(
expected,
);
});
});
15 changes: 15 additions & 0 deletions components/site/chrome-active.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Both `pathname === "/"` and `scrolled` must hold for `observed` to win.
// Off-home, the caller's observer never gets built but `active` still
// holds its last home-route value — the home check stops that stale value
// painting. On home, `active` is briefly stale between the route-change
// render and the new observer's first callback; `scrolled` (scrollY > 8)
// stands in for "observer has had a moment to fire". Caveat: back-nav
// with restored scroll skips this — a brief stale-tone flash is possible.
export function resolveActiveSection<T>(
pathname: string,
scrolled: boolean,
observed: T,
fallback: T,
): T {
return pathname === "/" && scrolled ? observed : fallback;
}
6 changes: 3 additions & 3 deletions components/ui/Frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ export function Frame({
children,
}: FrameProps) {
const sectionClass = [
"relative flex flex-col scroll-mt-[var(--nav-h)] px-[var(--frame-gutter)] py-[var(--frame-pad-y)]",
fill && "min-h-[min(100svh,var(--frame-min-h-cap))]",
"relative flex flex-col scroll-mt-[var(--nav-h)] px-frame-gutter py-frame-pad-y",
fill && "min-h-frame-min-h",
TONE_CLASS[tone],
className,
]
.filter(Boolean)
.join(" ");
return (
<section id={id} aria-labelledby={`${id}-h`} className={sectionClass}>
<div className="@container relative z-10 mx-auto flex w-full max-w-[var(--frame-max)] flex-1 flex-col">
<div className="@container relative z-10 mx-auto flex w-full max-w-frame flex-1 flex-col">
{children}
</div>
</section>
Expand Down