import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as MainStories from "./Main.stories";

<Meta of={MainStories} />

# Main

The `<main>` landmark and the content column. **Its width is the crux of the whole shell.**

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."

<Canvas of={MainStories.Playground} />

## Props

<Controls />

## The width is a value, not a constant

The cap is the least standardized number in the fleet: `max-w-5xl` (board-games, portly),
`max-w-6xl` *and* `max-w-3xl` mixed inside one app (mux-magic), `max-w-7xl`
(gallery-downloader, spoolbuddy), `max-w-[90rem]` (mail-sifter), `80rem` (points-market), a
raw `1400px` in plain CSS (plex-channels), a JS-computed one (rip-deck), and none at all in
three more.

`contentWidth` accepts three shapes, and the third is why this is a value rather than a
constant:

| Shape | Example | For |
| --- | --- | --- |
| A `screen.*` step | `contentWidth="lg"` | The ordinary page. `lg` (64rem) is the default. |
| `"full"` | `contentWidth="full"` | A wall of tiles that genuinely wants the window. |
| A `rem` literal | ``contentWidth={`${rem}rem`}`` | A cap computed at runtime. |

<Canvas of={MainStories.AllVariants} />

**`max-w-*` is not used and must not be.** Tailwind v4 owns `--container-*` for that scale
at *different* sizes than our `screen.*`, which is the collision that renamed the
container-query scale to `--cq-*`. The cap is an inline `max-inline-size` reading
`var(--screen-*)`, so it stays a token instead of becoming a literal.

## How it composes with the adaptive-columns hook

rip-deck's `useLayoutColumns` — lifted into this library separately — decides a column count
from the viewport, then folds it into a page cap with `contentMaxWidthRem(columns)`: one
column → 56rem, two → 72rem, three → 106rem. It returns a **number of rem**, which is
exactly the third shape above:

```tsx
const { columns } = useLayoutColumns({ cardCount: bays.length })

<Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
  <Header heading="Rip Deck" />

  <Main>
    <BayGrid columns={columns} />
  </Main>
</Shell>
```

The seam is one prop and no import in either direction: this package does not depend on a
layout hook to lay out a page, and the hook does not know a `Shell` exists. The value goes
on **`Shell`** rather than on `Main` so the header's cap stays in step with the column's as
the count changes — which is the same reason the two share one `contentWidth` at all.

## `@container` is on the column, not on `<main>`

A grid inside the content column answers to **the column's** width. A poster grid beside an
open rail and the same grid full-bleed are the pair a media query cannot tell apart, and
getting that wrong at intermediate widths is what the fleet's grids do today.

The container is declared on the capped inner column rather than on `<main>` deliberately.
`<main>` 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`. A container on `<main>` measures a
width the content never has: a grid would go three-up at `--cq-xl` while sitting in 976px
of space.

**`container-type: inline-size` implies `contain: layout style inline-size`**, and both
halves have consequences:

- **The element may not be sized by its own contents.** Here that is wanted — the column
  has a definite inline size from its parent, 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 fixed chrome *inside* `Main` gets it positioned against the column rather
  than the viewport. Every overlay in this library portals to `document.body` and is
  unaffected; an app's own fixed chrome belongs in `Shell`, beside `Main`.

## Two kinds of overflow, two different answers

<Canvas of={MainStories.Responsive} />

`wrap-anywhere` on the column handles content that *can* wrap and does not — a filesystem
path, a hash, a URL, none of which contain a break opportunity.

**`anywhere`, not `break-word`.** Both break the long word onto the next line, so the two are
indistinguishable 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 force its own column wider. Under
`break-word` the ink wraps while the intrinsic contribution stays the full token length, so
the page can still scroll sideways with **no overflowing element box anywhere in the tree**
for a `getBoundingClientRect()` assertion to catch. That is why the gate asserts
`document.documentElement.scrollWidth`, and why `Main.test.tsx` pins the computed value.

That is the cheap half of the horizontal-scroll fix; the other halves are `minmax(0, 1fr)`
and `overflow-x: clip` on `Shell`, plus `overflow-x: hidden` here for anything absolutely
positioned **inside** the scrollport — the next section says why that one cannot be left to
the frame.

A `<table>` is the one thing neither can save. Table columns have min-content widths that no
`overflow-wrap` reaches, so a wide table goes in a scroll container:

```tsx
<section
  aria-label="Transfer log, scrollable"
  className="charcuterie-scrollbar overflow-x-auto rounded-lg border border-border-subtle"
>
  <table>…</table>
