/* ===========================================================
   Art By Aleen
   Design reference: Figma CDaRMBsB0pT6xI28tZI4Pz
   Hero 52:339 (1440x820) · Original Work 131:996/149:57/150:74/150:90 (1980x1080)
   Room 52:363 (1440x1024) · Pet portraits 59:645 (1980x1080)

   Geometry is expressed in "design pixels": --u is one design px,
   so every number below is read straight off the Figma frame.
   =========================================================== */

/* ---------- fonts ---------- */
@font-face{font-family:"Moniqa Display";src:url("assets/fonts/Moniqa-LightDisplay.otf") format("opentype");font-weight:300;font-display:swap}
@font-face{font-family:"Moniqa Heading";src:url("assets/fonts/Moniqa-Heading.otf") format("opentype");font-weight:400;font-display:swap}
@font-face{font-family:"Moniqa Narrow Display";src:url("assets/fonts/Moniqa-LightNarrowDisplay.otf") format("opentype");font-weight:300;font-display:swap}
@font-face{font-family:"Moniqa Narrow Black";src:url("assets/fonts/Moniqa-BlackNarrowHeading.otf") format("opentype");font-weight:900;font-display:swap}
@font-face{font-family:"Moniqa Narrow";src:url("assets/fonts/Moniqa-MediumNarrowParagraph.otf") format("opentype");font-weight:500;font-display:swap}
@font-face{font-family:"Moniqa Cond";src:url("assets/fonts/Moniqa-SemBdCondParagraph.otf") format("opentype");font-weight:600;font-display:swap}
@font-face{font-family:"Moniqa Cond Black";src:url("assets/fonts/Moniqa-BlackCondParagraph.otf") format("opentype");font-weight:900;font-display:swap}
@font-face{font-family:"Moniqa Light Para";src:url("assets/fonts/Moniqa-LightParagraph.otf") format("opentype");font-weight:300;font-display:swap}
@font-face{font-family:"Moniqa Bold Para";src:url("assets/fonts/Moniqa-BoldParagraph.otf") format("opentype");font-weight:700;font-display:swap}
@font-face{font-family:"Moniqa Cond Italic";src:url("assets/fonts/Moniqa-CondensedItalicHeading.otf") format("opentype");font-weight:400;font-display:swap}
@font-face{font-family:"Moniqa Bold Narrow";src:url("assets/fonts/Moniqa-BoldNarrowHeading.otf") format("opentype");font-weight:700;font-display:swap}
@font-face{font-family:"Script";src:url("assets/fonts/NothingYouCouldDo.woff2") format("woff2");font-weight:400;font-display:swap}

/* ---------- tokens ---------- */
:root{
  --ink:#140A02;
  --work-bg:#3d1308;
  --cream:#f8e5ee;
  --script-red:#9d0d23;
  --pill:#9f2042;

  --ease-out: cubic-bezier(.16,.84,.36,1);
  --ease-io:  cubic-bezier(.66,0,.2,1);
  --ease-pop: cubic-bezier(.22,1.35,.36,1);   /* slight overshoot, for pop-ins */

  /* Figma puts the first baseline ~1.5px lower than the browser does for
     Moniqa Heading, at every size measured. Added back so the top values can
     stay the frame's own numbers. Must live on :root: where it was scoped to
     .room, every calc() using it in another section evaluated to invalid and
     dropped `top` entirely, dumping the text at its static position. */
  --baseline-fix: 1.5;

}

*{box-sizing:border-box;margin:0;padding:0}
/* Headings are bold by UA default, and most Moniqa cuts ship a SINGLE weight —
   so an inherited 700 makes the browser synthesise a bold and thicken every
   stroke. That is what made the hero's red script and the Original Work title
   look heavy against the frame. Normalised here; every rule below states the
   weight it actually wants. */
h1,h2,h3,h4,h5,h6{font-weight:400}
/* --- the scrollbar ---
   Scroll is held with overflow:hidden twice on this page: while the hero's
   clip runs, and while the transition into Custom Portraits cuts. Without a
   reserved gutter the bar vanishes for the length of each, the content gets
   ~15px wider and snaps back when it returns — which is the seam the owner
   saw at both ends of the hero clip. `stable` keeps the gutter there whether
   the bar is drawn or not, so nothing moves.

   The bar itself is restyled to the page rather than left as the browser's:
   a thin burgundy thumb on the hero's own near-black. Deliberately the
   standard properties and NOT ::-webkit-scrollbar — Chrome ignores the webkit
   pseudo-elements entirely once scrollbar-width or scrollbar-color is set, so
   mixing the two silently drops one of them. These three work in Chrome,
   Edge, Firefox and Safari alike. */
html{
  scroll-behavior:auto;
  scrollbar-gutter:stable;
  scrollbar-width:thin;
  scrollbar-color:var(--pill) var(--ink);
  /* No chaining and no rubber-band. Every transition here holds scroll while
     it plays, and an overscroll bounce at the top or bottom of a held page is
     movement the hold cannot take back. It also turns off pull-to-refresh,
     which on a page that opens with a video is only ever an accident. */
  overscroll-behavior:none;
}
body{
  background:var(--ink);
  color:var(--cream);
  font-family:"Moniqa Narrow",system-ui,sans-serif;
  overflow-x:hidden;
}
body.is-locked{overflow:hidden}
img{display:block;max-width:100%}
/* line breaks that only exist on the mobile frames */
.br-m{display:none}

/* one design pixel, letterboxed so the composition never breaks */
.stage{
  position:absolute; inset:0; margin:auto;
  pointer-events:none;
}
.stage > *{pointer-events:auto}

.stage--hero{ --u: min(calc(100vw / 1440), calc(100vh / 820));  width:calc(1440 * var(--u)); height:calc(820 * var(--u)); }
.stage--room{ --u: min(calc(100vw / 1440), calc(100vh / 1024)); width:calc(1440 * var(--u)); height:calc(1024 * var(--u)); }
.stage--pets{ --u: min(calc(100vw / 1980), calc(100vh / 1080)); width:calc(1980 * var(--u)); height:calc(1080 * var(--u)); }
.stage--toroom{ --u: min(calc(100vw / 1440), calc(100vh / 1024)); width:calc(1440 * var(--u)); height:calc(1024 * var(--u)); }
/* .stage--foot is not here: the footer sizes itself from its own width via a
   container query, not from the viewport. See the FOOTER block below. */


/* ========================= LOADING ==========================
   Covers the boot until the hero's intro can actually play. Before this the
   first thing on screen was the clip's poster — a 38 KB JPEG over the whole
   viewport — which is the blurry picture the owner reported.

   It is painted from the FIRST FRAME, with no JavaScript: the markup carries
   it, styles.css is render-blocking, and its background is the same --ink the
   body already has. So there is no moment at which anything else is visible
   and no flash when it goes.

   Sized in viewport units rather than design px on purpose — it is not on any
   frame, it sits outside every stage, and it has to look the same on a 402
   board and a 2560 one without a second set of numbers. */
.boot{
  position:fixed; inset:0; z-index:100;
  /* 100vw, not the right inset. `html` keeps scrollbar-gutter:stable, so a
     fixed inset:0 box stops 10px short of the window and leaves a strip of
     bare page down the edge. It is the same --ink either way and invisible,
     but a loading screen that does not actually reach the edge is the kind of
     thing that turns into a bug report the first time anything else changes. */
  width:100vw;
  background:var(--ink);
  display:grid; place-items:center;
  opacity:1;
  transition:opacity 520ms ease, visibility 0s linear 520ms;
}
/* visibility, not display: the fade needs something to fade, and after it the
   element must stop taking hits over the hero underneath. */
.boot.is-done{opacity:0; visibility:hidden; pointer-events:none}

.boot__inner{
  display:flex; flex-direction:column; align-items:center;
  gap:clamp(14px, 2.2vh, 26px);
}

/* The nav tagline's own cut and treatment, at a size that reads as a mark
   rather than as a title. Faded in on document.fonts.ready so the loading
   screen itself does not flash a fallback face — everything here is
   font-display:swap, and a boot screen is precisely where that shows. */
.boot__mark{
  font-family:"Moniqa Cond", system-ui, sans-serif; font-weight:600;
  font-size:clamp(13px, 1.5vw, 21px);
  letter-spacing:.3em; text-indent:.3em;      /* tracking pushes it off centre */
  text-transform:uppercase; color:var(--cream);
  opacity:0; transition:opacity 480ms ease;
}
.boot.is-ready .boot__mark{opacity:.92}

/* A hairline that fills. --p is written by main.js as the boot passes its
   milestones, so the bar reports something real rather than sweeping on a
   loop: it is not pretending to know a percentage it cannot measure, it is
   showing which of four things have happened. */
.boot__track{
  position:relative; overflow:hidden;
  width:min(210px, 42vw); height:2px;
  background:rgba(248,229,238,.16);
}
.boot__fill{
  position:absolute; left:0; top:0; bottom:0;
  width:calc(var(--p, 0) * 100%);
  background:var(--pill);
  transition:width 560ms var(--ease-out);
}

/* No scrolling behind it. The class was already in the markup and had no rule
   at all; main.js takes it off in the same breath as dismissing the loader. */
body.is-booting{overflow:hidden}

@media (prefers-reduced-motion: reduce){
  .boot,.boot__mark,.boot__fill{transition:none}
  .boot__mark{opacity:.92}
}


/* =========================== HERO =========================== */
/* One screen. The transition plays at its own rate rather than being scrubbed,
   so the hero needs no scroll range of its own — scrolling is intercepted and
   locked while the clip runs. */
.hero{position:relative; height:100vh; height:100dvh; overflow:hidden; background:var(--ink)}
.hero__pin{position:absolute; inset:0}
.hero__media{position:absolute;inset:0}
.hero__video{
  position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;object-position:center;
}
.hero__video--loop{opacity:0;transition:opacity 900ms ease}
.hero.is-looping .hero__video--intro{opacity:0;transition:opacity 900ms ease}
.hero.is-looping .hero__video--loop{opacity:1}

/* Sits over the loop and is revealed as the copy leaves. Never played — JS
   writes currentTime from scroll position. */
.hero__transit{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; object-position:center;
  opacity:0; pointer-events:none;
  transition:opacity 260ms ease;
}
.hero.is-transit .hero__transit{opacity:1}

/* Everything the visitor was reading clears out before the clip takes over,
   so the transition starts from a bare frame. */
.hero__pin .stage--hero,
.hero__pin .scroll-cue,
.hero__pin .skip{
  transition:opacity 420ms ease, transform 520ms var(--ease-out);
}
.hero.is-departing .stage--hero,
.hero.is-departing .scroll-cue,
.hero.is-departing .skip{
  opacity:0; transform:translateY(calc(-24 * var(--u, 1px))); pointer-events:none;
}

.hero__vignette{
  position:absolute;inset:0;pointer-events:none;
  background:
    radial-gradient(120% 80% at 62% 40%, transparent 40%, rgba(20,10,2,.55) 100%),
    linear-gradient(to bottom, rgba(20,10,2,.45) 0%, transparent 22%, transparent 70%, rgba(20,10,2,.6) 100%);
}

/* --- nav --- */
.nav__tag{
  position:absolute; left:calc(55 * var(--u)); top:calc(25 * var(--u));
  width:calc(300 * var(--u));
  font-family:"Moniqa Cond"; font-size:calc(35.416 * var(--u));
  line-height:1; text-transform:uppercase; color:var(--cream);
}
/* 52:339: Contact sits in the nav beside the tagline. Box is the union of the
   label (352,23 66x35) and the brush behind it (323,34 130x17). */
.nav__contact{
  position:absolute; left:calc(323 * var(--u)); top:calc(23 * var(--u));
  width:calc(130 * var(--u)); height:calc(35 * var(--u));
  display:block; text-decoration:none;
}
/* No stroke at rest: it paints on left to right on hover, then wipes back off.
   Driven by --draw rather than clip-path directly, same as the other reveals. */
