← Back to library

Ecommerce

Book Publisher

Publishing storefront: featured title with spec table, real interior previews and a retailer buy path.

2 tools★ 79
#publishing#catalog#light
Lovablev0

The prompt

Prompt
# Casey Books — Large-Print Activity Book Storefront

## What you're building
A catalog storefront for `{{brand}}`, an independent publisher of large-print seek-and-find and coloring books, aimed at adult children and care staff buying screen-free activities for an older reader. It does not take payment — it sells the decision and hands off to a retailer. The distinctive idea is **page proof**: real interior spreads are shown at a size where you can actually judge clue type, artwork density and layout, so the buyer's real question ("is the print big enough for them?") gets answered on screen instead of after delivery.

Accessibility is not a checklist item on this page; it is the product argument. A storefront for large-print books that itself needs a magnifying glass has lost before the buy button.

## Art direction
Mood: a well-made trade paperback lying on a kitchen table in morning light. Warm paper, deep green, one terracotta accent. Nothing glossy, nothing dark.

| Token | Hex | Use |
|---|---|---|
| `--paper` | `#FCFBF7` | Page background; header bar at 96% |
| `--white` | `#FFFFFF` | Card fills, spread viewer, menu sheet |
| `--sage` | `#E8EEE8` | Featured-book media panel, alternating band |
| `--sage-line` | `#DFE8E4` | Panel hairlines, spec-strip dividers |
| `--sage-dim` | `#D5E0DC` | Inactive tab underline |
| `--ink` | `#18201C` | Headlines, body text, nav links |
| `--ink-soft` | `#4E5751` | Deck copy, captions, retailer disclaimer |
| `--forest` | `#123F38` | Secondary headings, retailer link, footer plate |
| `--terracotta` | `#C44731` | Eyebrow labels, buy button fill, brand mark |
| `--gold` | `#F2C54B` | The vertical rules inside the spec strip — **rules only, never text** |

Verified contrast, so you are not guessing: `--ink` on `--paper` **16.6:1**; `--ink-soft` on `--paper` **7.3:1** and on `--sage` **6.4:1**; `--terracotta` on `--paper` **4.7:1** (it clears 4.5:1 for the eyebrow — re-verify after any palette tweak); `--white` on `--terracotta` **4.9:1**; `--paper` on `--forest` **11.3:1**. `--gold` on `--paper` measures **1.7:1**, which is why it carries rules and nothing else.

Radius is small and honest: `3px` on buttons, `4px` on cards, `0` on the spec strip. Book covers and interior spreads get a real paper shadow — `0 12px 28px rgba(24,32,28,0.14), 0 2px 6px rgba(24,32,28,0.08)` — and nothing else on the page gets a shadow at all. Where spreads overlap in the hero panel, rotate them `-1.5deg` and `+1deg` with `transform-origin: center`; the cover sits at `0deg` on top. Three assets, two angles, one flat — any more rotation reads as clip art.

**Type stack.** Two families, split by job.

- Display / headings / book titles: `Newsreader, Georgia, serif`, weight `600`.
- Body, labels, all UI, all navigation, every number: `"Atkinson Hyperlegible", Arial, sans-serif`, weights `400` and `700`. This face is chosen for legibility, not decoration — do not substitute it.

