/* ============================================================================================
   frames.css — b109 S01. The shared frame component, ported from docs/RECIPES.md recipe 2.

   One component, two uses: the three case rows on the landing (S02) and the five service tiles
   (S03). What the recipe gets from WebGL — the plane that takes the image over, the velocity
   bend, the RGB split — is not here and will not be: this site has no canvas and a Lighthouse
   floor to hold (RECIPE-MAP.md). What ships is the recipe's own failsafe, which is the CSS
   curtain, plus the parts that never needed a canvas: the art direction, the reserved box, the
   whole-row link, and the flood into the case page.

   Everything below uses tokens from tokens.css. No colour literal, no font family, no radius.
   ============================================================================================ */

/* ---- the link: the whole row is the target -------------------------------------------------
   The recipe's rule, and the reason the biggest thing on the page is clickable. `.press` is its
   press feedback (buttons scale, rows dim). */
.work-link{display:block;color:inherit;text-decoration:none}
.press{transition:opacity var(--t-press) var(--ease-out)}
.press:active{opacity:.72}
@media (prefers-reduced-motion:reduce){ .press{transition:none} }

/* ---- the frame ---------------------------------------------------------------------------- */
/* b110 SPEC §3: square. The reference draws no radius on a frame, and DIAGNOSIS 10 has been asking for the
   square-corner editorial break on Selected Work since b108. */
.work-frame{position:relative;display:block;overflow:hidden;margin:0;border-radius:0;background:var(--panel)}
.stage-light .work-frame{background:var(--bg-light-2,var(--panel))}

/* The two art directions are different shapes. Each box is reserved in CSS, or the swap at
   768 px shifts the page (the recipe measured 0.03 CLS from exactly this). */
.work-frame img{display:block;width:100%;height:auto;aspect-ratio:1800 / 1125}
@media (max-width:767px){ .work-frame[data-phone="1"] img{aspect-ratio:780 / 975} }

/* ---- the curtain ----------------------------------------------------------------------------
   JS only sets data-revealed. If it never arrives at all, frame-failsafe uncovers the frame 1.5 s after
   the first style pass — see the condition on that rule below; under reduced motion nothing is covered,
   because the whole block lives inside html.pv-anim.
   b111 S00: motion is gated by html.pv-anim, not by the media query alone — the class is set before first
   paint when the OS says no-preference OR the reviewer asked for it with ?motion=1.

   b118 S06: a shade, not a clip. clip-path animates on the main thread and repaints the picture on
   every one of the reveal's 900 ms of frames; a block the colour of the frame's own ground, slid off
   with translateY, is the same move on the compositor for nothing. It also brings back the one thing
   the WebGL plane had and the clip never did — a soft edge — because the shade carries its own
   gradient. The soft part sits ABOVE the frame at rest (top:-25%), so at rest the picture is covered
   by solid colour and only the travelling edge is ever soft. */
.work-frame{--frame-ground:var(--panel)}
.stage-light .work-frame{--frame-ground:var(--bg-light-2,var(--panel))}
html.pv-anim .work-frame::before{content:"";position:absolute;left:0;right:0;top:-25%;height:125.5%;
    z-index:2;pointer-events:none;
    background:linear-gradient(to bottom,transparent,var(--frame-ground) 20%);
    transform:translateY(0)}
/* The failsafe is for the page where the motion gate ran (it is inline) but frames.js did not (it is a
   file, and a file can be blocked). frames.js sets data-pv-frames the moment it wires the observer, so
   this timer is armed only where nothing is going to set data-revealed. It used to be unconditional,
   which was invisible while the WebGL plane drew the real curtain and would have opened every frame on
   the page 1.5 s after load once the plane was gone. */
html:not([data-pv-frames]).pv-anim .work-frame::before{animation:frame-failsafe 900ms var(--ease-out) 1.5s forwards}
html.pv-anim .work-frame[data-revealed="true"]::before{animation:none;transform:translateY(102%);
    transition:transform 700ms cubic-bezier(.22,1,.36,1)}   /* b126 r15: moves on its first frame (the in-out curve sat still for ~250 ms, so a flicked-to frame read as an empty box) */

/* the plane resolved a 1.05 zoom as its curtain finished (uProgress fed both); one transform is the
   same picture, and it is the property the compositor was built for.

   The clip stays, and it is what the old curtain used to animate. It is static now — inset to nothing
   until the frame is revealed, then none, in one step — because the shade above is already covering the
   frame at that moment, so the switch is invisible and never needs to be interpolated.

   It is not decoration. Measured on Lighthouse mobile: with the clip gone, the landing's LCP went
   2.33 s -> 2.63 s and the score 98 -> 97, repeatably, because the browser rasters all eight frame
   pictures during load instead of skipping the ones clipped to nothing. Covering them with an opaque
   shade does not save that work; clipping them away does. Nothing sets this image's opacity any more,
   so the transition that used to ride along with the clip is gone with it — the rule that set opacity
   was [data-gl="on"], and that layer is retired. */
