import { type MergeableRef, mergeRefs, useUniqueId, } from "@charcuterie/logic" import { type ComponentPropsWithRef, type ReactNode, type RefCallback, useContext, } from "react" import { type ContentWidth, DEFAULT_CONTENT_WIDTH, toMaxInlineSize, } from "../Shell/contentWidth.ts" import { ShellContext } from "../Shell/shellContext.ts" import { toClassName } from "../toClassName.ts" import { ScrollMemoryContext } from "./scrollMemoryContext.ts" import { useScrollMemory } from "./useScrollMemory.ts" export type MainProps = ComponentPropsWithRef<"main"> & { contentWidth?: ContentWidth } /** * The `
` landmark and the content column. * * ## The width is the whole component * * The owner's complaint, verbatim: * * > "All these apps seem to be really narrow with a very large * > max-width. By that I mean 1 column, but waaaaaaay too wide. In * > almost all cases, what I really want is something like Rip-Deck * > where the app has a narrower main column but the wrapping grids * > are all full-width in most of these apps only when you have too * > many items." * * That is two requirements, and they pull opposite ways: a * **narrow** column by default, which **widens** when the content * is a grid with enough items to justify it. A fixed `max-w-7xl` * answers neither — it is too wide for the one-column case, which * is the complaint, and it is a hard ceiling on the many-items * case. * * So the cap is a *value*, not a constant, and it arrives from * three places in priority order: this component's own * `contentWidth`, then `Shell`'s (the usual answer, so the header * matches), then `screen.lg`. * * ### How it composes with the adaptive-columns hook * * rip-deck's `useLayoutColumns` — being lifted into this library * separately — decides a column count from the viewport and folds * it into a page cap with `contentMaxWidthRem(columns)`: 1 column → * 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and * `ContentWidth` accepts `` `${number}rem` `` for exactly this: * * ```tsx * const { columns } = useLayoutColumns({ cardCount: bays.length }) * * *
*
* *
* * ``` * * The seam is deliberately one prop and no hook import: this * package must not depend on a layout hook to lay out a page, and * the hook must not know that a `Shell` exists. Putting the value * on `Shell` rather than on `Main` is what keeps the header's cap * in step with the column's as the count changes. * * ## `@container`, and what `contain: inline-size` costs * * The content column establishes a container query, so an app's * grids inside it respond to **the column's** width rather than * the window's — a poster grid beside an open rail and the same * grid full-bleed are the pair a media query cannot tell apart, * and getting it wrong at intermediate widths is what the fleet's * grids do today. * * `container-type: inline-size` implies `contain: layout style * inline-size`, and both halves have consequences worth stating: * * - **The element may not be sized by its own contents.** Here * that is free and in fact wanted: the column has a definite * inline size from its parent — itself a grid item with * `minmax(0, 1fr)` — so wide content can no longer widen it, * which is half of why the page does not scroll sideways. * - **`contain: layout` makes it a containing block for * `position: fixed` descendants.** An app rendering a fixed * element *inside* `Main` gets it positioned against the * column, not the viewport. Every overlay in this library * portals to `document.body` and is therefore unaffected * ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)); * an app's own fixed chrome belongs in `Shell`, beside `Main` * rather than in it. * * ## `relative` — the scrollport has to be the containing block * * `
` is the page's only vertical scrollport, so everything * in it is supposed to scroll *inside* it. An absolutely * positioned descendant does not, unless this element is its * containing block: with `position: static` here, the nearest * positioned ancestor is `Shell`, which is **outside** the * scrollport. The box then resolves against `Shell`, `
`'s * `overflow-y` has no authority over it, and its static position — * a couple of thousand pixels down a long document — becomes * scrollable overflow on `documentElement`. The page grows to * reach it and the reader gets a **second scrollbar** beside the * one `
` already has. * * The box does not have to be exotic. Tailwind's `sr-only` is * `position: absolute`, so the visually hidden * `(opens in a new tab)` on one link in a document * footer is enough. Measured on Folio at 1440x900: `
` was * 835px tall and `document.documentElement.scrollHeight` read * 2085, which is exactly where that span sat. * * `overflow-x: hidden` comes with it and is not decoration. * `relative` moves every absolutely positioned descendant's * containing block from `Shell` to here, which also moves what * clips them **sideways**: `Shell`'s `overflow-x: clip` no longer * reaches them, so a drawer parked at `translateX(110%)` inside * `Main` would draw a horizontal scrollbar on the scrollport * instead of being clipped. `hidden` rather than `clip` because * this element already scrolls on the other axis — a `clip` beside * an `auto` computes to `hidden` anyway, and writing what the * browser will use is the honest spelling. `Shell`'s note on the * clip explains the other half. * * ## It remembers where each history entry was scrolled to * * `Shell` gives this element the page's only vertical scrollport, * so the header and the rails stay put while the content moves. * The browser's own scroll restoration does not follow it there: a * browser remembers **the document scroller's** offset per history * entry, and `
` is not the document scroller. It also does * not restore anything at all across a same-document `pushState` * navigation, which is every link press in a single-page app. Back * therefore lands at the top of a list the reader had scrolled * half-way down, and no browser setting changes that. * * So this element remembers it, and **there is no prop for it**. * The history entry arrives on `ScrollMemoryContext`, which * `ReactRouterAdapter` puts at the app root: * * ```tsx * * * … * * * ``` * * It was a prop for one release — `scrollKey` — and that release is * the argument against a prop. One app wired it; the other three * carried on losing the reader's place with the library sitting * right there supporting it. Render no provider and nothing is * remembered, which is what an app with no router should get. * * `useScrollMemory.ts` holds the mechanism and `scrollMemory.ts` * the reasoning. * * ## Two more things it does, both one line * * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not * merely the reading cursor — without it, Safari and Firefox scroll * to `
` and leave the next Tab back in the header. * * `wrap-anywhere` so an unbroken string — a filesystem path, a * hash, a URL — wraps instead of widening the page. * * **`anywhere`, not `break-word`, and the difference is the whole * point.** Both break a long word onto the next line, so they look * identical in a screenshot. Only `anywhere` also shrinks the * element's **min-content size** — which is what a flex or grid * item's automatic minimum resolves against, and therefore what * decides whether the string can push its own column wider. With * `break-word` the ink wraps while the intrinsic contribution * stays the full length of the token, so the layout above it can * still be forced open. This is the second of the two overflow * sources found in plex-channels, and the nastier one: it produces * **no overflowing element box at all**, so every * `getBoundingClientRect()` in a test reads clean while the page * scrolls sideways. * * It is the cheap half of the horizontal-scroll fix; the other * halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A * `` is the one thing none of them can save: tables do not * wrap, so a wide one goes in an `overflow-x-auto` wrapper, which * `Main.mdx` shows. */ export const Main = ({ children, className, contentWidth, id, ref, ...mainProps }: MainProps): ReactNode => { const shell = useContext(ShellContext) const scrollEntry = useContext(ScrollMemoryContext) const setScrollport = useScrollMemory(scrollEntry) // Always called, never conditionally: a `Main` outside a `Shell` // still needs an id an app can point its own skip link at. const fallbackId = useUniqueId() const maxInlineSize = toMaxInlineSize( contentWidth ?? shell?.contentWidth ?? DEFAULT_CONTENT_WIDTH, ) return (
) : setScrollport } tabIndex={-1} > {/* `@container` is on the **capped column**, not on `
`, and the difference is not cosmetic. `
` is as wide as its grid track — 1184px beside an open rail on a 1440px window — while the column inside it is capped at `contentWidth`. Declaring the container on `
` makes every `cq-*` inside answer to a width the content never has: a grid would go three-up at `--cq-xl` while sitting in 976px of space. The container has to be the box the content is actually laid out in. */}
{children}
) }