```css
.h1     { font-family:Newsreader,Georgia,serif; font-weight:600;
          font-size: clamp(2.25rem, 4.44vw, 4rem);      /* 64px @1440 */
          letter-spacing:0; line-height:1.01; color:var(--ink); }
.h2     { font-family:Newsreader,Georgia,serif; font-weight:600;
          font-size: clamp(1.5rem, 1.88vw, 1.6875rem);  /* 27px @1440 */
          line-height:1.2; }
.h2-lg  { font-family:Newsreader,Georgia,serif; font-weight:600;
          font-size: clamp(2rem, 3.05vw, 2.75rem); line-height:1.1; }
.body   { font-family:"Atkinson Hyperlegible",Arial,sans-serif; font-weight:400;
          font-size:1.0625rem; line-height:1.6; color:var(--ink-soft); max-width:56ch; }
.spec-l { font-family:"Atkinson Hyperlegible",Arial,sans-serif; font-weight:400;
          font-size:1.0625rem; line-height:1.4; color:var(--ink-soft); }   /* 17px */
.spec-v { font-family:"Atkinson Hyperlegible",Arial,sans-serif; font-weight:700;
          font-size:1.25rem;   line-height:1.3; color:var(--ink); }        /* 20px */
.eyebrow{ font-family:"Atkinson Hyperlegible",Arial,sans-serif; font-weight:700;
          font-size:0.9375rem; line-height:1.55;        /* 15px */
          letter-spacing:0.06em; text-transform:uppercase; color:var(--terracotta); }
```

Face assignment: **serif for anything that names a thing** (headlines, section heads, book titles). **Atkinson for anything that is read as information** (paragraphs, nav, spec values, buttons, clue lists). Never set a paragraph in Newsreader; never set a book title in Atkinson.

**The minimum size rule, stated precisely so it does not contradict itself.** Every paragraph, list item, link, button label, spec value, form control and legal line on this site is **17px or larger** — including the footer. There is exactly **one** exception, and it is deliberate: the uppercase `.eyebrow` at 15px with `0.06em` tracking, which is a category label of four words or fewer and never carries information a buyer needs. Nothing else on the page goes below 17px. (Spec-strip labels are 17px and values 20px for this reason — a 14px label would have broken the rule the product is built on.)

Container `max-width: 1280px`, gutter `4vw`.

**H1 fit, checked against the supplied copy.** At 1440px the clamp resolves to `4.44vw = 63.9px`. The hero's left column takes `0.72fr` of a `0.72fr / 1fr` grid spanning the full viewport, so it resolves to ≈600px, leaving ≈500px of text width after the gutter and inner padding. Newsreader 600 averages ≈0.48em per character, so at 64px a character costs ≈31px. The three hand-broken lines measure `Easier, more` ≈369px, `enjoyable` ≈276px, `afternoons.` ≈338px — all inside 500px. Below 810px viewport the `2.25rem` floor engages (36px), where the longest line measures ≈207px against ≈313px available at 375px. The headline holds at three lines at every width.

## Copy

Every string the page renders, verbatim. Placeholders name a real person, product, retailer or catalog value and must be supplied. **No invented prices, no star ratings, no review counts, no sales figures, no testimonials, no "bestseller" claims.** Price and availability live at the retailer, and saying so is the honest differentiator.

Placeholders: `{{brand}}`, `{{brand-mark}}` (the one-word mark used in the Page Proof label), `{{maker-name}}`, `{{maker-first-name}}`, `{{retailer}}`, `{{year}}`, `{{contact-email}}`, and the per-book fields from the content model below.

### Chrome

| Slot | String |
|---|---|
| Skip link (first in DOM) | `Skip to content` |
| Wordmark | `{{brand}}` |
| Nav links | `All Books` · `Inside the books` · `Compare` · `About {{maker-first-name}}` |
| Retailer link | `{{retailer}} store ↗` |
| Search trigger (`aria-label`) | `Search books` |
| Search field label (visually hidden) | `Search books` |
| Search placeholder | `Search titles and scenes` |
| Search — no results | `No titles match "{{query}}". Try a scene name, or browse all books.` |
| Search — before typing | `Type to search the catalog.` |
| Menu trigger (under 900px) | `Menu` |
| Menu close (`aria-label`) | `Close menu` |
| `↗` glyph, visually-hidden companion | `(opens in a new tab)` |

**Anchor table.** Nothing on this page links to `#`.