html.pv-anim .work-frame img{transform:scale(1.08);clip-path:inset(0 0 100% 0)}
html:not([data-pv-frames]).pv-anim .work-frame img{animation:frame-zoom-failsafe 900ms var(--ease-out) 1.5s forwards}
html.pv-anim .work-frame[data-revealed="true"] img{animation:none;transform:none;clip-path:none;
    transition:transform 950ms var(--ease-out)}

/* the elastic: the shader bowed the plane on scroll velocity and split its channels. One stretch
   carries the same read. frames.js writes --pv-el; it rides the `scale` property, never `transform`,
   so the curtain and the hover keep theirs to themselves and nothing has to be sequenced. */
html.pv-anim #work .work-frame img{scale:1 calc(1 + var(--pv-el,0))}

@media (hover:hover) and (pointer:fine){
  /* the recipe brings the WebGL plane forward on hover; this is the same move without a plane */
  html.pv-anim .work-link:hover .work-frame img,
  html.pv-anim .work-link:focus-visible .work-frame img{
      transform:scale(1.035);transition:transform .9s var(--ease-out)}   /* b126 r11: 1.02 read as nothing at 1440 */
}

@keyframes frame-failsafe{ to{transform:translateY(102%)} }
@keyframes frame-zoom-failsafe{ from{clip-path:inset(0 0 0 0)} to{transform:none;clip-path:inset(0 0 0 0)} }

/* ---- the bar above the frame (b110 SPEC §7) ------------------------------------------------
   The reference captions a case above its image, in one line: index, name, what it is, and the cue hard
   right. It is a caption, so it is set at label size with the name at body weight, and it sits tight to
   the frame — 12 px, not a section gap. */
.work-bar{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;margin:0 0 12px;
  padding-bottom:12px;border-bottom:1px solid var(--line-dark-strong)}
.stage-dark .work-bar{border-bottom-color:var(--line-strong)}
.work-bar h3{margin:0;font-size:var(--fs-body);font-weight:600;letter-spacing:-.01em;line-height:1.2}
.work-bar .wm-n{color:var(--muted-on-light)}
.stage-dark .work-bar .wm-n{color:var(--muted-on-dark)}
.work-bar .wm-year{color:var(--muted-on-light)}
.stage-dark .work-bar .wm-year{color:var(--muted-on-dark)}
.work-bar .row-cue{margin-left:auto}
@media (max-width:559px){ .work-bar .row-cue{margin-left:0} }

/* the one sentence of outcome, under the frame it describes */
.work-list .wm-line{margin:14px 0 0;font-size:var(--fs-lede);line-height:1.55;max-width:52ch;
  color:var(--muted-on-light)}
.stage-dark .work-list .wm-line{color:var(--muted-on-dark)}

/* ---- the metadata row ----------------------------------------------------------------------
   12 columns from lg only. Between 768 and 1023 the recipe's grid broke into four islands, so
   there it is a two-column stack, and below 768 a single column in source order. */
.work-meta{margin-top:clamp(18px,1.8vw,28px);display:grid;gap:10px 24px}
.work-meta .wm-n{color:var(--muted-on-light)}
.stage-dark .work-meta .wm-n{color:var(--muted-on-dark)}
.work-meta h3{font-size:var(--fs-xl);font-weight:600;letter-spacing:-.018em;line-height:1.15;margin:0}
.work-meta .wm-line{font-size:var(--fs-body);line-height:1.6;margin:0;color:var(--muted-on-light);max-width:46ch}
.stage-dark .work-meta .wm-line{color:var(--muted-on-dark)}
.work-meta .wm-year{margin:0;color:var(--muted-on-light)}
.stage-dark .work-meta .wm-year{color:var(--muted-on-dark)}