.nav__contact-brush{
  position:absolute; left:0; top:calc(11 * var(--u));
  width:calc(130 * var(--u)); height:calc(17 * var(--u));
  object-fit:fill; max-width:none; pointer-events:none;
  /* Purely a wipe. At --draw 0 the clip already hides it completely, so the
     opacity fade that used to ride alongside had nothing to do except blur the
     wipe into a cross-fade. It now paints on from the left and wipes back off. */
  --draw:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 520ms var(--ease-io);
}
.nav__contact:hover .nav__contact-brush,
.nav__contact:focus-visible .nav__contact-brush{--draw:1}
.nav__contact-label{
  position:absolute; left:calc(29 * var(--u)); top:0;
  font-family:"Moniqa Cond Black"; font-weight:900;  /* the only cut we ship */
  font-size:calc(35.416 * var(--u));
  line-height:normal; color:var(--cream); text-transform:capitalize;
  text-shadow:calc(4 * var(--u)) calc(4 * var(--u)) calc(8 * var(--u)) rgba(0,0,0,.8);
}

/* Menu label and dot block are placed at their own absolute Figma coordinates.
   Flex-centering them mis-aligned the dots, because the design's text box is
   28 tall while a 35.4px line box is not. */
/* pointer-events:none is load-bearing. The box is full-width so the label and
   the dots can be placed at their own frame coordinates inside it, and with
   hit-testing left on it swallowed every hover in the nav row — Contact's
   brush never painted on because the pointer never reached the link. Same
   pattern as .foot__social: the box is inert, its children are not. :hover
   still resolves on this element, so the dots keep reacting. */
.nav__menu{
  position:absolute; left:0; top:0; width:100%; height:calc(60 * var(--u));
  cursor:pointer; pointer-events:none;
}
.nav__menu-label,.nav__dots{pointer-events:auto}
/* Given the dot block's own box (28..53) and centred in it, so the word and
   the dots share a centre line the way the frame has them — Figma renders both
   spanning y 28..52. Sitting at the frame's own top of 25 put MENU's ink
   centre at 36 against the dots' 40.5, which is the 4.5px the word rode high
   by. The +3.3 is the residual after line-box centring: this cut's ink is not
   centred inside its own em box, so the line box alone does not finish the
   job. Measured, not guessed — MENU's ink centre against the dot block's. */
.nav__menu-label{
  position:absolute; left:calc(1330 * var(--u)); top:calc((28 + 3.3) * var(--u));
  font-family:"Moniqa Narrow"; font-weight:500;    /* the only cut we ship */
  font-size:calc(35.42 * var(--u));
  height:calc(25 * var(--u)); line-height:calc(25 * var(--u));
  text-transform:uppercase; color:var(--cream);
}
.nav__dots{
  position:absolute; left:calc(1384 * var(--u)); top:calc(28 * var(--u));
  width:calc(25 * var(--u)); height:calc(25 * var(--u));
}
.nav__dots i{
  position:absolute; width:calc(10.5263 * var(--u)); height:calc(10.5263 * var(--u));
  border-radius:50%; background:var(--cream); display:block;
  transition:transform .35s var(--ease-out);
}
.nav__dots i:nth-child(1){left:0;                     top:0}
.nav__dots i:nth-child(2){left:calc(14.4736 * var(--u)); top:0}
.nav__dots i:nth-child(3){left:calc(14.4736 * var(--u)); top:calc(14.4735 * var(--u))}
.nav__dots i:nth-child(4){left:0;                     top:calc(14.4735 * var(--u))}
/* each eyelet has a dark centre, per Ellipse 5 in the frame */
.nav__dots i::after{
  content:""; position:absolute; left:50%; top:50%; translate:-50% -50%;
  width:calc(5.2632 * var(--u)); height:calc(5.2632 * var(--u));
  border-radius:50%; background:#000;
}
.nav__menu:hover .nav__dots i{transform:scale(1.18)}


/* ---- MENU (218:962) ----
   The open state of the hero's own nav, on the same 1440x820 stage, so every
   number below is read straight off that frame.

   The dots ARE the container before it opens: the blob's transform-origin is
   the dot block's own centre, so scaling it up from nothing grows it out of
   them rather than dropping a panel on top of them. Dot block is 25x25 at
   (1384, 28), centre (1396.5, 40.5); the blob's box is 207.498x248.324 at
   (1237, 0), so that centre sits at 76.87% / 16.31% of it. */
/* The button spans the whole nav row, because the label and the dots are
   placed at their own frame coordinates inside it and must keep them — so it
   is INERT, exactly as .nav__menu itself is, and only its two children are hit
   targets. pointer-events:auto here would swallow every hover in the row and
   Contact's brush would stop painting on; that is the trap this file already
   documents twice. A click on either child still activates the button.

   z-index puts the word over the container: .menu paints after this in the
   markup, so without it the blob covers MENU the moment it opens. */
.nav__menu-btn{
  position:absolute; inset:0; z-index:4;
  background:none; border:0; padding:0; margin:0;
  pointer-events:none; cursor:pointer;
  font:inherit; color:inherit;
}
.nav__menu-btn:focus-visible{outline:none}
.nav__menu-btn:focus-visible .nav__menu-label{
  outline:calc(2 * var(--u)) solid var(--pill); outline-offset:calc(4 * var(--u));
}

.menu{
  position:absolute; inset:0;
  pointer-events:none;                 /* the panel's own box is inert */
  z-index:3;
}
.menu__blob{
  position:absolute;
  left:calc(1237 * var(--u)); top:0;
  width:calc(207.498 * var(--u)); height:calc(248.324 * var(--u));
  /* the morph: grown out of the dot block's own centre */
  transform-origin:76.87% 16.31%;
  transform:scale(.06);
  opacity:0;
  transition:transform 520ms var(--ease-pop), opacity 200ms ease 60ms;
}
.nav__menu.is-open .menu__blob{
  transform:scale(1); opacity:1;
  transition:transform 560ms var(--ease-pop), opacity 140ms ease;
}
/* 218:981 centres a 170.71x221.831 shape in that box and draws it flipped and
   turned about ITS OWN centre: (207.498-170.71)/2 = 18.394 across,
   (248.324-221.831)/2 = 13.247 down. */
.menu__blob-art{
  position:absolute;
  left:calc(18.394 * var(--u)); top:calc(13.247 * var(--u));
  width:calc(170.71 * var(--u)); height:calc(221.831 * var(--u));
  max-width:none; display:block;
  transform:rotate(-10.62deg) scaleY(-1);
}

/* The word stays put and changes colour: closed it is cream over the clip,
   open it is the section brown over the cream container.

   The open position is DERIVED, not copied. The frame puts MENU at 25 closed
   and 33.85 open, i.e. it moves +8.85 — but we render the closed one at
   28+3.3 = 31.3, because this cut's ink is not centred in its em box and the
   line box alone left the word riding 4.5px high of the dots (see the closed
   rule above). That correction is +6.3 over the frame's own number, and it is
   a property of the cut, not of the state — so the open top is 33.85 + 6.3 =
   40.15. Horizontally the frame's numbers are ours already: 1330 -> 1318.39. */
.nav__menu-label{
  transition:left 420ms var(--ease-out), top 420ms var(--ease-out),
             color 260ms ease;
}
.nav__menu.is-open .nav__menu-label{
  left:calc(1318.39 * var(--u)); top:calc(40.15 * var(--u));
  color:var(--work-bg);
}
/* The eyelets have become the container, so they go: scaled into its middle
   and faded, fast, while it is still growing. */
.nav__dots{
  transition:transform 300ms var(--ease-io), opacity 180ms ease;
}
.nav__menu.is-open .nav__dots{
  transform:scale(.3); opacity:0; pointer-events:none;
}

/* The three destinations. Each is CENTRED on its own frame centre rather than
   ranged left — 218:982/983/984 have three different lefts and three different
   widths precisely because they are centred in the container. */
.menu__link{
  position:absolute;
  transform:translateX(-50%);
  font-family:"Moniqa Narrow Display"; font-weight:300;
  font-size:calc(28.42 * var(--u));
  line-height:normal; color:var(--work-bg);
  text-transform:uppercase; text-decoration:none; white-space:nowrap;
  pointer-events:none; opacity:0;
  translate:0 calc(-10 * var(--u));
  transition:opacity 220ms ease, translate 320ms var(--ease-out), color 160ms ease;
}
.menu__link--orig  {left:calc(1339.39 * var(--u)); top:calc(80.85 * var(--u))}
.menu__link--custom{left:calc(1343.39 * var(--u)); top:calc(125.85 * var(--u))}
.menu__link--pets  {left:calc(1342.89 * var(--u)); top:calc(170.85 * var(--u))}

.nav__menu.is-open .menu__link{
  pointer-events:auto; opacity:1; translate:0 0;
}
/* staggered behind the container, so they arrive into it rather than with it */
.nav__menu.is-open .menu__link--orig  {transition-delay:220ms}
.nav__menu.is-open .menu__link--custom{transition-delay:290ms}
.nav__menu.is-open .menu__link--pets  {transition-delay:360ms}

.menu__link:hover,
.menu__link:focus-visible{color:var(--script-red); outline:none}

/* --- the peek ---
   Hovering a title shows one piece of that kind of work. Three stacked images
   in one box, each shown by its own link's :hover through a sibling selector —
   the peeks are after the links in the markup for exactly that reason, so this
   needs no JS and no pointer tracking. Placed to the LEFT of the container,
   which is the only clear space on the frame at that height. */
.menu__peeks{
  position:absolute;
  left:calc(1012 * var(--u)); top:calc(34 * var(--u));
  width:calc(196 * var(--u)); height:calc(252 * var(--u));
  pointer-events:none;
}
.menu__peek{
  position:absolute; inset:0;
  width:100%; height:100%; object-fit:contain; object-position:center;
  max-width:none;
  filter:drop-shadow(calc(8 * var(--u)) calc(12 * var(--u))
                     calc(16 * var(--u)) rgba(0,0,0,.45));
  opacity:0; scale:.9;
  transition:opacity 240ms ease, scale 360ms var(--ease-out);
}
.menu__link--orig:hover   ~ .menu__peeks .menu__peek--orig,
.menu__link--custom:hover ~ .menu__peeks .menu__peek--custom,
.menu__link--pets:hover   ~ .menu__peeks .menu__peek--pets{opacity:1; scale:1}

@media (prefers-reduced-motion: reduce){
  .menu__blob,.menu__link,.menu__peek,.nav__dots,.nav__menu-label{transition:none}
  .menu__blob{transform:rotate(-10.62deg) scaleY(-1) scale(.06)}
  .nav__menu.is-open .menu__blob{transform:rotate(-10.62deg) scaleY(-1) scale(1)}
  .nav__menu.is-open .menu__link{translate:0 0}
}

/* --- hero title --- */
/* Box is the union of the cream title (86,261) and the red script Group 9,
   which starts at -29 — so the children sit at offsets inside it and the box
   itself is the hover target. */
.hero__title{
  position:absolute; left:calc(-29 * var(--u)); top:calc(261 * var(--u));
  width:calc(1310.857 * var(--u)); height:calc(288 * var(--u));
  white-space:nowrap;
}
.hero__title-main{
  position:absolute; left:calc(115 * var(--u)); top:0; display:block;
  font-family:"Moniqa Display"; font-weight:300;
  font-size:calc(276.678 * var(--u)); line-height:normal;
  color:var(--cream);
}
/* Group 9: two script layers with their own heavy negative tracking. Written
   on when the title is hovered, and kept once written. */