</section>
```

Because that container scrolls, the columns off screen have to be reachable **without a
mouse** — axe's `scrollable-region-focusable`, which is WCAG 2.1.1. There are two ways to
satisfy it and the choice is not a matter of taste:

1. **The content contains something focusable.** A row that ends in a link is enough:
   tabbing to it scrolls the container into view. This is the case above and the one the
   `Responsive` story ships, because a log row with no way into the record is a poor table
   anyway.
2. **`tabIndex={0}` on the container.** The answer for a table of pure text, and the one
   most guidance quotes. Note that Biome's `noNoninteractiveTabindex` rejects it on a
   non-interactive element and its **unsafe fix removes the attribute before a suppression
   is evaluated** — so `yarn lint` will silently turn a compliant table into a
   keyboard-trap. Prefer (1) where the data allows it.

`aria-label` on the container is worth it either way: it turns the scroller into a named
region, so the table is announced as a thing rather than as loose content.

## `relative`, and the second scrollbar

`<main>` is the page's only vertical scrollport, so the document itself must never scroll.
An absolutely positioned descendant breaks that unless `<main>` is its **containing block**.
With `position: static` the nearest positioned ancestor is `Shell`, which sits *outside* the
scrollport: the box resolves against the frame, `<main>`'s `overflow-y` has no authority over
it, and its static position — a couple of thousand pixels down a long document — lands as
scrollable overflow on `documentElement`. The page grows to reach it, and the reader gets a
**second scrollbar** beside the one `<main>` already has.

The box does not have to be exotic, which is what makes this hard to spot. Tailwind's
`sr-only` is `position: absolute`, so one visually hidden `(opens in a new tab)` span in a
document footer is enough. Measured on Folio at 1440x900 before this: `<main>` 835px tall,
`document.documentElement.scrollHeight` 2085 — the span's offset, exactly. Nothing on the
page looked wrong; there were simply two scrollbars.

`overflow-x: hidden` comes with the `relative` and is not decoration. Moving every
absolutely positioned descendant's containing block from `Shell` to `<main>` also moves what
clips them sideways, so `Shell`'s `overflow-x: clip` no longer reaches them — 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: `clip` beside an `auto` computes to `hidden` anyway, so writing `hidden` is
writing what the browser will use.

An app's own parked chrome still belongs in `Shell`, beside `Main` rather than in it. That is
what `Shell`'s clip is for, and it is the arrangement `Shell.stories.tsx` demonstrates.

## `tabIndex={-1}`

<Canvas of={MainStories.Interactive} />

`<main>` accepts focus so `Shell`'s skip link moves **focus** and not merely the scroll
position. Without it, Safari and Firefox scroll to `<main>` and leave the next Tab back up
in the header — which looks exactly like a skip link that works.

## The reader's place, kept

<Canvas of={MainStories.ScrollMemory} />

`Shell` gives this element the page's only vertical scrollport, so the header and the rails
stay put while the content moves. That is what `Shell.test.tsx` asserts, and this is what it
costs.

**The browser will not put the offset back.** A browser remembers the **document scroller's**
position per history entry, and `<main>` is not the document scroller. It also restores
nothing at all across a same-document `pushState` navigation, which is every link press in a
single-page app. So Back lands at the top of a list the reader had scrolled half-way down,
`history.scrollRestoration` has no bearing on it, and there is no browser setting that
changes it. The owner, on Docket:

> "If I scroll, click on an item, then go back, it resets me to the top. […] It should
> remember where the scroll position was previously. Isn't this handled by Chrome already?"

**There is no prop for it.** The history entry arrives on a context, and
`ReactRouterAdapter` puts it at the app root beside the link seam:

```tsx
import { ReactRouterAdapter } from "@charcuterie/ui/react-router"

<BrowserRouter>
  <ReactRouterAdapter>
    <Routes>…</Routes>
  </ReactRouterAdapter>
</BrowserRouter>
```

It was a prop for one release — `scrollKey` — and that release is the argument against a
prop. One app wired it and 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 right for an app with no history to remember.

An app on another router wires `ScrollMemoryProvider` itself, with any value that identifies
the history **entry** rather than the URL:

```tsx
<ScrollMemoryProvider entry={{ key: historyKey, path: location }}>
  <App />
</ScrollMemoryProvider>
```

### A filter is not a navigation

A filter chip, an expanded group and a selected tab each write a search param, and
`setSearchParams` **navigates** — so each one mints a history entry the memory has never
seen. Read as "unseen ⇒ start at the top", pressing a filter throws the reader to the top of
the list they were reading. That shipped, and it is why an entry remembers its **path**:

| The entry | What happens |
| --- | --- |
| One we have seen | Its remembered offset. Back and Forward land where you left them. |
| New, **same** path | Nothing. The scrollport is left exactly where the reader put it. |
| New, **different** path | The top, which is what a browser does for a new document. |

### It has to wait for the content

<Canvas of={MainStories.ScrollMemoryWithLateContent} />

A one-shot `scrollTop = offset` does **nothing** on a real page. The commit that changes the
route draws a skeleton, the rows land a fetch later, and an offset written against an empty
scrollport is clamped to `0` by the browser. Every check a one-shot restore could make still
passes — the offset was remembered, the write happened — and the reader is looking at the top
of the list.

So the restore re-applies through a `ResizeObserver` on the scrollport and on its content
column, and stops at the first of three things:

1. **The offset lands.** Anything past that would only fight the reader.
2. **The reader scrolls.** Any `wheel`, `touchstart`, `keydown` or `pointerdown` on the
   scrollport ends it, because a page that jumps out from under somebody who has already
   started reading is a worse bug than the one being fixed.
3. **Three seconds pass.** A page that slow has been read from the top already.

The offsets live in memory, not in `sessionStorage`. A history key survives a reload; the DOM
it described does not, and a pixel offset from before the reload points at a different row.
Restoring a stale number is worse than restoring nothing, because it looks deliberate.