@media (min-width:768px){
  .work-meta{grid-template-columns:repeat(2,minmax(0,1fr))}
  .work-meta .wm-n{grid-column:1}
  .work-meta h3{grid-column:1}
  .work-meta .wm-line{grid-column:2;grid-row:1 / span 2}
  .work-meta .wm-year{grid-column:1}
  .work-meta .row-cue{grid-column:2}
}
@media (min-width:1024px){
  .work-meta{grid-template-columns:repeat(12,minmax(0,1fr));gap:12px 24px}
  .work-meta .wm-n{grid-column:1 / span 2;grid-row:1}
  .work-meta h3{grid-column:3 / span 5;grid-row:1}
  /* three columns, not four: at span 4 this ran into the tag's track and the two printed on top of
     each other on every row — the sentence that says what the site does was the damaged one. */
  .work-meta .wm-line{grid-column:8 / span 3;grid-row:1 / span 2}
  /* The recipe's row ends in a year and a one-word label, which fit one column. This site records a
     category instead ("Custom build · Roofing"), and at one column it broke into three lines — so it
     takes two, still hard right. */
  .work-meta .wm-year{grid-column:11 / span 2;grid-row:1;text-align:right}
  .work-meta .row-cue{grid-column:3 / span 3;grid-row:2}
}

/* The cue is visible on every device — a cursor badge is an extra for mice, never the only
   affordance that a row leads somewhere. */
.row-cue{margin:0;color:var(--muted-on-light);
  text-decoration-line:underline;text-decoration-thickness:1px;text-underline-offset:.4em;
  text-decoration-color:var(--line-dark-strong);transition:color var(--t-press) ease}
.stage-dark .row-cue{color:var(--muted-on-dark);text-decoration-color:var(--line-strong)}
@media (hover:hover) and (pointer:fine){
  .work-link:hover .row-cue,.work-link:focus-visible .row-cue{color:var(--ink)}
  .stage-dark .work-link:hover .row-cue,.stage-dark .work-link:focus-visible .row-cue{color:var(--white)}
}
@media (prefers-reduced-motion:reduce){ .row-cue{transition:none} }

/* ---- the two layouts ----------------------------------------------------------------------- */
.work-list{list-style:none;margin:clamp(40px,5vw,72px) 0 0;padding:0;
  display:grid;gap:clamp(56px,7vw,112px)}

.frame-grid{list-style:none;margin:clamp(32px,4vw,56px) 0 0;padding:0;
  display:grid;gap:clamp(28px,3.4vw,56px)}
@media (min-width:768px){ .frame-grid{grid-template-columns:repeat(2,minmax(0,1fr))} }
@media (min-width:1024px){
  .frame-grid{grid-template-columns:repeat(12,minmax(0,1fr))}
  .frame-grid > li{grid-column:span 4}
  .frame-grid > li:nth-child(1),
  .frame-grid > li:nth-child(2){grid-column:span 6}
}
/* A tile is not a case row: its metadata is one column at every width, stacked under the picture.
   Letting it inherit the row's two-column split at 768–1023 pushed the service name to the bottom left
   with its own sentence in a second column, and the name read as orphaned. */
.frame-grid .work-meta{grid-template-columns:minmax(0,1fr);gap:6px}
.frame-grid .work-meta h3{font-size:var(--fs-l);display:inline-flex;align-items:center;gap:10px}
/* the tile's affordance: the arrow sits with the name, so a screen of five tiles carries five links and no
   five repeated sentences (b110 SPEC §6) */
.frame-grid .work-meta h3 .px-arrow{width:13px;height:11px;fill:currentColor;flex:none;
  transition:transform var(--t-fast) var(--ease)}
@media (hover:hover) and (pointer:fine){ .work-link:hover .work-meta h3 .px-arrow{transform:translateX(5px)} }
@media (prefers-reduced-motion:reduce){ .frame-grid .work-meta h3 .px-arrow{transition:none} }
.frame-grid .work-meta .wm-line{max-width:38ch}
.frame-grid .work-meta .wm-n,
.frame-grid .work-meta h3,
.frame-grid .work-meta .wm-line,
.frame-grid .work-meta .row-cue{grid-column:1;grid-row:auto;justify-self:start;text-align:left}
.frame-grid .work-meta .wm-year{display:none}

/* ---- the reveal stagger inside one grid ----------------------------------------------------
   80 ms apart, set per index — one orchestrated moment, not a queue. */
/* b111 S00: motion is gated by html.pv-anim, not by the media query alone — the class is set before first
   paint when the OS says no-preference OR the reviewer asked for it with ?motion=1. Same rules, same order;
   only the gate changed, so a reader who never touches the flag sees exactly what they saw before. */
html.pv-anim .frame-grid > li:nth-child(2) .work-frame[data-revealed="true"] img{transition-delay:80ms}
html.pv-anim .frame-grid > li:nth-child(3) .work-frame[data-revealed="true"] img{transition-delay:160ms}
html.pv-anim .frame-grid > li:nth-child(4) .work-frame[data-revealed="true"] img{transition-delay:240ms}
html.pv-anim .frame-grid > li:nth-child(5) .work-frame[data-revealed="true"] img{transition-delay:320ms}


