Ridgeline
aadmin2026-11-05 发布Site模板3 阅读

Ridgeline

类型:Site模板 · 收录:2026-11-05 01:12:49 · 源码:会员可见

[hidecontent type="vip1"]
# Recreate this site as a single HTML file: RIDGELINE

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline in a
`<script type="module">`. **Lenis is the only external code**, pulled through an import map; there
is no three.js and no canvas on this page. The spring solver, the shared ticker, the in-view
observer, the letter-and-line text engine, the opening panel, the counting figures, the rolling
labels, the photo markers and every hover are all written by hand. Hardcode every value given here
as a fixed constant.

Every number below is the shipped default, measured off the 1440-wide design frame (with 1024,
768 and 390 frames for the other layouts). Nothing in this document is a suggestion.

## What it is

A one-page site for **Ridgeline Roofing Co.**, a roofing contractor in Portland, Oregon ("A roof is
only as good as its worst seam.", "Roofs that hold. / Crews that show up.", "2140 SE Division St,
Portland, OR 97202"). The whole page sits as a **rounded white card on a soft grey field**, set in
**Instrument Sans** for display and body and **Geist Mono** for every label, meta line, nav item and
button, with a navy (`#133153`) reserved for the footer, the opening panel, the phone menu and the
filled buttons. Every photograph is a real roof job; every rule is a 1px hairline; every control is
a pill.

The page opens behind a navy panel where the house mark builds itself the way a roof goes on — the
outline traced, the house filled, three standing seams rising from the eave one after another —
while a counter runs `000 → 100` under the name. The panel then lifts off the top of the screen, its
lower edge folding into a roof ridge, and the hero plays in beneath it: the photograph settles
back from a slight zoom, the first headline line fades up and the muted second line **slides in
against it like a seam being closed**, the material chips demist out of a blur, and the header's
three pieces drop in from above.

Scrolling is smoothed by Lenis. The about block **pins** for most of a screen of scroll while its
statement fills in letter by letter and four figures roll up like a mechanical counter; the
services section then slides up over it. The rest of the page is scroll reveals on springs, two
parallax photographs, an accordion of the four steps of a job, marker-pinned photographs with
popovers, and a closing photograph that starts covering the whole viewport and is clipped in to its
frame as you scroll, the request form fading up beside it. Almost everything moves on a spring;
the only CSS transitions on the page are colour and opacity swaps.

## Page shell & libraries

```html
<script type="importmap">
{ "imports": { "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs" } }
</script>
```

- **Fonts** from Google Fonts, `display=swap`: **Instrument Sans** 400/500/600/700 and
  **Geist Mono** 400/500. `font-family: "Instrument Sans", system-ui, sans-serif` on `html`;
  `font-family: "Geist Mono", ui-monospace, monospace` wherever this document says *mono*.
- **Reset**: `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 }`,
  `img { display: block; max-width: 100% }`, `ul, ol { list-style: none }`,
  `a { color: inherit; text-decoration: none }`, `button, input { font: inherit; color: inherit;
  background: none; border: 0 }`.
- **Smooth scroll**: `const lenis = new Lenis({ smoothWheel: true })`, driven by a
  `requestAnimationFrame` loop (`lenis.raf(time)`). The opening panel and the phone menu call
  `lenis.stop()` while they are up and `lenis.start()` when they leave (and set
  `html { overflow: hidden; height: 100%; position: relative }` for the same span);
  `window.scrollTo(0, 0)` on load. In-page `#anchor` clicks go through `lenis.scrollTo(target)`.
- **Body**: `background: var(--soft); color: var(--ink)`.

### The rem grid (this is how the whole layout scales)

Every size on this page is in `rem`. Four design frames — 390, 768, 1024 and 1440 — each laid
out at a 16px root, so a value taken off a frame lands 1:1 at that frame's width:

```css
html { font-size: calc(100vw / 24.375); }                     /* below 390: the phone layout shrinks */
@media (min-width: 24.375rem) { html { font-size: 16px; } }   /* 390 → 1440: every frame at 1:1 */
@media (min-width: 90.0625rem) { html { font-size: calc(100vw / 90); } } /* above 1440 the 1440 frame scales up */
@media (min-width: 120rem)    { html { font-size: calc(120rem / 90); } } /* …and holds at 1920 */
```

Breakpoints, used exactly as named below: **md = 768px** (`48rem`), **lg = 1024px** (`64rem`),
**desk = 1440px** (`90rem`). "Below md" is the 390 frame. Media queries measure rem against the
initial 16px, so the scaling root never moves a breakpoint. Photo boxes that carry markers are
`container-type: size`, and the footer is a `@container` for its wordmark.

### Colour tokens (bake these in as CSS custom properties)

```css
:root {
  --page:    #fcfcfc;   /* the card and the page field */
  --surface: #ffffff;   /* the content well */
  --soft:    #f5f6f7;   /* inset panels, chips, inputs */
  --deep:    #133153;   /* the navy: footer, preloader, menu, filled buttons */

  --ink:     #0e2035;   /* primary text on light */
  --muted:   #5e6d7e;   /* secondary text */
  --accent:  #133153;   /* the eyebrow index */
  --on-dark: #ffffff;
  --on-dark-muted: #bdb9b3;   /* secondary text on photography and navy */

  --line:      #e4e7e5;              /* hairline rules */
  --line-soft: rgba(18, 22, 27, 0.12); /* the rule between steps */
  --line-warm: #e2dcd2;              /* the rule inside the open step */

  --live:        #429e5c;   /* the "crews out" dot */
  --track:       #dee3e8;   /* the 390 rail's groove */
  --ink-strong:  #0e1c32;   /* the 390 rail's fill and titles */
  --marker-open: #173a63;   /* a photo marker while its card is open */
  --scrim:       #000000;

  --shadow-card:    0 2px 12px 0 rgba(0,0,0,0.05);
  --shadow-form:    0 16px 48px 0 rgba(0,0,0,0.18);
  --shadow-popover: 0 8px 24px 0 rgba(0,0,0,0.22);
  --shadow-marker:  0 4px 6px 0 rgba(0,0,0,0.25);

  --blur-glass: 10px;          /* chips */
  --blur-glass-strong: 12px;   /* header, rail cards */

  --duration-fast: 150ms;
  --duration-normal: 250ms;
  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);

  --radius-page: 2rem;  --radius-frame: 1.5rem;  --radius-card: 1.25rem;  --radius-chip: 1.125rem;
  --radius-thumb: 1rem; --radius-assurance: 0.875rem; --radius-photo: 0.75rem; --radius-pill: 1.75rem;
}
```

### Type scale (size / line-height / tracking)

| name | size | leading | tracking | used for |
|---|---|---|---|---|
| d1 | 5.5rem (88px) | 5.5rem | −0.25rem | hero headline and stat numerals at 1440 |
| d2 | 4.5rem (72px) | 4.75rem | −0.1875rem | hero at 1024, CTA title at 1440, stats at 1024 |
| d3 | 3.5rem (56px) | 3.625rem | −0.125rem | statement at 1440, section titles at 1024, hero at 768 |
| d4 | 2.75rem (44px) | 3rem | −0.09375rem | section titles at 1440/768, hero at 390, footer statement |
| d5 | 2.25rem (36px) | 2.5rem | — | statement at 768, stats at 390 |
| h2 | 2rem (32px) | 2.5rem (2.25rem "compact" at 390) | −0.0625rem | step titles, form title, feature title, phone |
| h3 | 1.5rem (24px) | 2rem | −0.03125rem | card titles, the quote |
| lead | 1.25rem | 1.75rem | — | step body |
| body-lg | 1.125rem | 1.625rem | — | job titles, footer links, CTA intro |
| body | 1rem | 1.5rem | — | copy |
| meta | 0.875rem (14px) | 1.25rem | +0.0625rem when "caps" | every mono label, chip, button |
| micro | 0.75rem (12px) | 1.125rem | — | rail cards, the 390 status bar |

`--tracking-caps: 0.0625rem` (+1px) is on every uppercase Geist Mono label in a nav or button.
Display type never runs below 1.1 leading where a clipped text reveal plays.

## The animation engine (write this once, use it everywhere)

Every moving thing on this page is a spring integrated on one shared ticker. Implement this first
— the sections below refer to its parts by name.

### 1. Spring solver

`react-spring` semantics: `{ tension, friction, mass: 1 }`, semi-implicit Euler at 1ms substeps,
rest when `|velocity| < 0.01` and `|displacement| < 0.01`. A spring can also be a **duration
spring** `{ duration, easing }` (used for the loader counter and the pulses). Every spring targets
one or more named numeric channels; a writer turns channels into CSS each frame — **give every
transform channel a unit** (`y: 20` → `translate3d(0, 20px, 0)`, `x` likewise, `scale`, `rotate` in
deg), write `opacity` and `filter` as their own properties. Static offsets (a `translate(-50%,-50%)`
centring) must live in the standalone `translate:` / `rotate:` properties so a spring never
overwrites them.

### 2. Presets (the site's whole vocabulary of motion)

```js
SPRING        = { tension: 420, friction: 42 };  // everything interactive: hovers, menus, pickers, scrubs
SPRING_REVEAL = { tension: 150, friction: 25 };  // things arriving: scroll reveals, load-in, text lines
SPRING_CAMERA = { tension:  30, friction: 14 };  // photographs settling from a slight zoom
SPRING_SOFT   = { tension: 120, friction: 22 };  // a photo easing in under the cursor
SPRING_POP    = { tension: 260, friction: 14 };  // small overshoot: chips, marker popover, step arrow
SPRING_TRAIL  = { tension: 170, friction: 26 };  // the trailing edge of the rail thumb
SPRING_DRAW   = { tension:  90, friction: 19 };  // a stroke being traced on
TYPE_STAGGER  = 30;                              // ms per letter on the typed labels
REVEAL_MARGIN = "0px 0px -20% 0px";              // the shared trigger line: an element's top a fifth of the viewport above the bottom edge
```

`SPRING` is the one character — stiff, fast, no overshoot. The others exist only where the effect
*is* its feel.

### 3. One ticker

A single `requestAnimationFrame` loop. Subscribers register with a `frameInterval` (ms) and are
called no more often than that. Scroll scrubs use **10ms** (effectively every frame); the loop
stops when there are no subscribers.

### 4. The four behaviours

- **`Reveal(el, from, to, config, delayIn, rootMargin = REVEAL_MARGIN)`** — an
  `IntersectionObserver` on `el` with that `rootMargin`; the first time it intersects, spring from
  `from` to `to` after `delayIn` ms. Plays once and stays. `from` is written to the element on
  first paint, so nothing flashes.
- **`Hover(trigger, el, from, to, config)`** — `pointerenter` springs to `to`, `pointerleave`
  back to `from`. Disabled below 768px.
- **`Scrub(outer, inner, start, end, from, to, config)`** — a scroll-linked spring. Every frame
  (10ms), measure the **outer** element's `getBoundingClientRect()` and compute:

  ```js
  const poses = {
    "top top": bb.top,                      "center top": bb.top + bb.height/2,     "bottom top": bb.bottom,
    "top bottom": bb.top - vh,              "center bottom": bb.top + bb.height/2 - vh, "bottom bottom": bb.bottom - vh,
    "top center": bb.top - vh/2,            "center center": bb.top + bb.height/2 - vh/2, "bottom center": bb.bottom - vh/2 };
  const s = poses[start], e = poses[end], length = Math.abs(s - e);
  const progress = clamp(1 - (s + length) / length, 0, 1);
  ```

  and spring `interpolatedProgress` to it on `config`; the **inner** element is styled from that
  value (a linear interpolation of `from → to`). Measure the outer, animate the inner, or the
  translation feeds back into its own progress.
- **`Spring(el, …)`** — a plain one-shot at mount (the header's load-in).

### 5. The text engine

Lays every word out as an inline-flex item in a flex-wrap container and replaces the spaces with
`column-gap`, given **per call** in `em`: the space advance of the face **plus the style's
tracking**:

```js
SPACE = { sans: 0.2, sansBold: 0.19, sansMedium: 0.197, mono: 0.6 };
TRACKING = { d1: -0.25/5.5, d2: -0.125/4.5, d3: -0.125/3.5, d4: -0.09375/2.75 };   // em
wordGap = (face, tracking = 0) => SPACE[face] + tracking;
```

Three layers, each rendered only when asked for: **line** (all words on a line share one spring,
`lineStagger` per line index), **word**, **letter** (`letterStagger` per global letter index).
Modes: `once` (plays on first intersection with `rootMargin`, never replays) and `progress /
interpolate` (each letter's opacity is a function of a scroll progress 0→1: letter *i* of *n* fills
over the window `[i·c·(1−w)/(n−1)… +w]` where `c` is the stagger coefficient and `w = 1 − c` — a small
coefficient means one letter at a time). Emit the real sentence once in a visually-hidden span and
mark the split `aria-hidden` so the headline still reads as a sentence. The container is
`position: relative`, so any positioning lives on a wrapper.

### 6. Reduced motion

With `prefers-reduced-motion: reduce` the opening panel is not shown at all and every reveal
renders in its `to` state.

## The opening panel (build it precisely)

A `position: fixed; inset: 0; z-index: 60; overflow: hidden` panel in `--deep` with white text,
`role="status"`, containing a visually-hidden "Loading Ridgeline Roofing Co.". Behind everything,
the backdrop wordmark **RIDGELINE**: `position: absolute; inset-inline: 0; bottom: -0.1em;
text-align: center; font-size: 19.2cqw` (the panel is a `@container`; `12cqw` on viewports under
560px tall), `line-height: 1; font-weight: 700; letter-spacing: -0.045em; white-space: nowrap;
color: rgba(255,255,255,0.07)`.

Centred in the panel, a column (`gap: 1.25rem`, `1.5rem` from 1440, `padding-inline: 2rem`,
`1.25rem` below 768):

1. **The mark** — an SVG `viewBox="16 16 24 22"`, `width: 5rem` (`6rem` from 768, `7rem` from
   1440):
   ```
   HOUSE = "M18 25.4634L28 18L38 25.4634V36H18V25.4634Z"       (fill + 0.6 stroke, white, round joins, pathLength=1)
   SEAMS = [{x:22.5455,y:22.3902},{x:27.5455,y:19.3171},{x:32.5455,y:22.3902}]   rects, width 0.909, from y down to y=36, fill --deep
   ```
2. **RIDGELINE ROOFING CO.** — mono 14/20 caps-tracked, weight 500 (12/18 below 768).
3. **The counter row** — `margin-top: 0.75rem; width: 15rem` (`18rem` from 768), `gap: 0.75rem`:
   a `2.25rem`-wide tabular counter `000`, and a `2px`-tall groove in `rgba(255,255,255,0.35)`
   with a white fill scaling from the left.

**Timeline** (ms after the script runs; `lenis.stop()` immediately):

```js
PRELOADER = { trace: 100, fill: 650, seams: 700, seamStagger: 120, count: 1200, hold: 60, lift: 950 };
LOAD_DELAY = count + hold + 240 = 1500;   // when the hero's load motion starts
```

- at **100** the outline traces on: `stroke-dashoffset 1 → 0` on `SPRING_DRAW`;
- at **650** the house fills: `fill-opacity 0 → 1` on `SPRING_DRAW`; the seams' opacity follows
  the same value;
- at **700 + 120·i** seam *i* rises: `scaleY 0 → 1` about its bottom edge (`transform-box:
  fill-box; transform-origin: bottom`) on `SPRING`;
- from **0 to 1200** the counter runs `0 → 100` on a duration spring with `easeInOutCubic`, the
  number `String(Math.round(v)).padStart(3, "0")`, the fill `scaleX(v/100)`;
- at **1200** the contents fade: `opacity 1 → 0` and `translateY(0 → -16px)` on `SPRING`;
- at **1260** the panel lifts on `LIFT = { tension: 150, friction: 26 }`: `translateY(-lift·100%)`
  and, at the same time, its bottom edge folds into a ridge:
  `clip-path: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - ${min(1, lift·1.6)·16}vh), 0 100%)`;
- at **2210** the panel is removed and `lenis.start()`.

## The header

`position: sticky; top: 1rem; z-index: 50; margin-top: 2rem; padding-inline: 2rem` (`margin-top:
1.25rem; padding-inline: 1.25rem` below 768), a row with the logo group left and the actions right.
It sits inside the content well, and the `<main>` beneath it is pulled up by `−5rem` (`−5.5rem`
from 1024, `−4.25rem` below 768) so the header overlays the hero exactly where the frame draws it.

Two treatments, swapped with a **250ms colour transition** (the only kind of CSS transition on the
page): **glass** while the hero photograph is behind it — `border: 1px solid rgba(255,255,255,0.26);
background: rgba(255,255,255,0.12); color: #fff; backdrop-filter: blur(12px)` — and **solid** once
it is not — `border-color: var(--line); background: var(--page); color: var(--ink); box-shadow:
var(--shadow-card)`. "Behind it" is an `IntersectionObserver` on `#hero` with `rootMargin:
"-88px 0px 0px 0px"` — the viewport shrunk by the header's own 88px, so the swap lands exactly as
the photo leaves from under it.

- **Logo** — a round `3rem` button (`3.5rem` from 1440) holding the house mark, SVG
  `viewBox="0 0 56 56"`, `fill: currentColor`:
  `M18 25.4634L28 18L38 25.4634V36H18V25.4634ZM22.5455 22.3902H23.4545V36H22.5455V22.3902ZM27.5455 19.3171H28.4545V36H27.5455V19.3171ZM32.5455 22.3902H33.4545V36H32.5455V22.3902Z`
  with `fill-rule: evenodd`.
- **The links pill** (from 1024 only) — a `3.5rem`-tall pill (`radius 1.75rem; padding: 0.5rem;
  gap: 0.5rem`) holding **HOME · SERVICES · PROCESS · RECENT WORK** (`#`, `#work-we-do`,
  `#how-a-job-goes`, `#recent-work`), each a `2.5rem`-tall `radius 1.25rem` link, `padding-inline:
  1rem`, mono 14/20, weight 500, `+1px` tracking, its label cap-trimmed (`text-box: trim-both cap
  alphabetic`). Behind them one **plaque** — `position: absolute; top: 0.5rem; height: 2.5rem;
  radius 1.25rem`, white on glass / ink on solid — placed at the active link's `offsetLeft/Width`
  without animating on mount and resize (and again once `document.fonts.ready` resolves), then
  **sliding** on `SPRING` to whichever link is hovered or focused and back to **HOME** on leave.
  The link under the plaque is ink (glass) / white (solid); the others `rgba(255,255,255,0.82)` /
  `--muted`, swapped with a 150ms colour transition.
- **CTA** — the PillButton (below) **BOOK AN INSPECTION** → `#book-an-inspection`, hover label
  **Free · 40 min**, compact (`3rem` tall, `3.5rem` from 1440). Hidden below 768, where it moves
  into the foot of the hero.
- **Menu button** (below 1024) — a `3rem` round button with two `1.5px` bars in a `1rem × 0.75rem`
  box that cross into an X: top bar `translateY(c·5.25px) rotate(c·45deg)`, bottom bar the
  negative, on `SPRING`.

**Load-in**: the three blocks — logo, pill, actions — each `{ y: -30 → 0, opacity: 0 → 1 }` on
`SPRING_REVEAL`, delayed `LOAD_DELAY + 70·i`.

**The phone menu** (below 1024): a `position: fixed; inset: 0; z-index: -1` (under the header, so
the logo and the button stay usable) panel in `--deep`, `padding: 7rem 2rem 2rem` (`1.25rem` sides,
`1.5rem` bottom below 768), content bottom-aligned. It unfolds from the button's corner:
`opacity 0 → 1` and `clip-path: inset(${i·44}px ${i·gutter}rem ${i·100}% ${i·100}% round
${i·1.75}rem)` with `i: 1 → 0` on `SPRING` (`gutter` = 2rem, 1.25rem below 768). The links —
**HOME 01 · SERVICES 02 · PROCESS 03 · RECENT WORK 04** — are d4 bold rows (`d3` from 768),
`padding-block: 1rem`, ruled `rgba(255,255,255,0.16)` between, active white / others
`rgba(255,255,255,0.72)`, the index mono in `--on-dark-muted`; each rises `{ y: 24 → 0, opacity }`
on `SPRING_REVEAL` delayed `120 + 60·i`, then a row (`margin-top: 2rem`) with **RIDGELINE ROOFING
CO.** (hidden below 768) and the full-width CTA `120 + 60·4` later. Closing runs the springs back on
`SPRING` with no stagger. Scrolling is held while open; Escape closes it; a link closes it and
scrolls to its section **320ms** later; growing past 1024 closes it.

## The shared interaction primitives

**PillButton** — the one button shape: a `3.5rem` pill, `radius 1.75rem; padding-inline: 1.5rem`,
mono 14/20 uppercase, weight 500, `+1px` tracking, a diagonal arrow (9.65px box, path
`M0.5 9.15L9 0.65M9 9.65V0.65H0`, 1.3 stroke) at the far end. Tones: **light** — `--soft` field,
ink text, `gap 0.75rem`, hover `--surface` (150ms); **deep** — `--deep` field, white text, `gap
1rem`, hover `opacity: 0.9`. `block` layout fills its row with the arrow pushed to the edge. With
a **hover label**, the two labels stack in a `1.25rem`-tall clipped window; hovering springs the
stack `translateY(0 → -50%)` on `SPRING`, so the base label rolls up out and the hover label rolls
in from below; the window keeps the base label's width.

**Chip** — `display: flex; align-items: center; height: 2.25rem; radius 1.125rem; padding-inline:
1rem`, 14/20 weight 500. Variants: **soft** `--soft`/ink; **glass** `rgba(255,255,255,0.12)`, white,
`1px solid rgba(255,255,255,0.35)`, `backdrop-filter: blur(10px)`; **tag** (on the feature photo)
`height 2rem; radius 9999px; rgba(255,255,255,0.16); border rgba(255,255,255,0.28); blur 10px`,
**mono**; **meta** (under job cards) `height 2rem; radius 1rem; padding-inline 0.75rem; --soft;
--muted; mono`. Below 768 every chip's `padding-inline` is `1.25rem`.

**Eyebrow** — `(01)  ABOUT US`: mono 14/20 weight 500, the bracketed index in `--accent`, a
`1.2em` spacer, then the label. Typed on letter by letter the first time it crosses the trigger
line: letters `opacity 0 → 1` on `SPRING`, `letterStagger 30`, the label delayed
`(index.length + 2) × 30` ms after the index. On a photograph the whole thing is
`rgba(255,255,255,0.65)`.

**SectionHead** — eyebrow + two-line display title + an aside bottom-aligned to the right:
`display: flex; align-items: flex-end; gap: 4rem` (`2rem` from 1024; from 1440
`justify-content: space-between; gap: 0; padding-right: 1rem`; below 768 a column with `gap
0.75rem`). The left column (`gap 1rem`; `width: 55rem` from 1440) holds the `h2`: **d4 bold ink,
tracking −0.125rem** (d3 from 1024; `width: 47.5rem` from 1440; h2 32/36 −1px below 768), second
line in `--muted`. The aside is body 16/24 `--muted`, `width: 14rem` (`20rem` from 1024, `25rem`
from 1440, full below 768). Reveal: line 1 `{ y: 20 → 0, opacity }`, line 2 `{ x: 30 → 0,
opacity }` **260ms** later, both `SPRING_REVEAL` with `columnGap wordGap("sansBold", TRACKING.d3)`;
the aside fades in **420ms** after. Where a head is marked *flowBelowLg*, below 1024 both lines
run on as one wrapping sentence with `lineStagger 260`.

**HoverZoom** — a clipping frame whose photograph springs `scale 1 → 1.06` on `SPRING_SOFT` while
the pointer is over the nearest `[data-hover-zoom]` ancestor (the whole card), or the frame itself.

**Divider** — a `1px` presentational rule in `--line`, `--line-soft`, `--line-warm` or
`rgba(255,255,255,0.14)`.

**Photographs** are art-directed per frame with `<picture>`: `(min-width: 90rem)` → the 1440 crop,
`(min-width: 64rem)` → the 1024 crop, `(min-width: 48rem)` → the 768 crop, else the 390 crop, all
`object-fit: cover`, lazy except the hero.

## Layout & sections (in order)

```html
<div class="field">                         <!-- min-height: 100lvh; background: var(--soft); padding: 1rem  (0 below 768) -->
  <div class="card">                        <!-- radius 2rem; background: var(--page)  (below 768: --surface, padding 0 1.25rem 1.25rem) -->
    <div class="well">                      <!-- radius 2rem 2rem 0 0; background: var(--surface); padding: 1rem 1rem 0  (below 768: no radius, padding 0.75rem 0 0) -->
      status bar · header · <main> hero · [about pin: statement, stats, certifications] · work we do · how a job goes · recent work · book an inspection </main>
    </div>
    footer
  </div>
</div>
```

---

### 0 — Status bar

A row above the header: `padding: 0 1rem 1rem; justify-content: space-between`, mono 14/20 weight
500 `--muted`. Left: **PORTLAND, OR** (ink) `  ` **11°C, RAIN** `  ` `●` **4 CREWS OUT TODAY**;
right (ink, `white-space: pre`): **MON—SAT  7AM—6PM**. The separators are backslashes in `--muted`
with `1rem` gaps. The **live dot** is a `6px` `--live` disc with a second disc behind it on a loop:
`scale 1 → 2.8, opacity 0.6 → 0` over **1600ms** `easeOutCubic`, snap back, rest **700ms**,
repeat. Below 768: only the crews item and the hours, centred on one line at micro 12/18.

---

### 1 — Hero  ·  `#hero`

**The photo box**: `position: relative; height: calc(100svh - 6.25rem); min-height: 18rem; radius
1.5rem; overflow: hidden; container-type: size` (`min-height: 27rem` from 1024, `25rem` from 1440;
below 768 `height: calc(100svh - 4.125rem); min-height: 17rem`). It fills the first screen from
under the status bar to `2rem` above the bottom edge.

**The photograph** — `hero/hero.jpg` 2752×1440 (1024: `hero/hero-1024.jpg` 1920×1200; 768:
`hero/hero-768.jpg` 1408×1440; 390: `hero/hero-390.jpg` 700×1500), alt "Two roofers fitting a
standing-seam panel on a timber-framed house" — inside a **parallax layer**: a `Scrub` of the box
(`start: "top top"`, `end: "bottom top"`, on `SPRING`) driving `--drift 0 → 1` on an inner layer
that is `position: absolute; inset-inline: 0; top: -20cqh; bottom: 0; transform: translateY(calc(
var(--drift) * 20cqh - 0cqh))` — it drifts **down 20% of the box** as the hero scrolls out, moving
at 80% of the page's speed. Inside that, the photo **settles from `scale 1.12 → 1`** on
`SPRING_CAMERA`, started at **LOAD_DELAY (1500)**.

**Two scrims** over it: `linear-gradient(to bottom, rgba(0,0,0,0.62), rgba(0,0,0,0.18) 42%,
rgba(0,0,0,0.58))` and `linear-gradient(to right, rgba(0,0,0,0.42), rgba(0,0,0,0.05) 50%,
rgba(0,0,0,0))`.

**Headline** (`h1`, white, bold) — **"A roof is only as good"** / **"as its worst seam."** (the
second line `rgba(255,255,255,0.6)`). At 1440: **d1 88/88, tracking −0.25rem**, `position:
absolute; top: 8rem; left: 2rem; width: 56.25rem`; two engines, one per line, `columnGap
wordGap("sansBold", TRACKING.d1)`: line 1 `opacity 0 → 1` at `LOAD_DELAY`, line 2
**`x: -40 → 0, opacity`** at `SEAM_DELAY = LOAD_DELAY + 300` — it slides in against the first like
a seam closing. At 1024 (d2 72/76, `top: 7.5rem; width: 40rem`) and at 390 (d4 44/48 −0.09375rem,
`top: 6.25rem; left: 1.25rem; width: 17.375rem`) the muted clause **runs on after the first as one
sentence**, one engine with `lineIn { y: 0, opacity: 1 } / lineOut { y: 20, opacity: 0 }` and
`lineStagger 300`. At 768: d3 56/58, `top: 7.5rem; left: 2rem; width: 40rem`, two lines as at
1440 but with the run-on engine.

**Material callouts** (from 1024 up, on a second parallax layer with the same drift so they stay
on their seams). Each is a dot, a ring, a hairline leader and a two-line label placed on the
photograph's **cover crop**: with the box a size container and the frame `F = {w: 1376, h: 720}`
(`{960, 600}` from 1024 to 1439), `cover = max(100cqw / F.w, 100cqh / F.h)`, a design point maps
to `left: calc(50cqw + (x − F.w/2) * cover)`, `top: calc(50cqh + (y − F.h/2) * cover)`.

```js
DOT = 8; RING = 20; LABEL_GAP = 16; LEADER_START = 14;     // px in the design; the UI keeps its size, the photo scales
PULSE = { grow: 1600, rest: 1400, scale: 2.2, opacity: 0.4 }; pulsePhase = title => (title.length * 400) % 3000;
TITLE_AFTER_LEADER = 150; NOTE_AFTER_TITLE = 60;
hotspots = [
  { x: 620, y: 365, leader: 96, title: "Standing seam", note: "24 gauge, mechanically locked", lg: { x: 416, y: 326, leader: 48 } },
  { x: 738, y: 502, leader: 96, title: "Drip edge",     note: "Keeps water off the fascia",     wideOnly: true },   // 1440 only
];
```

The dot's centre is at `(x + 4, y + 4)`; the leader starts `x + 14` at the same `top`, is `1px`
tall in `rgba(255,255,255,0.5)`, `transform-origin: left`, `width: calc(leader * cover)`; the label
sits `LABEL_GAP` (a fixed `1rem`, not scaled) past the leader's end, its first line centred on the
leader (`top` at `y − 6`). A `2.5rem` round hit target around the 20px ring. At rest only the dot
(8px white) and the ring (20px, `0.5px` white stroke at 55%) show, with a **halo** — a 20px white
disc — breathing out of the ring: `scale 1 → 2.2, opacity 0.4 → 0` over 1600ms `easeOutCubic`, rest
1400ms, each callout starting at its own phase. Pointer enter or focus: the ring pops `scale 1 →
1.3` on `SPRING_POP`, the leader draws `scaleX 0 → 1` and the label fades in on `SPRING` (the halo
fades out while shown), and the label **types itself afresh on every visit**: title after 150ms,
note after `150 + title.length × 30 + 60`, letters `opacity` on `SPRING` at 30ms, mono 14/20,
weight 500, the note `margin-top: 0.125rem` at `rgba(255,255,255,0.62)`, `columnGap
wordGap("mono")`. As the ring grows, the leader's start moves out by the growth (`margin-left:
calc(grow * 0.625rem)`) and gives the same back at its end, so it never crosses the ring. Leave
draws it back and fades the label.

**Material chips** — **Asphalt shingle · Standing seam · Cedar shake · Flat roof** — glass chips,
`position: absolute; bottom: 2rem; left: 2rem`, a column with `gap 0.5rem` at 768, two wrapped
rows `width: 18rem` at 1024, one row at 1440; at 390 wrapping rows at `left/right: 1.25rem; bottom:
6.5rem`. On load each `{ opacity 0 → 1, filter blur(6px → 0) }` on `SPRING_REVEAL`, delayed
`LOAD_DELAY + 400 + 80·i`; write the filter as `none` once it is under 0.05 so the chip's own
backdrop blur survives.

**The job rail** (768 and up, on the photograph; hidden on short viewports) — one slider in two
parts:

- **The track**: `position: absolute; right: 2rem; bottom: 8rem` (`9rem` from 1440), `height:
  1.25rem; width: 24.0625rem` (`22.625rem` at 1024, `38rem` at 1440), `gap 1rem`: the current index
  **01** (mono 14/20 500, white), a `2px` groove in `rgba(255,255,255,0.35)` (`overflow: hidden`),
  and the total **03** in `--on-dark-muted`. The thumb — a white `2px` bar, **180px** wide at rest
  in the design (`thumb/16 rem × root px`) — is two edges on springs: `x = ratio × (grooveWidth −
  thumbWidth)`; the edge in the direction of travel moves on `SPRING`, the other on `SPRING_TRAIL`,
  so it stretches toward where it is going and draws its tail in (`width: end − start + thumb`,
  `translateX(start)`).
- **The rail**: `position: absolute; right: 2rem; bottom: 2rem; width: 24.0625rem` (`22.5625rem`,
  `gap 0.75rem` at 1024; `38rem` at 1440), a horizontal `overflow-x: auto; scroll-snap-type: x
  mandatory; overscroll-behavior-x: contain` list with no scrollbar, `gap 1rem`, `tabindex=0`,
  items `19.5rem` wide (`17.5rem` at 1024, `20.75rem` at 1440), `scroll-snap-align: start`.
  Each card is a button: `height: 5.5rem; radius 1rem; padding 1rem; gap 0.75rem; border 1px
  rgba(255,255,255,0.24); background rgba(255,255,255,0.14); backdrop-filter blur(12px)`, hover
  `rgba(255,255,255,0.2)` (from 1440: `height 6rem; radius 1.25rem; gap 1rem; padding-right 2rem`),
  a `3.5rem` (`4rem` at 1440) thumbnail at `radius 0.75rem`, then a text column at **micro 12/18
  weight 500** (`14px`, `width: 12.75rem` at 1440): the title in white and the meta line in
  **mono, +1px tracking**, `rgba(255,255,255,0.65)` (at 1440: `--on-dark-muted`, leading 1.3, no
  tracking). Cards:
  1. `hero/job-sellwood.jpg` — **Re-roof in progress, Sellwood** / **Asphalt architectural — day 2**
  2. `hero/job-hood-river.jpg` (`hero/job-thumb-hood-river.jpg` below 1440) — **Seam work, Hood
     River** / **24 gauge steel — day 4**, the meta line held to `9.0625rem` at 1440 so it wraps
  3. `hero/job-seam-check.jpg` — **Seam check, Hood River** / **Standing seam — day 3**
- On every scroll event and on resize: `ratio = scrollLeft / (scrollWidth − clientWidth)`; the
  active index is the card whose centre is nearest the slot's centre; clicking a card scrolls to
  `scrollWidth / 3 × index` smoothly. The rail is masked on whichever edge still hides a card:
  `mask-image: linear-gradient(to right, rgba(255,255,255,e₁) 0%, rgba(255,255,255,i₁) 15%, #fff
  45%, #fff 55%, rgba(255,255,255,i₂) 85%, rgba(255,255,255,e₂) 100%)` where for each edge
  `s = clamp(hiddenPx / 24, 0, 1)`, `e = 1 − s`, `i = 1 − 0.65·s`.

**At 390** the rail leaves the photograph for the page below it (`padding: 2rem 0 1rem`), in the
**light** tone: the track full width with `--track` groove and `--ink-strong` fill (thumb 203px),
the cards full-width `radius 1rem; --soft; padding 0.75rem` one at a time, titles `--ink-strong`,
meta `--muted`, no mask. And the CTA — a full-width light PillButton **BOOK AN INSPECTION** /
**Free · 40 min**, `height 3.25rem` — sits `position: absolute; inset-inline: 1.25rem; bottom:
1.25rem` on the photo, rising `{ y: 20 → 0, opacity }` on `SPRING_REVEAL` at `LOAD_DELAY + 600`.

---

### 2 — The pinned about block  ·  `#about-us`

From 768 up the block is `position: sticky; top: 0; min-height: 100svh; padding-top: 4rem` (clearing
the pinned header) inside a wrapper, followed by a **hold** — an empty `80svh` — and then the
services section on `position: relative; z-index: 10`, which slides up over the pinned block. The
statement's fill and the figures' roll are **scrubbed off the hold** (`start: "top bottom"`,
`end: "bottom bottom"` on it); below 768 nothing pins and they play from their own in-view.

**Statement** — `padding: 3rem 2rem` (from 1024 `padding-bottom 1.5rem`; from 1440 `padding-right
0; padding-bottom 3rem`; below 768 a column with a rule above, `padding: 1rem 0 2rem; gap 1.5rem`):
eyebrow **(01)  ABOUT US** and one paragraph, side by side from 1024 (`justify-content:
space-between; gap 1rem`), stacked with `gap 2rem` at 768. The paragraph: **d5 36/40 −0.0625rem**
`--muted` weight 400 (1024: d4 44/48, `width 41.75rem`, `margin-block: −0.5rem` to cap-trim; 1440:
d3 56/58 −0.125rem, `width 63.5rem`, `margin-block: −0.5625rem`; 390: h3 24/28 −0.0625rem):

> We **tear off, repair and re-flash roofs** across Portland and the Columbia Gorge. Twenty-eight
> years, **the same crew leader** from first nail to last.

The bold runs are weight 700 in `--ink`. It **fills in letter by letter with scroll**: text engine
in progress/interpolate mode, `columnGap wordGap("sans", TRACKING.d3)`, every letter from
`opacity 0.25` to `1` on `SPRING`, stagger coefficient **0.012** (about one letter at a time), over
the hold from 768; below 768 from the paragraph entering at the bottom until its centre reaches
the middle (`start "top bottom"`, `end "center center"`).

**Stats** — `margin-top: auto` inside the pinned block (the spare height opens between the
statement and the figures), `padding: 0 2rem 3rem` (`padding-top 1.5rem` at 1024; `0 0 2rem` below
768), a `dl` of four equal columns, `gap 1rem` (`0` at 1440; two columns `gap 0.5rem` at 390):

| numeral | label |
|---|---|
| **1400** | ROOFS / FINISHED |
| **25** | YEAR / WARRANTY |
| **2** | DAYS PER / BUILD |
| **28** | YEARS IN / PORTLAND |

Each cell: `padding-block 1.5rem`, a column with `gap 0.5rem` (from 1440 a row with `gap 0.75rem`,
the first cell `padding-right 1.5rem`, the others `padding-inline 1.5rem`; at 390 `radius 0.75rem;
--soft; padding 1.25rem`). The numeral is **d3 56/58 bold ink, tracking −0.25rem** (d2 at 1024,
**d1 88/88** at 1440, d5 36/40 −0.0625rem at 390); the label mono 14/20 weight 400 `--muted`,
`width 7.5rem; padding-top 0.25rem` (`5.625rem` at 1440; micro at 390), two lines.

**The odometer**: each digit is a window as wide as its final digit (an invisible copy sizes it),
`margin: -0.1em; padding: 0.1em; overflow: hidden`, with a reel `0…9` stacked in a column
`position: absolute; top: 0.1em; left: 50%` and `transform: translate(-50%, -r × 10%)` where `r` is
the rolled value. From 768 `r = digit × clamp((progress − lead) / span, 0, 1)` with `lead = (n − 1 −
i) × 0.08` and `span = 1 − (n − 1) × 0.08` — the ones place first, each place 8% of the scroll
behind the one to its right, running back when scrolled up. Elsewhere it plays once on first
view: `r 0 → digit` on `SPRING_REVEAL`, delayed `(n − 1 − i) × 90`.

**Certifications** — `padding: 0 2rem 2rem` (`3rem` at 1440; `0 0 2rem` below 768): a `--line`
rule, then a row `padding-block 1.5rem` (`1.25rem` at 390; a column `gap 1.5rem` below 1440) with
**CERTIFIED AND INSURED** (mono 14/20 500 `--muted`) and soft chips **Oregon CCB 204118 ·
Manufacturer certified · Bonded and insured · Workers comp covered · BBB accredited** (`gap 0.5rem`,
wrapping), then a rule. Each chip pops `{ scale 0.7 → 1, opacity }` on `SPRING_POP`, **60ms**
apart.

---

### 3 — Work we do  ·  `#work-we-do`

`radius 1.5rem; background: var(--soft); padding: 2rem; gap 2rem` (`gap 3rem` from 1024; `padding-block
3rem` at 1440; `gap 1.5rem; padding-inline 1.25rem` at 390), `overflow: hidden`.

**Head**: **(02)  WORK WE DO** — **"Four things,"** / **"done properly."** — aside "Not on the list?
We'll give you the name of someone who does it well." (`max-width 17.625rem` at 390).

**Four service cards** in one column, `gap 1rem` (two columns at 1440). A card: `height 13.75rem;
radius 1.25rem; background var(--page); padding 1.5rem; gap 1.5rem; box-shadow var(--shadow-card)`,
`data-hover-zoom` — a square `10.75rem` photo at `radius 1rem` in a HoverZoom, then a column
`justify-content: space-between` holding the title (**h3 24/32 weight 600 ink**), the copy
(14/20 `--muted`, `width 23.75rem` at 1024), and a mono 14/20 500 ink note pinned to the floor. At
390: `height auto; flex-direction column; gap 1rem; padding 1.25rem`, the photo a `11.25rem`-tall
band at `radius 0.875rem` using the `-390` crop.

| photo (`work/`) | title | copy | note |
|---|---|---|---|
| `tear-off.jpg` / `tear-off-390.jpg` | **Full tear-off and re-roof** | Down to the deck, so rot shows up now and not next winter. New underlayment, new flashing, shingles that lie flat. | 2 days typical |
| `leak-repair.jpg` / `leak-repair-390.jpg` | **Leak repair** | Most leaks start at a seam, a vent or a flashing. Water rarely gets in directly above the spot where it drips. | Booked same week |
| `storm-claims.jpg` / `storm-claims-390.jpg` | **Storm and hail claims** | We photograph the damage and meet your adjuster on the roof. The scope is written the way their software wants it. | Adjuster ready |
| `gutters.jpg` / `gutters-390.jpg` | **Gutters and attic ventilation** | A roof that can't drain or breathe fails ten years early. Nobody photographs this part, and it decides the rest. | Sized to the roof |

Alts: "Crew laying battens across a stripped roof deck", "Roofer sealing a chimney flashing joint",
"Roofer photographing hail damage on shingles with a phone", "New gutter, downpipe and ridge vents on
a finished roof". Each card **clears out of a blur as it rises**: `{ opacity 0 → 1, y 32px → 0,
filter blur(16px) → blur(0px) }` on `SPRING_REVEAL`, **140ms** apart in reading order.

**The checklist** — `padding-top 1.5rem; gap 1.5rem`: **WHAT'S IN A FULL RE-ROOF** (mono `--muted`)
over a grid of nine items, three columns at 1440 (two below — `width 54rem; column-gap 2rem` at
1024; one at 390), `column-gap 1rem`:

```
01 Roof deck, checked and repaired   04 Starter course        07 Step and valley flashing
02 Ice and water shield              05 Field shingles        08 Drip edge and gutters
03 Synthetic underlayment            06 Ridge cap and vents   09 Cleanup and magnet sweep
```

Each item: a `1px --line` rule that **draws** `scaleX 0 → 1` from the left, then a row
`padding-block 1.5rem; justify-content: space-between` with the index (mono `--muted`), the label
(16/24 weight 500 ink, `gap 1rem`) and a 14px plus (`M0 7H14M7 0V14`, `--muted` stroke) that fades
in. Delays: rule at `(i % 3) × 90 + floor(i / 3) × 160`, text **200ms** after its rule.

---

### 4 — How a job goes  ·  `#how-a-job-goes`

`padding: 2rem 2rem 4rem; gap 2rem` (`gap 3rem` from 1024; `padding: 3rem 2rem 6rem` at 1440;
`gap 1.5rem; padding: 2rem 0` at 390).

**Head**: **(03)  HOW A JOB GOES** — **"No surprises after"** / **"the ladder comes down."** — aside
"Four steps, in this order, every time. You'll know the price before we start." (`max-width
18.6875rem` at 390).

**The accordion** — an ordered list, one step open at a time, **01 open on load**. Per item, CSS
variables: `--step-inset-open: 1.5rem; --step-inset-closed: 0.5rem; --step-row-pad: 1.5rem;
--step-row-pad-open: 1.5rem` (390: `1.25rem / 0 / 1.25rem`). One spring on `SPRING` writes four
values as the step opens: `progress 0 → 1` (into `--step-progress`), `detail 0 → measured height`,
`spacer 0 → 1rem`, `divider 1px → 0`.

The card: `radius 1.25rem`, background `--surface` closed / `--soft` open (250ms colour
transition), `padding-left: calc(var(--step-inset-open) * var(--step-progress)); padding-right:
calc(var(--step-inset-closed) + (var(--step-inset-open) − var(--step-inset-closed)) *
var(--step-progress))`. Its **row** is a button: `gap 1.25rem; padding-top 1.5rem; padding-bottom:
calc(var(--step-row-pad) + (var(--step-row-pad-open) − var(--step-row-pad)) * var(--step-progress))`
— the index (mono 14/20 500 `--muted`), the title (**h2 32/40 −0.0625rem weight 600 ink**;
20/26 at 390), the note (mono `--muted`; micro at 390, dropped under the title), and a **24px
arrow** (`M6.5 17.5L18 6M18 18V6H6`, ink stroke 1.3) that turns **90°** on `SPRING_POP` while a
*closed* row is hovered. A closed step is 88px tall in every frame. Under the row, a clipped
`overflow: hidden` box whose `height` is the sprung `detail` (the open step's measured height, kept
current with a `ResizeObserver`, `auto` on first paint so nothing shifts), holding the detail
with `padding-bottom 1.5rem` (`1.25rem` at 390). After the card: the divider (`--line-soft`, its
height the sprung `divider`, also 0 when the *next* step is the open one) and the spacer.

**The detail** — a grid `grid-template-columns: 15rem minmax(0,1fr); gap 1.5rem` (1024: `18.75rem`;
1440: `height 16.25rem; columns 25rem 1fr; rows auto 1fr; column-gap 2rem; row-gap 0`; 390: one
column, `gap 1rem`):

- the **photo** in a HoverZoom `height 13.75rem; radius 1.25rem` (1440: spans both rows, `16.25rem`;
  390: `12.5rem`), loaded eagerly, **settling from `scale 1.12 → 1`** on `SPRING_CAMERA` once the
  step is open *and* has crossed the trigger line (so the step that starts open plays it as it
  scrolls into view);
- the **body** — lead 20/28 ink (`width 37.5rem` at 1440; 20/26 at 390);
- the **facts** — spanning both columns (1440: column 2, `align-self: end`; `margin-top 0.5rem` at
  390), `gap 1.5rem`: a `--line-warm` rule, then a row `justify-content: space-between` with a `dl`
  (a column `gap 1rem`; a row `gap 2.5rem` from 1024, `2rem` at 1440) of two facts — label mono
  `--muted`, value 16/24 500 ink — and a **deep** PillButton **BOOK AN INSPECTION** / **Free · 40
  min** → `#book-an-inspection` (full width, `height 3.25rem` at 390).

| # | title | note | photo (`process/`) | body | facts |
|---|---|---|---|---|---|
| 01 | **The inspection** | Free of charge | `inspection.jpg` 800×520 (`-1024` 600×440, `-768` 480×440, `-390` 620×400), "Two inspectors reviewing roof photographs on a tablet" | Two of us come out, photograph everything we find, and go through the pictures with you on the spot. No sales pitch from the top of a ladder. | ON SITE — About 40 minutes · YOU GET — Every photo we took |
| 02 | **The written quote** | Fixed price | `written-quote.jpg`, "Estimator pricing a re-roof over printed plans" | One page, itemised: tear-off, materials, disposal, permit, labour. If we open the deck and find rot, we stop and call you before we spend another dollar of your money. | ON PAPER — One page, itemised · HELD FOR — Thirty days |
| 03 | **The build** | 2 days typical | `the-build.jpg`, "Roofer marking a standing-seam panel during the build" | Tarps go down before the first shingle comes off. We run a magnet over the driveway every evening. Nails end up in the skip, not in your tyres. | ON SITE — Two days typical · CREW — One leader, start to finish |
| 04 | **The handover** | 25 year warranty | `handover.jpg`, "Finished dark standing-seam roof over a dormer" | We walk the roof with you, register the warranty with the manufacturer while we're standing there, and leave the spare bundles in your garage for future patches. | COVER — Twenty-five years · REGISTERED — The same day |

---

### 5 — Recent work  ·  `#recent-work`

`padding: 0 2rem 4rem; gap 2rem` (`gap 3rem` from 1024; `padding-bottom 6rem` at 1440; `gap 1.5rem;
padding: 2rem 0` at 390).

**Head** (*flowBelowLg*, `padding-bottom 0.25rem` from 1024, `0.5rem` at 1440): **(04)  RECENT
WORK** — **"Roofs you can drive"** / **"past and look at."** — aside "Ask for the address of any job
below. Most owners are happy to have you stand in the street and look up."

**The feature** — an `article` at `aspect-ratio: 640/400` (`896/400` from 1024, `1312/520` from
1440, `350/200` at 390), `position: relative; container-type: size`, raised to `z-index 20` while
one of its markers is open. Inside, a clipped `radius 1.5rem` frame holding a **centre-anchored
parallax layer** (`Scrub` of the article `"top bottom" → "bottom top"` on `SPRING`; inner layer
`top: -10cqh; bottom: -10cqh; translateY(calc(var(--drift) * 20cqh − 10cqh))` — it drifts from 10%
above its place to 10% below as the section scrolls through) with the photograph
`recent/feature-sellwood.jpg` 2624×1040 (`-1024` 1792×800, `-768` 1280×800, `-390` 700×400), alt
"Finished asphalt re-roof on a brick house in Sellwood", **settling from `scale 1.15 → 1`** on
`SPRING_CAMERA` on first view. Over it (768 up): a scrim `linear-gradient(to bottom,
rgba(0,0,0,0.05), rgba(0,0,0,0.55))`, the title **Asphalt re-roof, Sellwood** (h2 32/40 bold white,
tracking −0.125rem, `position absolute; bottom 3.5rem; left 2rem; width 36rem`; 1024 `47.5rem`;
1440 **d3 56/58**, `bottom 4rem; width 56.25rem`), the meta line **Asphalt architectural  —  2 days
—  Portland, OR** (mono 14/20 500 white, `white-space: pre-wrap`, `bottom 2rem; left 2rem`), and a
**tag** chip **Finished 2026** at `top 2rem; right 2rem` (`1rem` at 390). At 390 the title and
specs leave the photo: below it, **Asphalt re-roof, Sellwood** (18/26 500 ink) and meta chips
**Asphalt architectural · 2 days · Portland, OR**.

Two **markers** ride a second, unclipped parallax layer with the same drift (so their popovers are
never cut off), placed on the cover crop of the frame `F` = `{1312×520}` (1024 `{896×400}`, 768
`{640×400}`, 390 `{350×200}`): marker top-left at `left: calc(50cqw + (x + 16 − F.w/2) * cover −
1rem)`, `top` likewise.

```js
featureHotspots = [
  { x: 915, y: 195, label: "Bay window roof",       description: "Metal roof and flashing over the front bay",              lg: {595,131}, md: {485,112}, sm: {255,66} },   // flip: opens to the left (x > 656)
  { x: 595, y: 339, label: "Gutters and downspout", description: "The downpipe beside the porch, where water leaves the roof", lg: {355,240}, md: {275,207}, sm: {143,112} },
];
```

**The marker** (three states, shared by every marker on the page): a `2rem` box holding a disc
centred on it — `2rem`, `--soft`, a `14px`-wide plus (`M2 12H22M12 2V22`, ink stroke 2.6) — and,
behind it, a white pulse disc `scale 1 → 1.9, opacity 0.5 → 0` over **1400ms** `easeOutCubic`, rest
**2600ms**, repeat (hidden while open). Open: the disc springs to `2.5rem` on `{ tension: 320,
friction: 26 }`, goes `--marker-open` with `--shadow-marker`, the plus rotates **45°** into a cross
and turns white, and a **card** opens beside it: `position absolute; top 50%; translate: 0 -50%;
width max-content` (`12.5rem`, wrapping, at 390), `radius 1rem; --page; padding 1rem 1.25rem;
--shadow-popover`, holding the label (16/24 bold ink) and the description (14/20 `--muted`,
`margin-top 0.25rem`), on the marker's preferred side at a **1rem** gap with a `1rem` invisible
bridge so crossing the gap does not count as leaving; it grows out of the marker's edge
`{ scale 0.4 → 1, opacity }` on `SPRING_POP` (`transform-origin` at the marker's side). Placement
is measured each time it opens: the preferred side if it has room (viewport minus a `1rem`
gutter), otherwise the roomier side, and if neither fits it is shifted back inside the viewport.
It opens on mouse hover or keyboard focus, on tap where there is no hover; **only one card on the
page is open at a time** (a shared `active` id); it closes when the pointer leaves the marker and
its card, on a press anywhere else, on Escape, and once the marker scrolls out of view.

**The jobs row** — a grid, two columns `gap 1rem` (three from 1024; `gap 0.5rem` at 1440; one
column `gap 1.5rem` at 390): two job cards and the quote.

A **job card**: a `data-hover-zoom` photo box `height 12.5rem` (`15.625rem` from 1024), `position:
relative; container-type: size`, the photo at `radius 1.25rem` in a HoverZoom, one marker placed
on frame `{432×250}` (1024 `{288×250}`, 768 `{312×200}`, 390 `{350×200}`), card opening to the left;
then `gap 0.75rem`: the title (18/26 500 ink, `white-space: nowrap`) and meta chips.

| photo (`recent/`) | title | chips | marker |
|---|---|---|---|
| `beaverton.jpg` 864×504 (`-1024` 576×500, `-768` 624×400, `-390` 700×400), "Modern house with a new shingle roof in Beaverton" | **Shingle re-roof, Beaverton** | Architectural asphalt · 2 days | (79, 28) **Ridge cap** — "Capping tiles that seal the ridge and hips"; lg (35,37) md (55,19) sm (75,20) |
| `hood-river.jpg` (same variants), "Craftsman house with a standing-seam roof in Hood River" | **Standing seam, Hood River** | 24 gauge steel · 6 days | (346, 102) **Chimney flashing** — "Where the chimney meets the roof"; lg (254,77) md (245,71) sm (275,74) |

The **quote card**: spans two columns (one from 1024, `height 21rem; justify-content:
space-between`), `radius 1.25rem; --soft; padding 1.5rem; gap 2rem` (`1.25rem` at 390). It grows in
`{ scale 0.92 → 1, opacity }` on `SPRING_REVEAL`; the quote (**h3 24/32 weight 600 ink**, `width
35.125rem; max-width 100%`) fades in **60ms** after and the byline **140ms** after (both from the
card's own top edge, `rootMargin 0`): *"Two companies told me I needed a whole new roof. Ridgeline
found the split pipe boot in twenty minutes."* — **Marta Kelleher** (14/20 500 ink) / *Homeowner,
Sellwood* (mono `--muted`); the byline a row `justify-content: space-between` (a column `gap
0.5rem` from 1024).

---

### 6 — Book an inspection  ·  `#book-an-inspection`

`padding: 0 2rem 4rem; gap 2rem` (`gap 3rem` from 1024; `padding-bottom 6rem` at 1440; `gap 1.5rem;
padding-inline 0` at 390).

**The stage** (1440 only). Wrap the box in a *runway* `div`, and inside it a *stage* `height:
100svh; display: flex; align-items: center` with the box centred in it. Measure once (and on
resize) the box's distance to each viewport edge inside the stage — `--bleed-top/right/bottom/left`
in px. A `Scrub` of the runway (`"top bottom" → "bottom bottom"`, on `SPRING`) yields `p`; publish
`--shrink = clamp(p / 0.8, 0, 1)` and `--cta-in = clamp((p − 0.55) / 0.45, 0, 1)` on the box.
Nothing pins — the scene rides the normal scroll and reverses when scrolled back. Below 1440 the
variables rest at `1`.

**The box** — a column `gap 1rem` (a row from 1024; from 1440 `position: relative; display: block`):

1. **The photo frame**: `position relative; aspect-ratio 640/360; radius 1.25rem; overflow hidden`
   (1024: `height 35.5rem; width 25rem`; 1440: `aspect-ratio 1312/596; width 100%; overflow
   visible`; 390: `height 21.25rem`). Its **photo layer** is `position: absolute; inset: 0`, and at
   1440 pushed out to the viewport — `top: calc(var(--bleed-top) * -1)` and so on for all four
   edges — then clipped back in: `clip-path: inset(calc(var(--shrink) * var(--bleed-top))
   calc(var(--shrink) * var(--bleed-right)) calc(var(--shrink) * var(--bleed-bottom))
   calc(var(--shrink) * var(--bleed-left)) round calc(var(--shrink) * 1.25rem))`. So at `--shrink
   0` the photograph covers the whole screen and at `1` it sits in its rounded frame. The layer
   holds `cta/cta-roofer.jpg` 2624×1192 (`-1024` 800×1136, `-768` 1280×720, `-390` 700×680), alt
   "Roofer sitting on the ridge of a dark standing-seam roof", `object-fit cover`, under a scrim
   `linear-gradient(to right, rgba(0,0,0,0.45), rgba(0,0,0,0.05) 70%)`. At 1440 only, a clipped
   `radius 1.25rem` overlay holds the **light glow** — `cta/light-glow.svg` 860×660 (a
   `#DBE6F0` ellipse at 16%, `rx 310 ry 210`, blurred 60) — at `position absolute; top −16.25rem;
   left 20rem; width 53.75rem; height 41.25rem`, so the blur bleeds past the frame's edge.
   Everything below carries the **arrival** rule at 1440: `opacity: var(--cta-in);
   translate: 0 calc((1 − var(--cta-in)) * 1.5rem)`:
   - eyebrow **(05)  BOOK AN INSPECTION** (on-dark tone) at `top 2rem; left 2rem` (`1.25rem` at 390);
   - the title (`h2`) **"Ready to get"** / **"on the roof?"** (second line `rgba(255,255,255,0.55)`),
     **d4 44/48 bold white, tracking −0.1875rem**, `position absolute; top 4.75rem; left 2rem; width
     25rem` (1024: d3, `top 5.25rem; width 20rem`; 1440: **d2 72/76**, `top 5rem; width 37.5rem`;
     390: h2 32/36 −0.0625rem, `top 3.5rem; inset-inline 1.25rem`);
   - the intro (1024 up only) "Free inspection, photographs the same day, and a written price
     before anyone touches your house." — 18/26 `rgba(255,255,255,0.82)`, `left 2rem; top 14.25rem;
     width 20rem` (1440: `top 16rem; width 27.5rem`);
   - the phone block at `bottom 2rem; left 2rem; width 15.75rem; gap 0.75rem` (`1.25rem` insets at
     390): **OR CALL AND SPEAK TO DAVE** (mono 500 `rgba(255,255,255,0.6)`), **(503) 555 0148**
     (`tel:+15035550148`, h2 32/40 −0.0625rem bold white; h3 at 390), **MONDAY TO SATURDAY, 7AM TO
     6PM** (mono `rgba(255,255,255,0.5)`; micro at 390).
2. **The request form**: `radius 1.25rem; --soft; padding 1.5rem; gap 2rem` (1024: `flex: 1`; 1440:
   `position absolute; top 2rem; bottom 2rem; right 2rem; width 35rem; background var(--page);
   box-shadow var(--shadow-form)`; 390 `padding 1.25rem`), also under the arrival rule.
   - **FREE INSPECTION** (mono 14/20 500 ink), **Get a written price** (h2 32/40 −0.0625rem bold; h3
     at 390), and — below 1024 only — the intro copy in 18/26 `--muted` (16/24 at 390).
   - Fields (`gap 0.75rem; opacity 0.8`): **Name** and **Phone** share a row `gap 0.75rem` (a column
     at 1024 and 390; `gap 0.5rem` at 1440), then **Property address** full width. A field is
     `height 3.5rem; radius 1.75rem; --surface (--soft at 1440); padding-inline 1.5rem` (`1.25rem`,
     `height 3.25rem` for the pair at 390), 16/24 ink, placeholder `--muted`, label visually hidden.
   - **ROOF AGE, ROUGHLY** (mono `--muted`) over the picker: `margin-top 0.5rem; gap 0.5rem`,
     wrapping radios **Under 10 yrs · 10 to 20 · Over 20 · No idea**, the first checked. Each chip
     `height 2.5rem; radius 1.25rem; --surface (--soft at 1440); padding-inline 1rem`, 14/20 500
     ink, white when checked (150ms). One navy **plaque** (`--deep; radius 1.25rem`) slides under
     the chosen chip on `SPRING` — `translate(x, y)` and `width/height` from the chip's offsets —
     placed without animating on mount, resize and `fonts.ready`.
   - Pinned to the foot (`margin-top auto; gap 1rem`; centred at 390): a deep, block PillButton
     **BOOK A FREE INSPECTION** (`type=submit`, does nothing) and **WE ANSWER WITHIN ONE WORKING
     DAY** (mono `--muted`; micro at 390).

**The assurances** — a list `radius 1.25rem; --soft; padding 0.5rem; gap 0.5rem` (a row from 1024;
`padding 1.25rem` at 390) of three cards `radius 0.875rem; --surface; padding 1.25rem; gap 1rem`,
a row (a column from 1024, a row again at 1440): a **44px icon** — `rx 14` `--soft` box with an
ink glyph, `stroke-width 1.5`, `pathLength 1`, **traced on** (`stroke-dashoffset 1 → 0`) on
`SPRING_DRAW` when the strip crosses the trigger line, **200ms** apart, drawn once —
then the label (14/20 500 `--muted`) and the value (16/24 500 ink; `width 18.75rem` at 1440):

| glyph | label | value |
|---|---|---|
| shield `M30 16.5L22 13.5L14 16.5V22.5C14 26.5 18 29.5 22 30.5C26 29.5 30 26.5 30 22.5V16.5Z` | Licensed and insured | Oregon CCB 204118 |
| check `M14 23L19 28L30 16` | No sales pitch | We photograph, price, and leave |
| clock circle `cx 22 cy 22 r 8.25` + `M22 17V22L26 24` | Response time | One working day, every request |

---

### 7 — Footer

`@container`, `radius 2rem; --deep; padding-block 1.5rem` (`padding-top 2rem` from 1024 and at
390), white text, `overflow hidden`, edge to edge of the card (at 390 a rounded panel inside the
`1.25rem` gutter).

**Top row** — `padding: 0 2rem 2rem; gap 2rem` (1024: a row, `gap 8.5rem; padding-bottom 3rem`;
1440: `justify-content: space-between; padding-bottom 4rem`; 390: `gap 1.5rem; padding 0 1.25rem
1.5rem`): the statement **"Roofs that hold."** / **"Crews that show up."** (second line
`rgba(255,255,255,0.45)`), **d4 44/48 bold** (1024: d3, `width 36.5rem`; 390: h2 32/36 −0.0625rem),
and a nav (`width 12.5rem` from 1024) of links in 18/26 weight 500 — **Work we do** `#work-we-do`
· **How a job goes** `#how-a-job-goes` · **Recent jobs** `#recent-work` · **Warranty**
`#book-an-inspection` · **Careers on the crew** `#book-an-inspection` — a column with `gap 1rem`
from 1024; below it full-width rows `padding-block 1rem` ruled `rgba(255,255,255,0.15)` with a
white diagonal arrow (9.65px at 50%) on the right. Pointing at one link dims its siblings to
`opacity 0.4` (150ms) and keeps itself at 1.

**Contact** — `padding-inline 2rem` (`1.25rem` at 390): a `rgba(255,255,255,0.14)` rule drawing
`scaleX 0 → 1` on `SPRING_REVEAL`; a `dl` `padding-block 1.5rem; column-gap 2rem; row-gap 1.5rem`,
wrapping (`nowrap` from 1024; `gap 1rem / 1.25rem` at 390), cells `width 20rem` (`flex: 1` from
1024; `10.1875rem` at 390) fading in `300 + 90·i` ms:

| LOCATION | CONTACT US | MO—SAT | EMAIL |
|---|---|---|---|
| 2140 SE Division St / Portland, OR 97202 | (503) 555 0148 | 7:00am — 6:00pm | dave@ridgeline.co |

(labels mono 14/20 `rgba(255,255,255,0.5)`, values 16/24 500 white, `width 17.5rem` at 1440); then
a second rule drawing **150ms** after the first.

**The wordmark band** — `position relative; overflow hidden; width 100%; height 14.2663cqw`
(1024: `14.0121cqw`; 1440: `14.4886cqw`; 390: `12.5714cqw`), holding **RIDGELINE**: `position
absolute; font-weight 700; white-space nowrap; color rgba(255,255,255,0.07)`, `font-size
19.4293cqw / line-height 16.712cqw / top −1.345cqw / left 3.3967cqw` (1024: `19.0613 / 16.5751 /
−1.4315 / 3.125`; 1440: `19.8536 / 17.2989 / −1.4915 / 1.3494`; 390: `17.1671 / 15.4837 / −1.486 /
4.8571`), placed from the left, not centred. It **opens out as it arrives**: `letter-spacing:
calc(var(--wordmark-tracking) + var(--wordmark-spread) * (1 − var(--wordmark-progress)))` with
`--wordmark-tracking: −1.117cqw` (1024 `−0.8288cqw`, 1440 `−0.8759cqw`, 390 `0`) and
`--wordmark-spread: calc(var(--wordmark-tracking) * 1.3)` (`−1.5cqw` at 390); a Reveal springs
`--wordmark-progress 0 → 1` and `opacity 0 → 1` on `SPRING_REVEAL` with `rootMargin "0px 0px -2%
0px"` (it sits in the page's last 150px).

**Bottom row** — `padding: 1.5rem 2rem 0` (`2rem` top from 1024; `1.25rem` sides at 390),
`justify-content: space-between`: **© 2026 RIDGELINE ROOFING CO.  ·  OREGON CCB 204118** (mono 14/20
500 `--on-dark-muted`, `white-space: pre`; at 390 two lines **© 2026 RIDGELINE ROOFING CO.** /
**OREGON CCB 204118** in micro), and **Back to top** → `#hero`: the label (14/20 500 white) and a
28px white knob with a navy diagonal arrow (`rx 14`, path `M10.4444 17.5556L18 10M18 18V10H10`, stroke
`#133153` 1.3), hover `opacity 0.8`; at 390 only a 36px knob with an up arrow (`M18 23.0283V12.3431M23.6569
18L18 12.3431L12.3431 18`).

## Fixed parameters (bake these in)

```js
// Presets
SPRING = {420,42}; SPRING_REVEAL = {150,25}; SPRING_CAMERA = {30,14}; SPRING_SOFT = {120,22};
SPRING_POP = {260,14}; SPRING_TRAIL = {170,26}; SPRING_DRAW = {90,19}; TYPE_STAGGER = 30;
REVEAL_MARGIN = "0px 0px -20% 0px"; SPACE = { sans: .2, sansBold: .19, sansMedium: .197, mono: .6 };
TRACKING = { d1: -.04545, d2: -.02778, d3: -.03571, d4: -.03409 };   // em

// Opening panel
PRELOADER = { trace: 100, fill: 650, seams: 700, seamStagger: 120, count: 1200, hold: 60, lift: 950 };
LIFT = {150,26}; RIDGE = 16 /* vh */; LOAD_DELAY = 1500;

// Header
HEADER_HEIGHT = 88; ENTER_STAGGER = 70; MENU_LINKS_DELAY = 120; MENU_STAGGER = 60; SCROLL_AFTER_CLOSE = 320;
LINKS_PILL_WIDTH = 1024; PHONE_WIDTH = 768;

// Hero
SEAM_DELAY = 1800; CTA_DELAY = 2100; PARALLAX_RANGE = 20 /* cqh, top-anchored */;
CHIPS: START = 1900, STAGGER = 80, BLUR = 6;
HOTSPOT: FRAMES = { desk: 1376×720, lg: 960×600 }; DOT = 8; RING = 20; LABEL_GAP = 16; LEADER_START = 14;
         PULSE = { grow: 1600, rest: 1400, scale: 2.2, opacity: .4 }; TITLE_AFTER_LEADER = 150; NOTE_AFTER_TITLE = 60; ring pop 1.3
RAIL: thumb = 180 (203 on the 390 rail); SATURATE = 24; fade stops 0/15/45/55/85/100 %

// About
PIN_FROM = 768; hold = 80svh; UNREAD_OPACITY = .25; LETTER_WINDOW = .012;
ODOMETER: STAGGER = 90; SCRUB_LEAD = .08;   CERTS: STAGGER = 60 (SPRING_POP, scale .7 → 1)

// Work we do
CARD_BLUR = 16; CARD_RISE = 32; CARD_STAGGER = 140;
RULE_COLUMN_STAGGER = 90; RULE_ROW_STAGGER = 160; TEXT_AFTER_RULE = 200; COLUMNS = 3;   HOVER_ZOOM = 1.06

// How a job goes
SPACER_OPEN = 1rem; CAMERA_START = 1.12; closed step = 88px; arrow hover rotate 90 (SPRING_POP); disc/detail on SPRING

// Recent work
QUOTE_TEXT_DELAY = 60; QUOTE_BYLINE_DELAY = 140; FEATURE PARALLAX_RANGE = 10 (centre-anchored); feature settle 1.15
FEATURE_FRAMES = { desk: 1312×520, lg: 896×400, md: 640×400, sm: 350×200 }; JOB FRAMES = { desk: 432×250, lg: 288×250, md: 312×200, sm: 350×200 }
MARKER: SIZE = 32; SIZE_HOVER = 40; POPOVER_GAP = 16; disc {320,26}; PULSE = { grow: 1400, rest: 2600, scale: 1.9, opacity: .5 }; popover SPRING_POP scale .4 → 1

// Book an inspection
DESK = "(min-width: 90rem)"; SHRINK_UNTIL = .8; CONTENT_FROM = .55; ICON_STAGGER = 200; arrival rise 1.5rem

// Footer
WORDMARK_MARGIN = "0px 0px -2% 0px"; spread = tracking × 1.3 (−1.5cqw at 390); RULE_BOTTOM_DELAY = 150; CELL_START = 300; CELL_STAGGER = 90

// Section head
LAG = { secondLine: 260, aside: 420 }; line 2 from x: 30; eyebrow label delay (index.length + 2) × 30

// Breakpoints
md = 768, lg = 1024, desk = 1440 (the design base); root 16px from 390 to 1440, calc(100vw/90) above, held at 1920
```

## Assets

Every photograph and the glow load from the public bucket. `ASSET_BASE_URL =
https://storage.getlayers.ai/assets/ridgeline-503273343d` — the paths keep the project's
`public/assets/` folder structure.

**Hero**
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/hero/hero.jpg` — 2752×1440, the 1440 crop
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/hero/hero-1024.jpg` — 1920×1200
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/hero/hero-768.jpg` — 1408×1440
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/hero/hero-390.jpg` — 700×1500
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/hero/job-sellwood.jpg` · `hero/job-hood-river.jpg` · `hero/job-thumb-hood-river.jpg` · `hero/job-seam-check.jpg` — 192×192 rail thumbnails

**Work we do** (`work/`, 344×344 squares and 540×360 `-390` bands)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/work/tear-off.jpg` · `work/tear-off-390.jpg`
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/work/leak-repair.jpg` · `work/leak-repair-390.jpg`
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/work/storm-claims.jpg` · `work/storm-claims-390.jpg`
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/work/gutters.jpg` · `work/gutters-390.jpg`

**How a job goes** (`process/`, 800×520)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/process/inspection.jpg` · `process/inspection-1024.jpg` (600×440) · `process/inspection-768.jpg` (480×440) · `process/inspection-390.jpg` (620×400)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/process/written-quote.jpg`
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/process/the-build.jpg`
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/process/handover.jpg`

**Recent work** (`recent/`)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/recent/feature-sellwood.jpg` (2624×1040) · `-1024` (1792×800) · `-768` (1280×800) · `-390` (700×400)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/recent/beaverton.jpg` (864×504) · `-1024` (576×500) · `-768` (624×400) · `-390` (700×400)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/recent/hood-river.jpg` (864×504) · `-1024` · `-768` · `-390`

**Book an inspection** (`cta/`)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/cta/cta-roofer.jpg` (2624×1192) · `-1024` (800×1136) · `-768` (1280×720) · `-390` (700×680)
- `https://storage.getlayers.ai/assets/ridgeline-503273343d/cta/light-glow.svg` — the 860×660 blurred ellipse

**Glyphs** are drawn inline as SVG from the path data given above (the house mark, the diagonal
arrow, the step arrow, the plus, the hotspot dot and ring, the marker plus, the assurance glyphs, the
back-to-top knobs). Fonts come from Google Fonts.
[/hidecontent]
← 返回首页