| Control | Target |
|---|---|
| Skip link | `#main` |
| `All Books` | `#all-books` (section 4) |
| `Inside the books` | `#page-proof` (section 3) |
| `Compare` | `#choose` (section 5 — the intent chooser is the comparison aid) |
| `About {{maker-first-name}}` | `#about` (section 6) |
| `{{retailer}} store ↗` | `{{retailer-store-url}}`, new tab |
| `▤ Preview {{n}} real pages` | `#page-proof` |
| Every buy control | `{{book.retailerUrl}}`, new tab |
| Tab strip cells | not anchors — they filter section 4 in place |
| Intent rows | not anchors — they scroll to section 4 and highlight a card |

### 1 · Hero

| Slot | String |
|---|---|
| Eyebrow | `Large-print activity books` |
| `.h1`, line 1 | `Easier, more` |
| `.h1`, line 2 | `enjoyable` |
| `.h1`, line 3 | `afternoons.` |
| Deck | `Seek-and-find and coloring books with comfortable type, clear clues, and richly detailed scenes for screen-free time.` |
| Featured label | `Featured book` |
| Featured title | `{{book.title}}` at `.h2` |
| Spec-strip labels, in order | `Size` · `Pages` · `Scenes` · `Format` |
| Spec-strip values | `{{book.spec.size}}` · `{{book.spec.pages}}` · `{{book.spec.scenes}}` · `{{book.spec.format}}` |
| Preview link | `▤ Preview {{n}} real pages` |
| Buy button | `Buy {{book.shortTitle}} ↗` |
| Retailer responsibility line | `{{retailer}} handles payment, delivery, returns, and customer reviews.` |
| Media panel label (`--forest`, eyebrow weight) | `Actual interior pages` |

### 2 · Category tab strip

| Cell | Label |
|---|---|
| 1 | `Seek & Find` |
| 2 | `Coloring` |
| 3 | `For seniors` |
| 4 | `Large print` |

| Slot | String |
|---|---|
| Tablist (`aria-label`) | `Browse by category` |
| Section 4 empty state when a filter matches nothing | `No titles in this category yet — but there are more on the way below.` |

### 3 · Page proof

| Slot | String |
|---|---|
| Eyebrow (beside the `--terracotta` mark) | `{{brand-mark}} Page Proof` |
| `.h2-lg` | `See the pages before you choose.` |
| Right-column paragraph | `Every available book includes real interior previews, shown clearly enough to judge the artwork, clue size, and page layout.` |
| Counter line | `Page {{i}} of {{n}} · {{spread.sceneLabel}}` |
| Previous control (`aria-label`) | `Previous page` |
| Next control (`aria-label`) | `Next page` |
| Thumbnail dot (`aria-label`) | `Go to page {{i}} of {{n}}` |
| Live-region announcement on change | `Page {{i}} of {{n}}. {{spread.sceneLabel}}.` |
| Fallback when a book has no spreads | `Interior previews for this title are being scanned.` |

`{{spread.sceneLabel}}` is a per-spread field. Sample value, **not to be shipped**: *"Scene artwork"*.

### 4 · Choose your book

| Slot | String |
|---|---|
| Eyebrow | `Choose your book` |
| `.h2-lg` | `Two ways to slow down and enjoy the page.` |
| Card metadata line | `{{book.tags[0]}} · {{book.tags[1]}} · {{book.tags[2]}}` |
| Card title | `{{book.title}}` |
| Card description | `{{book.description}}` |
| Mini spec labels | `Size` · `Pages` · `Format` |
| Buy button | `Buy {{book.shortTitle}} ↗` |
| Preview text link | `See inside` |
| Forthcoming status chip | `Coming soon` |
| Forthcoming card, in place of the buy row | `Not yet at {{retailer}}. This page updates when it is.` |

Sample per-book values, **labelled as samples and not to be shipped**: tags *"Seek & Find · Full color · Gentle challenge"*; description *"Explore nostalgic scenes with large-print clue lists and a comfortable page layout."* Real titles and descriptions come from the catalog file.