/* ---- recipe 1: the progress line ------------------------------------------------------------
   The track is absolute over the list's full height, in the LEFT MARGIN — never under the text
   (the recipe's own mistake, made once and written down). The fill scales from the top; a
   transform, not a height, so a scroll frame costs no layout. */
.track{position:relative}
.track > ol{padding-left:1.75rem}
@media (min-width:768px){ .track > ol{padding-left:3rem} }
.line{position:absolute;left:0;top:.25rem;bottom:.25rem;width:2px;background:var(--line-dark-strong)}
.stage-dark .line{background:var(--line-strong)}
.line-fill{position:absolute;inset:0;background:var(--ink);transform:scaleY(0);transform-origin:top center}
.stage-dark .line-fill{background:var(--white)}
/* No transition: the fill is scrubbed frame by frame, so it must follow the scroll exactly. */
@media (prefers-reduced-motion:reduce){ .line-fill{transform:scaleY(1)} }

/* ---- recipe 2 step 6: the flood --------------------------------------------------------------
   Cross-document view transitions. A browser without them simply navigates, which is the whole
   fallback story. */
@view-transition{ navigation:auto }
/* b120 — the circle is gone. An expanding circle from the click point is the transition every
   presentation tool ships, and it reads like one: a hard geometric shape crossing a photograph.
   What replaces it is the move this site already makes everywhere else — something arrives from
   below while what it replaces gives way. The new page is uncovered by an inset wipe rising from
   the bottom, carried on a small upward drift and a 2 % overshoot that settles; the old page
   recedes 4 % and dims rather than sitting flat underneath. Both on the site's own --ease-out
   (cubic-bezier(.16,1,.3,1)): fast to start, long to settle, which is what makes it read as weight
   rather than as a wipe. The click point still sets --flood-y, so the wipe starts from the row you
   actually pressed instead of always from the page edge. */
@keyframes vt-rise{
  from{clip-path:inset(100% 0 0 0);transform:translateY(26px) scale(1.02)}
  to{clip-path:inset(0 0 0 0);transform:none}
}
@keyframes vt-recede{
  from{transform:none;opacity:1;filter:saturate(1)}
  to{transform:scale(.96);opacity:.25;filter:saturate(.6)}
}
@keyframes vt-hold{ from,to{opacity:1} }
@keyframes vt-fade-in{ from{opacity:0} to{opacity:1} }
/* b111 S00: motion is gated by html.pv-anim, not by the media query alone — the class is set before first
   paint when the OS says no-preference OR the reviewer asked for it with ?motion=1. Same rules, same order;
   only the gate changed, so a reader who never touches the flag sees exactly what they saw before. */
html.pv-anim:active-view-transition-type(flood)::view-transition-new(root){
    animation:760ms var(--ease-out,cubic-bezier(.16,1,.3,1)) both vt-rise;z-index:2;transform-origin:50% 100%}
html.pv-anim:active-view-transition-type(flood)::view-transition-old(root){
    animation:760ms var(--ease-out,cubic-bezier(.16,1,.3,1)) both vt-recede;z-index:1;transform-origin:50% 40%}

@media (prefers-reduced-motion:reduce){
  ::view-transition-new(root){animation:200ms ease both vt-fade-in}
  ::view-transition-old(root){animation:none}
}


/* ---- b118 S06: the WebGL frame layer is gone (was b111 S03, RECIPES §2 steps 4–5) -------------
   It drew three photographs on the landing and one on each case page, and for that a case page
   downloaded 1992 KB of three.js onto a page that is 445 KB without it, held 8 MB of heap instead of
   2, and opened a WebGL context to fade in a single picture. The curtain, the resolve-zoom, the hover
   and the scroll elastic are all above this line now, in CSS, on the compositor. What is NOT carried
   over is the chromatic split: it was three texture reads per fragment and only ever visible during a
   fast scroll. The z-index floor below stays — the pages' own stacking has been keyed on it since b111.*/
main .wrap{position:relative;z-index:4}
.topbar,.float-cta,.menu-ov,.pv-consent{z-index:60}
/* b126 r5: a warm ground, not a black box, while a lazy frame is still on its way */
.work-list .work-frame,.frame-grid .work-frame{background:#1a1916}

/* b126 r15: a frame already on screen when the page lands (Back to work, a reload mid-page) is shown, not unveiled */
html.pv-anim .work-frame.pv-instant::before,
html.pv-anim .work-frame.pv-instant img{transition:none !important;animation:none !important}