.hero__title-script{
  position:absolute; inset:0; display:block; pointer-events:none;
  --draw:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 1150ms var(--ease-io);
}
.hero__title.is-drawn .hero__title-script{--draw:1}
.hero__script-a,.hero__script-b{
  position:absolute; top:0; display:block;
  /* 400 explicitly: these live inside the <h1>, which is bold by UA default,
     and "Script" only ships a 400 face — so the browser was synthesising the
     bold and fattening every stroke. Same for the two <h2> cases below. */
  font-family:"Script"; font-weight:400; font-size:calc(217.017 * var(--u));
  line-height:normal; color:#792036; text-transform:capitalize;
}
.hero__script-a{left:0;                    letter-spacing:calc(-30.3824 * var(--u))}
.hero__script-b{left:calc(530 * var(--u)); letter-spacing:calc(-13.021 * var(--u))}

/* per-letter reveal */
.hero__title .ltr{
  display:inline-block;
  transform:translateY(.42em) rotate(2deg);
  opacity:0; filter:blur(calc(14 * var(--u)));
  transition:
    transform 1100ms var(--ease-out),
    opacity 800ms ease,
    filter 900ms ease;
}
.hero.is-revealed .hero__title .ltr{transform:none;opacity:1;filter:blur(0)}

/* generic entrance */
.reveal-up{opacity:0;transform:translateY(calc(18 * var(--u)));transition:opacity .7s ease, transform .9s var(--ease-out)}
.hero.is-revealed .reveal-up{opacity:1;transform:none}



.art > *{pointer-events:auto}

@keyframes float{
  0%,100%{translate:0 0}
  50%{translate:0 calc(-10 * var(--u))}
}


.cat > *{pointer-events:auto}



/* staged entrance for the copy block */

/* ==================== ORIGINAL WORK ====================
   Figma 145:34 (landing) and 131:996 (section) share a 1980x1080 stage — wider
   than the 1440x1024 the rest of the site uses, so it gets its own --u. */
.stage--wide{
  --u: min(calc(100vw / 1980), calc(100vh / 1080));
  width:calc(1980 * var(--u)); height:calc(1080 * var(--u));
}

/* Tall, so the reel has scroll range; the pin is what you actually see.

   Held hidden until the clip has finished. When it finishes, the page is
   jumped to this section's top in one step — and because .orig__last is the
   same clip parked on the same frame the hero just ended on, that jump lands
   on identical pixels and cannot be seen. Nothing scrolls between the two
   screens. visibility (not display) keeps the sticky pin working. */
/* 380vh, not 320: the reel still finishes in the first 220vh, and the last
   60vh holds Panels centred while the next section's wash rises over it. */
.orig{
  position:relative; height:380vh; background:var(--ink); visibility:hidden;
  /* how long the section takes to fade off the reversed clip on the way back.
     main.js reads the same number to know when it may tear the section down. */
  --rewind:420ms;
}
.orig.is-live{visibility:visible}
.orig__pin{position:sticky; top:0; height:100vh; overflow:hidden}

/* the bare wall, always on — .orig__last covers it until the landing clears.
   Four of them now, one per kind of work, cross-faded by whichever canvas is
   centred: main.js calls setWall() from the reel's own layout on desktop and
   from the column's centre observer on mobile, so the two share a mechanism.
   The blur and the scale stay on the container so the stack shares them. */
.orig__bg{
  position:absolute; inset:0; width:100%; height:100%;
  transform:scale(1.04);            /* hides the blur's soft edge */
}
/* NO RUNTIME BLUR. The frame's blur(4px) is baked into the desktop cut of each
   wall (`wall-*-b.webp`, built by _work_src/blurwalls.py); a phone takes the
   sharp file, as its own frame draws it. main.js picks per breakpoint.

   It was `filter:blur()` on .orig__bg, which re-rasterised a full-screen blur
   every frame for the 900ms of every wall cross-fade. Moving it onto each wall
   fixed that and made it worse overall — FOUR full-viewport blur layers
   (1487x853, 1.27 MP each) instead of one. A Gaussian blur is one of the most
   expensive filters there is, and none of it needs to happen at runtime: the
   walls never change. Baked, the cost is zero and the files are 24% smaller. */
.orig__wall{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; max-width:none;
  opacity:0; transition:opacity 900ms ease;
}
.orig__wall.is-on{opacity:1}

/* The clip's last frame, matched to how the <video> was fitted so the two are
   pixel-identical at the swap. Fades to the bare wall on the clearing beat,
   which is what takes the white line off screen. */
.orig__last{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; object-position:center;
  /* 360ms, not 700: the clip ends and the section is there. Long enough that
     the two walls cross rather than cut, short enough that nothing is dwelt
     on — there is no landing beat behind it any more. */
  opacity:1; transition:opacity 360ms ease;
}
.orig.is-clearing .orig__last{opacity:0}
/* --- the way back ---
   The section FADES AWAY and the reversed clip is what is underneath it: the
   copy and the canvases go, the wall goes with them, and the clip comes up in
   their place. The owner's call, and it replaces a hard swap — `transition:
   none` on the clip with the section torn down in the same frame.

   The clip is above .orig__bg and below .stage--wide in the markup, so it only
   has to fade IN for the wall to disappear behind it; the wall is faded too so
   that "everything in it" is literally true rather than merely covered.

   Wins over .orig.is-clearing above on source order, not specificity — the two
   selectors weigh the same and this one is second. Do not move it. */
.orig.is-rewinding .orig__last{opacity:1; transition:opacity var(--rewind) ease}
.orig.is-rewinding .stage--wide,
.orig.is-rewinding .orig__bg{opacity:0; transition:opacity var(--rewind) ease}

/* --- section copy (131:996) --- */
.orig__lead,.orig__title-main,.orig__title-script{
  position:absolute; white-space:nowrap;
  text-shadow:calc(2 * var(--u)) calc(6 * var(--u)) calc(12 * var(--u)) rgba(0,0,0,.8);
}
.orig__lead{
  font-family:"Moniqa Bold Narrow"; font-size:calc(78.078 * var(--u));
  line-height:normal; color:var(--cream);
  opacity:0; transform:translateY(calc(26 * var(--u)));
  transition:opacity 700ms ease, transform 900ms var(--ease-out);
}
.orig__lead--l{left:calc(280 * var(--u));  top:calc((91 + var(--baseline-fix)) * var(--u))}
.orig__lead--r{left:calc(1464 * var(--u)); top:calc((98 + var(--baseline-fix)) * var(--u))}
.orig.is-open .orig__lead--l{opacity:1;transform:none;transition-delay:240ms}
.orig.is-open .orig__lead--r{opacity:1;transform:none;transition-delay:380ms}

/* A wrapper that does nothing here — it exists so mobile can make the title
   and the leads sticky without a second copy of the markup. */