**Card count is derived, not fixed:** one card per entry in `{{books[]}}`, available titles first, forthcoming titles after at 60% opacity with the status chip and no buy button. Do not hide forthcoming titles — a visible pipeline signals an active publisher.

### 5 · Which activity sounds right?

| Slot | String |
|---|---|
| Eyebrow | `Which activity sounds right?` |
| `.h2-lg` | `Start with how you want to spend the time.` |
| Instruction line | `Select an activity to bring the matching book forward.` |
| Row index | `01` · `02` · `03` · `04` (zero-padded, one per catalog entry) |
| Row sentence | `{{book.intent}}` |
| Row's matching title, beneath the sentence | `{{book.title}}` |
| Row (`aria-pressed`) announcement on select | `{{book.title}} highlighted below.` |

Sample `intent` values, **not to be shipped**: *"I want to search colorful scenes"*, *"I want to find objects in black-and-white scenes"*.

**Row count is derived:** exactly one row per entry in `{{books[]}}`, in the same order as section 4. Four books gives four rows.

### 6 · About the publisher

| Slot | String |
|---|---|
| Eyebrow (left column, the column's only content) | `Made by {{maker-name}}` |
| `.h2-lg` | `Clear pages are part of the design.` |
| Paragraph 1 | `{{maker-name}} directs, selects, edits, arranges, and prepares each activity book for print — the trim size, the type size, the clue lists, and the order the scenes appear in.` |
| Paragraph 2 | `Some illustrations are created with generative tools and then reviewed, corrected, and laid out by hand before anything goes to print. Saying so is part of making a book you can trust.` |

Keep paragraph 2. It is trust, not liability.

### 7 · Retailer handoff and footer

| Slot | String |
|---|---|
| Featured title (`.h2`, `--paper`) | `{{book.title}}` |
| `.h2-lg` question | `Ready to see current price and availability?` |
| Body line | `{{retailer}} handles payment, delivery, returns, and customer reviews.` |
| Button | `Buy {{book.shortTitle}} on {{retailer}} ↗` |
| Footer column 1 heading / links | `Books` — `All books` · `Inside the books` · `Compare titles` |
| Footer column 2 heading / links | `Publisher` — `About {{maker-first-name}}` · `How the books are made` · `{{contact-email}}` |
| Footer column 3 heading / links | `Elsewhere` — `{{retailer}} store ↗` |
| Legal line | `© {{year}} {{brand}}. Current price and availability are shown at {{retailer}}.` |

### Alt text and non-visible labels

Alt text is copy: it is what a screen-reader user reads instead of the image. It is **generated from named catalog fields**, not written per-render by the builder, and the fields are part of the content model below.

| Asset | Alt string |
|---|---|
| Book cover, anywhere it appears | `Front cover of {{book.title}}, a {{book.spec.size}} {{book.spec.format}} book.` |
| Interior spread, hero panel and viewer | `Interior spread from {{book.title}}: {{spread.altDescription}}` |
| Hero panel, when a book has no spreads | Render no `<img>` — show the `--sage` panel with the fallback line from section 3 |

`{{spread.altDescription}}` is a required, human-written sentence stored beside each spread, and it must describe **what the scene contains and that a clue list is present** — e.g. *"a farmhouse pantry scene beside a large-print checklist of twenty objects to find"*. A spread with no `altDescription` fails the content check and does not ship. Never `"book image"`, never the filename, never the title alone.

Decorative and non-visible:

| Element | Treatment |
|---|---|
| Gold vertical rules in the spec strip | `aria-hidden="true"` |
| `▤` page glyph on the preview link | `aria-hidden="true"` |
| `↗` on every off-site control | `aria-hidden="true"` + the visually-hidden companion text |
| The square outline mark beside the wordmark | `role="img"`, `aria-label="{{brand}}"` |
| Magnifier glyph in the search control | `aria-hidden="true"`; the field has its own visually-hidden `<label>` |
| Terracotta mark beside the Page Proof eyebrow | `aria-hidden="true"` |
| Numbered row indices in section 5 | `aria-hidden="true"`; the button's accessible name is the intent sentence |

## Layout
Roughly **five and a half screens** at 1080px — a sanity figure, not a target. Never pad whitespace to hit it. What matters is the proportion: **section 4 is the tallest band on the page and section 3 gets the most vertical space of any single component**, because the spread viewer is the conversion device.

Sticky header, 76px, `rgba(252,251,247,0.96)` with `backdrop-filter: blur(10px)` and a hairline bottom. Contents: the square outline mark plus the `{{brand}}` wordmark in Newsreader small caps; the four nav links; the search control; and the `--forest` retailer link. Under 900px it collapses to mark + `Menu`.

1. **Hero.** Asymmetric grid, `grid-template-columns: minmax(0, 0.72fr) minmax(0, 1fr)`, no gap — the right column is a full-bleed `--sage` panel running to the viewport edge.
   - **Left (on `--paper`):** eyebrow, `.h1` over three hand-broken lines, the deck sentence, then the featured-title block — the `Featured book` label in `--ink-soft` at `1.0625rem`, the title at `.h2`, then the spec strip.
   - **Spec strip:** four cells in a `repeat(4, auto)` grid, each separated by a `2px` vertical `--gold` rule on its left edge with `14px` of padding-left. Each cell is a `.spec-l` label over a `.spec-v` value. Values wrap to two lines rather than shrink.
   - **Action row:** the underlined `▤ Preview {{n}} real pages` link in `--ink`, and beside it the primary button — `--terracotta` fill, `--white` label, `3px` radius, `14px 24px` padding. Beneath both, the retailer-responsibility line at `1.0625rem` in `--ink-soft`.
   - **Right (`--sage` panel):** the `Actual interior pages` label in `--forest`, then a layered arrangement of three assets — two interior spreads rotated ±1.5° with the paper shadow, overlapped, and the cover sitting proud on top at the right, unrotated. The overlap is the composition; do not lay them out in a neat row.
2. **Category tab strip**, immediately under the hero. Four equal cells divided by hairlines, each a `.h2`-scale serif word. The active cell carries a `3px --terracotta` underline; inactive cells carry `--sage-dim`. `role="tablist"` with arrow-key support. They filter section 4 in place.
3. **Page proof.** On `--paper`. Header row: the `--terracotta` mark and the Page Proof eyebrow on the left with `.h2-lg` beneath, and a right-hand column holding the one-paragraph explanation. Viewer: **a single spread** on `--white` with the paper shadow and `4px` radius, rendered at **at least 760px wide on viewports of 1024px and up**, and at `width: 100%` below that — the size is the point, not the styling. Never crop the clue list out of frame to tidy the composition. Beneath: the counter line in `--ink-soft`, prev/next controls at 44px, and a row of `{{n}}` thumbnail dots that are real `<button>`s carrying `aria-current`.
4. **Choose your book.** On `--sage`, the tallest band. Eyebrow, `.h2-lg`, then a `repeat(2, 1fr)` card grid on `--white`, `4px` radius, 40px padding, hairline border. Each card, in order: the metadata line, the title at `.h2`, the description, a three-row mini spec list reusing the label/value pattern, a cover thumbnail with the paper shadow, then the buy button and the `See inside` link on the same row. Forthcoming cards use the same shape at 60% opacity with the status chip in place of the buy row.
5. **Which activity sounds right?** On `--paper`. The accessibility-first alternative to the tab strip, for a buyer who does not know the product vocabulary. Eyebrow, `.h2-lg`, the instruction line, then one numbered row per book with the index in `--ink-soft`. Rows are real `<button>` elements with `aria-pressed`; selecting one scrolls to section 4 and applies a `2px --terracotta` outline to the matching card for two seconds. Selection is reversible and never navigates.
6. **About the publisher.** On `--paper`, `grid-template-columns: 140px minmax(0, 1fr)`. Left column: the eyebrow only. Right: `.h2-lg` and the two paragraphs.
7. **Retailer handoff.** A `--forest` plate with `--paper` type. Flex row: left, the featured title at `.h2` plus the `.h2-lg` question and the body line; right, one large `--terracotta` button. Footer beneath on `--paper`: wordmark, the three link columns, the legal line — everything at 17px or larger.

**Content model.** One file drives the catalog; adding a title should never require touching a component.

```ts
type Spread = {
  src: ImageAsset;
  sceneLabel: string;      // ≤4 words, shown in the counter line
  altDescription: string;  // required, one sentence: what the scene shows + the clue list
};

type Book = {
  slug: string;
  title: string;            // serif, used in h2 and card headings
  shortTitle: string;       // used inside button labels
  status: 'available' | 'forthcoming';
  tags: [string, string, string];   // 3 clauses, ≤3 words each
  description: string;      // ≤26 words
  spec: { size: string; pages: number; scenes: string; format: string };
  cover: ImageAsset;
  spreads: Spread[];        // ≥6 for an available title; these ARE the product page
  retailerUrl: string;      // absolute, opens in a new tab
  intent: string;           // ≤8 words, first person — feeds section 5
};
```

`spec` keys map 1:1 to the four spec-strip cells in the order shown. `spreads` must be real scans or real print-ready interiors — a rendered mockup here defeats the section that exists to prevent returns. A `forthcoming` title may ship with zero spreads; an `available` one may not.

**Copy length constraints for catalog data:** `.h1` ≤6 words · section `.h2-lg` ≤9 words · card description ≤26 words · spec value ≤4 words · every `.eyebrow` ≤4 words · intent row ≤8 words · `sceneLabel` ≤4 words.

## Conversion architecture
The funnel is **catalog → detail → retailer handoff**, and its whole job is to remove one specific pre-purchase doubt before the buyer leaves the site.

- **Hook:** the `.h1` sells the afternoon, not the book. Benefit framing over feature framing, because the buyer is usually not the reader.
- **Catalog layer (sections 2, 4, 5):** three entry paths into the same catalog — by category (tab strip), by browsing (cards), by intent (numbered rows). A visitor who does not know the product vocabulary can still self-select via section 5.
- **Detail layer (hero spec strip + section 3):** the spec strip answers the measurable questions at a glance — trim, extent, scene count, format. Section 3 answers the unmeasurable one, *can they actually see it*, with real interiors at real size. This is the single most important component on the page; a stock mockup here kills the whole argument.
- **Objection order:** ① *will the print be big enough* (page proof, given the most vertical space) → ② *which one is right for them* (cards plus intent rows) → ③ *who made this and is it careful work* (publisher, with the production disclosure) → ④ *what does it cost* (deliberately deferred to the retailer, framed as current price and availability).
- **Handoff:** every buy path leaves for the same retailer product page, in a new tab, with `rel="noopener noreferrer"`, and every buy button is preceded by the explicit retailer-responsibility line. Never imply on-site checkout. The `↗` glyph on every off-site control is doing real work — keep it.
- **CTA repetition:** the primary buy button appears three times (hero, product card, closing plate) with identical label pattern and destination. Preview is the secondary CTA and appears twice (`▤ Preview {{n}} real pages` in the hero, `See inside` on each card). The header retailer link is a tertiary escape hatch for returning buyers.
- **Single next action:** open the featured title at the retailer.

## Motion
Deliberately minimal. This audience is served by stillness; motion here reads as noise.

- Section content fades in with `opacity 0 → 1` and `translateY(12px)` over `450ms cubic-bezier(0.22,1,0.36,1)` via `IntersectionObserver` at `threshold: 0.2`, once. Nothing reverses.
- The page-proof viewer cross-fades between spreads over `260ms ease-out` — a fade, never a slide or a page-curl. Preload the adjacent spread on hover or focus of the next control.
- Buttons: background darkens 8% over `140ms ease-out`; the terracotta button also lifts 1px. Cards lift 2px and deepen their shadow over `200ms`. The tab underline slides between cells over `220ms cubic-bezier(0.4,0,0.2,1)`.
- No parallax, no marquee, no auto-advancing carousel, no scroll-scrubbing, no counters. The spread viewer never auto-advances — an older reader must never lose their place because a timer fired.
- **`prefers-reduced-motion: reduce` resolves to one behaviour:** the reveal `IntersectionObserver` is **never constructed** and every section renders at `opacity: 1` in its final position from first paint; the tab underline jumps between cells; spread changes are instant. Do not implement this by setting `transition-duration: 0.01ms` on everything — a near-zero duration is still a scroll-triggered reveal that can flash, and it leaves the observer running for no reason. Hover *colour* changes stay; hover *transforms* do not.

Subscribe to the preference rather than reading it once:

```js
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const [reduced, setReduced] = useState(mq.matches);
useEffect(() => {
  const onChange = () => setReduced(mq.matches);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}, []);
```

An empty-dependency `useEffect` that reads `mq.matches` once never re-runs when the user flips the OS setting with the page open.

## Build notes
React + Tailwind. Static, no cart, no payment code, no analytics that would justify a consent banner.

- **Image strategy — the images are the payload.** Interior spreads and covers must survive being looked at closely, so they get the budget and everything else on the page gets none.

  | Asset | Rendered at | Source width | Formats | Target weight | Loading |
  |---|---|---|---|---|---|
  | Hero spreads (2) | ≈380px wide, rotated | 1600px | AVIF q≈62 → WebP → JPEG via `<picture>` | ≤260 KB | eager |
  | Hero cover (1) | ≈300px wide | 900px | same chain | ≤140 KB | eager, `fetchpriority="high"` — this is the LCP element |
  | Viewer spread, current | ≥760px wide at ≥1024px | 1600px | same chain | ≤260 KB | eager for spread 1; `<link rel="prefetch">` for spread 2 |
  | Viewer spreads 3…n | as above | 1600px | same chain | ≤260 KB | `loading="lazy" decoding="async"`, fetched on navigation |
  | Card covers (one per book) | ≈240px wide | 900px | same chain | ≤140 KB | `loading="lazy" decoding="async"` |

  **The lazy boundary is the fold of the hero.** The three hero assets are eager; everything below — including every card cover and every spread after the first — is lazy. Do not fetch all `{{n}}` spreads on mount. Serve `srcset` at 1× and 2× and set `sizes` accurately so a phone never downloads the desktop asset. **Every image carries explicit `width` and `height`**, and every image box also carries an `aspect-ratio` so the space is reserved from CSS before a byte arrives — that is what holds CLS at 0 while the spreads are still in flight.

  Placeholder policy: if a book's `cover` or a `spread` is missing, render a `--sage` block at the same aspect ratio with a hairline border and the fallback line from **Copy** — never a stock photo, never a generated mockup, never a rendered 3D book. The section exists to prevent returns; a fake spread would cause them.
- **Fonts** — no font files ship with this prompt, so do not write an `@font-face` block pointing at files you do not have. Load `Newsreader` 600 and `Atkinson Hyperlegible` 400/700 from Google Fonts with `display=swap`; in Next.js use `next/font/google`, which self-hosts at build time and avoids the `@next/next/no-page-custom-font` lint. Arial is the Atkinson fallback and is close enough in metrics that body text does not reflow noticeably; Georgia stands in for Newsreader and is not metrically identical, so give the `.h1` a `min-height: calc(3 * 1.01em)` to absorb the swap. If the deliverable must be offline, say so and ship the fallback stacks — but note in the handover that Atkinson is chosen for legibility and its absence is a real regression, not a cosmetic one.
- **Accessibility is the product, not a checkbox.**
  - Base font `17px`. The layout must survive `200%` browser zoom **and** a `24px` user-set base font size with no horizontal scroll — test both, not one.
  - Skip link first in DOM.
  - `outline: 2px solid #18201C; outline-offset: 3px` on light surfaces, `2px solid #FCFBF7` on `--forest`; never `outline: none`.
  - Hit targets ≥ `44 × 44px` — including the viewer's prev/next controls and **every thumbnail dot**, which is where this rule is usually broken.
  - The search field has a real `<label>` (visually hidden is fine); the tab strip is a keyboard-operable `role="tablist"`; the intent rows are `<button aria-pressed>`.
  - The spread viewer announces page changes through a polite live region using the string in **Copy**.
- **Mobile (< 768px)**
  - The hero becomes one column with the `--sage` media panel *above* the copy — the cover is the hook on a phone.
  - The spec strip becomes a `repeat(2, 1fr)` grid with the gold rules moving to the left edge of each cell.
  - Section 4's cards stack to one column.
  - The spread viewer goes full-bleed with pinch-zoom explicitly enabled: `<meta name="viewport" content="width=device-width, initial-scale=1">` and **no** `maximum-scale` and **no** `user-scalable=no`. Zoom is how this audience reads.
  - The tab strip becomes a horizontally scrollable `scroll-snap` row with a visible edge fade.
  - Under 900px the header collapses to mark + `Menu`; the sheet holds the four nav links, the search field and the retailer link.
- **Performance budget, with the strategy that meets it** — LCP < 2.0s on 4G, and the LCP element is the hero cover: it is eager, `fetchpriority="high"`, and preceded by no blocking script. CLS = 0, held by explicit `width`/`height` plus `aspect-ratio` on every image box and a `min-height` on the `.h1` against font swap. Total JS < 90KB gzipped — there is no carousel library, no lightbox library and no animation library on this page; the viewer is index state plus a cross-fade, and the filter is an array `.filter()`. The only per-interaction work is swapping one `<picture>`'s source, which is why prefetching the adjacent spread is enough to make navigation feel instant.

## Acceptance checks
- At `200%` browser zoom and again at a `24px` user default font size, no text is clipped and the page never scrolls horizontally.
- Every paragraph, link, button label, spec value and legal line measures 17px or larger. The only sub-17px type anywhere in the DOM is the `.eyebrow` class at 15px.
- The page-proof viewer renders an interior spread at ≥760px wide at viewports of 1024px and above, and at full container width below that. Pinch-zoom works on mobile and clue-list text is legible in a screenshot without further zoom.
- Every buy control opens the retailer in a new tab with `rel="noopener noreferrer"`, and each is preceded by the retailer-responsibility line.
- No price, star rating, review count, testimonial or sales figure appears anywhere in the DOM; every such slot renders a visible `{{placeholder}}`.
- Every image has descriptive alt text generated from `title`, `spec` and `altDescription`. An alt-text audit produces zero empty, filename-derived, or title-only values on non-decorative images, and a spread missing `altDescription` fails the content check rather than rendering.
- All interactive targets measure ≥ 44 × 44px, including every thumbnail dot, and full keyboard traversal reaches the tab strip, the intent rows, the spread controls and every buy button with a visible focus ring.
- Section 4 renders one card per catalog entry and section 5 renders one row per catalog entry, in the same order. Filtering to a category with no matches shows the empty-state line rather than a blank band.
- Throttle to slow 3G and scroll the whole page: CLS stays 0, the three hero assets load first, and no spread after the first and no card cover is requested until it is needed.
- With `prefers-reduced-motion: reduce`, no reveal observer is constructed, all content is present at its final position on load, and the spread viewer changes instantly. Flipping the OS setting with the page open changes the behaviour without a reload.
- No control on the page links to `#` or to a destination outside the anchor table in **Copy**.
- Every string the page renders comes from **Copy**, or is a listed placeholder, or is a catalog field defined in the content model.

More like this