.orig__head{display:contents}
.orig__title{position:absolute; inset:0; pointer-events:auto}
.orig__title-main{
  left:calc(703.97 * var(--u)); top:calc((64 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-weight:400; font-size:calc(134.845 * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase;
  opacity:0; transform:translateY(calc(-22 * var(--u)));
  transition:opacity 700ms ease, transform 900ms var(--ease-out);
}
.orig.is-open .orig__title-main{opacity:1;transform:none}
.orig__title-script{
  left:calc(675 * var(--u)); top:calc(67.22 * var(--u));
  font-family:"Script"; font-weight:400; font-size:calc(99.919 * var(--u));
  line-height:normal; color:var(--script-red); text-transform:capitalize;
  --draw:0; clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 1100ms var(--ease-io);
}
.orig.is-open .orig__title.is-drawn .orig__title-script,
.orig.is-open .orig__title:hover .orig__title-script{--draw:1}

/* --- the reel ---
   Items ride an arc: centred and upright in the middle, sinking and turning
   away to either side. JS writes --rx/--ry (the item's centre, in design px),
   --rr (degrees) and --rs (scale); the box itself never moves. */
.reel{position:absolute; inset:0; pointer-events:none}

/* Each painting is placed at its own size in the design, not cropped into one
   shared box: 131:996 centres landscapes at 655x878, 149:57 abstract at
   509x680, 150:74 mini canvases at 760x1350 and 150:90 panels at 477x659. So
   the box is per item (--cw/--ch) and the arc only has to move its centre.
   --nx/--ny/--nw/--nfs are that frame's own script name, measured from the
   top-left of its canvas. */
.reel__item{
  --cw:655.239; --ch:877.627; --ox:0; --oy:0;
  --nx:16.85; --ny:640.17; --nw:620.879; --nfs:118.965;

  position:absolute; left:0; top:0; margin:0;
  width:calc(var(--cw) * var(--u)); height:calc(var(--ch) * var(--u));
  transform:
    translate(calc((var(--rx,990) + var(--ox) - var(--cw) / 2) * var(--u)),
              calc((var(--ry,628) + var(--oy) - var(--ch) / 2) * var(--u)))
    rotate(calc(var(--rr,0) * 1deg))
    scale(var(--rs,1));
  transform-origin:50% 50%;
  opacity:0;
  transition:transform 520ms var(--ease-out), opacity 600ms ease;
}
/* Promoted ONLY WHILE THE REEL IS MOVING. .reel__art carries a drop-shadow,
   which follows the cut-out's alpha and is real raster work, so a layer per
   canvas means scroll moves a texture instead of redrawing the shadow. But
   `will-change` left on permanently is four large textures held for the life of
   the page — ~20 MB of GPU memory on a 3x phone, for a section the visitor is
   not always looking at. main.js adds .is-moving on scroll and drops it 200ms
   after the last one. */
.reel.is-moving .reel__item{will-change:transform}
/* One arc serves all four, but each frame hangs its own canvas a little off
   that shared centre — mini canvases most of all, 73px high, which is the
   difference between keeping the easel's feet and cutting them off. --ox/--oy
   carry each frame's own centre, so the arc stays one curve and every canvas
   still lands exactly where its frame puts it. */
.reel__item--landscapes{--cw:655.239; --ch:877.627;  --ox:0.69;  --oy:0.17;   --nx:16.85;  --ny:640.17; --nw:620.879; --nfs:118.965}
.reel__item--abstract  {--cw:508.688; --ch:679.545;  --ox:0.18;  --oy:-6.70;  --nx:-40;    --ny:491;    --nw:587.724; --nfs:141.363}
.reel__item--mini      {--cw:759.633; --ch:1350.458; --ox:-1.65; --oy:-73.24; --nx:2.22;   --ny:825.16; --nw:756.301; --nfs:115.588}
.reel__item--panels    {--cw:476.687; --ch:659.197;  --ox:4.15;  --oy:-15.87; --nx:-56.44; --ny:386.89; --nw:586.902; --nfs:201.599}

.orig.is-open .reel__item{opacity:1}
.reel__art{
  width:100%; height:100%; object-fit:cover; display:block;
  filter:drop-shadow(calc(6.953 * var(--u)) calc(10.43 * var(--u))
                     calc(6.258 * var(--u)) rgba(0,0,0,.25));
}
/* the name rides inside its own canvas, and is written on only in the middle.
   Figma tracks every one of these at exactly -0.03em and hangs the shadow off
   the same ratio, so both follow --nfs rather than being written out again. */
.reel__name{
  position:absolute;
  left:calc(var(--nx) * var(--u)); top:calc(var(--ny) * var(--u));
  width:calc(var(--nw) * var(--u));
  font-family:"Script"; font-size:calc(var(--nfs) * var(--u));
  line-height:normal; color:#f8e5ec; text-transform:capitalize;
  letter-spacing:calc(var(--nfs) * -0.03 * var(--u)); white-space:nowrap;
  text-shadow:0 calc(var(--nfs) * 0.0797 * var(--u))
                calc(var(--nfs) * 0.0797 * var(--u)) rgba(0,0,0,.5);
  --draw:0; opacity:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 950ms var(--ease-io), opacity 260ms ease;
}
.reel__item.is-centre .reel__name{--draw:1;opacity:1}

/* ---- work -> room hand-off ---- */



/* ======================= PRODUCT DETAIL =======================
   Figma "Desktop - 19" (90:807), a 1980x1080 stage — the same stage Original
   Work uses, so every number below is read straight off that frame.

   It is a FIXED OVERLAY over the whole viewport, not a section in the flow,
   and that is the design: the visitor opens a kind of work from the reel and
   the page does not move at all. Scroll is held while it is up (main.js takes
   the hold as 'work'), the wall and the copy hold still, and stepping between
   pieces slides ONLY the canvas — up and down here, left and right on mobile —
   while the name and the two lines cross-fade under it.

   `overflow:hidden` on the section is load-bearing: the pieces waiting either
   side of the one on screen are parked a viewport away in dvh/vw, so the clip
   is what guarantees they are never visible however tall or wide the window
   is. Parking them in design px cannot make that guarantee — the stage
   letterboxes and the viewport does not. */
.work{
  position:fixed; inset:0; z-index:60;
  background:var(--work-bg);
  overflow:hidden;
  /* The page is held for as long as this is up, so there is no scrolling for
     the browser to claim a gesture for — and the canvas's own drag needs every
     pointermove it can get. */
  touch-action:none;
  opacity:0; transition:opacity 420ms ease;
}
.work.is-open{opacity:1}
/* [hidden] is removed by main.js on the first open. Kept in the markup so the
   overlay cannot flash during boot and so the art inside it is never fetched
   until a visitor asks for a kind of work. */
.work[hidden]{display:none}

.stage--work{
  --u: min(calc(100vw / 1980), calc(100vh / 1080));
  width:calc(1980 * var(--u)); height:calc(1080 * var(--u));
}

/* --- the wall ---
   The frame's own background (image 18, at -19/-46 over 2017x1126) IS
   wall-panels: 90:807 draws a Panel, and the four walls the reel cross-fades
   are the four kinds of work. So this stack is the reel's, and main.js turns
   on whichever wall belongs to the kind being shown.

   Covering the VIEWPORT rather than being placed at the frame's own numbers,
   for the same reason .orig__bg does: the stage letterboxes, and a wall placed
   in stage units would leave bare page down both sides of a wide window. The
   frame's 2017x1126 over 1980x1080 is a 1.04 scale, which is what the
   transform below reproduces — it also hides the blur's soft edge. */
/* Same baked wall as the reel's, and for the same reason — the frame's blur is
   in the file, not in a filter. */
.work__bg{
  position:absolute; inset:0;
  transform:scale(1.05);
}
.work__wall{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; max-width:none;
  opacity:0; transition:opacity 520ms ease;
}
.work__wall.is-on{opacity:1}

/* --- back to Original Work ---
   No frame specifies this. It is the nav's own condensed caps plus the drawn
   underline .hang__cta already uses, so it belongs to the site rather than
   being invented for this screen. Placed on the STAGE, so it sits at the top
   left of the composition rather than of the window — the same corner the
   hero's tagline occupies. */
.work__back{
  position:absolute; left:calc(60 * var(--u)); top:calc(52 * var(--u));
  display:flex; align-items:center; gap:calc(12 * var(--u));
  background:none; border:0; padding:0; cursor:pointer;
  color:var(--cream);
  font-family:"Moniqa Cond"; font-size:calc(44 * var(--u));
  line-height:1; text-transform:uppercase;
  text-shadow:calc(2 * var(--u)) calc(4 * var(--u)) calc(10 * var(--u)) rgba(0,0,0,.75);
}
.work__back-arrow{
  width:calc(38 * var(--u)); height:calc(38 * var(--u)); display:block;
  transition:transform 320ms var(--ease-out);
}
.work__back:hover .work__back-arrow,
.work__back:focus-visible .work__back-arrow{transform:translateX(calc(-8 * var(--u)))}
/* A plain rule in the label's own colour, drawn from the left. It was the
   painted stroke .hang__cta uses at first, and that was wrong here: that
   stroke is a wide ragged brush mark sized for 70px script, and under 44px
   condensed caps it read as a smear rather than an underline. */
.work__back-label{position:relative; display:block}
.work__back-label::after{
  content:""; position:absolute; left:0; right:0;
  bottom:calc(-7 * var(--u)); height:calc(2 * var(--u));
  background:currentColor;
  transform:scaleX(0); transform-origin:0 50%; opacity:0;
  transition:opacity 300ms ease, transform 0ms linear 300ms;
}
.work__back:hover .work__back-label::after,
.work__back:focus-visible .work__back-label::after{
  transform:scaleX(1); opacity:1;
  transition:transform 420ms var(--ease-out), opacity 170ms ease;
}

/* --- the canvases ---
   The deck IS the frame's canvas box (607.432x840 at 1046,125) and every piece
   is centred in it. The frame's own piece is 794x1098 — exactly that box's
   aspect — so a piece of another shape is CONTAINED in the box rather than
   cropped to it, which is the frame made general rather than a departure from
   it. --bw/--bh are that fit, worked out per piece at build time: CSS cannot
   divide by a custom property, so the fit cannot be computed here.

   perspective lives on the piece because the tilt is on the art inside it —
   the piece itself carries the slide, and the two must not fight. */
.work__deck{
  position:absolute;
  left:calc(1046 * var(--u)); top:calc(125 * var(--u));
  width:calc(607.432 * var(--u)); height:calc(840 * var(--u));
  transition:transform 220ms var(--ease-out);
}
/* The deck does not loop, so a gesture past either end has to answer for
   itself: with scroll held, "nothing happened" is indistinguishable from a
   page that has stopped responding. */
.work__deck.is-end-next{transform:translateY(calc(-16 * var(--u)))}
.work__deck.is-end-prev{transform:translateY(calc(16 * var(--u)))}
.work__piece{
  position:absolute; left:50%; top:50%; margin:0;
  width:calc(var(--bw) * var(--u)); height:calc(var(--bh) * var(--u));
  perspective:calc(1500 * var(--u));
  /* --step is the piece's index relative to the one on screen, written by
     main.js. 0 is centred; anything else is parked a viewport away in the
     direction it will travel — DESKTOP SLIDES VERTICALLY. dvh rather than
     design px so it is off screen at any window shape; the section's own
     overflow:hidden is the guarantee. */
  transform:translate(-50%, calc(-50% + var(--step, 0) * 118dvh));
  transition:transform 640ms var(--ease-io);
}
/* Only the kind being shown is in the deck at all. The others are not merely
   transparent: a piece of another kind would otherwise transition across the
   screen when the visitor opens a different kind. */
.work__piece[hidden]{display:none}

.work__art{
  width:100%; height:100%; display:block; object-fit:contain;
  /* drop-shadow, not the frame's box-shadow: three of these pieces are cut out
     around a wooden easel and a box shadow would throw the shadow of a
     rectangle that is not there. On the frame's own rectangular piece the two
     are identical. Numbers are 90:807's own, 25/29/26 at 40%. */
  filter:drop-shadow(calc(var(--shx) * var(--u)) calc(var(--shy) * var(--u))
                     calc(var(--shb) * var(--u)) rgba(0,0,0,var(--sha)));
  --shx:25; --shy:29; --shb:26; --sha:.4;
  transform:
    rotateX(calc(var(--tiltx, 0) * 1deg))
    rotateY(calc(var(--tilty, 0) * 1deg))
    scale(var(--tilts, 1));
  transition:transform 200ms var(--ease-out), filter 220ms ease;
}
/* --- the tilt ---
   Hover on desktop, press-and-drag on mobile. main.js writes --tiltx/--tilty
   from where the pointer is over the canvas and --gx/--gy for the glare; the
   200ms transition on the transform is what damps it, so it follows the
   pointer rather than snapping to it.

   The glare is a pseudo-element rather than a third node so it can read the
   SAME tilt properties the art does and stay welded to it. It is masked by the
   art's own alpha (--art, set by main.js alongside the src) — unmasked, a
   rectangle of light would sit over the easel's legs and the bare wall between
   them. Mobile does not get it: see mobile.css. */
.work__piece::after{
  content:""; position:absolute; inset:0; pointer-events:none;
  background:radial-gradient(circle at calc(var(--gx, 50) * 1%) calc(var(--gy, 50) * 1%),
             rgba(255,255,255,.45) 0%, rgba(255,255,255,0) 78%);
  -webkit-mask-image:var(--art, none); mask-image:var(--art, none);
  -webkit-mask-size:100% 100%;        mask-size:100% 100%;
  -webkit-mask-repeat:no-repeat;      mask-repeat:no-repeat;
  opacity:0;
  transform:
    rotateX(calc(var(--tiltx, 0) * 1deg))
    rotateY(calc(var(--tilty, 0) * 1deg))
    scale(var(--tilts, 1));
  transition:transform 200ms var(--ease-out), opacity 200ms ease;
}
.work__piece.is-tilting::after{opacity:1}
.work__piece.is-tilting .work__art{--shx:30; --shy:44; --shb:34; --sha:.55}

/* --- name and lines ---
   A parallel list to the deck: they cross-fade where the canvas slides. Each
   piece keeps its own nodes rather than one block being rewritten, because the
   red script has to draw itself on per piece and a rewritten node cannot
   re-run its own reveal. */
.work__texts{position:absolute; inset:0; pointer-events:none}
.work__text{
  position:absolute; inset:0;
  opacity:0; visibility:hidden;
  transition:opacity 380ms ease, visibility 0ms linear 380ms;
}
.work__text.is-on{opacity:1; visibility:visible; transition:opacity 460ms ease 200ms}

.work__title{position:absolute; inset:0}
.work__title-main,.work__title-script{position:absolute; white-space:nowrap}
.work__title-main{
  left:calc(392 * var(--u)); top:calc((125 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-weight:400; font-size:calc(160.684 * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase;
  text-shadow:calc(2 * var(--u)) calc(6 * var(--u)) calc(14 * var(--u)) rgba(0,0,0,.75);
}
/* Signed across the name, not under it — 90:817 sits 24 left and 10 above
   90:813 and overlaps it the whole way. Tracking here is POSITIVE 0.03em,
   unlike every other script on the site: the frame reports 3.617 at 120.568. */
.work__title-script{
  left:calc(368 * var(--u)); top:calc(115 * var(--u));
  font-family:"Script"; font-weight:400; font-size:calc(120.568 * var(--u));
  line-height:normal; color:var(--script-red); text-transform:capitalize;
  letter-spacing:.03em;
  --draw:0; clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 1000ms var(--ease-io);
}
.work__text.is-on .work__title-script{--draw:1; transition-delay:260ms}

.work__desc{
  position:absolute;
  left:calc(392 * var(--u)); top:calc((405 + var(--baseline-fix)) * var(--u));
  width:calc(596 * var(--u));
  font-family:"Moniqa Light Para"; font-weight:300; font-size:calc(64 * var(--u));
  line-height:normal; color:var(--cream);
  text-shadow:calc(2 * var(--u)) calc(4 * var(--u)) calc(12 * var(--u)) rgba(0,0,0,.6);
}
/* 90:826 is three paragraphs, the middle one a single space: the gap between
   the two lines is a blank LINE, not a margin, and it scales with the type. */
.work__desc-gap{visibility:hidden}

/* --- the CTA ---
   One box for the pair: the label (424,709) and the brush behind it
   (326,744 493x50), so the union starts at 326,709. The brush is not there at
   rest — it wipes on under the words on hover, and on mobile while the CTA is
   held down (main.js adds .is-pressed). */
.work__cta{
  position:absolute;
  left:calc(326 * var(--u)); top:calc(709 * var(--u));
  width:calc(493 * var(--u)); height:calc(125 * var(--u));
  display:block; text-decoration:none;
}
.work__cta-brush{
  position:absolute; left:0; top:calc(35 * var(--u));
  width:calc(493 * var(--u)); height:calc(50 * var(--u));
  object-fit:fill; max-width:none; pointer-events:none;
  --draw:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 560ms var(--ease-io);
}
.work__cta:hover .work__cta-brush,
.work__cta:focus-visible .work__cta-brush,
.work__cta.is-pressed .work__cta-brush{--draw:1}
/* Written out as capitals: text-transform:capitalize skips the FIRST letter of
   an element in Chrome, so "getThis" would render "getThis". See HANDOFF.md. */
.work__cta-label{
  position:absolute; left:calc(98 * var(--u)); top:0;
  font-family:"Script"; font-size:calc(98.57 * var(--u));
  letter-spacing:-.03em;
  line-height:normal; color:#f8e5ec; white-space:nowrap;
  text-shadow:calc(2 * var(--u)) calc(4 * var(--u)) calc(10 * var(--u)) rgba(0,0,0,.5);
}

/* --- the way in ---
   The centred canvas in the reel is the hit target, and only the centred one:
   .reel carries pointer-events:none and this turns it back on for whichever
   piece is signed. Same trap .nav__menu and .foot__social are documented for. */
.reel__open{
  position:absolute; inset:0;
  background:none; border:0; padding:0;
  cursor:pointer; pointer-events:none;
}
.reel__item.is-centre .reel__open{pointer-events:auto}
.reel__open:focus-visible{outline:calc(3 * var(--u)) solid var(--pill); outline-offset:calc(6 * var(--u))}

@media (prefers-reduced-motion: reduce){
  /* No travel: the canvases cross-fade in place, and nothing is parked off
     screen to slide back in. main.js marks the piece on screen .is-on
     whichever mode this is, so the fade has something to key off. */
  .work__piece{transition:opacity 260ms ease; transform:translate(-50%,-50%); opacity:0}
  .work__piece.is-on{opacity:1}
  .work__art,.work__piece::after{transition:none}
  .work__title-script{transition:none}
  .work__text.is-on .work__title-script{--draw:1}
}


/* ============ ORIGINAL WORK -> CUSTOM PORTRAITS ============ */
/* Figma frame 153:109 (1440x1024). Every number below is read off it.

   The first two beats are scroll-driven — the visitor's scroll is the clock,
   and a CSS transition fighting that reads as lag. The third is NOT: the cut
   is triggered by scroll and then plays itself. See main.js for why. */
/* Pulled up over Original Work's last pinned screen (-160vh: 100vh of pin
   plus the 60vh Original Work now holds Panels for), so the wash rises over
   the Panels canvas rather than over an empty wall. No background of its own,
   for the same reason. */
/* pointer-events:none is LOAD-BEARING, and the arithmetic is why. Original
   Work's reel finishes exactly where this section starts — 380vh of section
   minus a 100vh pin is 280vh of travel, the reel uses all but REEL_HOLD (60vh)
   of it, and -160vh puts this section's top on that same 220vh mark. So for
   the whole of the Panels hold, this pin is stuck over the viewport with the
   reel underneath it, and it swallowed every click on the Panels canvas: it
   was the one kind of work that could not be opened. Verified with
   elementFromPoint at the centred canvas — `toroom__pin`, then `toroom__wash`.
   Nothing in this section is interactive, so the whole thing is inert. */
.toroom{position:relative; height:200vh; margin-top:-160vh; pointer-events:none}
.toroom__pin{
  position:sticky; top:0; height:100vh; height:100dvh; overflow:hidden;
  --u: min(calc(100vw / 1440), calc(100vh / 1024));

  /* Where the cut runs, in design px below the stage's own centre line. The
     stage is always centred in this pin (`.stage{inset:0;margin:auto}`), so
     50% of the pin IS stage y 512 at every viewport and this number holds
     without re-deriving.

     37.32 was the brush stroke's own y off the frame, and with the stroke gone
     it put the line on the script's baseline — visibly low, not through the
     word. 14.21 is the **ink-mass centroid** of "Custom Portraits" as this cut
     renders it: measured off a 1440x1024 still, ink spans y 470..561 and the
     mass centres on 526.21. The plain bounding-box centre (515.5, i.e. +3.5)
     is wrong for this face — the capitals and the t/i ascenders are tall and
     sparse, so the box centre lands at the TOP of the lowercase and the line
     shaves their heads instead of halving them. */
  --cut-y: 14.21;
}
.stage--toroom{ --u: min(calc(100vw / 1440), calc(100vh / 1024)); width:calc(1440 * var(--u)); height:calc(1024 * var(--u)); }

/* Beat 1 — the cream rises out of the bottom-left corner.
   The top edge is a slant, and the left end of it leads: at half way the left
   corner is already covered while the right is still below the fold, which is
   what makes it read as coming up out of that corner rather than as a blind
   being raised. Oversized so the slant's own corners never come into view. */
.toroom__wash{
  position:absolute; left:-5%; right:-5%; top:-5%; bottom:-5%;
  background:var(--cream);
  --rise:0;
  clip-path:polygon(
    0% 120%,
    100% 120%,
    100% calc(130% - var(--rise) * 130%),
    0% calc(100% - var(--rise) * 130%));
}

/* Beat 2 — the script writes itself on, left to right */
.toroom__title{
  position:absolute;
  left:calc(261 * var(--u)); top:calc(447 * var(--u));
  width:calc(918 * var(--u));
  font-family:"Script"; font-size:calc(122.166 * var(--u));
  line-height:normal; color:var(--work-bg); text-transform:capitalize;
  letter-spacing:calc(-3.665 * var(--u)); white-space:nowrap;
  --draw:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
}

/* Beat 3 — the burgundy arrives as a thin line that cuts the screen, and the
   script written across it, in half; then it opens out into the full field.
   It ends on var(--work-bg), which is exactly what .room is painted in, so the
   room simply continues it.

   The brush stroke that used to do this job is gone. So is the scrubbing: this
   beat runs on its own clock, triggered by scroll rather than driven by it.
   Scrubbed, the field was only ever as open as the visitor had scrolled — so
   Custom Portraits could not start until they had scrolled further still, and
   scrolling back un-opened it a pixel at a time instead of closing.

   --cut is how far the line has run out from the centre and --exp how far it
   has opened, both 0..1. Registered, so the keyframes can interpolate them —
   the same reason every other reveal here rides a registered property instead
   of animating clip-path directly. The line sits on the frame's own cut,
   37.32 design px below centre, and is 4 design px thick until it opens. */
@property --cut{ syntax:'<number>'; inherits:false; initial-value:0 }
@property --exp{ syntax:'<number>'; inherits:false; initial-value:0 }

.toroom__fill{
  position:absolute; inset:0;
  background:var(--work-bg);
  --cut:0; --exp:0;
  clip-path:inset(
    calc(50% + var(--cut-y) * var(--u) - 2 * var(--u) - var(--exp) * 60%)
    calc(50% - var(--cut) * 50%)
    calc(50% - var(--cut-y) * var(--u) - 2 * var(--u) - var(--exp) * 60%)
    calc(50% - var(--cut) * 50%));
}

/* The line runs out to both edges, holds for a beat so the cut registers as a
   cut, then opens. Closing is the same move backwards. */
@keyframes toroom-open{
  0%  {--cut:0; --exp:0; animation-timing-function:cubic-bezier(.55,0,.12,1)}
  40% {--cut:1; --exp:0}
  50% {--cut:1; --exp:0; animation-timing-function:cubic-bezier(.3,.72,.15,1)}
  100%{--cut:1; --exp:1}
}
@keyframes toroom-close{
  0%  {--cut:1; --exp:1; animation-timing-function:cubic-bezier(.55,0,.12,1)}
  55% {--cut:1; --exp:0}
  66% {--cut:1; --exp:0; animation-timing-function:cubic-bezier(.5,0,.35,1)}
  100%{--cut:0; --exp:0}
}
.toroom.is-opening .toroom__fill{animation:toroom-open  1150ms both}
.toroom.is-closing .toroom__fill{animation:toroom-close  900ms both}
/* Held open. main.js swaps .is-opening for this on animationend rather than
   leaning on fill-mode, because headless never advances CSS animations at all
   and a state that only exists as an animation's fill never lands there. */
.toroom.is-open .toroom__fill{--cut:1; --exp:1}

@media (prefers-reduced-motion: reduce){
  /* no sweep and no cut: it simply arrives on the burgundy the room needs */
  .toroom{height:100vh}
  .toroom__wash{clip-path:none}
  .toroom__fill{clip-path:none}
  .toroom__title{--draw:1}
  .toroom.is-opening .toroom__fill,
  .toroom.is-closing .toroom__fill{animation:none}
}

/* =========================== ROOM =========================== */
/* .room.is-live is set by the transition above once its fill has landed. */
.room.is-live{visibility:visible}
/* Figma frame 52:363 (1440x1024). Same background as the work section, so the
   two read as one room rather than two slides — no wipe between them. */
/* Pulled up so its top lands exactly where the transition's fill completes:
   the burgundy it arrives on is the burgundy the fill just opened out, so the
   section is revealed in place instead of being scrolled down to. Held hidden
   until then, or it would slide up over the transition from part way through. */
.room{
  position:relative; height:100vh; height:100dvh; margin-top:-100vh;
  visibility:hidden;
  background:var(--work-bg); overflow:hidden;

}

/* the scene is exported at 1448 wide and hangs 4px past the left edge, exactly
   as the frame places it, so the dresser is cropped the way it is in Figma */
.room__scene{
  position:absolute;
  /* The scene stretches to the viewport width so the dresser and the plant
     keep meeting the edges as they do in the frame; the SVG already carries
     preserveAspectRatio="none". Height still tracks --u, so the furniture keeps
     its designed height and only widens on screens wider than 1440:1024. */
  left:50%; translate:-50% 0;
  width:max(100vw, calc(1448 * var(--u)));
  top:calc(380 * var(--u)); height:calc(657 * var(--u));
  pointer-events:none;
}
.room__scene svg{display:block;width:100%;height:100%;overflow:visible}

/* The stage letterboxes, so on a screen wider than 1440:1024 there is bare wall
   either side. In the frame the dresser and the plant are cut off by the frame
   edge, so they have to keep touching the real viewport edge instead of
   floating mid-wall. Only the two edge pieces move; the sofa group stays on the
   design grid. The divide by --u converts screen px into SVG user units. */
/* NOTE: edge bleed is parked. Sliding the dresser and plant out to the
   viewport edge just opens a wide empty gap between them and the sofa, because
   the frame has no furniture out there. Filling wide screens needs a design
   decision, not a transform. --bleedu is still published for whatever we pick. */

/* Each furniture piece lands on its own. fill-box origins let the pieces grow
   from where they meet the floor rather than from the SVG's own corner. */
.fx-piece{
  transform-box:fill-box; transform-origin:50% 100%;
  opacity:0; transform:translateY(calc(16 * var(--u))) scale(.88);
  transition:opacity 380ms ease, transform 620ms var(--ease-pop);
}
.room.is-scene-in .fx-piece{opacity:1;transform:none}
/* they arrive around the room left to right */
.room.is-scene-in #fx-dresser      {transition-delay:0ms}
.room.is-scene-in #fx-lamp         {transition-delay:120ms}
.room.is-scene-in #fx-sofa         {transition-delay:240ms}
.room.is-scene-in #fx-table        {transition-delay:380ms}
.room.is-scene-in #fx-plant-pot    {transition-delay:500ms}
.room.is-scene-in #fx-plant-foliage{transition-delay:600ms}

/* The plant is drawn as one silhouette mass with highlights painted over it,
   so the leaves cannot move independently without redrawing the artwork.
   Swaying the whole crown from where the stems enter the pot reads correctly. */
@keyframes foliage-sway{
  0%,100%{transform:rotate(-1.15deg)}
  50%    {transform:rotate(1.15deg)}
}
.room.is-scene-in #fx-plant-foliage{
  transform-origin:50% 94%;
  animation:foliage-sway 6.5s var(--ease-io) 1200ms infinite;
}

/* --- the three sizes --- */
.sizes{position:absolute;inset:0;pointer-events:none}

/* Each button is the frame's image box. Label, price and script note are
   positioned inside it, so a size carries its own price wherever the box goes. */
.size{
  position:absolute; background:none; border:0; padding:0; cursor:pointer;
  pointer-events:auto;
  left:calc(var(--bx) * var(--u)); top:calc(var(--by) * var(--u));
  width:calc(var(--bw) * var(--u)); height:calc(var(--bh) * var(--u));
  opacity:0; transform:translateY(calc(28 * var(--u))) scale(.985) rotate(var(--rot,0deg));
  transition:opacity 800ms ease, transform 1000ms var(--ease-out);
}
/* the resting transform keeps the frame's own slight rotation */
.room.is-art-in .size{opacity:1;transform:rotate(var(--rot,0deg))}

/* All three canvases share a centre line at y=295 in the frame, which is why
   they can stack cleanly on one another: only x and scale have to change.
   Removing .is-stacking is what deals them out to their places. */
.room.is-stacking .size{
  transform:translateX(calc(var(--selx) * var(--u))) scale(var(--sts,1)) rotate(var(--rot,0deg));
}
.room.is-stacking .size:not(.size--l) .size__art{filter:none}
/* z-order is permanent, not tied to .is-stacking: the moment that class was
   removed the order reverted to DOM order and the smaller canvases jumped in
   front of the large one mid-slide. They never overlap at rest, so this is
   free. */
.room.is-stacking .size__label,
.room.is-stacking .size__note,
.room.is-stacking .size__num,
.room.is-stacking .size__cur{opacity:0}
.size:focus-visible{outline:calc(2 * var(--u)) solid var(--cream);outline-offset:calc(6 * var(--u))}

.size__art{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; max-width:none;
  /* drop-shadow, not box-shadow: the PNGs are cutouts, so the shadow has to
     follow the painted canvas rather than the transparent box around it */
  filter:drop-shadow(calc(var(--sx) * var(--u)) calc(var(--sy) * var(--u))
                     calc(var(--sb) * var(--u)) rgba(0,0,0,.25));
  transition:transform 500ms var(--ease-out), filter 500ms var(--ease-out);
}
.size:hover .size__art,
.size:focus-visible .size__art{transform:scale(1.035)}

.size__label{
  position:absolute;
  /* centred on the canvas rather than placed by the frame's left offset, and
     sized so the line sits inside the canvas width instead of overhanging it */
  left:0; width:100%; text-align:center;
  top:calc((var(--ly) + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-size:calc(var(--lf, 34.8) * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase; white-space:nowrap;
  /* the sizing text pops in under each canvas as its own beat */
  opacity:0; transform:scale(.8);
  transform-origin:50% 0;
  transition:opacity 300ms ease, transform 520ms var(--ease-pop);
}
.room.is-labels-in .size__label{opacity:1;transform:none}
.size--m .size__label{transition-delay:90ms}
.size--s .size__label{transition-delay:180ms}

/* the number and the currency mark are separate text layers in the frame, each
   with its own size and origin, so the wrapper contributes no box of its own */
.size__price{display:contents}
/* line-height must be normal, not 1: the script face carries ~1.34 line boxes
   and Figma lays the text out on that, so forcing 1 lifted the prices ~20px */
.size__num,.size__cur{
  position:absolute; font-family:"Script"; line-height:normal;
  color:var(--script-red); white-space:nowrap;
}
.size__num{left:calc(var(--nx) * var(--u)); top:calc(var(--ny) * var(--u)); font-size:calc(var(--nf) * var(--u))}
.size__cur{left:calc(var(--cx) * var(--u)); top:calc(var(--cy) * var(--u)); font-size:calc(var(--cf) * var(--u))}

.size__note{
  position:absolute; pointer-events:none;
  left:calc(var(--tx) * var(--u)); top:calc(var(--ty) * var(--u));
  width:calc(var(--tw) * var(--u));
  font-family:"Script"; font-size:calc(var(--tf) * var(--u));
  line-height:normal; color:var(--script-red);
  text-transform:capitalize; white-space:nowrap;
}

/* The note and the price are both written on by hand on hover and wiped when
   the cursor leaves — only a chosen size keeps them. All three notes overlap
   in the frame, so at most one being visible is what makes that legible. */
/* The sweep is driven by a registered custom property rather than by
   transitioning clip-path itself: the chosen size's parent is transforming at
   the same time, and a clip-path transition on the promoted layer does not
   reliably run. --draw has to be registered or the browser treats it as a
   string and snaps 0 -> 1 with no animation. */
@property --draw{ syntax:'<number>'; inherits:false; initial-value:0 }
.size__note,.size__num,.size__cur{
  --draw:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  opacity:0;
  transition:--draw 780ms var(--ease-io), opacity 200ms ease;
}
.room.is-copy-in .size:hover        .size__note,
.room.is-copy-in .size:hover        .size__num,
.room.is-copy-in .size:hover        .size__cur,
.room.is-copy-in .size:focus-visible .size__note,
.room.is-copy-in .size:focus-visible .size__num,
.room.is-copy-in .size:focus-visible .size__cur,
.room .size.is-selected .size__note,
.room .size.is-selected .size__num,
.room .size.is-selected .size__cur{--draw:1;opacity:1}

/* per-size geometry, read straight off frame 52:363 and rebased on its box */
.size--l{
  --bx:184; --by:55; --bw:480; --bh:480;
  --sx:12; --sy:7; --sb:14;
  --lx:142; --ly:448; --lf:34.8;   /* 191 wide inside a 480 canvas */
  --nx:266; --ny:299; --nf:119.692;
  --cx:412.65; --cy:332.47; --cf:83.031;
  --tx:-41; --ty:211; --tw:563; --tf:52.09;
  z-index:3;
  --selx:295;      /* 90:835 moves the large group +295 to centre it */
  --sts:1;
}
.size--m{
  --bx:716; --by:108; --bw:257; --bh:374;
  --sx:10; --sy:9; --sb:4;
  --lx:30;  --ly:343; --lf:31;     /* 176 inside 257 */
  --nx:163.2; --ny:219; --nf:100.807;
  --cx:237.31; --cy:246.79; --cf:69.93;
  --tx:-40; --ty:173; --tw:336; --tf:35.09;
  z-index:2;
  --selx:-124.5;   /* canvas centre 844.5 -> 720 */
  --sts:1.2834;    /* 480 / 374: match the LARGE height, so it hides behind it */
  transition-delay:120ms;
}
.size--s{
  --bx:1091; --by:179.24; --bw:164.465; --bh:231.879;
  --rot:-0.08deg;
  --sx:6; --sy:8; --sb:4;
  --lx:-12; --ly:231.76; --lf:28;  /* 147 inside 164 */
  --nx:85; --ny:127.76; --nf:78.196;
  --cx:142.49; --cy:149.32; --cf:54.245;
  --tx:-61; --ty:106.76; --tw:286; --tf:31.09;
  z-index:1;
  --selx:-453.2;   /* canvas centre 1173.2 -> 720 */
  --sts:2.0700;    /* 480 / 231.879, height-matched like the medium */
  transition-delay:240ms;
}

/* --- copy --- */
.room__lead{
  position:absolute; left:calc(479 * var(--u)); top:calc((618 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-size:calc(46.8 * var(--u));
  line-height:normal; color:var(--cream); text-transform:capitalize; white-space:nowrap;
}
.room__sub{
  position:absolute; left:calc(479 * var(--u)); top:calc((681 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-size:calc(29.8 * var(--u));
  line-height:normal; color:var(--cream); text-transform:capitalize; white-space:nowrap;
}
.room__lead,.room__sub{
  opacity:0; transform:translateY(calc(24 * var(--u)));
  transition:opacity 700ms ease, transform 900ms var(--ease-out);
}
.room.is-copy-in .room__lead{opacity:1;transform:none}
.room.is-copy-in .room__sub {opacity:1;transform:none;transition-delay:110ms}

/* --- selected state, Figma "Desktop - 20" (90:835) ---
   The chosen size slides to the middle of the stage carrying its label, note
   and price with it, because they all live inside the button. The other two
   sizes clear out and the prompt copy is replaced by the hand-off. */
.room.is-chosen .size{pointer-events:none}
.room.is-chosen .size:not(.is-selected){opacity:0;transform:scale(.94) rotate(var(--rot,0deg))}
.room.is-art-in .size.is-selected{
  transform:translateX(calc(var(--selx) * var(--u))) rotate(var(--rot,0deg));
  transition:opacity 800ms ease, transform 1000ms var(--ease-io);
}
.room.is-chosen .room__lead,
.room.is-chosen .room__sub{opacity:0;transform:translateY(calc(-14 * var(--u)));transition-delay:0ms}

.room__done-title,.room__done-sub{
  position:absolute; color:var(--cream);
  font-family:"Moniqa Heading"; line-height:normal;
  text-transform:capitalize; white-space:nowrap;
  opacity:0; transform:scale(.86); transform-origin:50% 50%;
  transition:opacity 320ms ease, transform 560ms var(--ease-pop);
  pointer-events:none;
}
.room__done-title{
  left:calc(567.72 * var(--u)); top:calc((610 + var(--baseline-fix)) * var(--u));
  font-size:calc(65.9 * var(--u));
}
.room__done-sub{
  left:calc(456 * var(--u)); top:calc((686.36 + var(--baseline-fix)) * var(--u));
  font-size:calc(42 * var(--u));
}
.room__wa{
  position:absolute;
  left:calc(888.73 * var(--u)); top:calc(653.84 * var(--u));
  width:calc(96.16 * var(--u)); height:calc(96.16 * var(--u));
  display:block; opacity:0; transform:scale(.5);
  transform-origin:50% 50%; pointer-events:none;
  transition:opacity 300ms ease, transform 620ms var(--ease-pop);
}
.room__wa img{width:100%;height:100%}
.room.is-chosen .room__done-title{opacity:1;transform:none;transition-delay:420ms}
.room.is-chosen .room__done-sub  {opacity:1;transform:none;transition-delay:540ms}
.room.is-chosen .room__wa{opacity:1;transform:none;transition-delay:660ms;pointer-events:auto}
.room__wa:hover{transform:scale(1.08)}

/* ======================= PET PORTRAITS ======================= */
/* The lamp is a switch, not a dimmer. --on is 0 or 1 and nothing else: the
   beam's opacity, the lamp's own brightness and the --lit factor on the wood
   and the title all read from it, so they can only ever strike together.
   Registered so a keyframe can drive it; stepped so it never tweens. */
@property --on{ syntax:'<number>'; inherits:true; initial-value:0 }

/* A filament striking: catches, drops out, catches again, gutters once, holds.
   steps(1,end) is what makes each stop a hard cut — with any other timing
   function the browser tweens between them and it reads as a fade again. */
@keyframes lamp-strike{
  0%   { --on:0 }
  9%   { --on:1 }
  15%  { --on:0 }
  22%  { --on:1 }
  30%  { --on:0 }
  35%  { --on:1 }
  47%  { --on:0 }
  52%  { --on:1 }
  100% { --on:1 }
}
/* Figma frame 59:645 (1980x1080). Every number below is read straight off it.
   --baseline-fix is deliberately NOT redeclared here: scoped to one section it
   makes every calc() using it elsewhere invalid.

   The section plays in the dark and then gets lit — see the ordering notes on
   each block, and playPets() in main.js for the timings. */
.pets{
  position:relative; height:100vh; height:100dvh;
  background:var(--work-bg); overflow:hidden;
  --on:0;
  --lit:calc(.3 + .7 * var(--on));
}
.pets.is-lamp-on{ animation:lamp-strike 620ms steps(1,end) forwards }

/* --- the lamp (155:385-155:405) ---
   Twenty loose vectors in the frame with no group around them; lifted out of
   the frame's own SVG export, so both files carry FRAME coordinates in their
   viewBox and sit at the same box. The plate is screwed to the wall and never
   moves; the head swings on the joint where it meets the plate, (1872, 550),
   which is 89.3% 70.6% of that box. */
.pets__lamp-mount,.pets__lamp-head{
  position:absolute;
  left:calc(1630 * var(--u)); top:calc(372 * var(--u));
  width:calc(271 * var(--u)); height:calc(252 * var(--u));
  max-width:none; pointer-events:none;
}
.pets__lamp-head{
  transform-origin:89.3% 70.6%;
  transform:rotate(-62deg);          /* drooping, switched off */
  transition:transform 1500ms cubic-bezier(.34,1.15,.42,1);
}
.pets.is-lamp-aimed .pets__lamp-head{transform:rotate(0deg)}

/* The lamp reads as lit off the same switch — no transition, or it would
   smear across the flicker instead of snapping with it. */
.pets__lamp-head,.pets__lamp-mount{filter:brightness(calc(.55 + .45 * var(--on)))}

/* --- the beam (Group 2, 59:646) ---
   LIFTED from the frame's own SVG export, not reconstructed. The previous
   version was a 699.849x1421.464 shaft turned 79.08deg about (919.614,
   281.003), and it was wrong — it put the cone at 480.738/-524.825 over
   880.798x1610.52 where the frame has it at 116.277/-54.002 over
   1656.52x684.71, so the light spilled far lower and wider than the design and
   read as a general glow rather than a cone.

   Same technique as the wall lamp: export the frame as SVG, strip the embedded
   rasters (13.9 MB -> 303 KB), and the three blurred polygons are left in
   frame coordinates with identity transforms. So there is no rotation here at
   all any more. The box is the union of the three Gaussian filter regions the
   file reports, which is what the blur actually paints into.

   Off until the lamp is aimed. */
.pets__beam{
  position:absolute;
  left:calc(116.277 * var(--u)); top:calc(-54.002 * var(--u));
  width:calc(1656.52 * var(--u)); height:calc(684.71 * var(--u));
  max-width:none; pointer-events:none;
  opacity:var(--on);
}

/* --- the hanging slices --- */
.slices{position:absolute;inset:0;pointer-events:none}
.slice{
  position:absolute; max-width:none;
  left:calc(var(--sx) * var(--u)); top:calc(var(--sy) * var(--u));
  width:calc(var(--sw) * var(--u)); height:calc(var(--sh) * var(--u));
  object-fit:contain;
  /* the frame throws the shadow up and to the left now, away from the lamp */
  filter:drop-shadow(calc(-15 * var(--u)) calc(-9 * var(--u)) calc(4 * var(--u)) rgba(0,0,0,.25))
         saturate(calc(.45 + .55 * var(--lit))) brightness(var(--lit));
  opacity:0;
  /* They start stacked behind the main slice and slide out to their nails.
     Both offsets are centre-to-centre against the main slice: compensating x
     only, and measuring from box edges rather than centres, left them hanging
     below and beside it — which is what looked like ghost copies of the cat. */
  transform:translate(calc(var(--stx) * var(--u)), calc(var(--sty,0) * var(--u)))
            scale(var(--sts,1));
  transition:opacity 500ms ease, transform 1000ms var(--ease-out);
}
.pets.is-discs-in .slice{opacity:1;transform:none}
.slice--main{--sx:602.695; --sy:-3;      --sw:374.453;--sh:501.5;  --stx:0;--sts:1;z-index:5}
.slice--l1  {--sx:468.079; --sy:207.456; --sw:197.984;--sh:351.971;--stx:222.85;--sty:-135.69;--sts:1.4248;z-index:4}
.slice--r1  {--sx:923.119; --sy:207.456; --sw:204.768;--sh:364.032;--stx:-235.58;--sty:-141.72;--sts:1.3776;z-index:4}
.slice--l2  {--sx:269;     --sy:190.392; --sw:179.912;--sh:319.844;--stx:430.97;--sty:-102.56;--sts:1.5680;z-index:3}
.slice--r2  {--sx:1152.535;--sy:191.340; --sw:177.424;--sh:315.420;--stx:-451.33;--sty:-101.30;--sts:1.5899;z-index:3}
.pets.is-discs-in .slice--l1{transition-delay:90ms}
.pets.is-discs-in .slice--r1{transition-delay:150ms}
.pets.is-discs-in .slice--l2{transition-delay:230ms}
.pets.is-discs-in .slice--r2{transition-delay:290ms}

/* --- the title, which is up from the start but in the dark --- */
.pets__eyebrow{
  position:absolute;
  left:calc(305 * var(--u)); top:calc((79 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-size:calc(80 * var(--u));
  line-height:normal; font-weight:400; color:var(--cream);
  text-transform:uppercase; white-space:nowrap;
}
.pets__eyebrow-script{
  position:absolute;
  left:calc(381 * var(--u)); top:calc(116 * var(--u));
  font-family:"Script"; font-size:calc(49.803 * var(--u));
  line-height:normal; color:var(--script-red); text-transform:capitalize;
  letter-spacing:calc(-1.4941 * var(--u)); white-space:nowrap;
  text-shadow:calc(-5 * var(--u)) 0 calc(5 * var(--u)) rgba(0,0,0,.5);
}

/* Being "in the dark" belongs to the objects, not to the section: the wall
   stays exactly as it always is, and only the wood slices and the title are
   unlit until the lamp finds them. --lit is defined on .pets from --on (see
   the top of this section) so it snaps with the strike; the colour drains
   along with the brightness because that is what dim light actually does —
   brightness alone reads as a grey wash over full-saturation wood. */
.pets__eyebrow,.pets__eyebrow-script{
  filter:saturate(calc(.45 + .55 * var(--lit))) brightness(var(--lit));
}

/* --- copy --- */
.pets__more,.pets__title,.pets__sub,.pets__memo,.hang__note{
  position:absolute; color:var(--cream); white-space:nowrap;
}
.pets__more{
  left:calc(939 * var(--u)); top:calc((152 - 1.5) * var(--u));   /* the condensed italic sits 3px lower than the other cuts */
  font-family:"Moniqa Cond Italic"; font-size:calc(36.84 * var(--u)); line-height:normal;
  opacity:0; transform:translateY(calc(10 * var(--u)));
  transition:opacity 600ms ease, transform 800ms var(--ease-out);
}
.pets.is-more-in .pets__more{opacity:1;transform:none}

.pets__title{
  left:calc(131 * var(--u)); top:calc((623 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-size:calc(50 * var(--u));
  line-height:normal; text-transform:uppercase; font-weight:400;
  opacity:0; transform:translateY(calc(16 * var(--u)));
  transition:opacity 700ms ease, transform 900ms var(--ease-out);
}
.pets.is-title-in .pets__title{opacity:1;transform:none}

.pets__nope{
  position:absolute;
  left:calc(562 * var(--u)); top:calc(646 * var(--u));
  font-family:"Script"; font-size:calc(30 * var(--u));
  letter-spacing:calc(-0.9 * var(--u));
  line-height:normal; color:var(--script-red); text-transform:capitalize;
  white-space:nowrap;
  --draw:0; clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 700ms var(--ease-io);
}
.pets.is-nope-in .pets__nope{--draw:1}

.pets__sub{
  left:calc(131 * var(--u));
  font-family:"Moniqa Light Para"; font-size:calc(49.997 * var(--u));
  line-height:normal; font-weight:300;
  opacity:0; transform:translateY(calc(14 * var(--u)));
  transition:opacity 600ms ease, transform 800ms var(--ease-out);
}
.pets__sub--1{top:calc((687 + var(--baseline-fix)) * var(--u))}
.pets__sub--2{top:calc((748.071 + var(--baseline-fix)) * var(--u))}
.pets.is-sub-in .pets__sub--1{opacity:1;transform:none}
.pets.is-sub-in .pets__sub--2{opacity:1;transform:none;transition-delay:120ms}

/* --- the hanging blank: wood, price and CTA swing together --- */
/* The box spans the whole cluster so the pivot can sit on the nail at the top
   of the string rather than on any one child. The cluster moved as one between
   frame versions, by (-118, -16), so every offset inside it is unchanged: the
   box still runs from the CTA's left edge (1118) to the note's right edge
   (1631.258), and the nail is the wood's centre (1364.962, ~496) — 48.12%
   across and 1.875% down. */
.hang{
  position:absolute;
  left:calc(1118 * var(--u)); top:calc(487 * var(--u));
  width:calc(513.258 * var(--u)); height:calc(479.922 * var(--u));
  transform-origin:48.12% 1.875%;
  opacity:0; transform:scale(.9);
  transition:opacity 500ms ease, transform 700ms var(--ease-pop);
}
@keyframes hang-swing{
  0%,100%{transform:rotate(-1.25deg)}
  50%    {transform:rotate(1.25deg)}
}
.pets.is-hang-in .hang{
  opacity:1; transform:none;
  animation:hang-swing 5.2s ease-in-out 700ms infinite;
}
.hang__wood{
  position:absolute;
  left:calc((1190.34 - 1118) * var(--u)); top:0;
  width:calc(349.244 * var(--u)); height:calc(620.878 * var(--u));
  max-width:none; object-fit:contain;
  filter:drop-shadow(calc(17.297 * var(--u)) calc(21.621 * var(--u)) calc(23.784 * var(--u)) rgba(0,0,0,.25));
}
.hang__price{display:contents}
.hang__num,.hang__cur{
  position:absolute; font-family:"Script"; line-height:normal;
  color:var(--script-red); white-space:nowrap;
}
.hang__num{
  left:calc((1312 - 1118) * var(--u)); top:calc((787 - 487) * var(--u));
  font-size:calc(70.201 * var(--u)); letter-spacing:calc(-2.106 * var(--u));
}
.hang__cur{
  left:calc((1378 - 1118) * var(--u)); top:calc((807 - 487) * var(--u));
  font-size:calc(51.207 * var(--u));
}

/* An <a> now, not a <button>: it opens the WhatsApp thread. The reset below
   is what a button gave for free and a link does not. */
.hang__cta{
  position:absolute; left:0; top:calc((869 - 487) * var(--u));
  background:none; border:0; padding:0; cursor:pointer;
  display:inline-block; text-decoration:none;
  font-family:"Script"; font-size:calc(70.201 * var(--u));
  letter-spacing:calc(-2.106 * var(--u));
  line-height:normal; color:var(--script-red); text-transform:capitalize;
  white-space:nowrap;
}
/* The underline is a drawn stroke rather than a rule: it carries the same
   slight rise the script has, and sweeps on from the left. */
.hang__cta::after{
  content:""; position:absolute; left:0; right:calc(6 * var(--u));
  bottom:calc(4 * var(--u)); height:calc(14 * var(--u));
  background:no-repeat left center/100% 100% url("assets/img/cta-underline.svg");
  /* Draws on from the left, but leaves by fading at full length: the transform
     reset is delayed until after the fade, so it never shrinks back. */
  transform:scaleX(0); transform-origin:0 50%; opacity:0;
  transition:opacity 300ms ease, transform 0ms linear 300ms;
}
.hang__cta:hover::after,
.hang__cta:focus-visible::after{
  transform:scaleX(1); opacity:1;
  transition:transform 420ms var(--ease-out), opacity 170ms ease;
}

.hang__note{
  left:calc((1479 - 1118) * var(--u)); top:calc((947 - 487 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Heading"; font-size:calc(22.711 * var(--u));
  line-height:normal; text-transform:uppercase;
}

/* --- the memo, swiped in on its brush --- */
.pets__brush{
  position:absolute;
  left:calc(89 * var(--u)); top:calc(822 * var(--u));
  width:calc(792 * var(--u)); height:calc(251 * var(--u));
  max-width:none; pointer-events:none;
  /* A hard clip edge reads as a wipe passing over the stroke. Revealing it
     under a wide soft ramp instead lets the stroke's own ragged edges and
     spatter do the work, so it reads as pigment being laid down. The angle is
     off-square so the edge does not travel as a vertical line. */
  --draw:0;
  -webkit-mask-image:linear-gradient(93deg,
    #000 0 calc(var(--draw) * 132% - 32%),
    transparent calc(var(--draw) * 132%) 100%);
          mask-image:linear-gradient(93deg,
    #000 0 calc(var(--draw) * 132% - 32%),
    transparent calc(var(--draw) * 132%) 100%);
  -webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
  transition:--draw 900ms var(--ease-io);
}
.pets.is-note-in .pets__brush{--draw:1}
.pets__memo{
  left:calc(131.24 * var(--u)); top:calc((913.08 + var(--baseline-fix)) * var(--u));
  width:calc(610.768 * var(--u));
  font-family:"Moniqa Bold Para"; font-size:calc(32.885 * var(--u));
  line-height:normal; font-weight:700; white-space:normal;
  opacity:0; transition:opacity 420ms ease;
}
.pets.is-note-in .pets__memo{opacity:1;transition-delay:380ms}

@media (prefers-reduced-motion: reduce){
  /* no swing and no darkness: it simply arrives lit */
  .pets__beam,.slice,.pets__more,.pets__title,.pets__sub,.hang,.pets__memo,
  .pets__eyebrow,.pets__eyebrow-script{
    opacity:1!important;transform:none!important;transition:none!important;animation:none!important;
  }
  .pets__lamp-head{transform:none!important;transition:none!important}
  .pets__lamp-head,.pets__lamp-mount{filter:none!important}
  .pets{--on:1}
  .pets.is-lamp-on{animation:none}
  .pets__nope{--draw:1;transition:none}
  .pets__brush{--draw:1;transition:none}
}


/* =========================== FOOTER =========================== */
/* Figma frame 85:784 (1980x1080). Every number below is read straight off it.

   Font note: the frame calls for Moniqa Black Heading and Moniqa Black
   Condensed Italic Heading. Neither cut is in assets/fonts — the closest we
   hold are the Black NARROW Heading and the (regular-weight) Condensed Italic
   Heading, which is what is used here. Everything else is an exact match. */
/* The section takes the design's OWN aspect rather than being forced to 100vh.
   That is what lets the photo reach every edge: at 1980:1080 the stage fills
   the section exactly, so the background — placed at the frame's numbers and
   already overflowing them — covers it corner to corner with nothing cropped
   and nothing faked in behind.

   Forcing 100vh cannot do that. A 1.83 composition in a viewport of some other
   aspect either letterboxes (bars) or scales up to cover (and then loses the
   nav off the top and the legal line off the bottom). Letting the footer be
   its own height sidesteps both: on a taller window you simply see a little of
   the section above it, which is ordinary for a footer.

   container-type gives --u the section's real content width, so a scrollbar
   cannot shift the composition the way 100vw would. */
.foot{
  position:relative; width:100%;
  aspect-ratio:1980 / 1080;
  container-type:inline-size;
  background:var(--work-bg); overflow:hidden;
}
.stage--foot{ --u: calc(1cqw / 19.8); inset:0; width:auto; height:auto; }   /* 100cqw = the section's width, so 1980 design px == that width */

/* Placed at the frame's own numbers, INSIDE the stage — so it scales on the
   same --u the type does and the composition holds together at every viewport.
   Covering the viewport instead (object-fit:cover) matched the design only at
   1980:1080: at 1440x900 the wordmark drifted down off the artwork, and at
   1600x740 it landed on top of it, because the photo was following the screen
   while the type followed the stage.

   It overflows the stage on purpose — (-4, -331) at 1984x1411 on a 1080-tall
   board is how the frame crops it — and .foot's own overflow:hidden clips it. */
.foot__bg{
  position:absolute;
  left:calc(-4 * var(--u)); top:calc(-331 * var(--u));
  width:calc(1984 * var(--u)); height:calc(1411 * var(--u));
  max-width:none;
}

/* --- nav --- */
/* Figma centres each of these on its own x, so left is the CENTRE and the
   label is translated back by half its own width. */
.foot__nav-link{
  position:absolute; top:calc((122 + var(--baseline-fix)) * var(--u));
  transform:translateX(-50%);
  font-family:"Moniqa Narrow Black"; font-size:calc(25.09 * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase;
  text-align:center; white-space:nowrap; text-decoration:none;
  opacity:.92; transition:opacity 220ms ease;
}
/* a stroke drawn under the label, in from the left, like the pets CTA */
.foot__nav-link::after{
  content:""; position:absolute;
  left:0; right:0; bottom:calc(-7 * var(--u)); height:calc(2 * var(--u));
  background:var(--cream);
  transform:scaleX(0); transform-origin:0 50%;
  transition:transform 300ms var(--ease-out);
}
.foot__nav-link:hover,.foot__nav-link:focus-visible{opacity:1}
.foot__nav-link:hover::after,.foot__nav-link:focus-visible::after{transform:scaleX(1)}
.foot__nav-link--home  {left:calc(200.5 * var(--u))}
.foot__nav-link--custom{left:calc(364.5 * var(--u))}
.foot__nav-link--orig  {left:calc(565.5 * var(--u))}
.foot__nav-link--pets  {left:calc(750.5 * var(--u))}

/* --- wordmark --- */
.foot__wordmark{
  position:absolute;
  left:calc(163 * var(--u)); top:calc((180 + var(--baseline-fix)) * var(--u));
  width:calc(731 * var(--u));
  font-family:"Moniqa Display"; font-weight:300;
  font-size:calc(230.093 * var(--u)); line-height:normal;
  color:var(--cream); white-space:nowrap;
}
/* Figma puts this at 736, which leaves its last letter 4.07 short of the "n"
   in Aleen. Nudged so the two ink edges line up exactly, measured off
   actualBoundingBoxRight — the advance width is 14px wider than the ink and
   aligning on that would overshoot. */
.foot__where{
  position:absolute;
  left:calc(740.07 * var(--u)); top:calc((375 + var(--baseline-fix)) * var(--u));
  font-family:"Moniqa Cond Italic"; font-size:calc(36.353 * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase;
  white-space:nowrap;
}

/* --- social ---
   "Floating in the air": each one drifts on its own phase so they never line
   up into a single pulse. */
/* inset:0 makes this a full-stage box. It sits after the nav in the DOM, so
   with pointer-events left on it swallowed every nav click. */
.foot__social{position:absolute; inset:0; list-style:none; margin:0; padding:0; pointer-events:none}
.foot__social-link{
  position:absolute; display:block; pointer-events:auto;
  animation:foot-float 5.4s ease-in-out infinite;
  transition:transform 260ms var(--ease-out), filter 260ms ease;
}
.foot__social-link img{width:100%; height:100%; display:block}
.foot__social-link--ig{
  left:calc(1551 * var(--u)); top:calc(742 * var(--u));
  width:calc(123 * var(--u)); height:calc(123 * var(--u));
  filter:drop-shadow(0 calc(1.902 * var(--u)) calc(1.902 * var(--u)) rgba(0,0,0,.5));
  animation-delay:-0.4s;
}
.foot__social-link--fb{
  left:calc(1690 * var(--u)); top:calc(742 * var(--u));
  width:calc(105 * var(--u)); height:calc(105 * var(--u));
  animation-delay:-2.1s;
}
.foot__social-link--tt{
  left:calc(1635 * var(--u)); top:calc(820 * var(--u));
  width:calc(111 * var(--u)); height:calc(111 * var(--u));
  animation-delay:-3.6s;
}
@keyframes foot-float{
  0%,100%{transform:translateY(calc(-5 * var(--u))) rotate(-1.2deg)}
  50%    {transform:translateY(calc(5 * var(--u)))  rotate(1.2deg)}
}
.foot__social-link:hover,.foot__social-link:focus-visible{
  animation-play-state:paused;
  transform:scale(1.09);
  filter:drop-shadow(0 calc(4 * var(--u)) calc(10 * var(--u)) rgba(0,0,0,.55));
}

/* --- for enquiries ---
   The stroke behind the block is not there at rest; hovering or focusing the
   block wipes it on left to right, the same drawn-stroke idea the pets CTA
   underline uses. */
/* The block is exactly the stroke's own box (1433,934 at 514x63.38), which is
   what everything in it sits inside — it used to be inset:0 over the whole
   stage, so hovering anywhere in the footer wiped the stroke on. Offsets below
   are written against the frame's numbers so they still read off the design. */
.foot__enquiries{
  position:absolute;
  left:calc(1433 * var(--u)); top:calc(934 * var(--u));
  width:calc(514 * var(--u)); height:calc(63.38 * var(--u));
}
.foot__enq-brush{
  position:absolute; left:0; top:0;
  width:100%; height:100%;
  max-width:none; pointer-events:none;
  --draw:0;
  clip-path:inset(0 calc(102% - var(--draw) * 104%) 0 0);
  transition:--draw 620ms var(--ease-io);
}
.foot__enquiries:hover .foot__enq-brush,
.foot__enquiries:focus-within .foot__enq-brush{--draw:1}

.foot__enq-label{
  position:absolute;
  left:calc((1602.63 - 1433) * var(--u)); top:calc((949.07 - 934 + var(--baseline-fix)) * var(--u));
  transform:translateX(-50%);
  font-family:"Moniqa Narrow Black"; font-weight:900;  /* the only cut we ship */
  font-size:calc(31.503 * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase;
  text-align:center; white-space:nowrap;
}
/* Rectangle 1 (88:804) is the pill; the label sits inside it at 1730.67 */
.foot__contact{
  position:absolute;
  left:calc((1700.569 - 1433) * var(--u)); top:calc((944.048 - 934) * var(--u));
  width:calc(124.303 * var(--u)); height:calc(37.874 * var(--u));
  border-radius:calc(48.556 * var(--u));
  background:var(--pill); color:var(--cream); text-decoration:none;
  font-family:"Moniqa Cond Black"; font-size:calc(34.393 * var(--u));
  line-height:1; text-transform:capitalize; white-space:nowrap;
  display:flex; align-items:center; justify-content:center;
  padding-bottom:calc(3 * var(--u));   /* the cut sits high in its line box */
  transition:transform 240ms var(--ease-out), filter 240ms ease;
}
.foot__contact:hover,.foot__contact:focus-visible{
  transform:scale(1.05);
  filter:brightness(1.12);
}

/* --- legal --- */
.foot__legal{
  position:absolute; left:calc(48 * var(--u));
  font-family:"Moniqa Narrow Black"; font-size:calc(14 * var(--u));
  line-height:normal; color:var(--cream); text-transform:uppercase;
  white-space:nowrap; opacity:.85;
}
.foot__legal--1{top:calc((1013 + var(--baseline-fix)) * var(--u))}
.foot__legal--2{top:calc((1028 + var(--baseline-fix)) * var(--u))}

@media (prefers-reduced-motion: reduce){
  .foot__social-link{animation:none}
  .foot__enq-brush{--draw:1;transition:none}
}


/* =========================== A11Y =========================== */
@media (prefers-reduced-motion: reduce){
  .hero__title .ltr,.reveal-up,
  .room__scene,.room__lead,.room__sub{
    opacity:1!important;transform:none!important;filter:none!important;transition:none!important;animation:none!important;
  }
  /* the sizes keep their drop-shadow and the frame's rotation */
  .size{opacity:1!important;transform:rotate(var(--rot,0deg))!important;transition:none!important}
  .size__art{transform:none!important;transition:none!important}
  /* furniture and sizing text arrive already in place */
  .fx-piece,.size__label{opacity:1!important;transform:none!important;transition:none!important}
  #fx-plant-foliage{animation:none!important}
  .room__done-title,.room__done-sub,.room__wa{transition:none!important}
  .room.is-chosen .room__done-title,
  .room.is-chosen .room__done-sub,
  .room.is-chosen .room__wa{opacity:1!important;transform:none!important}
  /* a chosen size still travels to the middle, just without the glide */
  .room.is-art-in .size.is-selected{
    transform:translateX(calc(var(--selx) * var(--u))) rotate(var(--rot,0deg))!important;
  }
  .size__note{transition:none}
}
