/* ─────────────────────────────────────────────────────────────────────────
   Matt Smith-Wood — component system
   Requires tokens.css. Prefix: msw-

   Replaces the six page-scoped kt- classes in kitchen-draft.html, which could
   only ever style one page. Nothing here hard-codes a value; if a number
   appears below that is not a token, it is a bug.

   RETIRED — do not reintroduce:
     .msw-stroke   the hand-drawn rule. Rejected 2026-08-11.
     .msw-gesture  the Rock Salt hand script. Retired 2026-08-12 — Matt's real
                   signature was found and vectorised, so a hand font is a
                   worse answer to a question that is now closed.

   THE MARKS — both real, both vector, both currentColor:
     assets/msw-monogram.svg   the m. Recolour with `color`. Works to 32px.
     assets/msw-signature.svg  his actual handwriting. Never below 140px wide:
                               it is a sign-off, not a nav mark.
   Never a text "m.", never a redrawn approximation, never a script font
   standing in for the signature. See .msw-mark / .msw-signoff below.
   ───────────────────────────────────────────────────────────────────────── */

/* ── Grounds ─────────────────────────────────────────────────────────
   Each ground sets its OWN ink and its own hairline. Never hand-pick a text
   colour on a ground - that is how the terracotta 3.4:1 failure gets in.

   Intended page sequence:
     image -> paper -> BLACK -> paper -> BLACK -> paper -> accent(once) -> black
   Black bands are the kitchens page's own language, and that page is the
   artefact Matt has approved. Never two darks adjacent - paper separates
   them. Accent (brick) appears once, late.                                */
.msw-ground        { background: var(--ground);        color: var(--ink);
                     --hairline-now: var(--hairline); }
.msw-ground--paper { background: var(--white);         color: var(--ink);
                     --hairline-now: var(--hairline); }
.msw-ground--quiet { background: var(--ground-quiet);  color: var(--ink);
                     --hairline-now: var(--hairline); }
/* The quiet breath. Camel, not terracotta - terracotta as a large panel
   reads washed out and only carries black ink at 6.2:1, weak at scale.
   It is a detail colour now, not a ground. */
.msw-ground--panel { background: var(--ground-panel);  color: var(--ink-on-panel);
                     --hairline-now: var(--hairline); }
.msw-ground--dark  { background: var(--ground-invert); color: var(--ink-invert);
                     --hairline-now: var(--hairline-invert); }
.msw-ground--accent{ background: var(--ground-accent); color: var(--ink-on-accent);
                     --hairline-now: var(--hairline-invert); }
/* The warm dark. Use where black would read cold - the ranges, a quote.
   Never place it directly against black: at 1.97:1 they are distinct, but
   only just, and touching them wastes the difference. */
.msw-ground--warm  { background: var(--ground-warm);   color: var(--ink-invert);
                     --hairline-now: var(--hairline-invert); }

/* Terracotta's remaining job: a rule, a small mark, a detail. Never a
   field of it, and never behind body copy. */
.msw-rule--accent { border-color: var(--rule-accent); }

/* Scrim over hero photography. NEUTRAL black, top and bottom. A warm or
   red-tinted scrim pushes every photograph towards the brand colour and
   fights the imagery instead of serving it. */
.msw-scrim { position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(180deg,
      var(--scrim-top) 0%, rgba(0,0,0,0) 34%,
      rgba(0,0,0,0) 52%, var(--scrim-bottom) 100%); }

/* ── Dark textured ───────────────────────────────────────────────────
   Flat black reads digital. A fine grain over it reads printed, which is the
   single cheapest way to make a dark section feel physical. Scran Doh does
   this and it does more work than anything else on that page.

   The noise is an inline SVG data URI, so there is no image request.        */
.msw-ground--textured {
  background-color: var(--ground-invert);
  color: var(--ink-invert);
  position: relative;
  isolation: isolate;
}
.msw-ground--textured::before {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' seed='7'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.055 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
  background-size: 180px 180px;
}
/* A heavier grain for hero-scale sections where the texture should be felt. */
.msw-ground--textured.is-coarse::before { opacity: 1.8; background-size: 240px 240px; }

/* ── Layout ──────────────────────────────────────────────────────────── */
.msw-section      { padding: var(--s-5) var(--gutter); }
.msw-section--tight { padding-block: var(--s-4); }
.msw-inner        { max-width: var(--container); margin-inline: auto; }
.msw-bleed        { margin-inline: calc(var(--gutter) * -1); }  /* escape the gutter */

/* ── Type ────────────────────────────────────────────────────────────── */
.msw-hero    { font-family: var(--display); font-weight: var(--display-weight);
               font-size: var(--t-hero); line-height: var(--lh-hero);
               letter-spacing: var(--track-hero); text-transform: uppercase;
               text-wrap: balance; }
.msw-display { font-family: var(--display); font-weight: var(--display-weight);
               font-size: var(--t-display); line-height: var(--lh-display);
               letter-spacing: var(--track-display); text-transform: uppercase; }
.msw-h2      { font-family: var(--display); font-weight: 600; font-size: var(--t-h2);
               line-height: var(--lh-h2); text-transform: uppercase;
               letter-spacing: -.01em; }
.msw-label   { font-family: var(--body); font-size: var(--t-label); font-weight: 500;
               letter-spacing: var(--track-label); text-transform: uppercase; }
/* Tiny, very wide caps set against the hero. The contrast between 11px at
   0.32em and 184px at -0.035em IS the luxury signal - era's whole trick.  */
.msw-label--wide { letter-spacing: var(--track-wide); font-size: var(--t-label); }
.msw-micro   { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
               letter-spacing: var(--track-wide); text-transform: uppercase; }
.msw-body    { font-family: var(--body); font-weight: var(--body-weight);
               font-size: var(--t-body); line-height: var(--lh-body);
               max-width: var(--measure); }

/* ── Block type ──────────────────────────────────────────────────────
   Display type as a solid mass rather than a line of words. Tight leading,
   negative tracking, uppercase, and allowed to run to the edge.            */
.msw-block      { font-family: var(--display); font-weight: 700; text-transform: uppercase;
                  font-size: clamp(3rem, 12vw, 11rem); line-height: .82;
                  letter-spacing: -.035em; margin: 0; }
.msw-block--fit { font-size: clamp(3rem, 15.5vw, 15rem); }   /* one word, edge to edge */
.msw-block--out { margin-inline-start: -.06em; }             /* optical flush-left */
.msw-block em   { font-style: normal; display: block; color: var(--ink-invert-mute); }
.msw-ground--paper .msw-block em,
.msw-ground .msw-block em { color: rgba(0,0,0,.38); }

/* Stacked block — each line its own row, alternating alignment. */
.msw-blockstack            { display: grid; gap: 0; }
.msw-blockstack > *        { margin: 0; }
.msw-blockstack > :nth-child(even) { text-align: right; }

/* ── Rules · the line system ─────────────────────────────────────────
   Replaces the hand-drawn stroke. Straight, structural, and derived from the
   Frame's 5px so the whole system shares one weight logic.                  */
.msw-rule            { border: 0; border-top: var(--hairline-w) solid var(--hairline);
                       margin: var(--s-3) 0; }
.msw-rule--heavy     { border-top-width: var(--frame-w); border-top-color: var(--ink-strong); }
.msw-rule--bleed     { margin-inline: calc(var(--gutter) * -1); }
.msw-ground--dark .msw-rule,
.msw-ground--textured .msw-rule { border-top-color: var(--hairline-invert); }
.msw-ground--dark .msw-rule--heavy,
.msw-ground--textured .msw-rule--heavy { border-top-color: var(--ink-invert); }

/* Labelled rule — a line with a word sitting on it. Section marker. */
.msw-rule-label { display: grid; grid-template-columns: auto 1fr; gap: var(--s-2);
                  align-items: center; margin: var(--s-4) 0 var(--s-3); }
.msw-rule-label::after { content: ""; border-top: var(--hairline-w) solid var(--hairline); }
.msw-ground--dark .msw-rule-label::after,
.msw-ground--textured .msw-rule-label::after { border-top-color: var(--hairline-invert); }

/* Vertical rule — separates columns without a box. */
.msw-vrule { border-left: var(--hairline-w) solid var(--hairline); padding-left: var(--s-3); }
.msw-ground--dark .msw-vrule,
.msw-ground--textured .msw-vrule { border-left-color: var(--hairline-invert); }

/* Ruled list — specification rows divided by lines, no table markup. */
.msw-ruled      { list-style: none; padding: 0; margin: 0; }
.msw-ruled > li { display: grid; grid-template-columns: 14ch 1fr; gap: var(--s-2);
                  padding: var(--s-2) 0; border-top: var(--hairline-w) solid var(--hairline);
                  font-size: .95rem; }
.msw-ruled > li:last-child { border-bottom: var(--hairline-w) solid var(--hairline); }
.msw-ruled dt, .msw-ruled .k { font-size: var(--t-label); letter-spacing: var(--track-label);
                               text-transform: uppercase; font-weight: 600; }
.msw-ground--dark .msw-ruled > li,
.msw-ground--textured .msw-ruled > li { border-top-color: var(--hairline-invert); }
.msw-ground--dark .msw-ruled > li:last-child,
.msw-ground--textured .msw-ruled > li:last-child { border-bottom-color: var(--hairline-invert); }
@media (max-width: 620px) { .msw-ruled > li { grid-template-columns: 1fr; gap: .25rem; } }

/* ── Frame · the 5px primitive ───────────────────────────────────────── */
.msw-frame         { border: var(--frame-w) solid var(--ink-strong);
                     border-radius: var(--radius); padding: var(--frame-pad); }
.msw-frame--invert { border-color: var(--ink-invert); color: var(--ink-invert); }
.msw-frame--hair   { border-width: var(--hairline-w); border-color: var(--hairline);
                     padding: var(--s-3); }
.msw-frame--hair.msw-frame--invert { border-color: var(--hairline-invert); }

/* ── The mark · always the real asset ──────────────────────────────────
   FIXED v1.0: these are painted with a CSS mask, not <img>.

   An <img> cannot inherit currentColor - the SVG resolves it against its own
   document, which means BLACK. On the oxblood ground that is #000 on #2c0603,
   about 1.2:1, i.e. invisible. That bug shipped in the first v1.0 build and
   is exactly what the note under THE MARKS warns about.

   Masking paints the shape in currentColor from an external file, so one
   asset serves every ground and the 75KB signature is not inlined per use.

   Markup:  <span class="msw-mark" role="img" aria-label="Matt Smith-Wood"></span>
   Never:   <img src="msw-monogram.svg">                                   */
.msw-mark {
  display: inline-block; height: 2rem; aspect-ratio: 129.82 / 180.86;
  background-color: currentColor;
  -webkit-mask: url("assets/msw-monogram.svg") no-repeat center / contain;
          mask: url("assets/msw-monogram.svg") no-repeat center / contain;
}
.msw-mark--lg  { height: clamp(5rem, 14vw, 11rem); }
.msw-mark--xl  { height: clamp(8rem, 26vw, 22rem); }
.msw-mark--fade{ opacity: .10; }        /* watermark behind content */

/* No mask support: fall back to the file as a background. It renders in its
   own colours rather than the ground's, which is wrong but visible - and
   strictly better than an invisible mark. */
@supports not ((-webkit-mask: url("a.svg")) or (mask: url("a.svg"))) {
  .msw-mark { background: url("assets/msw-monogram.svg") no-repeat center / contain; }
}

/* ── Sketch / photograph pair · CASE STUDY PAGES ONLY ─────────────────
   His own hand-drawn perspective of a room, beside the photograph of that
   room built. Added 2026-08-12 after finding the Lisbon sketches.

   SCOPE, and this is the important part: this is a PROJECT-PAGE component.
   It belongs inside a case study, showing that project's drawings against
   that project's photographs. It is NOT a brand element and it does not
   set the register of the wider site.

   The trap it exists to avoid: a project's scheme is the CLIENT'S taste,
   not Matt's identity. Lisbon is yellow, ochre and terracotta because that
   is what Lisbon wanted. Let one project's palette leak up into the site
   chrome and every future project is shown wearing a previous client's
   colours - which makes the work look like a house style instead of a
   response to a brief. Interior designers live or die on that distinction.

   So: brand chrome comes from the brand artefacts. Project colour stays
   inside the project's own band and stops at its edge.

   Rules:
     - Same room, same angle. A mismatched pair reads as decoration and
       destroys the entire point. If the angle does not match, do not pair.
     - Sketch LEFT, photograph RIGHT. Drawing precedes building; the reading
       order should say so.
     - The sketch sits on WHITE and nothing else - never on a brand ground.
       It is pencil and marker on paper, and a coloured ground both turns it
       into a sticker and drags the project's colour into the chrome.
     - Never crop the sketch to match the photo's aspect. Let it float with
       its own whitespace - that whitespace is what says "drawing".
     - Do not promise this on projects that have no sketches. Three exist,
       all Lisbon. Whether Matt sketches every job is unverified.          */
.msw-pair        { display: grid; grid-template-columns: 1fr 1fr;
                   align-items: center; gap: var(--s-4); }
.msw-pair__sketch{ background: var(--white); padding: var(--s-3); }
.msw-pair__sketch img { display: block; width: 100%; height: auto;
                        mix-blend-mode: multiply; }  /* kills the paper white */
.msw-pair__built img  { display: block; width: 100%; height: auto; }
.msw-pair__label { font-family: var(--body); font-size: var(--t-label);
                   letter-spacing: var(--track-wide); text-transform: uppercase;
                   color: var(--ink); padding-top: var(--s-2);
                   border-top: var(--hairline-w) solid var(--hairline); }

/* Overlap variant: the sketch breaks into the photograph's edge, so the two
   read as one object rather than two cards. Use once per page at most. */
.msw-pair--overlap { grid-template-columns: 1fr 1fr; gap: 0; }
.msw-pair--overlap .msw-pair__sketch { position: relative; z-index: 2;
                                       margin-right: calc(-1 * var(--s-5)); }

@media (max-width: 720px) {
  .msw-pair, .msw-pair--overlap { grid-template-columns: 1fr; gap: var(--s-3); }
  .msw-pair--overlap .msw-pair__sketch { margin-right: 0; }
}

/* ── The signature · his real handwriting, vector ─────────────────────
   Painted with a CSS mask in currentColor - see .msw-mark above for why an
   <img> is wrong here. One asset serves light and dark grounds.

   It is a SIGN-OFF, not a logo: the close of a page, the end of the story
   section, beside the enquiry form. Once per page, at the bottom of it.
   Below ~140px the strokes fill in and it reads as a smudge — hence the
   min-width, which is a legibility floor and not a style choice.          */
.msw-signoff {
  display: block; width: clamp(140px, 22vw, 260px); aspect-ratio: 3702 / 1146;
  background-color: currentColor;
  -webkit-mask: url("assets/msw-signature.svg") no-repeat center / contain;
          mask: url("assets/msw-signature.svg") no-repeat center / contain;
}
.msw-signoff--lg  { width: clamp(200px, 34vw, 420px); }
@supports not ((-webkit-mask: url("a.svg")) or (mask: url("a.svg"))) {
  .msw-signoff { background: url("assets/msw-signature.svg") no-repeat center / contain; }
}

/* -- THE FULL LOGO * monogram over signature ------------------------
   The whole lockup as it exists on his own artwork, and it is now MEASURED
   from that artwork rather than spaced by eye, which is exactly what the
   previous version of this rule did while claiming otherwise.

   Source: Logos/WhatsApp Image 2025-11-23 at 17.33.30. Ink bounding boxes
   from that file, expressed in units of the monogram's own height:
       signature width   1.693
       gap between them  0.078
   Both marks were checked against the vectors we ship before the ratios
   were trusted: monogram 0.7147 wide against the SVG's 0.7178, signature
   3.230 against the SVG's 3.230. Same artwork, so the proportions carry.

   ONE knob, --lock-m, the monogram's height. Everything else derives from
   it, so the two halves cannot drift apart at some breakpoint nobody
   thought to check. The old rule clamped them independently, and at a 96px
   mark the signature came out 300px against a correct 162px - very nearly
   double. It was never wrong on any screen anyone looked at, because it
   was never used on any page.

   Its FLOOR is set by the signature, not by the mark. .msw-signoff does
   not read below 140px wide, and 140 / 1.693 = 83px, hence 5.2rem. Size a
   lockup off the monogram alone and you get a legible m sitting above an
   illegible smudge.

   The artwork's two ink centres differ by 5.6% of the signature's width.
   NOT reproduced: at footer scale that is a 15px drift, which reads as a
   mistake to everyone who cannot see the source file. Centred, with the
   measurement recorded here so it is not lost if this is ever revisited. */
.msw-lockup { --lock-m: clamp(5.2rem, 13vw, 10rem);
              display: grid; justify-items: center; color: inherit;
              gap: calc(var(--lock-m) * .078); }
.msw-lockup .msw-mark    { height: var(--lock-m); }
.msw-lockup .msw-signoff { width: calc(var(--lock-m) * 1.693); }

/* Mark used as a section divider rather than a logo. */
.msw-markdivide { display: grid; grid-template-columns: 1fr auto 1fr; gap: var(--s-3);
                  align-items: center; margin: var(--s-5) 0; }
.msw-markdivide::before, .msw-markdivide::after {
  content: ""; border-top: var(--hairline-w) solid var(--hairline); }
.msw-ground--dark .msw-markdivide::before, .msw-ground--dark .msw-markdivide::after,
.msw-ground--textured .msw-markdivide::before, .msw-ground--textured .msw-markdivide::after {
  border-top-color: var(--hairline-invert); }

/* ── Full-width image ────────────────────────────────────────────────
   Edge to edge, no container, no radius. The caption sits on a rule beneath
   rather than floating on the photograph.                                   */
.msw-figure          { margin: 0; }
.msw-figure img      { display: block; width: 100%; height: auto; }
.msw-figure--tall img{ aspect-ratio: 4/5; object-fit: cover; }
.msw-figure--wide img{ aspect-ratio: 21/9; object-fit: cover; }
.msw-figure--band img{ aspect-ratio: 32/9; object-fit: cover; }
.msw-figure figcaption { display: flex; justify-content: space-between; gap: var(--s-2);
                         flex-wrap: wrap; padding-top: var(--s-1);
                         border-top: var(--hairline-w) solid var(--hairline);
                         margin-top: var(--s-2);
                         font-size: var(--t-label); letter-spacing: var(--track-label);
                         text-transform: uppercase; font-weight: 600; }
.msw-ground--dark .msw-figure figcaption,
.msw-ground--textured .msw-figure figcaption { border-top-color: var(--hairline-invert); }

/* Image pair and triptych — full-bleed, hairline gutters only. */
.msw-imgrow    { display: grid; gap: var(--hairline-w); }
.msw-imgrow--2 { grid-template-columns: 1fr 1fr; }
.msw-imgrow--3 { grid-template-columns: repeat(3, 1fr); }
.msw-imgrow img{ display: block; width: 100%; height: 100%; object-fit: cover;
                 aspect-ratio: 4/3; }
@media (max-width: 720px) { .msw-imgrow--3 { grid-template-columns: 1fr 1fr; }
                            .msw-imgrow--2 { grid-template-columns: 1fr; } }

/* Type over image — the only place text sits on a photograph. Needs the
   scrim, because contrast over an unknown image is otherwise a gamble.      */
.msw-overimg          { position: relative; isolation: isolate; }
.msw-overimg img      { display: block; width: 100%; height: 100%; object-fit: cover; }
.msw-overimg__scrim   { position: absolute; inset: 0; z-index: 1; pointer-events: none;
                        background: linear-gradient(to top, rgba(0,0,0,.78) 0%,
                                    rgba(0,0,0,.35) 45%, rgba(0,0,0,.08) 100%); }
.msw-overimg__body    { position: absolute; inset-inline: 0; bottom: 0; z-index: 2;
                        padding: var(--s-4) var(--gutter); color: var(--ink-invert); }

/* ── Range block ─────────────────────────────────────────────────────── */
.msw-range__grid { display: grid; grid-template-columns: 1fr auto; gap: var(--s-4);
                   align-items: start; }
.msw-range__name { writing-mode: vertical-rl; transform: rotate(180deg);
                   font-family: var(--display); font-weight: 700; text-transform: uppercase;
                   font-size: clamp(1.75rem, 4vw, 3.25rem); line-height: .9;
                   letter-spacing: -.02em; color: rgba(255,255,255,.22); }
.msw-ground .msw-range__name,
.msw-ground--paper .msw-range__name,
.msw-ground--panel .msw-range__name { color: rgba(0,0,0,.16); }

/* ── Button · 48px clears the 44px tap-target floor ──────────────────── */
.msw-btn { display: inline-flex; align-items: center; justify-content: center;
           min-height: 48px; padding: 0 var(--s-3);
           border: var(--frame-w) solid var(--ink-strong); border-radius: var(--radius);
           background: var(--ink-strong); color: var(--ink-invert);
           font-family: var(--body); font-size: .875rem; font-weight: 600;
           letter-spacing: .08em; text-transform: uppercase; text-decoration: none;
           transition: background var(--dur) var(--ease), color var(--dur) var(--ease); }
.msw-btn:hover { background: transparent; color: var(--ink-strong); }
/* .msw-btn had no focus style at all. It inherits ground colour, so the
   ring is currentColor and works on every band the button appears on. */
.msw-btn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
/* Every ground that sets inverted ink has to invert the button too, and this
   originally listed only dark and textured. On brick and on ember the button
   kept its light-ground treatment, so the HOVER state put black on the
   accent: #000 on #a4402c is 3.3:1, under the 4.5:1 floor for 14px bold.
   Inverted, the resting state is 21:1 and the hover is white on brick at
   6.3:1 - both pass, and the ground keeps setting its own ink rather than a
   page hand-picking a colour, which is the rule the grounds exist to hold. */
.msw-ground--dark .msw-btn, .msw-ground--textured .msw-btn,
.msw-ground--accent .msw-btn, .msw-ground--warm .msw-btn {
           border-color: var(--ink-invert); background: var(--ink-invert); color: var(--ink-strong); }
.msw-ground--dark .msw-btn:hover, .msw-ground--textured .msw-btn:hover,
.msw-ground--accent .msw-btn:hover, .msw-ground--warm .msw-btn:hover {
           background: transparent; color: var(--ink-invert); }
.msw-btn:focus-visible { outline: 3px solid var(--ink-strong); outline-offset: 3px; }
.msw-ground--dark .msw-btn:focus-visible,
.msw-ground--textured .msw-btn:focus-visible,
.msw-ground--accent .msw-btn:focus-visible,
.msw-ground--warm .msw-btn:focus-visible { outline-color: var(--ink-invert); }

/* ── Nav · horizontal and visible, replacing hamburger-only desktop ──── */
.msw-nav         { display: flex; align-items: center; justify-content: space-between;
                   gap: var(--s-3); padding: var(--s-2) var(--gutter); flex-wrap: wrap; }
.msw-nav__list   { display: flex; gap: var(--s-3); list-style: none; padding: 0; margin: 0;
                   flex-wrap: wrap; }
.msw-nav__list a { font-size: var(--t-label); letter-spacing: var(--track-label);
                   text-transform: uppercase; font-weight: 600; text-decoration: none;
                   color: inherit; display: inline-flex; align-items: center;
                   min-height: 44px; }   /* house-standard tap-target floor */
.msw-nav__list a:hover         { text-decoration: underline; text-underline-offset: 5px; }
.msw-nav__list a[aria-current] { text-decoration: underline; text-underline-offset: 5px; }
.msw-nav__list a:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }

/* ── Caption bar · borrowed from sims-hilditch ───────────────────────── */
.msw-captionbar { display: flex; justify-content: space-between; gap: var(--s-2);
                  align-items: baseline; padding-top: var(--s-2);
                  border-top: var(--hairline-w) solid var(--hairline); flex-wrap: wrap; }
.msw-ground--dark .msw-captionbar,
.msw-ground--textured .msw-captionbar { border-top-color: var(--hairline-invert); }

/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 720px) {
  .msw-range__grid { grid-template-columns: 1fr; }
  .msw-range__name { writing-mode: horizontal-tb; transform: none; font-size: 1.5rem; }
  .msw-blockstack > :nth-child(even) { text-align: left; }
}

/* ══════════════════════════════════════════════════════════════════════
   THE RAIL · added v1.0
   Scorecard dimension 6 (wayfinding) scores Matt's live site 0 — hamburger
   only, no orientation of any kind. This component alone takes it to 5,
   which makes it the cheapest five points available anywhere in the build.

   A 5 requires: a fixed scroll indicator, a hairline, a live position
   counter, AND a state flip at the end (SCROLL becoming TO TOP).

   It is fixed to the left gutter, vertical, and deliberately tiny — 10px
   at 0.32em tracking against a 184px hero. That size contrast is the point.
   ══════════════════════════════════════════════════════════════════════ */
.msw-rail { position: fixed; left: var(--s-3); top: 0; bottom: 0; z-index: 40;
            display: flex; flex-direction: column; align-items: center;
            justify-content: center; gap: var(--s-2);
            pointer-events: none; mix-blend-mode: difference; color: #fff; }
.msw-rail__label { writing-mode: vertical-rl; font-family: var(--body);
                   font-size: var(--t-micro); font-weight: 500;
                   letter-spacing: var(--track-wide); text-transform: uppercase; }
/* The hairline track, and the filled portion showing scroll depth. */
.msw-rail__track { position: relative; width: var(--hairline-w); flex: 0 0 34vh;
                   background: currentColor; opacity: .35; }
.msw-rail__fill  { position: absolute; left: 0; right: 0; top: 0; height: 0%;
                   background: currentColor; opacity: 1;
                   transition: height var(--dur) var(--ease); }
.msw-rail__count { font-family: var(--body); font-size: var(--t-micro);
                   font-weight: 500; letter-spacing: .06em;
                   font-variant-numeric: tabular-nums; }
/* The end-state flip. Becomes a real button, so it needs pointer events. */
.msw-rail__top { pointer-events: auto; display: none; background: none;
                 border: 0; color: inherit; cursor: pointer; padding: var(--s-1);
                 min-height: 44px; min-width: 44px;   /* house standard floor */
                 writing-mode: vertical-rl; font-family: var(--body);
                 font-size: var(--t-micro); font-weight: 500;
                 letter-spacing: var(--track-wide); text-transform: uppercase; }
.msw-rail[data-at-end="true"] .msw-rail__label,
.msw-rail[data-at-end="true"] .msw-rail__track { display: none; }
.msw-rail[data-at-end="true"] .msw-rail__top   { display: block; }
.msw-rail__top:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }

/* Below 900px the gutter is too tight for a vertical rail, and a phone
   already has a scrollbar. Hide it rather than cramming it. */
@media (max-width: 900px) { .msw-rail { display: none; } }
/* No blend-mode support, or forced colours: fall back to plain ink. */
@supports not (mix-blend-mode: difference) {
  .msw-rail { mix-blend-mode: normal; color: var(--ink); }
}

/* ── NOTE for anyone opening this library locally ─────────────────────
   The masked marks do NOT render over file:// — Chromium blocks mask-image
   fetches from a file origin, so they silently vanish rather than erroring.
   That is a browser restriction, not a bug in this system.
   Serve over HTTP to see them:  python -m http.server 8791
   Verified over HTTP: both marks paint white on oxblood at 18.5:1.       */

/* ══════════════════════════════════════════════════════════════════════
   v1.1 · the three components that carry the remaining scorecard points
   Added 2026-08-12. Each exists to move one dimension, and says which.
   ══════════════════════════════════════════════════════════════════════ */

/* ── .msw-hrail DELETED 2026-08-13 ───────────────────────────────────
   It was a horizontal strip inside a vertical page. The across section
   does that job properly - the content travels rather than the user
   dragging a scrollbar - so keeping both was two devices for one idea.
   Codex flagged exactly this: "the page keeps introducing a new
   interaction grammar instead of building one."                        */

/* ── BOUNDARY TYPE · scorecard 4 (edge behaviour) 2 -> 4 ──────────────
   "Display type crosses section boundaries - cut by the edge above or
   below." One word, set enormous, straddling two grounds so the ground
   change slices it. Purely decorative, so it is aria-hidden in markup and
   the real heading is set normally elsewhere.

   Put .msw-cross on a section that follows the one being crossed into.  */
.msw-cross { position: relative; }
.msw-cross__word { position: absolute; left: var(--gutter); top: 0;
  transform: translateY(-50%); margin: 0; pointer-events: none;
  font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(3.5rem, 15vw, 13rem); line-height: .8;
  letter-spacing: var(--track-hero); text-transform: uppercase;
  white-space: nowrap; color: currentColor; }
@media (max-width: 700px) { .msw-cross__word { font-size: clamp(2.75rem, 17vw, 5rem); } }

/* ── SELECTOR · scorecard 7 (signature moment) 2 -> 4 ─────────────────
   "One genuine interaction that only this brand would have."
   Tabs that swap the imagery and copy IN PLACE rather than linking away -
   era-residence's amenity selector, mapped to the four services the live
   site already publishes. Built as real tabs so it works with a keyboard
   and a screen reader; without JS every panel is simply visible.        */
.msw-sw__tabs { display: flex; flex-wrap: wrap; gap: 0; margin: 0; padding: 0;
                list-style: none; border-bottom: var(--hairline-w) solid var(--hairline-now, var(--hairline)); }
.msw-sw__tab { appearance: none; background: none; border: 0; color: inherit;
  cursor: pointer; font-family: var(--display); font-weight: 600;
  font-size: clamp(1.05rem, 2.4vw, 1.6rem); text-transform: uppercase;
  letter-spacing: -.01em; padding: var(--s-2) var(--s-3) var(--s-2) 0;
  margin-right: var(--s-3); min-height: 44px; opacity: .42;
  border-bottom: 3px solid transparent; margin-bottom: -1px;
  transition: opacity var(--dur) var(--ease); }
.msw-sw__tab:hover { opacity: .75; }
.msw-sw__tab[aria-selected="true"] { opacity: 1; border-bottom-color: currentColor; }
.msw-sw__tab:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.msw-sw__panel[hidden] { display: none; }
.msw-sw__panel { display: grid; grid-template-columns: 1.15fr 1fr; gap: var(--s-4);
                 align-items: center; padding-top: var(--s-4); }
.msw-sw__panel img { width: 100%; aspect-ratio: 5/4; object-fit: cover; display: block; }
.msw-sw__count { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
                 letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .6; }
@media (max-width: 820px) { .msw-sw__panel { grid-template-columns: 1fr; } }

/* ── QUOTE · his own words, set at display scale ──────────────────────
   The live site already carries this line; here it gets the room it was
   always owed. Serif quotes would be a lie - the system has no serif. */
.msw-quote { margin: 0; max-width: 26ch; font-family: var(--display);
             font-weight: 600; font-size: clamp(1.6rem, 4.4vw, 3.4rem);
             line-height: 1.06; letter-spacing: -.02em; }
.msw-quote__cite { display: block; margin-top: var(--s-3); font-family: var(--body);
                   font-size: var(--t-label); font-weight: 500; font-style: normal;
                   letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .7; }

/* ══════════════════════════════════════════════════════════════════════
   v1.2 · THE ARCH, THE SEAM, THE DOT, BRASS
   Small craft. Each of these is his, not borrowed:
     arch   an arched doorway, an arched kitchen opening and a circular
            mirror all appear in his own Lisbon photography. Era uses an
            arch; Matt already builds them.
     seam   Fabbrica Group, one of his five client-locked June references.
            His note: "two-image curtain line using light and interiors".
     dot    his mark is "m." The full stop is half the logo.
     brass  his kitchens are full of it. A grey rule is a default.
   ══════════════════════════════════════════════════════════════════════ */

/* ── 1 · THE ARCH ─────────────────────────────────────────────────────
   One geometry at several scales. A semicircular head on straight jambs,
   because a stadium shape is what you get when nobody looked at a building. */
:root {
  --arch-r: 50% 50% 0 0 / 36% 36% 0 0;   /* the arch, expressed as a radius */
}

/* Imagery masked into an arch. Chapter openings, gallery heads. */
.msw-arch          { border-radius: var(--arch-r); overflow: hidden; }
.msw-arch > img    { display: block; width: 100%; height: 100%; object-fit: cover; }

/* The opening. A black shutter with a real arch-shaped hole punched in it,
   widening until its edges leave the viewport.

   FIXED after review: v1.2 used `mix-blend-mode: destination-out`, which is
   NOT a CSS blend mode - it is a Canvas composite operation. It computed to
   `normal`, so instead of cutting a hole it painted a white arch onto a
   black shutter. And the shutter was VISIBLE BY DEFAULT with only JS
   removing it, so with JavaScript off the entire site was a black screen.
   That is the opposite of progressive enhancement.

   Now: display:none by default, JS opts IN, and the hole is a real CSS mask.
   No JS, no overlay, no way to break the page.                            */
.msw-reveal { display: none; position: fixed; inset: 0; z-index: 90;
              pointer-events: none; background: var(--black);
              -webkit-mask: radial-gradient(circle, #000 100%, #000 100%);
              mask-composite: exclude; -webkit-mask-composite: xor; }
.msw-reveal[data-play="true"] {
  display: block;
  /* Two mask layers: the full field minus an arch. The arch is the hole. */
  -webkit-mask:
    linear-gradient(#000, #000),
    url("assets/shapes/arch.svg") no-repeat bottom center / var(--rev, 22vmin) auto;
  mask:
    linear-gradient(#000, #000),
    url("assets/shapes/arch.svg") no-repeat bottom center / var(--rev, 22vmin) auto;
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  animation: msw-arch-open 1100ms var(--ease) forwards;
}
.msw-reveal[data-play="done"] { display: none; }
@keyframes msw-arch-open {
  0%   { --rev: 22vmin;  opacity: 1; }
  70%  { --rev: 170vmax; opacity: 1; }
  100% { --rev: 300vmax; opacity: 0; }
}
@property --rev { syntax: "<length>"; inherits: false; initial-value: 22vmin; }
/* The mark waits inside the arch, then goes. */
.msw-reveal__mark { position: absolute; left: 50%; bottom: 13vmin;
  transform: translateX(-50%); height: 7vmin; color: #fff;
  background-color: currentColor; aspect-ratio: 129.82 / 180.86;
  -webkit-mask: url("assets/msw-monogram.svg") no-repeat center / contain;
          mask: url("assets/msw-monogram.svg") no-repeat center / contain; }
/* Below 600 the nav has shed its 4th item and the remaining three spread
   wide enough that "Kitchens" reaches the centre - where this mark is
   pinned at left:50%. Measured at 360/390/430: the boxes intersect by
   1074px sq, and the mark is drawn straight through the word. It is
   aria-hidden decoration and the word is not, so the decoration goes. */
.msw-reveal[data-play="true"] .msw-reveal__mark {
  animation: msw-mark-out 620ms var(--ease) forwards; }
@keyframes msw-mark-out { to { opacity: 0; transform: translateX(-50%) scale(1.4); } }
/* Reduced motion gets no reveal at all. Not a faster one - none. */
@media (prefers-reduced-motion: reduce) { .msw-reveal { display: none !important; } }

/* ── 2 · THE SEAM · Fabbrica ──────────────────────────────────────────
   50/50, and the seam is made by the PHOTOGRAPH - a flute, a jamb, a wall
   corner. No divider rule: a CSS line down the middle is precisely the
   thing this reference is not. The mark sits centred on the join. */
/* grid-template-rows is load-bearing: without it the row is sized by the
   taller photograph's aspect ratio. A 1000x1500 image in a 720px column
   makes the row 1080px, which on a 900px viewport pushed the hero name
   180px below the fold - a gate failure (primary action in the first
   screen) that no amount of padding would have fixed. */
.msw-seam { position: relative; display: grid; grid-template-columns: 1fr 1fr;
            grid-template-rows: 100svh; min-height: 100svh;
            background: var(--black); }
.msw-seam > figure { margin: 0; position: relative; overflow: hidden;
                     min-height: 0; height: 100%; }
.msw-seam img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Each half is pushed towards the join so the vertical inside each
   photograph lands on it. Tune per pair with --l and --r. */
.msw-seam__l img { object-position: var(--l, 78% 50%); }
.msw-seam__r img { object-position: var(--r, 22% 50%); }
/* .msw-seam__mark was deleted with the homepage nav rewrite: the signature
   in .msw-pagenav--over is the brand mark now, and two in one band was the
   inconsistency. Its rules went with it rather than being left to rot. */
@media (max-width: 820px) {
  .msw-seam { grid-template-columns: 1fr; grid-template-rows: 88svh; min-height: 88svh; }
  .msw-seam__r { display: none; }   /* one photograph beats a squeezed two */
}

/* ── 3 · THE DOT ──────────────────────────────────────────────────────
   His mark ends in a full stop. Reuse it: the end of a chapter, the hover
   state of a link, the rail's marker. One shape, three jobs. */
.msw-dot { display: inline-block; width: .34em; height: .34em;
           border-radius: 50%; background: currentColor; }
.msw-dot--end { display: block; width: 10px; height: 10px;
                margin: var(--s-5) 0 0; border-radius: 50%;
                background: var(--brass); }
.msw-ground--dark .msw-dot--end,
.msw-ground--warm .msw-dot--end,
.msw-ground--accent .msw-dot--end { background: var(--brass-invert); }
/* Links take the dot instead of an underline. */
.msw-dotlink { text-decoration: none; color: inherit; position: relative;
               padding-right: .85em; }
.msw-dotlink::after { content: ""; position: absolute; right: 0; bottom: .14em;
  width: .32em; height: .32em; border-radius: 50%; background: currentColor;
  transform: scale(0); transition: transform var(--dur) var(--ease); }
.msw-dotlink:hover::after,
.msw-dotlink:focus-visible::after { transform: scale(1); }

/* ── 4 · BRASS ────────────────────────────────────────────────────────
   Every rule was grey, which is a default rather than a decision. His
   kitchens are full of brass, so the rules are brass. Deliberately
   low-chroma: bright gold reads as a bad 2013 gradient. */
:root {
  --brass:        #a98545;
  --brass-invert: #c9a969;   /* lifted, for dark grounds */
}
.msw-rule--brass { border: 0; border-top: var(--hairline-w) solid var(--brass);
                   margin: 0; }
.msw-ground--dark  .msw-rule--brass,
.msw-ground--warm  .msw-rule--brass,
.msw-ground--accent .msw-rule--brass { border-top-color: var(--brass-invert); }
.msw-ground--dark .msw-sw__tabs,
.msw-ground--warm .msw-sw__tabs { border-bottom-color: var(--brass-invert); }
/* Scoped: unscoped, this overwrote the correct currentColor underline on
   PAPER grounds with the light brass, about 2:1 - under the 3:1 floor for
   non-text contrast. */
.msw-ground--dark .msw-sw__tab[aria-selected="true"],
.msw-ground--warm .msw-sw__tab[aria-selected="true"] { border-bottom-color: var(--brass-invert); }

/* Living corners: see v7 at the end of this file. The version that stood
   here animated the shadow's opacity and transform on a 26s loop, which
   silently overrode the v7 rules - a CSS animation beats a normal
   declaration, so the new opacity never applied and the shadow drifted.
   Deleted rather than left as a comment, because that is exactly the kind
   of dormant rule that comes back. */


/* ══════════════════════════════════════════════════════════════════════
   v1.3 · SCRUBBED SEQUENCE · CONTINUOUS GROUND · VIEW TRANSITIONS
   ══════════════════════════════════════════════════════════════════════ */

/* ── VIEW TRANSITIONS ─────────────────────────────────────────────────
   Cross-document, so moving between pages morphs rather than blinks.
   Named elements persist across the navigation. */
@view-transition { navigation: auto; }

::view-transition-old(root),
::view-transition-new(root) { animation-duration: 320ms;
                              animation-timing-function: var(--ease); }
/* A view-transition-name must be UNIQUE in the document. Naming both the
   nav mark and the seam mark "msw-mark" threw a duplicate-name error on
   every single transition - nine of them across one scrub. The nav mark
   keeps the name because it is the element that persists across pages;
   the seam mark exists only on the homepage hero and does not need one. */
.nav .msw-mark { view-transition-name: msw-mark; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* ── SCRUBBED RANGE SEQUENCE ──────────────────────────────────────────
   A tall track that pins one viewport-high stage. Scrolling the track
   scrubs the collection: the name steps, the imagery cross-fades, and the
   ground interpolates continuously between the two ranges either side of
   the playhead.

   Sticky rather than fixed, so it releases naturally at both ends and
   needs no scroll maths to un-pin.                                       */
/* The ground follows the range. The 120ms linear step is deliberate and is
   The ground is static and there is no transition on it: a scroll-driven
   section restarting a background transition on every frame is what
   strobed. */
/* colour belongs on the SECTION, not only on the stage. It used to sit
   solely on .msw-seq__stage, so anything else in this dark section
   inherited the page ink #333 and rendered at 1.43:1 on #16161a. That took
   out the visualiser CTA - the only link from the homepage to the thing
   the site exists to sell - and the whole no-JS nine-range fallback with
   it. The identical component higher up the page measures 19.17:1.

   I caused it by moving that CTA out of the stage to fix a grid bug, which
   is the shape of the trap: an element that is correct in one parent and
   invisible in its sibling. Ink on the ground that owns the background. */
.msw-seq { background: #16161a; color: #fff; }
.msw-seq__track { position: relative; height: 420svh; }   /* 9 ranges of travel */
.msw-seq__stage { position: sticky; top: 0; height: 100svh;
  display: grid; grid-template-columns: 1fr 1fr; align-items: center;
  gap: var(--s-5); padding: var(--s-5) var(--gutter); color: #fff;
  overflow: hidden; }
.msw-seq__figs { position: relative; height: min(74svh, 620px); }
.msw-seq__fig { position: absolute; inset: 0; opacity: 0;
  transition: opacity 520ms var(--ease), transform 900ms var(--ease);
  transform: scale(1.04); }
.msw-seq__fig.is-on { opacity: 1; transform: scale(1); }
.msw-seq__fig img { width: 100%; height: 100%; object-fit: cover; display: block; }
.msw-seq__name { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(2.5rem, 7.5vw, 6rem); line-height: .9;
  letter-spacing: var(--track-display); text-transform: uppercase; margin: 0;
  view-transition-name: seq-name; }
.msw-seq__copy { font-size: 1rem; line-height: 1.62; max-width: 46ch;
  margin: var(--s-3) 0 0; opacity: .9; }
.msw-seq__num { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .6; }
/* A brass progress hairline across the foot of the stage. */
.msw-seq__bar { position: absolute; left: var(--gutter); right: var(--gutter);
  bottom: var(--s-4); height: 1px; background: rgba(255,255,255,.22); }
.msw-seq__bar::after { content: ""; position: absolute; inset: 0 auto 0 0;
  width: var(--seq-p, 0%); background: var(--brass-invert);
  transition: width 120ms linear; }
/* --seq-p is written every frame by ranges.js. It was declared here with a
   0% fallback and never written by anything for two releases, so the bar sat
   permanently empty - decoration pretending to be a progress indicator. */

/* Without JS the stage is not sticky and every range is simply listed. */
.msw-seq:not([data-enhanced]) .msw-seq__track { height: auto; }
.msw-seq:not([data-enhanced]) .msw-seq__stage { position: static; height: auto; }
.msw-seq:not([data-enhanced]) .msw-seq__fig { position: static; opacity: 1;
  transform: none; margin-bottom: var(--s-3); }
.msw-seq:not([data-enhanced]) .msw-seq__figs { height: auto; }
.msw-seq__list { margin: 0; padding: 0; list-style: none; }
.msw-seq[data-enhanced] .msw-seq__list { position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip-path: inset(50%); }   /* stays for screen readers */

@media (max-width: 820px) {
  .msw-seq__stage { grid-template-columns: 1fr; gap: var(--s-3);
                    align-content: center; }
  .msw-seq__figs { height: 40svh; }
  .msw-seq__track { height: 340svh; }
}
@media (prefers-reduced-motion: reduce) {
  .msw-seq__fig { transition: opacity 1ms; transform: none; }
  .msw-seq { transition: none; }
}

/* ══════════════════════════════════════════════════════════════════════
   v2 · THE ACROSS · one section, not the whole site
   ──────────────────────────────────────────────────────────────────────
   The page reads DOWN, then ACROSS, then DOWN. One axis change, in one
   place, doing one job: walking the length of a collection.

   This supersedes .msw-seq, which pinned a stage and CROSS-FADED nine
   images in place. Cross-fading is not traversal - nothing travels, so
   the section reads as a slideshow that happens to be pinned. Here the
   bays physically move past you.

   The pinned-translateX technique was rejected for a whole site (it breaks
   find-in-page and scroll restoration and needs a fake scroll height). For
   ONE section those costs are bounded and the payoff is real: the browser
   keeps normal vertical scrolling, so wheel, trackpad, keyboard, scrollbar
   and touch all behave, and the content is still a normal document.
   ══════════════════════════════════════════════════════════════════════ */

/* One static ground. The --across-ground custom property and the 140ms
   background transition are both gone: nothing wrote the property once the
   section stopped interpolating, and a background transition on a
   scroll-driven section is the shape of the strobe that cost three rounds
   to find. Left dormant it was an invitation to reintroduce it. */
.msw-across { position: relative; background: var(--black); }

/* Travel = one viewport per bay, plus one to read the last one. Set from
   the bay count in markup so the maths lives in one place. */
.msw-across__travel { height: calc(var(--bays, 9) * 62svh + 100svh); }

.msw-across__stage { position: sticky; top: 0; height: 100svh;
  overflow: hidden; display: grid; grid-template-rows: auto 1fr auto;
  gap: var(--s-3); padding: var(--s-5) 0 var(--s-4); color: #fff; }

.msw-across__head { padding: 0 var(--gutter); }

/* The row that actually moves. translate3d keeps it on the compositor. */
.msw-across__row { display: flex; gap: var(--s-3); align-items: stretch;
  min-height: 0; height: 100%;
  padding: 0 var(--gutter); will-change: transform;
  transform: translate3d(var(--x, 0px), 0, 0); }

/* The bay must FIT the stage row, not overflow it. With a fixed 4/5 aspect
   the image plus name plus copy plus finish ran taller than the available
   height and overflow:hidden simply cut the copy off mid-sentence. The
   image now flexes and the text is what sets the floor. */
.msw-across__bay { flex: 0 0 clamp(240px, 30vw, 420px);
  display: flex; flex-direction: column; height: 100%; min-height: 0; }
.msw-across__bay img { width: 100%; flex: 1 1 auto; min-height: 0;
  object-fit: cover; display: block; filter: saturate(.94); }
.msw-across__name, .msw-across__copy, .msw-across__finish { flex: 0 0 auto; }
.msw-across__name { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(1.1rem, 2vw, 1.6rem); text-transform: uppercase;
  letter-spacing: -.01em; margin: var(--s-2) 0 var(--s-1); }
.msw-across__copy { font-size: .82rem; line-height: 1.55; opacity: .82; margin: 0; }
.msw-across__finish { font-family: var(--body); font-size: var(--t-micro);
  font-weight: 500; letter-spacing: var(--track-wide); text-transform: uppercase;
  opacity: .6; display: block; margin-top: var(--s-2); }

/* Foot: a brass hairline that fills, and the count. No rail - this is
   inside one section and it is the only place a progress line is honest. */
.msw-across__foot { display: flex; align-items: center; gap: var(--s-3);
  padding: 0 var(--gutter); }
.msw-across__bar { flex: 1; height: 1px; background: rgba(255,255,255,.22);
  position: relative; }
.msw-across__bar::after { content: ""; position: absolute; inset: 0 auto 0 0;
  width: var(--p, 0%); background: var(--brass-invert); }
.msw-across__count { font-family: var(--body); font-size: var(--t-micro);
  font-weight: 500; letter-spacing: var(--track-wide); text-transform: uppercase;
  opacity: .7; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* ── No JS, small screens, reduced motion: no pinning, no travel.
      The bays become a plain vertical list. Same DOM, nothing hidden. ── */
.msw-across:not([data-enhanced]) .msw-across__travel { height: auto; }
.msw-across:not([data-enhanced]) .msw-across__stage { position: static; height: auto;
  overflow: visible; }
.msw-across:not([data-enhanced]) .msw-across__row { flex-wrap: wrap;
  transform: none; }
.msw-across:not([data-enhanced]) .msw-across__bay { flex: 1 1 260px; }

@media (max-width: 900px) {
  .msw-across__travel { height: auto !important; }
  .msw-across__stage { position: static !important; height: auto !important;
                       overflow: visible !important; }
  .msw-across__row { flex-wrap: wrap; transform: none !important; }
  .msw-across__bay { flex: 1 1 260px; }
  .msw-across__foot { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .msw-across__travel { height: auto; }
  .msw-across__stage { position: static; height: auto; overflow: visible; }
  .msw-across__row { flex-wrap: wrap; transform: none; }
  .msw-across { transition: none; }
}

/* ── The across, flat variant ─────────────────────────────────────────
   Constant ground. Used for Selected work, where the photographs carry
   the colour and drifting the ground through each project's scheme would
   break the rule above: a project's palette is the client's taste, not
   Matt's identity. */
.msw-across--flat { background: var(--black); }
.msw-across--flat .msw-across__bay { flex: 0 0 clamp(260px, 34vw, 520px); }
.msw-across__cap { flex: 0 0 auto; font-family: var(--body);
  font-size: var(--t-label); letter-spacing: .04em; opacity: .82;
  margin-top: var(--s-2); }
.msw-across__cap b { font-weight: 500; letter-spacing: var(--track-label);
  text-transform: uppercase; }

/* The nine range names, set as a plain confident list. */
.msw-names { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-4);
  list-style: none; margin: var(--s-4) 0 0; padding: var(--s-3) 0 0;
  border-top: var(--hairline-w) solid var(--brass-invert); }
.msw-names li { font-family: var(--display); font-weight: 600;
  font-size: clamp(1.1rem, 2.2vw, 1.7rem); text-transform: uppercase;
  letter-spacing: -.01em; }

/* ══════════════════════════════════════════════════════════════════════
   v2.1 · BAY VARIANTS · ARCH DOORWAYS · THE HORIZONTAL WIPE
   Read off era-residence: bays are not a uniform carousel. Widths vary,
   images go full bleed, type is staggered rather than centred, and a
   quote lands over imagery instead of beside it.
   ══════════════════════════════════════════════════════════════════════ */

/* ── Bay widths. A uniform strip reads as a carousel; varied widths read
      as a walk past a building. ── */
.msw-across__bay--wide  { flex: 0 0 min(92vw, 1180px); }
/* No negative margin: inside a flex row it pulls the bay over BOTH
   neighbours, and the text column next to it was being sat on. A 100vw
   bay is already full bleed relative to the stage - the row's own gutter
   padding is the only offset needed. */
.msw-across__bay--full  { flex: 0 0 min(100vw, 1400px); }
.msw-across__bay--full img { object-position: 50% 42%; }
.msw-across__bay--narrow{ flex: 0 0 clamp(190px, 20vw, 300px); }

/* ── The doorway. An arch cut into a bay, so moving past it reads as
      stepping through rather than sliding by. This is the arch promoted
      from a one-off opening to the transition device it should always
      have been. ── */
.msw-across__bay--arch img { border-radius: var(--arch-r); }
.msw-across__bay--arch .msw-across__cap { text-align: center; }

/* ── The text bay. Keeps the vertical rhythm inside a horizontal walk:
      a tall, narrow column of type between two images, which is what
      stops the section reading as pure gallery. ── */
.msw-across__bay--text { flex: 0 0 clamp(300px, 32vw, 480px);
  justify-content: center; }
.msw-across__say { font-family: var(--display); font-weight: 600;
  font-size: clamp(1.35rem, 2.6vw, 2.35rem); line-height: 1.1;
  letter-spacing: -.02em; margin: 0; }
.msw-across__by { display: block; margin-top: var(--s-3);
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .6; }

/* ── A quote laid OVER a full-bleed image, era's move. The scrim is only
      under the text, so the photograph is not dimmed as a whole. ── */
.msw-across__bay--quote { position: relative; }
.msw-across__bay--quote .msw-across__over {
  position: absolute; right: 6%; bottom: 12%; max-width: 30ch; z-index: 2; }
.msw-across__mark { display: block; font-family: var(--display);
  font-weight: 800; font-size: 2.4rem; line-height: .6; opacity: .8;
  margin-bottom: var(--s-2); }
@media (max-width: 900px) {
  .msw-across__bay--full { flex: 1 1 260px; margin-inline: 0; }
  .msw-across__bay--quote .msw-across__over { position: static; max-width: none;
    margin-top: var(--s-2); }
}

/* ══════════════════════════════════════════════════════════════════════
   THE HORIZONTAL WIPE — replaces the arch reveal
   The arch opening was a flop: a shape expanding from the bottom centre
   reads as a spotlight, not a door, and at full size it is just a fade.
   A horizontal sweep suits a site whose whole idea is moving sideways,
   and it previews the gesture before the visitor has made it.

   Two panels part from a seam, on an expo-out curve so almost all the
   travel happens in the first third and the last of it glides.
   ══════════════════════════════════════════════════════════════════════ */
.msw-wipe { display: none; position: fixed; inset: 0; z-index: 90;
            pointer-events: none; }
.msw-wipe__panel { position: absolute; top: 0; bottom: 0; width: 50.5vw;
  background: var(--black); will-change: transform; }
.msw-wipe__panel--l { left: 0;  transform: translate3d(0,0,0); }
.msw-wipe__panel--r { right: 0; transform: translate3d(0,0,0); }
.msw-wipe__mark { position: absolute; left: 50%; top: 50%;
  transform: translate(-50%,-50%); height: 8vmin; color: #fff;
  background-color: currentColor; aspect-ratio: 129.82 / 180.86;
  -webkit-mask: url("assets/msw-monogram.svg") no-repeat center / contain;
          mask: url("assets/msw-monogram.svg") no-repeat center / contain; }

.msw-wipe[data-play="true"] { display: block; }
.msw-wipe[data-play="true"] .msw-wipe__panel--l {
  animation: msw-wipe-l 2050ms cubic-bezier(.16,1,.3,1) 380ms forwards; }
.msw-wipe[data-play="true"] .msw-wipe__panel--r {
  animation: msw-wipe-r 2050ms cubic-bezier(.16,1,.3,1) 380ms forwards; }
.msw-wipe[data-play="true"] .msw-wipe__mark {
  animation: msw-wipe-mark 900ms cubic-bezier(.16,1,.3,1) 120ms forwards; }
.msw-wipe[data-play="done"] { display: none; }

@keyframes msw-wipe-l    { to { transform: translate3d(-101%,0,0); } }
@keyframes msw-wipe-r    { to { transform: translate3d(101%,0,0); } }
@keyframes msw-wipe-mark { to { opacity: 0; transform: translate(-50%,-50%) scale(.82); } }

@media (prefers-reduced-motion: reduce) { .msw-wipe { display: none !important; } }

/* ── FULL-SCREEN PANELS ───────────────────────────────────────────────
   Dex: fewer, bolder, full screen. Six panels, each a whole viewport.

   The image is ABSOLUTELY POSITIONED to fill, not a flex child. The flex
   version fought the absolute overlays: the quote panel's image collapsed
   to nothing and the display-scale caption landed on top of the quote.
   Absolute fill removes the sizing negotiation entirely - the panel is a
   frame, everything else is layered on it. */
/* Specificity: `.msw-across--flat .msw-across__bay` is two classes and was
   beating this one-class rule, so every "full screen" panel came out at
   34vw (490px of 1440). Matched here rather than reaching for !important. */
.msw-across .msw-across__bay--screen,
.msw-across--flat .msw-across__bay--screen { flex: 0 0 100vw; position: relative;
  display: block; height: 100%; padding: 0; overflow: hidden; }
.msw-across__bay--screen > img { position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover; }
.msw-across__bay--screen::after { content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(0,0,0,.28) 0%, rgba(0,0,0,0) 34%,
              rgba(0,0,0,0) 46%, rgba(0,0,0,.62) 100%); }

.msw-across__bay--screen .msw-across__cap {
  position: absolute; left: var(--gutter); bottom: var(--s-4); margin: 0;
  z-index: 3; opacity: 1; display: grid; gap: 4px; border: 0; padding: 0; }
.msw-across__bay--screen .msw-across__cap b {
  font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(2.4rem, 7vw, 6rem); line-height: .9;
  letter-spacing: var(--track-display); text-transform: uppercase; }
.msw-across__bay--screen .msw-across__cap span {
  font-family: var(--body); font-size: var(--t-label); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .9; }

/* The quote panel carries the quote INSTEAD of a display-scale name -
   two things at display scale in one frame is one too many. */
.msw-across__bay--quote .msw-across__cap b { font-size: var(--t-label);
  font-family: var(--body); font-weight: 500; letter-spacing: var(--track-wide); }
/* MEASURE. The cap used to sit here as max-width:22ch, and ch resolves
   against the element's OWN font - which on .msw-across__over is the body
   font at 16px, not the display font the quote is set in. So the column
   was pinned at a fixed 232px while the type inside it grew from 27px to
   42px: six lines at 901, ten from 1280 up, for a 103-character quote.
   The cap belongs on the element that carries the display font, where ch
   tracks the type. Measured after: five lines from 901 to 2560. */
.msw-across__bay--screen .msw-across__over { position: absolute; z-index: 3;
  left: var(--gutter); right: auto; top: 50%; transform: translateY(-50%);
  max-width: min(46vw, 40rem); }
.msw-across__bay--screen .msw-across__say { font-size: clamp(1.5rem, 3vw, 2.6rem);
  line-height: 1.12; max-width: 24ch; }
/* CONTRAST. The bay scrim above is a top-and-bottom gradient with a clear
   window between 34% and 46%, and this block is centred at top:50% - i.e.
   inside the clear window. On the Lisbon frame that window is a bright
   cream wall, so white display type was landing on near-white. It gets its
   own plate: a soft ellipse with no hard edge, sized off the text box, so
   the photograph is not dimmed as a whole. */
.msw-across__bay--screen .msw-across__over::before { content: ""; position: absolute;
  z-index: -1; inset: -16% -18% -20% -18%; pointer-events: none;
  background: radial-gradient(115% 100% at 32% 50%, rgba(0,0,0,.66) 0%,
              rgba(0,0,0,.46) 48%, rgba(0,0,0,0) 78%); }

/* The text panel: no image, so it needs its own ground and no scrim.
   Its breathing room belongs INSIDE the 100vw bay. External margins change
   the bay pitch and desynchronise the viewport-based traverse. */
/* Anchored LOW, not centred. Widening the measure made the quote taller
   and, centred in the stage, it ran up into "WALK THROUGH IT." - which is
   fixed furniture at the top of the section, not part of this bay's box.
   The previous attempt at this targeted .msw-across__over, which does not
   exist inside a text bay, so it governed nothing. */
.msw-across__bay--screen.msw-across__bay--text { display: grid;
  box-sizing: border-box; place-content: end start;
  padding: 0 var(--s-5) clamp(56px, 11vh, 120px);
  background: var(--black); }
.msw-across__bay--screen.msw-across__bay--text::after { content: none; }
/* Measured at 1920: a fixed 20ch measure put this quote on TEN lines in a
   ~500px column with roughly a thousand pixels of empty black beside it.
   Both the measure and the size scale now, so the line count stays near
   five wherever it is read. */
.msw-across__bay--screen.msw-across__bay--text .msw-across__say {
  font-size: clamp(1.6rem, 3.6vw, 4.4rem);
  max-width: min(26ch, 46vw); }


@media (max-width: 900px) {
  /* Two classes, to match the specificity of the 100vw rule above. With one
     it lost the cascade and the bays stayed a full viewport wide inside a
     padded row, so their right-hand side was clipped rather than shown. */
  .msw-across .msw-across__bay--screen,
  .msw-across--flat .msw-across__bay--screen { flex: 1 1 260px; height: auto;
    aspect-ratio: 3/4; }
  .msw-across__bay--screen.msw-across__bay--text { aspect-ratio: auto;
    padding: var(--s-5) var(--gutter); }
  .msw-across__bay--screen .msw-across__cap b { font-size: 1.7rem; }
  /* THE QUOTE COMES OFF THE IMAGE HERE, rather than being tuned to fit.
     Below 900 the traverse stops being a horizontal walk of 100vw frames
     and wraps into cards, and a card is 270-350px wide - NARROWER at 900
     (three per row) than at 390 (one per row). So no viewport-based size
     works: every rule that improves 390 makes 900 worse. Measured with the
     block still in: 4 lines at 390, 8 at 600, 13 at 900, over a photograph
     with nowhere for a scrim to sit.
     The quote is not lost. It appears a second time further down the page
     on its own plate, where it reads in six lines at every width from 390
     to 900 and carries the same attribution. One good telling beats two,
     and the bay goes back to being a photograph like its seven siblings. */
  .msw-across__bay--screen .msw-across__over { display: none; }
}

/* ── FULL-BLEED STAGE · the screen variant ────────────────────────────
   Dex: "full section height and width". With the heading occupying a grid
   row the panels only got the middle band, and the row gap cut black
   stripes between them. For the screen variant the stage becomes ONE
   frame: panels fill it edge to edge, and the heading and foot are laid
   over the top rather than taking space from it. */
.msw-across--flat .msw-across__stage { display: block; padding: 0; gap: 0; }
.msw-across--flat .msw-across__head { position: absolute; top: var(--s-5);
  left: 0; right: 0; z-index: 4; pointer-events: none;
  padding: 0 var(--gutter); }
.msw-across--flat .msw-across__row { height: 100%; gap: 0; padding: 0;
  align-items: stretch; }
.msw-across--flat .msw-across__foot { position: absolute; left: 0; right: 0;
  bottom: var(--s-3); z-index: 4; padding: 0 var(--gutter); }
/* The heading needs its own scrim or it sits on whatever the panel shows. */
.msw-across--flat .msw-across__stage::before { content: ""; position: absolute;
  inset: 0 0 auto 0; height: 34vh; z-index: 3; pointer-events: none;
  background: linear-gradient(180deg, rgba(0,0,0,.72) 0%, rgba(0,0,0,0) 100%); }
/* Caption clears the right edge and the foot. */
.msw-across__bay--screen .msw-across__cap { right: var(--gutter);
  bottom: calc(var(--s-5) + var(--s-3)); }
@media (max-width: 900px) {
  .msw-across--flat .msw-across__stage { display: grid; }
  .msw-across--flat .msw-across__head { position: static; padding: 0 var(--gutter); }
  .msw-across--flat .msw-across__row { gap: var(--s-3); padding: 0 var(--gutter); }
  .msw-across--flat .msw-across__stage::before { content: none; }
}

/* height:100% did not resolve through the block-level stage (panel came out
   600 of 900). The stage is exactly one viewport, so state that directly
   rather than relying on a percentage chain. */
.msw-across--flat .msw-across__row { height: 100svh; }
.msw-across .msw-across__bay--screen,
.msw-across--flat .msw-across__bay--screen { height: 100svh; }
@media (max-width: 900px) {
  .msw-across--flat .msw-across__row { height: auto; }
  .msw-across .msw-across__bay--screen,
  .msw-across--flat .msw-across__bay--screen { height: auto; }
}
/* Reduced motion (or no JS) above 900: across.js never pins, so the row
   wraps. The generic `:not([data-enhanced])` bay rule then squeezed these
   full-screen frames to 288px columns, clipping the quotes mid-word and
   splitting LISBON across two frames. Here each frame keeps its full width
   and they stack, which is the same walk read downwards. The foot's
   "01 / 06" counter has nothing to count without the traverse. */
@media (min-width: 901px) {
  .msw-across:not([data-enhanced]) .msw-across__row { height: auto; }
  .msw-across:not([data-enhanced]) .msw-across__bay--screen { flex: 0 0 100%;
    margin-block: 0; }
  .msw-across:not([data-enhanced]) .msw-across__foot { display: none; }
}

/* ══════════════════════════════════════════════════════════════════════
   v3 · SCRIPT COUNTERPOINT · EDGE TYPE · CHAPTER MARK
   ══════════════════════════════════════════════════════════════════════ */

/* ── 1 · THE SCRIPT COUNTERPOINT ──────────────────────────────────────
   Era sets a hard serif headline and drops one script word into it
   ("THE COAST YOU WANTED / yours / THIS YEAR"). We retired script FONTS,
   correctly - a font imitating handwriting next to real handwriting is
   the worse answer. But he HAS handwriting, so the version of this idea
   that is actually his is one word of his own signature set inside the
   headline.

   Inline, baseline-aligned, and masked so it takes the heading's colour.
   Optical size is tuned to the cap height either side, not to the line
   box, or it reads as a sticker dropped into the sentence.               */
.msw-script { display: inline-block; vertical-align: baseline;
  width: var(--script-w, 4.2em); height: 0.92em;
  margin: 0 .06em -0.06em; background-color: currentColor;
  -webkit-mask: url("assets/msw-signature.svg") no-repeat center / contain;
          mask: url("assets/msw-signature.svg") no-repeat center / contain; }
/* Never below 140px wide, the legibility floor from the mark rules. */
@media (max-width: 640px) { .msw-script { width: var(--script-w-sm, 3.6em); } }

/* ── 2 · EDGE TYPE ────────────────────────────────────────────────────
   Era runs "NEW GOLDEN MILE" into the photograph's edge. We removed the
   boundary-crossing word because it did not work vertically; horizontally,
   against a full-bleed panel, it does - the type is cut by the frame
   rather than by a colour change, which is a real edge instead of an
   implied one.

   Sits on the panel and deliberately overruns it. The panel's own
   overflow:hidden does the cutting.                                      */
.msw-edgetype { position: absolute; z-index: 3; margin: 0;
  left: calc(-1 * var(--s-4)); bottom: 8%;
  font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(3rem, 11vw, 9.5rem); line-height: .84;
  letter-spacing: var(--track-hero); text-transform: uppercase;
  white-space: nowrap; pointer-events: none; }
.msw-edgetype--right { left: auto; right: calc(-1 * var(--s-4)); text-align: right; }
.msw-edgetype--top   { bottom: auto; top: 12%; }
@media (max-width: 900px) {
  .msw-edgetype { position: static; font-size: clamp(2rem, 9vw, 3rem);
                  white-space: normal; margin-bottom: var(--s-2); }
}

/* ── 3 · THE CHAPTER MARK ─────────────────────────────────────────────
   Dex killed the rail and was right - a progress bar is not orientation.
   But era keeps a page number and a vertical SCROLL mark, which tells you
   where you are without pretending to be navigation.

   This is that: a chapter number and name, fixed to the left gutter, that
   changes as chapters pass. No track, no fill, no percentage. It states a
   position rather than measuring one.                                     */
.msw-chapter { position: fixed; left: var(--gutter); bottom: var(--s-4);
  z-index: 40; display: flex; align-items: center; gap: var(--s-2);
  pointer-events: none; mix-blend-mode: difference; color: #fff;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  opacity: 0; transition: opacity 420ms var(--ease); }
.msw-chapter[data-on="true"] { opacity: .82; }
.msw-chapter__n { position: relative; padding-right: var(--s-2); }
.msw-chapter__n::after { content: ""; position: absolute; right: 0; top: 50%;
  width: 10px; height: 1px; background: currentColor; }
.msw-chapter__name { transition: opacity 220ms var(--ease); }
@media (max-width: 900px) { .msw-chapter { display: none; } }
@supports not (mix-blend-mode: difference) {
  .msw-chapter { mix-blend-mode: normal; color: var(--ink); }
}

/* v3.1 - the shadow system is GONE, at Dex's instruction: the two-layer
   plant shadows, the --shadow-x/-blur/-alpha tokens, and the display-type
   text-shadow that shared them. A cast shadow under a cut-out is the tell
   that a cut-out is what it is; without it the object simply sits on the
   ground, which is the more confident answer. */

/* ══════════════════════════════════════════════════════════════════════
   v4 · THE DOORWAY · CORNER MARKS · THE INDEX
   ══════════════════════════════════════════════════════════════════════ */

/* ── 1 · THE DOORWAY ──────────────────────────────────────────────────
   The arch, stuck to scroll. It pins and the aperture widens as you move
   through, with what is beyond visible through it the whole way. You do
   not watch a transition, you walk through one.

   Unenhanced (no JS, reduced motion, narrow) the aperture is simply open
   at full size and the section is a normal full-bleed image. */
.msw-door { position: relative; background: var(--black); }
.msw-door__travel { height: 100svh; }
.msw-door[data-enhanced] .msw-door__travel { height: 220svh; }
.msw-door__stage { position: relative; height: 100svh; overflow: hidden;
  display: grid; place-items: center; }
.msw-door[data-enhanced] .msw-door__stage { position: sticky; top: 0; }

.msw-door__aperture { position: absolute; inset: 0; --open: 320vmax;
  -webkit-mask: url("assets/shapes/arch.svg") no-repeat center bottom / var(--open) auto;
          mask: url("assets/shapes/arch.svg") no-repeat center bottom / var(--open) auto; }
.msw-door__aperture img { width: 100%; height: 100%; object-fit: cover; display: block; }

.msw-door__label { position: relative; z-index: 2; margin: 0; text-align: center;
  color: #fff; opacity: var(--label-o, 1); pointer-events: none;
  font-family: var(--body); font-size: var(--t-label); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; }
/* Sits below the arch spring line so it reads as a sign over a door. */
.msw-door[data-enhanced] .msw-door__label { align-self: end; margin-bottom: 12vh; }

@media (max-width: 900px) {
  .msw-door__travel, .msw-door__stage { height: 62svh; }
  .msw-door__aperture { --open: 320vmax; }
}

/* ── 2 · CORNER MARKS ─────────────────────────────────────────────────
   Four brass crop marks at the frame of a full-bleed panel. These are the
   register marks off his own printed brochure - print furniture on screen,
   taken from his artefact rather than invented.

   One element, four gradients. Anything else needs four spans in markup
   for something purely decorative. */
.msw-marks { position: absolute; inset: var(--s-3); z-index: 4;
  pointer-events: none; opacity: .72;
  --m: 16px; --w: 1px; --c: var(--brass-invert);
  background:
    linear-gradient(var(--c),var(--c)) left  top    / var(--w) var(--m) no-repeat,
    linear-gradient(var(--c),var(--c)) left  top    / var(--m) var(--w) no-repeat,
    linear-gradient(var(--c),var(--c)) right top    / var(--w) var(--m) no-repeat,
    linear-gradient(var(--c),var(--c)) right top    / var(--m) var(--w) no-repeat,
    linear-gradient(var(--c),var(--c)) left  bottom / var(--w) var(--m) no-repeat,
    linear-gradient(var(--c),var(--c)) left  bottom / var(--m) var(--w) no-repeat,
    linear-gradient(var(--c),var(--c)) right bottom / var(--w) var(--m) no-repeat,
    linear-gradient(var(--c),var(--c)) right bottom / var(--m) var(--w) no-repeat; }
@media (max-width: 900px) { .msw-marks { display: none; } }

/* ── 3 · THE INDEX ────────────────────────────────────────────────────
   Six full-bleed panels earn a quiet moment. This is it: dense, purely
   typographic, no imagery at all. After a sequence of photographs a page
   of type is the strongest available change of pace, and it is the one
   layout on the site with no picture in it.

   Rows are a grid so the columns align exactly - a list that almost lines
   up reads as a mistake, where one that lines up reads as a document. */
.msw-index { border-top: var(--hairline-w) solid var(--brass); }
.msw-index__row { display: grid; grid-template-columns: 3.5rem 1fr 1fr 1fr;
  gap: var(--s-3); align-items: baseline; padding: var(--s-3) 0;
  border-bottom: var(--hairline-w) solid var(--hairline);
  text-decoration: none; color: inherit; }
a.msw-index__row:hover { background: rgba(0,0,0,.03); }
a.msw-index__row:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.msw-index__n { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: .1em; color: var(--brass); font-variant-numeric: tabular-nums; }
.msw-index__name { font-family: var(--display); font-weight: 600;
  font-size: clamp(1.15rem, 2.4vw, 1.9rem); text-transform: uppercase;
  letter-spacing: -.01em; }
.msw-index__meta { font-family: var(--body); font-size: .82rem; opacity: .72; }
.msw-index__head { display: grid; grid-template-columns: 3.5rem 1fr 1fr 1fr;
  gap: var(--s-3); padding-bottom: var(--s-2);
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .55; }
@media (max-width: 720px) {
  .msw-index__row, .msw-index__head { grid-template-columns: 2.5rem 1fr; }
  .msw-index__meta:last-child { display: none; }
}

/* ══════════════════════════════════════════════════════════════════════
   v5 · Dex's review list
   ══════════════════════════════════════════════════════════════════════ */

/* 2 · KITCHENS: no per-range ground. The colour change between ranges is
      jarring — it reads as a fault, not a transition. One ground, and the
      imagery carries the change. Light-and-shadow still moves the rest of
      the site; this section opts out because it is scroll-driven and any
      ground change here lands on top of the scrub. */
/* (kitchens ground is set statically on the base .msw-seq rule now) */

/* 3 · SELECTED WORK: butt the imagery up. A gutter between two full-bleed
      photographs is a seam that means nothing. Gaps only where text sits
      between images, because there the space is doing a job. */
/* Dex: "butt up the selected works so the imagery is touching - remove
   the gutter unless text is between." The first attempt only zeroed
   margin-LEFT, so every bay kept its 40px margin-right and the gutters
   came out exactly inverted: 40px between two photographs, and 0 either
   side of the text bay, which is the one place a gutter does a job.
   Measured 40/40/0/0/0 where it should read 0/0/40/40/0.

   Stated positively this time: bays have no margin at all. The text bay's
   own inner padding supplies the pause on both sides without changing its
   100vw pitch, so image bays touch and the traverse stays in step. */
.msw-across--flat .msw-across__row { gap: 0; }
.msw-across__bay { margin-inline: 0; }

/* 5 · THE TRACKER, back on the left edge. The bottom-left counter collided
      with body copy and with the door label. Vertical, in the gutter, out
      of the way of everything — which is where it was before and why it
      worked. Not a progress bar: no fill, no percentage. */
.msw-chapter { position: fixed; left: var(--s-3); top: 50%; bottom: auto;
  transform: translateY(-50%); z-index: 40;
  display: flex; flex-direction: column; align-items: center; gap: var(--s-2);
  writing-mode: vertical-rl; text-orientation: mixed;
  pointer-events: none; mix-blend-mode: difference; color: #fff;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  opacity: 0; transition: opacity 420ms var(--ease); }
.msw-chapter[data-on="true"] { opacity: .8; }
.msw-chapter__n { padding: 0; }
.msw-chapter__n::after { content: ""; position: absolute; left: 50%; bottom: -14px;
  width: 1px; height: 26px; background: currentColor; }
.msw-chapter__name { margin-top: 28px; }
@media (max-width: 1100px) { .msw-chapter { display: none; } }

/* 6 · EDGE TYPE above the frame furniture. It was sitting under the panel
      scrim and the caption, so the overlap read as a mistake. */
.msw-edgetype { z-index: 5; }
.msw-across__bay--screen::after { z-index: 1; }
.msw-across__bay--screen .msw-across__cap { z-index: 4; }

/* 7 · THE DIMENSION MARK — replaces the dot.
      A dot is a full stop with nothing to do. This is the mark an architect
      puts under a measurement: a rule with tick ends. It comes from
      drawings, which is his world, and unlike the dot it can carry a label.
      Used to close a passage of copy, or to underline a statement. */
.msw-dim { display: block; position: relative; height: 1px; width: 96px;
  margin: var(--s-4) 0 0; background: var(--brass); }
.msw-dim::before, .msw-dim::after { content: ""; position: absolute; top: -4px;
  width: 1px; height: 9px; background: var(--brass); }
.msw-dim::before { left: 0; } .msw-dim::after { right: 0; }
.msw-dim--wide { width: 190px; }
.msw-ground--dark .msw-dim, .msw-ground--warm .msw-dim,
/* NOT brass on brick: #c9a969 against #a4402c is two warm mid-tones and
   the mark came out olive. On the accent ground it takes the ink. */
.msw-ground--accent .msw-dim { background: var(--ink-on-accent); opacity: .5; }
.msw-ground--dark .msw-dim::before, .msw-ground--dark .msw-dim::after,
.msw-ground--warm .msw-dim::before, .msw-ground--warm .msw-dim::after {
  background: var(--brass-invert); }
.msw-ground--accent .msw-dim::before, .msw-ground--accent .msw-dim::after {
  background: var(--ink-on-accent); }
/* Underline variant: sits under a heading like a drawn rule. */
.msw-dim--under { margin-top: var(--s-2); width: min(100%, 320px); }

/* 8 - speech marks: the single definition now lives further down, next
      to the clipping fix. Two competing rules for one component is how a
      doubled-backslash content value survived three passes: the later
      rule masked it, so the broken one never showed itself. */


/* 9 · THE LAST THREE SECTIONS. They were the same height as everything
      above and read as an afterthought. Room is the cheapest luxury signal
      there is — the close should feel like it takes its time. */
.pad--tall { padding-top: clamp(6rem, 16vh, 12rem);
             padding-bottom: clamp(6rem, 16vh, 12rem); }
.msw-close { min-height: 78svh; display: grid; align-content: center; }
/* minmax(0, 1fr), and it is load-bearing. An `auto` column is sized by its
   items' MAX-CONTENT, and .msw-cred's content when it is crawling is two
   full copies of the run - measured at 2719px against a 990px viewport.
   The track grew to fit it, the footer grew with the track, and the whole
   document picked up 1.7 screens of horizontal scroll. overflow:hidden on
   the strip's own viewport does not prevent this: it clips the paint, it
   does not remove the intrinsic contribution. The fix has to be on the
   track. */
.msw-signoff-band { min-height: 46svh; display: grid; align-content: center;
                    grid-template-columns: minmax(0, 1fr);
                    padding: var(--s-5) 0; }
/* The footer supplies its OWN padding instead of borrowing .pad--t, and
   that is not tidying. .pad--t is defined per page, in each page's inline
   <style>, and the definitions disagree: five pages say
   `var(--s-5) var(--gutter)`, kitchens and services say
   `clamp(4.5rem,12vh,9rem) 0`. One class, two meanings, and the footer
   carried whichever the page happened to hold.

   Nothing showed that until the credentials strip bled full width by
   cancelling the gutter with negative margins. On the two pages where
   there was no gutter to cancel, it hung 50px past the right edge and
   gave the page a horizontal scrollbar. Those same two pages were also
   printing the designer credit flush against the glass.

   So the gutter moves onto the children that actually want one, the
   footer's box becomes the same on all seven pages, and the strip is
   full bleed by doing nothing rather than by guessing. */
.msw-signoff-band > .wrapc,
.msw-signoff-band > .dj-credit { padding-inline: var(--gutter); }

/* 2 · EDGE TYPE was clipped, not layered. The --right variant sat at
      right:-38px inside a panel with overflow:hidden, so no z-index could
      ever rescue it - the frame was cutting it off before stacking was
      considered. "Bring it higher in the index" needed the word inside the
      frame, not above other elements. It now sits within the panel and
      earns its impact from scale. */
.msw-edgetype { left: var(--gutter); right: auto; }
.msw-edgetype--right { left: auto; right: var(--gutter); text-align: right; }
/* Clears "WALK THROUGH IT.", which is laid over the top of the stage. At
   14% the word printed through the heading at 901 to 1920 wide. */
.msw-edgetype--top { top: max(14%, calc(var(--s-5) + 9.5rem)); bottom: auto; }

/* 4 · SPEECH MARKS. content:"C" was written with a doubled backslash,
      so CSS rendered the literal text C at 191px instead of a quote
      mark, and z-index:-1 pushed it behind the section ground. Literal
      character, positive stacking, no escaping to get wrong. */
/* The mark rendered correctly and was then CLIPPED. At 191px with
   top:-80px / left:-27px it hung outside its element, and every one of
   these sits inside an ancestor with overflow:hidden - the bay frame on
   the work traverse, and the .msw-hosts wrapper that cast light needs on
   the quote band. Same fault as the London edge type: present, correct,
   invisible. No z-index could rescue it, because clipping happens before
   stacking is considered.

   So the mark now sits INSIDE the box at the top-left, and goes behind
   the words with z-index:-1. That is safe here where it was not before:
   .msw-quoted sets position:relative with z-index:1 and isolation, which
   makes a stacking context, so -1 paints behind the text but cannot fall
   through to the section ground. */
.msw-quoted { position: relative; z-index: 1; isolation: isolate; }
.msw-quoted::before { content: "“"; position: absolute;
  left: -.04em; top: -.05em; z-index: -1; pointer-events: none;
  font-family: var(--display); font-weight: var(--display-weight);
  line-height: .74; font-size: var(--quote-size, 4.2em);
  color: var(--quote-ink, currentColor); opacity: var(--quote-o, .16); }
.msw-across__over .msw-quoted::before { --quote-o: .3; }
/* On the black traverse the mark at 30% read as a smudge behind the first
   word. Lifted, and pulled clear of the text so it frames rather than
   collides. */
.msw-across__bay--text .msw-quoted::before { --quote-o: .42;
  left: -.10em; top: -.16em; }
/* Grey rather than tinted ink, per Dex, so it reads as a mark rather than
   as faded text. Warm grey: a neutral one goes blue against the paper. */
/* Sized in em, so on body copy 4.2em is only ~71px while on a display
   quote it is 191px. Dex wants big marks everywhere, so the body variant
   gets its own multiple rather than a shared one that suits neither. */
.msw-quoted--grey { --quote-ink: #8c8880; --quote-o: .5; --quote-size: 8.5em; }

/* ══════════════════════════════════════════════════════════════════════
   v6 · THE CLOSE, MADE OF SOMETHING
   Dex: "make the contact section more exciting - texture and archway on
   the corners." Two devices, both from his world, neither decorative
   for its own sake:

   · ARCH OUTLINES in the corners. Same geometry as the doorway - straight
     jambs under a semicircular head - but drawn as hairlines, like an
     elevation on a drawing board. They rise past the section edge and get
     cropped, which is what makes them read as architecture rather than a
     sticker: buildings do not fit their paper. Drawn in CSS (a border with
     a 50%-of-width top radius IS an arch), so nothing to fetch and they
     inherit the ink colour of whatever ground they sit on.

   · GRAIN over the brick. A one-off accent ground held at a flat #a4402c
     reads as a swatch; under fine monochrome noise it reads as a fired
     surface. SVG feTurbulence inline, multiplied, 4 percent - felt, not
     seen. */
.msw-close--arched { position: relative; overflow: hidden; }
.msw-close--arched > .wrapc { position: relative; z-index: 2; }

.msw-archline { position: absolute; z-index: 1; pointer-events: none; bottom: 0;
  border: 1px solid currentColor; border-bottom: 0;
  border-radius: calc(var(--aw) / 2) calc(var(--aw) / 2) 0 0;
  width: var(--aw); height: calc(var(--aw) * 1.18); opacity: .3; }
/* Concentric pair per corner - one arch is a shape, two are a drawing.
   The inner keeps the 1px weight, so the pair reads as drawn rather than
   as a thick line. */
.msw-archline::before { content: ""; position: absolute; inset: 30px 30px 0 30px;
  border: 1px solid currentColor; border-bottom: 0;
  border-radius: calc((var(--aw) - 60px) / 2) calc((var(--aw) - 60px) / 2) 0 0; }

/* BOTH arches sit ON the bottom edge, because arches spring from a ground
   line - that is the whole difference between an arch and a circle. The
   first attempt floated one in the top-right corner and it cropped down to
   two bare verticals: the head was off the canvas, so nothing read as an
   arch at all. Cropping horizontally (by the left and right edges) keeps
   the head and loses the jamb, which is the half that carries the shape.
   Two openings on one ground line is an arcade elevation. */
.msw-archline--bl { --aw: clamp(220px, 27vw, 420px); left: calc(var(--aw) * -0.34); }
.msw-archline--br { --aw: clamp(130px, 15vw, 230px); right: calc(var(--aw) * -0.3); }
@media (max-width: 700px) { .msw-archline--br { display: none; } }

/* The grain. Multiply keeps the brick hue; the noise only takes light off. */
.msw-close--arched::after { content: ""; position: absolute; inset: 0;
  z-index: 0; pointer-events: none; opacity: .07; mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: 240px 240px; }

/* ══════════════════════════════════════════════════════════════════════
   v7 · A CAST LIGHT · B MATERIAL · C LIVING CORNERS
   Dex's brief, in his words: panelling, mood lighting, texture (concrete,
   metals, wood), bold lines, dark contrasts. Three devices cover it, and
   the reason all three can coexist is that they occupy different layers:
   A is light, B is surface, C is object. Nothing competes.

   Distribution rule: no section gets two of them, and no device appears
   in two adjacent sections. Restraint is the brief.
   ══════════════════════════════════════════════════════════════════════ */

/* ── A · CAST LIGHT ───────────────────────────────────────────────────
   An arch of light thrown across a ground from an opening off frame.
   His motif (the arch), his positioning (light and shadow), and
   brutalism's actual subject (what light does to mass) in one device.

   Same arch geometry as the doorway, but on a 100x260 viewBox rather than
   100x140. That matters: with preserveAspectRatio="none" a 100x140 arch
   stretched tall turns its semicircular head into an ellipse and reads as
   a blade. Keeping the head at 50 units and lengthening only the jambs
   keeps it an arch at any height.

   Colour comes from the ground it sits on, so it cannot clash. */
.msw-cast { position: absolute; inset: 0; z-index: 1; overflow: hidden;
            pointer-events: none; }
.msw-cast__beam { position: absolute; bottom: -7%;
  width: var(--bw, 130px); height: var(--bh, 338px);   /* 1 : 2.6 */
  background: var(--beam, rgba(255,255,255,.12));
  -webkit-mask: var(--msw-archmask) no-repeat; mask: var(--msw-archmask) no-repeat;
  -webkit-mask-size: 100% 100%; mask-size: 100% 100%;
  /* Sheared to the fixed light direction the shadow tokens already use. */
  transform: skewX(var(--skew, -20deg)) translateX(var(--rake, 0px));
  filter: blur(var(--soften, 4px)); }
:root { --msw-archmask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 260' preserveAspectRatio='none'%3E%3Cpath d='M0 260 V50 A50 50 0 0 1 100 50 V260 Z' fill='%23000'/%3E%3C/svg%3E"); }

.msw-cast--single .msw-cast__beam { left: 9%; }
/* An arcade is a repeat, so the beams must not drift in size. Falloff is
   opacity only, which is how light actually behaves. */
.msw-cast--arcade .msw-cast__beam:nth-child(1) { left: 2%; }
.msw-cast--arcade .msw-cast__beam:nth-child(2) { left: 23%; opacity: .8; }
.msw-cast--arcade .msw-cast__beam:nth-child(3) { left: 44%; opacity: .6; }
.msw-cast--arcade .msw-cast__beam:nth-child(4) { left: 65%; opacity: .42; }
/* Bigger, softer beams for tall sections. */
.msw-cast--tall .msw-cast__beam { --bw: 165px; --bh: 429px; --soften: 7px; }
@media (max-width: 700px) {
  .msw-cast--arcade .msw-cast__beam:nth-child(n+3) { display: none; }
}

/* ── B · MATERIAL GROUND ──────────────────────────────────────────────
   The grounds stop being flat colour and become material. All procedural:
   no image to fetch, no asset to keep in sync, and it scales to any
   section height for free.

   overlay blend rather than multiply, so a texture lightens where the
   ground is dark and darkens where it is light - one rule, both grounds. */
.msw-mat { position: absolute; inset: 0; z-index: 0; pointer-events: none; }

/* PANELLING. Dex named this first and it is the most his. What separates
   panelling from a striped background is the SHADOW GAP: a real panel has
   a dark recess on one side and a catch of light on the other, because it
   stands proud of the wall. Three stops per repeat, not one. */
.msw-mat--panel {
  background-image: repeating-linear-gradient(90deg,
    var(--panel-gap)   0px, var(--panel-gap)   1px,   /* the shadow gap  */
    var(--panel-lit)   1px, var(--panel-lit)   3px,   /* the light catch */
    transparent 3px, transparent var(--panel-w, 84px));
  opacity: .9; }
/* A shadow gap cannot darken pure black - on --ground-dark the first
   version was literally invisible, only the 7% light catch survived. So
   the two stops are tokens, and dark grounds lead with LIGHT: the recess
   is read from the highlight beside it, which is how a lit panel actually
   reads in a dark room. Mood lighting, not a striped background. */
:root { --panel-gap: rgba(0,0,0,.22); --panel-lit: rgba(255,255,255,.07); }
.msw-ground--dark, .msw-seq, .msw-signoff-band {
  --panel-gap: rgba(0,0,0,.55); --panel-lit: rgba(255,255,255,.16); }
.msw-ground--accent { --panel-gap: rgba(0,0,0,.3); --panel-lit: rgba(255,235,225,.14); }
/* Fluted: narrower, rounded, closer to reeded joinery than to boards. */
.msw-mat--fluted { --panel-w: 22px;
  background-image: repeating-linear-gradient(90deg,
    var(--panel-gap) 0px, transparent 6px,
    var(--panel-lit) 11px, transparent 16px,
    var(--panel-gap) var(--panel-w));
  opacity: .8; }

/* BOARD-MARKED CONCRETE. The shuttering grain brutalism is named after
   (beton brut = raw concrete): board joints plus fine aggregate noise. */
.msw-mat--concrete {
  background-image:
    repeating-linear-gradient(90deg,
      rgba(0,0,0,.15) 0 1px, rgba(255,255,255,.04) 1px 3px,
      transparent 3px 86px, rgba(0,0,0,.06) 86px 87px),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='260' height='260' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: auto, 260px 260px;
  opacity: .5; mix-blend-mode: overlay; }

/* RAW PLASTER. Low-frequency turbulence: trowel drift, not grain. */
.msw-mat--plaster {
  background-image:
    radial-gradient(120% 90% at 18% 6%, rgba(255,255,255,.08), transparent 62%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.02 0.05' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23p)'/%3E%3C/svg%3E");
  background-size: auto, 300px 300px;
  opacity: .34; mix-blend-mode: overlay; }

/* Sections carrying a material need their content lifted above it. */
.msw-mat + .wrapc, .msw-mat ~ .wrapc { position: relative; z-index: 2; }

@supports not (mix-blend-mode: overlay) {
  .msw-mat--concrete, .msw-mat--plaster { opacity: .16; mix-blend-mode: normal; }
}

/* ── C · LIVING CORNERS ───────────────────────────────────────────────
   Now with the real two-layer assets. The plant is STILL and the shadow
   is a separate image under one fixed light, because a swaying cut-out
   reads as a GIF the moment two of them fall in sync.

   Used ONCE per ground type and never beside another corner: one plant is
   a detail, plants in every corner is somebody else's site. */
/* The shadow layer sits under the plant, offset along the fixed light
   direction. It is a real render, not a CSS silhouette. */
/* The shadow render is a wide soft blur. At 50% on paper it read as a
   grey stain sitting beside the plant rather than as its shadow: on a
   light ground a diffuse mid-grey is just dirt. Pulled well back on
   paper, allowed to carry on dark grounds where it has somewhere to go,
   and tightened toward the plant so the two layers read as one object. */


/* ── Shared: sections hosting any of the three need a stacking root ────
   NO overflow here. It used to say `overflow: hidden` and that silently
   killed position:sticky on every descendant - which took out the
   kitchens scrub, the site's strongest moment, because .msw-seq is one of
   the sections that got this class. An ancestor with a non-visible
   overflow becomes the sticky element's scroll container, so the stage
   stopped pinning and just scrolled away.

   Nothing needed the crop anyway: .msw-mat is inset:0 on a relative
   parent so it cannot overflow, .msw-cast clips its own beams, .msw-corner
   sits at bottom:0, and .msw-close--arched sets its own overflow because
   its arches really do hang outside. Clip where something actually
   escapes, never as a blanket. */
.msw-hosts { position: relative; }
.msw-hosts > .wrapc { position: relative; z-index: 3; }

/* ══════════════════════════════════════════════════════════════════════
   v8 · DOORS AND PAGES
   The homepage linked to exactly one other page. Services and Kitchens
   are built, approved and live, and were unreachable from it.

   The rule: nav stays anchors, because the homepage is an experience and
   not a menu - turning the nav into page links throws away the traverse
   and the doorway. Instead each section carries ONE door at its end,
   where the argument lands and you have a reason to want more.
   ══════════════════════════════════════════════════════════════════════ */

/* ── THE DOOR ─────────────────────────────────────────────────────────
   Not a button. A button is a control; this is a threshold, so it is
   built as a full-width rule you cross, with an arch springing from it. */
.msw-door-out { display: block; position: relative; margin-top: var(--s-5);
  padding: var(--s-3) 0 0; border-top: 1px solid currentColor;
  text-decoration: none; color: inherit; }
.msw-door-out__row { display: flex; align-items: baseline; gap: var(--s-3);
  justify-content: space-between; flex-wrap: wrap; }
.msw-door-out__label { font-family: var(--body); font-size: var(--t-micro);
  font-weight: 500; letter-spacing: var(--track-wide); text-transform: uppercase;
  /* .68 measured 4.34:1 at 10px on the Work card, against 4.5 required. */
  opacity: .8; }
.msw-door-out__say { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(1.6rem, 4.2vw, 3.1rem); line-height: 1; letter-spacing: -.02em;
  display: flex; align-items: center; gap: .4em; }
/* The arrow is a drawn rule with a head, not a glyph: a text arrow sits on
   the baseline and reads as a link, a drawn one reads as a direction. */
.msw-door-out__arrow { display: inline-block; position: relative;
  width: clamp(38px, 6vw, 78px); height: 1px; background: currentColor;
  transition: width var(--dur) var(--ease); }
.msw-door-out__arrow::after { content: ""; position: absolute; right: 0; top: -4px;
  width: 9px; height: 9px; border-top: 1px solid currentColor;
  border-right: 1px solid currentColor; transform: rotate(45deg);
  transform-origin: top right; }
.msw-door-out:hover .msw-door-out__arrow,
.msw-door-out:focus-visible .msw-door-out__arrow { width: clamp(58px, 9vw, 118px); }
.msw-door-out:focus-visible { outline: 2px solid currentColor; outline-offset: 8px; }
/* The arch springing from the threshold line, cropped by it. */
.msw-door-out::before { content: ""; position: absolute; right: 0; top: -34px;
  width: 68px; height: 34px; border: 1px solid currentColor; border-bottom: 0;
  border-radius: 34px 34px 0 0; opacity: .3; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
  .msw-door-out__arrow { transition: none; }
}

/* ── SKIP LINK ────────────────────────────────────────────────────────
   Keyboard-only bypass past the nav/hero straight to <main id="main">.
   Unrelated to skip.js, which skips the scroll-scrubbed sequence, not
   navigation. */
.msw-skip-link { position: fixed; top: -60px; left: var(--gutter); z-index: 9999;
  background: var(--white); color: var(--black); padding: var(--s-2) var(--s-3);
  font-family: var(--body); font-size: 13px; text-decoration: none;
  transition: top .15s; }
.msw-skip-link:focus { top: var(--s-2); }

/* ── PAGE NAV ─────────────────────────────────────────────────────────
   Sub-pages DO get real navigation: you arrived on purpose, and the
   experience argument does not apply once you are off the homepage. */
/* flex-wrap is the backstop, not the design. Measured on a 390px phone the
   signature plus three tracked labels came to 401px in a 390px viewport and
   the whole PAGE scrolled sideways - a nav that does not fit is not allowed
   to drag the document with it. Wrapping to a second row is the honest
   failure; the rule below stops it ever getting there. */
.msw-pagenav { position: sticky; top: 0; z-index: 50;
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: var(--s-3); padding: var(--s-2) var(--gutter);
  background: var(--black); color: var(--white);
  border-bottom: 1px solid rgba(255,255,255,.14); }
/* THE OVERLAID VARIANT. The homepage used to carry a different navigation
   entirely - its own .nav, links on the LEFT, no signature, and a separate
   monogram floating in the centre - while every other page carried this
   one, signature left and links right. Same five words, two different
   components, which is the thing you notice without being able to name it.
   The homepage now uses this component too and only changes what the hero
   requires: no bar, no border, laid over the photograph rather than above
   it. Structure, order and spacing are identical everywhere. */
.msw-pagenav--over { position: absolute; top: 0; left: 0; right: 0;
  background: none; border-bottom: 0; padding-top: var(--s-3);
  color: #fff; }
/* The solid bar can afford .62 opacity because it sits on flat black. Over
   a photograph it cannot: painted-pixel samples ran 1.74:1 to 3.93:1 across
   nineteen widths, with exactly one passing. Full opacity, and a scrim of
   its own so the links have a ground rather than borrowing the hero's. */
.msw-pagenav--over::before { content: ""; position: absolute; inset: -10% 0 auto 0;
  height: 220%; pointer-events: none; z-index: -1;
  background: linear-gradient(180deg, rgba(14,7,18,.78) 0%,
              rgba(14,7,18,.45) 42%, rgba(14,7,18,0) 100%); }
.msw-pagenav--over .msw-pagenav__list a { opacity: 1;
  text-shadow: 0 1px 12px rgba(14,7,18,.7); }

.msw-pagenav__home { display: flex; align-items: center; gap: var(--s-2);
  min-height: 44px;   /* house-standard floor: it measured 30px */
  text-decoration: none; color: inherit; }
/* His real signature, not a wordmark: assets/marks/wordmark.svg does not
   exist and never did. The rule was well-formed and would have rendered a
   silent blank - the exact failure this codebase keeps producing. */
.msw-pagenav__mark { display: block; width: clamp(150px, 20vw, 210px); height: 30px;
  background: currentColor;
  -webkit-mask: url("assets/msw-signature.svg") no-repeat left center / contain;
          mask: url("assets/msw-signature.svg") no-repeat left center / contain; }
@supports not ((-webkit-mask: url("")) or (mask: url(""))) {
  .msw-pagenav__mark { background: url("assets/msw-signature-white.png") no-repeat left center / contain; }
}
.msw-pagenav__list { display: flex; gap: var(--s-3); list-style: none; margin: 0; padding: 0; }
/* min-height is the house-standard 44px floor, and its absence here made
   every sub-page nav link render 13px tall - a gate failure on Services,
   Kitchens and Contact at once, because they all share this component.
   .msw-nav__list a has carried the floor since the beginning; this one was
   written later and simply never got it. inline-flex so the box actually
   takes the height rather than the text sitting in a taller line box. */
.msw-pagenav__list a { display: inline-flex; align-items: center; min-height: 44px;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; text-decoration: none;
  color: inherit; opacity: .62; }
.msw-pagenav__list a:hover, .msw-pagenav__list a[aria-current="page"] { opacity: 1; }
/* One focus treatment across the site. These fell back to Chromium's 1px
   auto ring while Contact used an authored 2px/3px one; same control, two
   answers. Contact's is the direction. */
.msw-pagenav__list a:focus-visible, .msw-pagenav__home:focus-visible,
.msw-dotlink:focus-visible {
  outline: 2px solid currentColor; outline-offset: 3px; opacity: 1; }
.msw-pagenav__list a[aria-current="page"] { border-bottom: 1px solid currentColor; }
@media (max-width: 700px) { .msw-pagenav__list { gap: var(--s-2); } .msw-pagenav__mark { width: 96px; } }
/* 0.32em on three words is 38px of pure air, and on a phone that air is the
   difference between fitting and not. The wide tracking is a display effect
   set AGAINST the hero; in a 30px-tall nav bar it is buying nothing. */
@media (max-width: 460px) {
  .msw-pagenav { gap: var(--s-2); }
  .msw-pagenav__list { gap: var(--s-2); }
  .msw-pagenav__list a { letter-spacing: var(--track-label); }
}

/* ── PAGE HERO ────────────────────────────────────────────────────────
   Shorter than the homepage hero. You already know where you are; the
   page has to deliver, not to arrive. */
.msw-phero { position: relative; overflow: hidden;
  padding: clamp(4rem, 12vh, 9rem) 0 clamp(3rem, 8vh, 6rem); }
.msw-phero__k { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .7; }
.msw-phero h1 { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(2.6rem, 9vw, 7rem); line-height: .94; letter-spacing: -.03em;
  margin: var(--s-2) 0 0; }
.msw-phero__sub { max-width: 54ch; margin-top: var(--s-3); font-size: 1.05rem; }

/* ── THE STAGES (services) ────────────────────────────────────────────
   Five numbered stages. Deliberately NOT the hand-drawn journey line yet:
   that asset exists but is unverified at this width, and a line that
   breaks is worse than no line. Numbered rows read as a process and
   survive at any width. */
.msw-stages { list-style: none; margin: 0; padding: 0; }
.msw-stage { display: grid; grid-template-columns: 5.5rem 1fr; gap: var(--s-3);
  padding: var(--s-4) 0; border-top: 1px solid rgba(0,0,0,.12); align-items: start; }
.msw-ground--dark .msw-stage { border-top-color: rgba(255,255,255,.16); }
.msw-stage__n { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(2rem, 5vw, 3.4rem); line-height: .9; opacity: .22; }
.msw-stage h3 { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(1.3rem, 3vw, 2rem); line-height: 1.02; margin: 0 0 var(--s-2); }
.msw-stage p { margin: 0; max-width: 62ch; }
@media (max-width: 700px) { .msw-stage { grid-template-columns: 3rem 1fr; gap: var(--s-2); } }

/* ── RANGE GRID (kitchens page) ───────────────────────────────────────
   The homepage shows nine ranges as a scrub you cannot control. The page
   owes you the opposite: all nine at once, comparable, in your order. */
.msw-ranges { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
  background: rgba(255,255,255,.14); }
@media (max-width: 900px) { .msw-ranges { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .msw-ranges { grid-template-columns: 1fr; } }
.msw-range { position: relative; display: block; background: var(--black);
  color: var(--white); text-decoration: none; overflow: hidden; }
.msw-range img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover;
  transition: transform 620ms var(--ease); }
.msw-range:hover img, .msw-range:focus-visible img { transform: scale(1.03); }
.msw-range:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; }
.msw-range__body { padding: var(--s-3); }
.msw-range__n { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .55; }
.msw-range h3 { font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(1.4rem, 2.6vw, 2rem); line-height: 1; margin: var(--s-1) 0 var(--s-2);
  letter-spacing: -.02em; }
.msw-range p { margin: 0; font-size: .92rem; opacity: .82; }
@media (prefers-reduced-motion: reduce) { .msw-range img { transition: none; } }

/* ── THE PICKER (visualiser v0) ───────────────────────────────────────
   The real visualiser is a separate build. This is the honest v1 of the
   same loop: choose a range, say where it is going, and the enquiry
   carries the choice. It captures the lead the visualiser would capture
   without pretending to be the visualiser. */
.msw-pick { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: var(--s-3) 0 0;
  padding: 0; list-style: none; }
.msw-pick__opt { position: relative; }
.msw-pick__opt input { position: absolute; inset: 0; opacity: 0; width: 100%;
  height: 100%; margin: 0; cursor: pointer; }
/* The chips measured 29px. They are the primary control of the enquiry
   section, so they were the worst tap targets on the site. */
.msw-pick__opt span { display: flex; align-items: center; min-height: 44px;
  padding: .7em 1.1em; border: 1px solid currentColor;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase;
  opacity: .55; transition: opacity var(--dur) var(--ease); }
/* MEASURED BROKEN, fixed 2026-08-13. This was `background: currentColor;
   color: var(--black)`, and currentColor in a background resolves against the
   element's OWN computed color - which that very block had just set to black.
   So the chosen range rendered as black on black at 1:1: invisible, on the one
   control the whole section exists for. Never pair `background: currentColor`
   with a `color` in the same block; name both ends explicitly, per ground. */
.msw-pick__opt input:checked + span { opacity: 1;
  background: var(--ink); color: var(--ground); }
.msw-ground--dark .msw-pick__opt input:checked + span,
.msw-ground--warm .msw-pick__opt input:checked + span,
.msw-ground--accent .msw-pick__opt input:checked + span {
  background: var(--ink-invert); color: var(--ink-strong); }
.msw-pick__opt input:focus-visible + span { outline: 2px solid currentColor; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .msw-pick__opt span { transition: none; } }

/* ── FORM ─────────────────────────────────────────────────────────────
   Hairline rules, no boxes. A boxed input on a page with no other boxes
   is the single loudest thing on it, which is the wrong emphasis for a
   form nobody wants to fill in. */
.msw-field { display: block; margin-top: var(--s-4); }
.msw-field > span { display: block; font-family: var(--body); font-size: var(--t-micro);
  font-weight: 500; letter-spacing: var(--track-wide); text-transform: uppercase;
  opacity: .68; margin-bottom: var(--s-1); }
.msw-field input, .msw-field textarea, .msw-field select {
  width: 100%; background: none; border: 0; border-bottom: 1px solid currentColor;
  padding: .6em 0; min-height: 44px;   /* measured 41px on a phone */
  color: inherit; font-family: var(--body); font-weight: 300;
  font-size: 1.05rem; border-radius: 0; }
.msw-field textarea { resize: vertical; min-height: 5.5em; }
/* outline:0 with a 1px-to-2px border swap is not a focus indicator. On a
   photograph, under a scrim, one extra pixel on a hairline is invisible -
   the scorecard's focus pass walked the kitchens form and found all three
   fields reporting no ring at all. :focus-visible gets a real outline;
   :focus keeps the border weight change as the mouse affordance, so
   clicking into a field still reads without a ring being painted for
   people who did not ask for one. */
.msw-field input:focus, .msw-field textarea:focus, .msw-field select:focus {
  outline: 0; border-bottom-width: 2px; }
.msw-field input:focus-visible, .msw-field textarea:focus-visible,
.msw-field select:focus-visible {
  outline: 2px solid currentColor; outline-offset: 3px; }
.msw-field input::placeholder, .msw-field textarea::placeholder { color: inherit; opacity: .34; }

/* ── AMBIENT SPILL ────────────────────────────────────────────────────
   The highest-value device in the research and the cheapest honest one:
   a blurred duplicate of a photograph sitting behind the sharp one, so a
   framed image pools light onto its ground instead of being pasted on it.

   The reason it never reads as a 2010s gradient is that the colour is
   SAMPLED FROM THE PHOTOGRAPH. Nobody picks it, so it is right by
   construction, and for a designer whose photographs are the product it
   is the correct source of light.

   No extra elements: the figure carries --spill inline with the same URL
   as its <img>, so the browser serves it from cache. Sits at z-index 0
   behind the picture, and is purely decorative, so it is invisible to
   assistive tech by being a pseudo-element. */
/* :where() so this carries ZERO specificity. Written as a plain
   `.msw-spill { position: relative }` it sat later in the file than
   `.msw-seq__fig { position: absolute; inset: 0 }` at equal specificity
   and silently won, dropping all nine kitchen figures out of their
   absolute stack. The host keeps whatever positioning it already has;
   this only supplies one when there is none. */
:where(.msw-spill) { position: relative; }
.msw-spill { isolation: isolate; }
.msw-spill::before { content: ""; position: absolute; z-index: 0;
  inset: 8% 6%; background-image: var(--spill); background-size: cover;
  background-position: center; pointer-events: none;
  filter: blur(var(--spill-blur, 72px)) saturate(var(--spill-sat, 1.25));
  opacity: var(--spill-o, .78); transform: scale(1.06); }
.msw-spill > * { position: relative; z-index: 1; }
/* Blur is expensive on a scroll-driven section, so the kitchens sequence
   gets a smaller radius rather than a second compositing layer. */
.msw-seq__fig { --spill-blur: 58px; --spill-o: .7; }
/* Held until ranges.js marks the section near (see there). Not gated on
   [data-enhanced]: that is set by a deferred script, after first style, by
   which time the backgrounds have already been requested. Without JS the
   figures simply have no glow. */
.msw-seq:not([data-near]) .msw-spill::before { background-image: none; }
@supports not (filter: blur(1px)) { .msw-spill::before { display: none; } }

/* ══════════════════════════════════════════════════════════════════════
   THE WALL · the page transition
   A solid plane with real material on it sweeps across and occludes the
   outgoing page. Brutalism is mass, so the transition has mass: it takes
   the shadow-gap panelling and the concrete noise, and a lit hairline on
   the leading face so it reads as a solid with a thickness rather than a
   coloured rectangle.

   It is NOT the View Transitions API. The wall needs its own material and
   its own lit edge, and an element animated outside the transition is not
   part of the document snapshot, so a real element gives the control this
   needs. It also degrades to nothing if the script never loads.
   ══════════════════════════════════════════════════════════════════════ */
.msw-wall { position: fixed; inset: 0; z-index: 9000; pointer-events: none;
  background-color: var(--black);
  background-image:
    repeating-linear-gradient(90deg,
      rgba(0,0,0,.55) 0 1px, rgba(255,255,255,.16) 1px 3px, transparent 3px 84px),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.75' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='260' height='260' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
  background-size: auto, 260px 260px;
  transform: translateX(-101%); will-change: transform; }
/* The lit face. Which edge catches the light depends on which way the
   plane is travelling, so each direction lights its own leading edge. */
.msw-wall::after { content: ""; position: absolute; top: 0; bottom: 0; width: 2px;
  background: rgba(255,255,255,.5); }
.msw-wall--leave-fwd::after,  .msw-wall--arrive-fwd::after  { right: 0; }
.msw-wall--leave-back::after, .msw-wall--arrive-back::after { left: 0; }

/* Leaving: sweep in until the frame is covered, and stop. The navigation
   happens on the far end of this, so the plane must NOT continue off. */
.msw-wall--leave-fwd  { animation: msw-wall-in-l 420ms cubic-bezier(.76,0,.24,1) forwards; }
.msw-wall--leave-back { animation: msw-wall-in-r 420ms cubic-bezier(.76,0,.24,1) forwards; }
@keyframes msw-wall-in-l { from { transform: translateX(-101%); } to { transform: translateX(0); } }
@keyframes msw-wall-in-r { from { transform: translateX(101%); }  to { transform: translateX(0); } }

/* Arriving: the plane is already over the viewport, put there before
   first paint by the inline head script. Slide it off the way it came. */
html[data-wall] .msw-wall,
.msw-wall--arrive-fwd, .msw-wall--arrive-back { transform: translateX(0); }
.msw-wall--arrive-fwd  { animation: msw-wall-out-l 460ms cubic-bezier(.76,0,.24,1) 40ms forwards; }
.msw-wall--arrive-back { animation: msw-wall-out-r 460ms cubic-bezier(.76,0,.24,1) 40ms forwards; }
@keyframes msw-wall-out-l { from { transform: translateX(0); } to { transform: translateX(101%); } }
@keyframes msw-wall-out-r { from { transform: translateX(0); } to { transform: translateX(-101%); } }

/* The pre-paint cover is declared INLINE in each page head, not here:
   from an external sheet it loses the race against the page's own inline
   <style>, and the browser paints one full paper frame before it applies.
   A frame burst caught exactly that. This rule only removes it once the
   wall element is real and animating. */
html[data-wall] .msw-wall ~ * { }

@media (prefers-reduced-motion: reduce) {
  .msw-wall { display: none; }
  html[data-wall]::before { display: none; }
}

/* ══════════════════════════════════════════════════════════════════════
   THE RANGE BLOCK
   The kitchens page was a faithful port of Matt's approved page: it got
   its words back and no design idea of its own, and scored 21 against a
   homepage on 36. This is the idea.

   Four moves, and every value in it is derived from something real:
     · the Main Shoot full-bleed, at the scale the photograph deserves
     · the range name at the site's top type size, cut by the band above
     · a vertical ACCENT beside the imagery, which is what CLASSIC and
       Industrial always were on his own page - they sat in a
       `vertical-title-div` in a grid cell next to the image grid, not as
       section headings. Ported into a copy column they became a clipped
       fragment, which is why the first attempt dropped them.
     · a finish strip: the names read out of the supplier filenames, each
       swatch sampled from the cabinetry in that finish's own photograph
   ══════════════════════════════════════════════════════════════════════ */
.rng { position: relative; padding: 0 0 var(--s-5);
       --name-pull: calc(.16 * clamp(3.4rem, 13.4vw, 11.5rem));
       /* clip, NOT hidden. The long names are meant to run off the edge:
          at 184px "COPENHAGEN." measures 1438px against a 1296px content
          box and pushed the document to 1510px wide. Being cut by the
          edge is the point; the horizontal scrollbar was not.

          overflow-x: clip rather than hidden because clip does not make a
          scroll container, so it cannot do to a sticky descendant what
          `overflow: hidden` on .msw-hosts did to the kitchens scrub. */
       overflow-x: clip; }

/* The counter needs clearance that TRACKS the name's negative pull, not a
   flat margin: the name is translated up .16em to sit tight against the
   image, which at 184px is 29px, so a fixed 24px left it buried. */
.rng__n { display: block; padding: var(--s-5) var(--gutter) 0;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .55;
  margin-bottom: calc(var(--s-3) + var(--name-pull)); }

.rng__name { font-family: var(--display); font-weight: 900;
  font-size: clamp(3.4rem, 13.4vw, 11.5rem); line-height: .78;
  letter-spacing: -.035em; margin: 0; padding: 0 var(--gutter);
  position: relative; z-index: 3; transform: translateY(-.16em); }

.rng__main { position: relative; width: 100%; aspect-ratio: 16 / 9;
             overflow: hidden; background: #000;
             container-type: size; }   /* so the accent can size off it */
.rng__main img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Sized to FIT. At 12vw the run measured 880px against an 810px frame and
   lost its first and last letters; Dex wants the whole word readable. */
.rng__accent { position: absolute; inset: 0 0 0 auto; z-index: 2;
  display: flex; align-items: center; pointer-events: none; overflow: hidden;
  writing-mode: vertical-rl; text-orientation: mixed; }
/* Sized off the frame's own height divided by the character count, so
   any word fits by construction. A single vw size cannot serve both:
   CLASSIC is 7 characters and INDUSTRIAL is 10, and at one size the
   second ran 974px inside a 639px frame. --acc-len is set per word. */
.rng__accent span { font-family: var(--display); font-weight: 900;
  font-size: min(8.4rem, calc(88cqh / var(--acc-len, 8)));
  line-height: .92; letter-spacing: .04em;
  color: #fff; opacity: .92; mix-blend-mode: difference;
  transform: translateX(-.06em); }

.rng__copy { padding: var(--s-4) var(--gutter) 0; max-width: 64ch; }
.rng__copy p { margin: 0; }

/* THE FINISH STRIP. A specification schedule, not a colour picker.
   There is deliberately no door-construction column: the words shaker,
   inframe, painted and handleless appear once or twice across the whole
   source with no mapping to any range, and a plausible spec beside a
   client's product is the one thing on the page a supplier could
   contradict. If Matt supplies construction per range, it goes here. */
.rng__spec { padding: var(--s-4) var(--gutter) 0; }
.rng__spech { display: flex; align-items: baseline; gap: var(--s-3);
  border-bottom: 1px solid currentColor; padding-bottom: 10px;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; }
.rng__spech span:first-child, .rng__spech span:last-child { opacity: .55; }
.rng__spech span:last-child { margin-left: auto; }
.rng__row { display: grid; grid-template-columns: 26px 1fr auto; gap: var(--s-3);
  align-items: center; padding: 15px 0;
  border-bottom: 1px solid color-mix(in srgb, currentColor 22%, transparent); }
.rng__sw { width: 26px; height: 26px;
  border: 1px solid color-mix(in srgb, currentColor 45%, transparent); }
.rng__fin { font-family: var(--display); font-weight: 600;
  font-size: clamp(1rem, 2vw, 1.35rem); letter-spacing: -.01em; }
.rng__meta { font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .6; }
@supports not (color: color-mix(in srgb, red 50%, blue)) {
  .rng__row { border-bottom-color: rgba(128,128,128,.35); }
  .rng__sw  { border-color: rgba(128,128,128,.6); }
}

/* The two frames are a COMPARISON of finishes, not a continuous run of
   imagery, so a gutter belongs between them. Same rule as the Selected
   work traverse: pictures butt, and space appears only where the content
   changes register. */
.rng__cameos { display: grid; grid-template-columns: 1.62fr 1fr;
  gap: var(--s-3); padding: var(--s-5) var(--gutter) 0; }
.rng__cameos figure { margin: 0; position: relative; overflow: hidden; }
.rng__cameos img { width: 100%; height: 100%; object-fit: cover;
                   display: block; aspect-ratio: 4 / 3; }
/* No blend mode on the caption. A photograph is not a predictable ground:
   difference-blended over the pale Vintage Pink frame it was invisible. */
.rng__cameos figcaption { position: absolute; left: 0; bottom: 0;
  padding: 9px 14px; background: rgba(15,15,15,.82); color: #fff;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; }
@media (max-width: 760px) {
  .rng__cameos { grid-template-columns: 1fr; }
  .rng__accent { display: none; }   /* no room for it beside a phone frame */
}

/* ══════════════════════════════════════════════════════════════════════
   MOOD LIGHT
   Chosen from the depth prototype. Not a vignette in the filter sense: a
   falloff with a source. Dark gathers at the edges and light pools where
   the content sits, so a section reads as LIT rather than tinted. That is
   the same argument as ambient spill, applied to the ground instead of to
   a photograph.

   The two other candidates were rejected: the blurred near plane and
   atmospheric perspective on the far plane. Recorded so nobody rebuilds
   them - they were prototyped, looked at, and turned down.

   Sits above the ground and below the content. Sections using it must
   establish a stacking context, which .msw-hosts already does.
   ══════════════════════════════════════════════════════════════════════ */
.msw-moodlight { position: relative; }
.msw-moodlight::after { content: ""; position: absolute; inset: 0; z-index: 1;
  pointer-events: none;
  background: radial-gradient(var(--mood-size, 120% 90%)
    at var(--mood-at, 24% 50%),
    rgba(255,244,230, var(--mood-lift, .13)) 0%,
    rgba(255,244,230, calc(var(--mood-lift, .13) * .3)) 34%,
    rgba(0,0,0, var(--mood-fall, .26)) 74%,
    rgba(0,0,0, calc(var(--mood-fall, .26) * 1.9)) 100%); }
.msw-moodlight > * { position: relative; z-index: 2; }
/* On paper the lift has nothing to lift and the falloff turns it grey, so
   light grounds get the falloff only, and gently. */
.msw-ground .msw-moodlight, .msw-moodlight.msw-ground {
  --mood-lift: 0; --mood-fall: .07; }

/* ── THE LIVING CORNER ────────────────────────────────────────────────
   One object, one section, used once on the whole site. The white-bract
   bougainvillea: white because the magenta fought the brick accent
   badly, and white bougainvillea is a real cultivar so this is not a
   fake recolour.

   POSITION FOLLOWS SHAPE. The asset is 1200x451 - a 2.7:1 LANDSCAPE
   hanging branch, the woody stem running the long way with the bloom
   mass gathered on one side of it. As a bottom corner it measured
   374x141 and read as a smear along the bottom edge, because a hanging
   branch has no business at a bottom corner. An upright plant belongs
   there; a branch belongs along a TOP edge, hanging down. Rotated 180deg
   rather than mirrored, so the mass hangs below the stem and the thick
   cut end enters from off-page right with the growth thinning toward the
   middle - the direction a branch actually runs when it comes over a
   wall.

   CUT BY TWO EDGES. Placement is the only depth cue available here:
   there is no shadow layer (shadows were dropped site-wide) and no
   blurred near plane (prototyped, rejected). 48% of the branch sits
   above the section top and 14% past its right, so the section crops it
   on both. Whole inside the frame it is a sticker, which is what went
   wrong every previous time.

   Ground rule from the palette test: white bract on dark and brick only.
   On paper it disappears. */
.msw-corner { position: absolute; z-index: 2; pointer-events: none;
  width: clamp(460px, 64vw, 940px); }
.msw-corner img { display: block; width: 100%; height: auto; }
.msw-corner--t { right: 0; top: 0; transform: translate(14%, -48%) rotate(180deg); }
@media (max-width: 820px) { .msw-corner { display: none; } }
/* The close's 78svh is room for the branch to hang into. With the branch
   hidden that room was 228px of bare brick above "Let's bring your vision
   to life" and as much again below GET STARTED at 390. Sizing to content
   (64px each side) was too tight, so it keeps half a screen: about 110px
   each side at 390x844, with the 260px arch pair clear of the top. */
@media (max-width: 820px) { .msw-close:has(> .msw-corner) { min-height: 50svh; } }
@media (prefers-reduced-motion: no-preference) {
  /* The plant is still. Only its opacity settles as the section arrives,
     so it reads as part of the room rather than as an element that flew
     in. One transition, no loop: a corner that keeps moving is a GIF. */
  .msw-corner { opacity: 0; transition: opacity 900ms var(--ease) 120ms; }
  .msw-corner[data-in="true"] { opacity: var(--corner-o, .92); }
}
@media (prefers-reduced-motion: reduce) { .msw-corner { opacity: var(--corner-o, .92); } }

/* ══════════════════════════════════════════════════════════════════════
   PROOF
   The site was making four claims with nothing behind them: BBC Interior
   Design Masters, Commercial, Hospitality, and kitchens actually
   installed. Every one had photography sitting unused. These are the
   components that put the evidence next to the claim.
   ══════════════════════════════════════════════════════════════════════ */

/* A row of frames that substantiates one claim. Butted, because they are
   one project seen several ways rather than separate things being
   compared - the gutter rule from the work traverse, applied here. */
.msw-proof { padding: var(--s-5) 0 0; }
.msw-proof__k { padding: 0 var(--gutter); margin: 0 0 var(--s-3);
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .62; }
.msw-proof__row { display: grid; grid-auto-flow: column;
  grid-auto-columns: minmax(240px, 1fr); gap: 0;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  /* Feathered ends, so a horizontal run does not stop dead against the
     page edge. One line, from the research. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 3%, #000 97%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 3%, #000 97%, transparent); }
.msw-proof__row figure { margin: 0; scroll-snap-align: start; }
.msw-proof__row img { display: block; width: 100%; height: 100%;
  aspect-ratio: 4 / 3; object-fit: cover; }
@media (max-width: 700px) { .msw-proof__row { grid-auto-columns: 78vw; } }

/* ── The same strip, doubled: what was drawn above what got built ─────
   The two rows have to stay in register, so this is NOT two scrollers.
   One scroller holds one row of figures, and each figure stacks its own
   pair - which makes sliding them out of alignment structurally
   impossible rather than merely unlikely.
   Only four columns, not five. There is a fifth render, but the only
   photograph of that room has two people in the foreground, and a pair
   that does not correspond teaches the reader to distrust the ones that
   do. The fifth render lives on the project page instead. */
.msw-proof__key { padding: 0 var(--gutter); margin: calc(var(--s-3) * -1) 0 var(--s-3);
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .4; }
.msw-proof__row--pairs { grid-auto-columns: minmax(260px, 1fr); }
.msw-proof__pair { display: grid; align-content: start; }
/* A hairline only between the drawing and the room, so the eye reads the
   column as a pair rather than as two unrelated frames butted together. */
.msw-proof__pair img + img { border-top: var(--hairline-w) solid var(--hairline-invert); }
.msw-proof__pair figcaption { padding: var(--s-2) var(--s-2) var(--s-4);
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .55; }
@media (max-width: 700px) { .msw-proof__row--pairs { grid-auto-columns: 72vw; } }

/* A single full-bleed frame used as a band. */
.msw-bleed { margin: 0; }
.msw-bleed img { display: block; width: 100%; height: auto;
  max-height: 62svh; object-fit: cover; }

/* The portrait on About. Deliberately small and square-cropped: he is the
   author of the work, not the subject of the page, and a large portrait
   would outrank the rooms. */
/* The portrait as a COLUMN cropped by the section edge, from shotgun C.
   Dex kept the current headings, signature and accents and took only the
   image treatment. A 168px square read as an avatar: it cannot compete
   with 184px display type a few inches away, so the eye skipped it.

   Bleeding past the top and bottom obeys the rule the rest of the site
   already follows - an object must be cut by an edge or it reads as a
   sticker. */
/* height STATED, not auto. An absolutely positioned <img> with height:auto
   ignores `bottom` and takes its intrinsic ratio, so it bled past the top
   and then stopped 425px short of the bottom - cropped at one edge and
   floating at the other, which is the worst of both. 114% is top -7% plus
   bottom -7%. */
.msw-portrait { position: absolute; right: 0; top: -7%; z-index: 1;
  width: clamp(170px, 23vw, 330px); height: 114%;
  object-fit: cover; object-position: 52% 30%; margin: 0; }
/* Scoped to this section, NOT to .msw-hosts. A blanket overflow on the
   shared wrapper is what killed position:sticky on the kitchens scrub. */
.msw-about { overflow: hidden; }
/* NOT the generic .two 50/50 grid. That splits whatever is left AFTER the
   image reservation, so the copy took a quarter of the width: measured
   24ch at 1920 and 11ch at 1150, which is twenty-five lines of ribbon.
   The copy gets a stated minimum measure instead and the heading takes
   the remainder, and the image reservation is part of the same sum rather
   than padding applied on top of it.

   Written as .wrapc.two so it beats the generic rule on specificity
   rather than on source order. */
.msw-about > .wrapc.two { position: relative; z-index: 2;
  /* min-content on the heading track, NOT 0. With minmax(0,1fr) the
     display type simply overflowed its column: at 1440 it overflowed into
     whitespace and looked fine, at 1150 it overflowed straight through
     the paragraph. min-content forces the track to fit "STRUCTURE", so
     the copy is pushed rather than sat on. */
  grid-template-columns: minmax(min-content, 1fr) minmax(30ch, 44ch);
  padding-right: calc(clamp(170px, 23vw, 330px) + var(--s-5)); }
/* The two-column tracks are intrinsically wider than the space beside the
   portrait just above 1320px: at 1360px Chromium resolves them to 610.25px
   + 64px gap + 315.83px, putting the copy 10.88px into the image. Keep the
   single-column composition through 1400px; at 1401px the same fixed track
   minimums clear the portrait, and the clearance only grows from there. */
@media (max-width: 1400px) {
  .msw-about > .wrapc.two { grid-template-columns: minmax(0, 1fr); }
  /* The stacked row is taller; hold the deliberate bleed at 49px. */
  .msw-portrait { top: -49px; height: calc(100% + 98px); }
}
@media (max-width: 900px) {
  .msw-portrait { position: static; width: clamp(120px, 22vw, 200px);
    aspect-ratio: 1; height: auto; margin: 0 0 var(--s-3); }
  .msw-about > .wrapc.two { padding-right: var(--gutter); }
}

/* ══════════════════════════════════════════════════════════════════════
   THE JOURNEY
   The nine-range scrub used to end on a link hanging off the bottom of a
   black band, with a blank paper section under it. Dex: "give it some
   love, we need a proper section for this design journey, not text
   hanging off the bottom and a blank section."

   Three steps and no more, because three is all that is TRUE. An earlier
   version of this site carried a five-stage process that existed nowhere
   in Matt's copy because I invented it. These three are the loop the
   kitchens picker actually runs.
   ══════════════════════════════════════════════════════════════════════ */
.msw-journey { position: relative; overflow: hidden; background: var(--black);
  color: var(--white); padding: clamp(5rem, 13vh, 10rem) 0 clamp(4rem, 10vh, 8rem); }

/* The photograph is a PLATE behind the content, not a background image:
   half-bled off the right so the section has somewhere to recede to. */
.msw-journey__plate { position: absolute; inset: 0 0 0 46%; z-index: 0; }
.msw-journey__plate img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* It has to hand over to the ground rather than stop at a hard vertical,
   which would read as two panels rather than one room. */
.msw-journey__plate::after { content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg, var(--black) 0%, rgba(15,15,15,.72) 34%,
    rgba(15,15,15,.18) 74%, rgba(15,15,15,.06) 100%); }
.msw-journey__in { position: relative; z-index: 3; }

.msw-journey__k { opacity: .72; }
/* This was a one-off scale: 112px/-3.36px against .msw-display's
   96px/-1.92px at 1440, so the page carried two display sizes 16.7%
   apart with tracking 75% tighter on one of them. Same family, same
   weight, different system - which is the definition of drift. It uses
   the display tokens now; the only thing kept local is the measure and
   the margins, which are about this section's layout, not its voice. */
.msw-journey__h { font-family: var(--display); font-weight: var(--display-weight);
  font-size: var(--t-display); line-height: var(--lh-display);
  letter-spacing: var(--track-display);
  margin: var(--s-2) 0 var(--s-5); max-width: 14ch; }

/* Numbered, ruled, and held to a measure. A drawn rule between steps
   rather than a card each: cards would make three small boxes where the
   page wants one continuous argument. */
.msw-journey__steps { list-style: none; margin: 0; padding: 0; max-width: 62ch; }
.msw-journey__steps li { display: grid; grid-template-columns: 4.5rem 1fr;
  column-gap: var(--s-3); row-gap: 6px; align-items: start;
  padding: var(--s-4) 0; border-top: 1px solid rgba(255,255,255,.2); }
.msw-journey__steps li:last-child { border-bottom: 1px solid rgba(255,255,255,.2); }
/* span 2 rows. The li has THREE children in a two-column grid, so
   without this the description wrapped into column one and set itself one
   word per line under the number. */
.msw-journey__n { grid-row: span 2; align-self: start;
  font-family: var(--display); font-weight: var(--display-weight);
  font-size: clamp(1.6rem, 3vw, 2.4rem); line-height: .9; opacity: .34; }
.msw-journey__steps b { display: block; font-family: var(--display); font-weight: 600;
  font-size: clamp(1.05rem, 2.1vw, 1.5rem); line-height: 1.1; letter-spacing: -.01em;
  margin-bottom: 6px; }
.msw-journey__steps span:not(.msw-journey__n) { display: block; font-size: .96rem;
  line-height: 1.6; opacity: .78; max-width: 46ch; }
@media (max-width: 640px) {
  .msw-journey__steps li { grid-template-columns: 2.6rem 1fr; gap: var(--s-2); }
}

/* The nine ranges, and the only place on the site where all nine are
   visible at once.

   These were flat colour chips sampled from each range's photograph, and
   they did not work: five of the nine came back within dE 10 of each
   other, because the ranges genuinely ARE nine warm neutrals. They differ
   by MATERIAL and FINISH, not by hue, so colour was the wrong encoding -
   no amount of better sampling was going to separate them.
   They are now 28px crops of the ranges themselves, taken from the most
   textured square in each frame's cabinetry band, because texture carries
   exactly what colour could not. One sprite, 24kb, nine cells.
   var(--chip) stays behind as the cell's mean colour, so the row still
   reads as nine distinct swatches before the sprite arrives. */
/* A wrapping flex row put eight on one line and orphaned Sydney on the
   next. Nine wants to be seen as nine, so it is set as a 3x3 block sized
   to the steps column beside it rather than to the whole page. */
.msw-journey__chips { list-style: none; display: grid; max-width: 44rem;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-3) var(--s-4); margin: var(--s-5) 0 0; padding: 0; }
@media (max-width: 620px) {
  .msw-journey__chips { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.msw-journey__chips li { display: flex; align-items: center; gap: 10px;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase; opacity: .82; }
.msw-journey__chip { width: 28px; height: 28px; flex: 0 0 auto;
  background-color: var(--chip);
  background-image: url("assets/photos/range-chips.jpg");
  background-size: 900% 100%;
  background-position: calc(var(--i) * 12.5%) 50%;
  border: 1px solid rgba(255,255,255,.28); }

.msw-journey__go { max-width: 62ch; }

/* ══════════════════════════════════════════════════════════════════════
   THE WAY OUT · escaping the kitchens sequence
   ──────────────────────────────────────────────────────────────────────
   .msw-seq__track is 420svh (340svh on phones). A visitor who does not
   care about the nine ranges has four screens of pinned content between
   them and the rest of the page, and no way through it but the wheel.

   Three decisions worth writing down:

   1 · It is a CHILD OF THE STICKY STAGE, not a fixed overlay. Sticky
       already means "on screen exactly while this section is pinned", so
       the placement does the appear/disappear with no scroll listener, no
       IntersectionObserver, and no threshold to get wrong. Fixed controls
       are what get stranded on the page after their section is gone.
       It is absolutely positioned, like .msw-seq__bar above it, so it
       creates no grid item and cannot disturb the two-column stage.

   2 · It names the DESTINATION. "Skip" tells you what you lose; "Design
       yours" tells you where you land - and that section is the one that
       asks for the enquiry, so the fastest way past the ranges is also
       the fastest way to the point.

   3 · It carries its own opaque ground. On desktop it sits over the
       section colour, so only the hairline reads and it looks like a
       ghost button cut into the ground. On a phone the stage is one
       column and the same corner is over a photograph, where white on an
       unknown image is a contrast lottery. The fill settles it at 18.0:1
       in both places rather than hoping.

   No-JS: hidden entirely. Without ranges.js the section is not pinned at
   all - it is a plain readable list of nine - so there is nothing to skip
   and a skip control would be an answer to a question nobody asked.
   ══════════════════════════════════════════════════════════════════════ */
.msw-seq__out { display: none; }
.msw-seq[data-enhanced] .msw-seq__out {
  position: absolute; z-index: 4;
  right: var(--gutter); bottom: calc(var(--s-4) + var(--s-2));
  display: inline-flex; align-items: center; gap: .85em;
  min-height: 44px; padding: 0 var(--s-3);   /* house floor: 44px tap target */
  background: #16161a; border: var(--hairline-w) solid rgba(255,255,255,.6);
  color: #fff; text-decoration: none;
  font-family: var(--body); font-size: var(--t-micro); font-weight: 500;
  letter-spacing: var(--track-wide); text-transform: uppercase;
  white-space: nowrap;
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease),
              border-color var(--dur) var(--ease);
}
/* "Skip to" is the quiet half; the range destination is the loud half.
   .68 rather than the .6-ish this register usually runs at, because this
   text has to survive BOTH grounds: .68 white on the #16161a fill measures
   8.7:1, and .68 ink on #fff (the hover inversion) measures 6.2:1. At .62
   the inverted state fell to 4.6:1 - still AA, but with no margin left for
   a future tweak to the fill. Both states measured off painted pixels. */
.msw-seq__out__pre { opacity: .68; }
.msw-seq__out__arr { opacity: .68;
  transition: transform var(--dur) var(--ease); }
.msw-seq[data-enhanced] .msw-seq__out:hover {
  background: #fff; color: #16161a; border-color: #fff; }
.msw-seq[data-enhanced] .msw-seq__out:hover .msw-seq__out__arr {
  transform: translateY(2px); }
.msw-seq[data-enhanced] .msw-seq__out:focus-visible {
  outline: 2px solid #fff; outline-offset: 3px; }

/* Landing focus. skip.js moves focus to the section so a keyboard user
   carries on from where the page went, not from where it was. The ring on
   a full-bleed section is noise - the arrival is the feedback. */
.msw-journey[tabindex="-1"]:focus { outline: none; }

@media (prefers-reduced-motion: reduce) {
  .msw-seq__out__arr { transition: none; }
}
/* Below the phone breakpoint the stage is one column and the gutter is
   20px; the label at 10px/.32em still fits on one line at 320px. */
@media (max-width: 380px) {
  .msw-seq[data-enhanced] .msw-seq__out { padding: 0 var(--s-2); gap: .6em; }
}

/* ══════════════════════════════════════════════════════════════════════
   THE DIPTYCH · BBC Interior Design Masters
   era-residence's arch section, rebuilt around two real photographs.

   THE ARGUMENT. The two IDM frames are the same room shot from opposite
   ends - the Kuba-cloth headboard is in both - so "two windows that turn
   out to be one room" is not a device here, it is the fact. era needed
   one render cut in two to stage that; this is the room Matt built to a
   television deadline, from both ends.

   DEPTH, three planes, per the brief and WITHOUT blur (rejected: nearness
   is carried by overlap and by being cut, not by focus):
     back    ember plate, drifts slowest, leaves as the image takes over
     middle  two panels, crops counter-drifting, then opening to a diptych
     front   bougainvillea cut by the bottom-left corner, over everything

   Progressive enhancement: every start value is declared here, so with
   idm.js absent or blocked the section still stands as a composition.
   ══════════════════════════════════════════════════════════════════════ */
.msw-idm { position: relative; height: 300vh; background: var(--ground); }
.msw-idm__pin { position: sticky; top: 0; height: 100vh; overflow: hidden; }

.msw-idm__plate { position: absolute; left: 6vw; top: 26vh; width: 34vw;
  height: 62vh; background: var(--ember); will-change: transform; }

.msw-idm__label { position: absolute; left: var(--gutter); top: 9vh; z-index: 4; }

.msw-idm__win { position: absolute; overflow: hidden; will-change: transform;
  margin: 0; }
.msw-idm__win img { position: absolute; inset: -6% 0; width: 100%; height: 112%;
  object-fit: cover; will-change: transform; }
/* A is a size container so the caption inside it can be set in cqw and
   grow WITH the panel as it expands to the half-bleed. */
.msw-idm__win--a { left: 14vw; top: 22vh; width: 27vw; height: 62vh;
  container-type: size; }
.msw-idm__win--a img { object-position: 32% 30%; }
.msw-idm__win--b { left: 47vw; top: 10vh; width: 31vw; height: 80vh; }
/* FLIPPED, Dex's call and the best idea in the section. In the original
   frame the wardrobe mirror sits on the RIGHT, which puts it on the
   outside edge of the diptych where it does nothing. Mirrored, it lands
   on the inside edge hard against panel A, and the bed reflected in it
   continues panel A's bed across the join - so the two halves meet in a
   reflection rather than at a butt joint, and the room reads continuous.
   The frame is symmetric plaster either side of the reveal, so nothing
   in it reads as reversed. idm.js must compose this into its transform. */
.msw-idm__win--b img { object-position: 50% 40%; transform: scaleX(-1); }

/* The word rides the top edge of the taller panel and is CUT by it, so
   the letters are always over photograph and never over paper. */
.msw-idm__wordclip { position: absolute; inset: 0; z-index: 3;
  pointer-events: none; clip-path: inset(10vh 0 0 0); }
.msw-idm__word { position: absolute; top: calc(10vh - .16em); left: 50%;
  transform: translateX(-50%); margin: 0; white-space: nowrap;
  font-family: var(--display); font-weight: 900;
  font-size: clamp(5rem, 16.5vw, 15rem); line-height: 1;
  letter-spacing: var(--track-hero); color: var(--white); opacity: 0; }

/* The caption sits ON the image from the first frame - white over the
   rug's dark ground - rather than starting in ink on the paper and
   flipping. Type set in cqw against panel A, so it is the same lockup at
   both scales, which is what sells the expansion. */
.msw-idm__cap { position: absolute; left: clamp(16px, 6cqw, 40px);
  bottom: clamp(16px, 6cqh, 48px); z-index: 4; color: var(--white);
  max-width: min(30ch, calc(100% - 32px)); }
.msw-idm__cap .msw-label { color: var(--white); opacity: .85;
  font-size: clamp(.58rem, 2.4cqw, .72rem); }
.msw-idm__cap h2 { font-family: var(--display); font-weight: 600;
  font-size: clamp(.95rem, 4.6cqw, 1.9rem); line-height: 1.15;
  letter-spacing: -.015em; margin: var(--s-2) 0 0; }
.msw-idm__cap p { font-size: clamp(.55rem, 2.2cqw, .7rem);
  letter-spacing: var(--track-wide); text-transform: uppercase;
  opacity: .8; margin: var(--s-3) 0 0; }

.msw-idm__corner { position: absolute; left: 0; bottom: 0; z-index: 5;
  width: clamp(240px, 24vw, 420px); pointer-events: none;
  transform: translate(-26%, 34%); opacity: .88; }
.msw-idm__corner img { display: block; width: 100%; height: auto; }

/* Below 820 the scrub goes away, as every other scrub here does: a
   static stack of label, both frames offset, caption in ink. */
@media (max-width: 820px) {
  /* overflow-x CLIP, not hidden: panel B is deliberately pushed past the
     right edge below, and without this the document itself scrolled
     sideways by 62px at 390. clip does not create a scroll container, so
     it cannot strand anything sticky the way hidden would. */
  .msw-idm { height: auto; overflow-x: clip; }
  .msw-idm__pin { position: static; height: auto; overflow: visible;
    padding: var(--s-6) 0; }
  .msw-idm__plate, .msw-idm__wordclip, .msw-idm__corner { display: none; }
  .msw-idm__label { position: static; margin: 0 var(--gutter) var(--s-4); }
  .msw-idm__win { position: static; width: auto; height: auto;
    overflow: visible; }
  .msw-idm__win img { position: static; width: 100%; height: auto; inset: auto; }
  .msw-idm__win--a { container-type: normal; }
  /* A runs edge to edge; B is pushed off the right so the viewport CUTS
     it. The offset that carries the diptych on desktop becomes an offset
     plus an edge here, rather than a small inset thumbnail (which is what
     a symmetric inset gave: 284px of a 390px screen, reading as an
     afterthought next to a full-bleed frame). */
  .msw-idm__win--b { margin: var(--s-4) -16vw 0 24vw; overflow: hidden; }
  .msw-idm__cap { position: static; color: var(--ink); max-width: none;
    margin: var(--s-4) var(--gutter) 0; }
  .msw-idm__cap .msw-label { color: var(--ink); font-size: var(--t-micro); }
  .msw-idm__cap h2 { font-size: 1.3rem; }
  .msw-idm__cap p { font-size: var(--t-micro); }
}
@media (prefers-reduced-motion: reduce) {
  .msw-idm { height: auto; }
  .msw-idm__pin { height: 100vh; }
}

/* ══════════════════════════════════════════════════════════════════════
   BECOMING · one room, three times · [data-becoming]
   Pen, model, photograph. Behaviour in system/becoming.js.

   THE DEFAULT IS THE STATIC THREE-UP, and the pinned cross-dissolve is the
   enhancement, switched on by .is-live which the script adds to itself.
   Written the other way round first, and it was wrong: with the script
   blocked the section was 260vh of sticky scrolling past ONE unchanging
   drawing. Degrading to a quiet three-up costs nothing and is a correct
   thing to be on its own.

   The script also declines to add .is-live under prefers-reduced-motion, so
   stillness and no-JavaScript land in the same good place rather than
   needing two separate treatments.
   ══════════════════════════════════════════════════════════════════════ */
.msw-bec { position: relative; }
.msw-bec__pin { padding: var(--s-5) 0; }
.msw-bec__in { max-width: var(--container); margin: 0 auto; width: 100%;
  padding: 0 var(--gutter); }
.msw-bec__h { font-family: var(--display); font-weight: 900;
  font-size: clamp(2rem, 5.4vw, 4.2rem); line-height: .94;
  letter-spacing: -.02em; text-transform: uppercase; margin: var(--s-2) 0 0; }
.msw-bec__body { margin-top: var(--s-4); }

/* — static default: three plates, three captions, everything readable — */
.msw-bec__frame { margin: 0; display: grid; gap: var(--s-2);
  grid-template-columns: repeat(3, 1fr); }
.msw-bec__layer { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2;
  object-fit: cover; border: 1px solid var(--hairline); background: #fff; }
.msw-bec__caps { list-style: none; margin: var(--s-3) 0 0; padding: 0;
  display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-3); }
.msw-bec__cap { opacity: 1; }
/* .72, not the .55 a quiet label wants: at 10px this is small text needing
   4.5:1, and .55 measures 3.14 against paper. */
.msw-bec__capn { display: block; font-family: var(--body); font-size: var(--t-micro);
  font-weight: 500; letter-spacing: var(--track-wide); text-transform: uppercase;
  opacity: .72; margin-bottom: var(--s-2); }
.msw-bec__capp { font-family: var(--display); font-weight: 600;
  font-size: clamp(1.05rem, 1.8vw, 1.4rem); line-height: 1.3;
  letter-spacing: -.012em; margin: 0; }
.msw-bec__capm { margin: var(--s-2) 0 0; font-size: .9375rem; line-height: 1.58;
  opacity: .78; }
.msw-bec__rail { display: none; }
.msw-bec__door { margin-top: var(--s-4); }

@media (max-width: 760px) {
  .msw-bec__frame, .msw-bec__caps { grid-template-columns: 1fr; }
}

/* — enhanced: pinned, and the three plates become one — */
.msw-bec.is-live { height: 260vh;
  --o2: 0; --o3: 0; --s2: 1.03; --s3: 1.03; }
.msw-bec.is-live .msw-bec__pin { position: sticky; top: 0; min-height: 100svh;
  display: grid; align-content: center; }
.msw-bec.is-live .msw-bec__body { display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(24ch, 1fr);
  gap: clamp(20px, 3.4vw, 56px); align-items: center; }
/* One 3:2 box on a white ground, so the sketch reads as a drawing ON
   something rather than as a hole in the page. */
.msw-bec.is-live .msw-bec__frame { position: relative; display: block;
  aspect-ratio: 3 / 2; background: #fff; border: 1px solid var(--hairline);
  overflow: hidden; }
.msw-bec.is-live .msw-bec__layer { position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover; border: 0;
  will-change: opacity, transform; }
.msw-bec.is-live .msw-bec__layer--2 { opacity: var(--o2); transform: scale(var(--s2)); }
.msw-bec.is-live .msw-bec__layer--3 { opacity: var(--o3); transform: scale(var(--s3)); }
/* Captions STEP rather than dissolve - two blocks of prose fading through
   each other is just an unreadable moment. Stacked in one grid cell so the
   column does not resize as the text changes length. */
.msw-bec.is-live .msw-bec__caps { display: grid; grid-template-columns: 1fr;
  margin: 0; }
.msw-bec.is-live .msw-bec__cap { grid-area: 1 / 1; opacity: 0;
  transition: opacity 260ms ease; pointer-events: none; }
.msw-bec.is-live[data-at="0"] .msw-bec__cap:nth-child(1),
.msw-bec.is-live[data-at="1"] .msw-bec__cap:nth-child(2),
.msw-bec.is-live[data-at="2"] .msw-bec__cap:nth-child(3) { opacity: 1; pointer-events: auto; }
/* Three segments, the passed ones filled. Wayfinding, not decoration: it
   says the section has an end and roughly where you are in it. */
.msw-bec.is-live .msw-bec__rail { display: grid; grid-auto-flow: column; gap: 6px;
  margin: var(--s-4) 0 0; max-width: 180px; }
.msw-bec.is-live .msw-bec__rail span { height: 2px; background: var(--hairline);
  transition: background 260ms ease; }
.msw-bec.is-live[data-at="0"] .msw-bec__rail span:nth-child(-n+1),
.msw-bec.is-live[data-at="1"] .msw-bec__rail span:nth-child(-n+2),
.msw-bec.is-live[data-at="2"] .msw-bec__rail span:nth-child(-n+3) { background: var(--ink); }

@media (max-width: 900px) {
  .msw-bec.is-live { height: 240vh; }
  .msw-bec.is-live .msw-bec__body { grid-template-columns: 1fr; gap: var(--s-3); }
}

/* ══════════════════════════════════════════════════════════════════════
   PAPER SURFACE, and TYPE PRESSED INTO IT · Dex's idea, 25 Aug 2026
   texture 0.4 · mottle 440px · press 1.0

   APPLIED WITH background-blend-mode, NOT AN OVERLAY ELEMENT. The obvious
   build is a ::before with mix-blend-mode, and it was the wrong one here:
   it needs position:relative on every ground, which changes the stacking
   context for the archlines, the cast beams and the living corner that all
   already position themselves against those sections. Blending the
   background against its own colour adds nothing to the box model and
   cannot disturb anything.

   ONE SVG, TWO FREQUENCIES. Real paper is not one frequency. A single
   fractalNoise gives fibre and reads as digital noise on flat colour; the
   long MOTTLE - the sheet is not evenly dense - is what makes the eye read
   surface. They are multiplied together inside the filter rather than
   stacked as two background layers, because as two layers the top one is
   opaque and hides the other. That mistake made the first prototype look
   like rust.

   soft-light, not multiply: multiply only ever takes light away, so it
   darkens the ground and never gives the raised tooth.

   background-attachment: fixed, per the 13 Aug research note - a texture
   that scrolls with its section reads as a pattern; held still it reads as
   the surface the page is printed on.
   ══════════════════════════════════════════════════════════════════════ */
.msw-ground,
.msw-ground--warm,
.msw-ground--panel,
.msw-ground--accent,
.msw-ground--dark {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='440' height='440'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.004' numOctaves='2' seed='4' stitchTiles='stitch' result='m'/%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='2' seed='11' stitchTiles='stitch' result='f'/%3E%3CfeBlend in='f' in2='m' mode='multiply' result='b'/%3E%3CfeColorMatrix in='b' type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='0.55' intercept='0.22'/%3E%3CfeFuncG type='linear' slope='0.55' intercept='0.22'/%3E%3CfeFuncB type='linear' slope='0.55' intercept='0.22'/%3E%3CfeFuncA type='linear' slope='0' intercept='0.4'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='440' height='440' filter='url(%23p)'/%3E%3C/svg%3E");
  background-size: var(--tex-scale) var(--tex-scale);
  background-attachment: fixed;
  background-blend-mode: soft-light;
}
/* The brick close band had its own grain - multiply at .07, darkening only.
   Two textures on one ground is noise, and this one supersedes it. */
.msw-close--arched::after { background-image: none; }

/* PRESSED TYPE. A deboss: the letter sits INTO the sheet, dark edge above
   and light below, which is what letterpress does.

   DISPLAY TYPE ONLY, and the selector list is the enforcement. At body
   sizes the two shadows close up on the stem and the word just looks
   blurred, so this is applied to the named display classes rather than to
   headings in general - and the offset is in em precisely so it cannot be
   inherited down to 15px and still register.

   Applied from the ground rather than tagged in markup, so it stays one
   edit instead of a class sprinkled across seven pages. */
:is(.msw-ground--warm, .msw-ground--accent, .msw-ground--dark) {
  --press-shadow:
    0 calc(-0.012em * var(--press)) 0 rgba(0, 0, 0, calc(.42 * var(--press))),
    0 calc( 0.012em * var(--press)) 0 rgba(255, 255, 255, calc(.20 * var(--press)));
}
/* On a light ground the ink has changed, not the light: dark type on paper
   needs the highlight above and the shadow below, or it floats instead of
   stamping. */
:is(.msw-ground, .msw-ground--panel) {
  --press-shadow:
    0 calc(-0.012em * var(--press)) 0 rgba(255, 255, 255, calc(.55 * var(--press))),
    0 calc( 0.012em * var(--press)) 0 rgba(0, 0, 0, calc(.16 * var(--press)));
}
.msw-press,
:is(.msw-ground, .msw-ground--warm, .msw-ground--panel,
    .msw-ground--accent, .msw-ground--dark)
  :is(.msw-display, .svc-st__h, .msw-bec__h, .msw-journey__h, .sn-brief__line) {
  text-shadow: var(--press-shadow);
}

/* fixed attachment stutters on iOS and dies inside a transformed ancestor.
   The traverse and the pinned sections are transformed, so those grounds
   scroll their texture instead of holding it. */
@supports (-webkit-touch-callout: none) {
  .msw-ground, .msw-ground--warm, .msw-ground--panel,
  .msw-ground--accent, .msw-ground--dark { background-attachment: scroll; }
}

/* ══════════════════════════════════════════════════════════════════════
   THE BOARD · his moodboard composition, as a section · .msw-board

   Read off his own boards: a run of flat blocks sharing a baseline, the
   photograph laid ACROSS their edges rather than beside them, and one
   round form against all the rectangles.

   THE PALETTE IS SET PER PAGE, via --b1..--b4 and --bo. That is the point
   of promoting this out of the Sona page: a project's board should be
   THAT project's colours. Four identical blocks on every project page
   would be wallpaper, which is what the component was on its way to
   becoming.

   Two rules learned the hard way on the first build:
     · height:auto on the photograph is LOAD-BEARING. The img carries
       width/height attributes, which map to presentational height, and
       aspect-ratio is ignored unless one axis is auto. Without it the
       frame rendered full height and sat over the heading.
     · the round form must contrast with the GROUND it lands on. The first
       version was --ember on an ember section. Invisible.
   ══════════════════════════════════════════════════════════════════════ */
.msw-board { position: relative; display: flex; align-items: flex-end; gap: 0;
  height: clamp(240px, 26vw, 330px); margin-top: var(--s-5); }
/* Unequal, sharing a baseline. Four equal blocks read as a swatch chart;
   his boards vary the widths, which is what stops it looking like a key. */
.msw-board__blk { position: relative; z-index: 1; height: 62%; }
.msw-board__blk:nth-child(1) { flex: 30; background: var(--b1); }
.msw-board__blk:nth-child(2) { flex: 24; background: var(--b2); }
.msw-board__blk:nth-child(3) { flex: 15; background: var(--b3); }
.msw-board__blk:nth-child(4) { flex: 31; background: var(--b4); }
/* The photograph BREAKS the block line rather than sitting level with it.
   Flush against them the device disappears; the overlap is the whole idea. */
/* DRIVEN BY HEIGHT, not width, and this took three goes.
   Wide (40%) and the frame covered the SECOND block edge to edge, so a
   four-block palette showed three. Narrow (34%) fixed that and broke the
   device instead: the frame came out SHORTER than the blocks, so it no
   longer crossed their top line, which is the entire point.
   Setting the height and letting aspect-ratio supply the width fixes both
   at once, and the blocks came down to 62% to guarantee the overhang.
   width:auto is what lets aspect-ratio apply - the same one-axis-auto rule
   that bit the photograph the first time. */
.msw-board__ph { position: absolute; left: 10%; bottom: 0; z-index: 2;
  height: 80%; width: auto; aspect-ratio: 3 / 2; object-fit: cover; }
.msw-board__o { position: absolute; right: 6%; bottom: 48%; z-index: 0;
  width: clamp(84px, 12%, 150px); aspect-ratio: 1; border-radius: 50%;
  background: var(--bo); }
@media (max-width: 700px) {
  .msw-board { height: 210px; margin-top: var(--s-4); }
  .msw-board__ph { left: 6%; height: 76%; }
  .msw-board__o { display: none; }
}


/* == CREDENTIALS * the strip at the top of the sign-off ================
   Matt's two rarest assets were small print. The BBC run had a full
   diptych on the homepage and nothing anywhere else; the Victorian
   Plumbing collaboration was three words at the end of a sentence, and on
   the old site a bare link. This is the letterhead: the things that are
   true about him, on every page, next to his name.

   IT ALWAYS MOVES. The first build only crawled once the items stopped
   fitting on one line, on the reasoning that a marquee of three reads as
   padding for a short list. Dex saw both and chose the crawl, so the crawl
   is what it does - the answer to "only three" is more credentials, not
   less motion. The hairline between items is there to make the repeat read
   as a rhythm rather than a stutter.

   The static centred row below is still the real stylesheet, and it is
   what a visitor gets with no JavaScript or with reduced motion asked for.
   Everything the crawl needs is added by system/cred.js on top of it.

   Same enhancement contract as becoming.js: everything above the .is-live
   rules is the state the page holds with no JavaScript at all.           */
.msw-cred {
  --cred-gap:   clamp(2rem, 6vw, 4.5rem);
  --cred-speed: 36;              /* px per second. Constant at any width. */
  position: relative; z-index: 3;
  /* Full bleed, and it needs no negative margin to get there: the sign-off
     band has no inline padding of its own. A crawl that stopped short of
     the screen edge would read as a bug rather than as a margin. */
  margin: 0 0 var(--s-5);
  padding-bottom: var(--s-4);
  min-width: 0;                  /* see the note on .msw-signoff-band */
  border-bottom: var(--hairline-w) solid var(--hairline-invert);
}
/* clip, not hidden, where it is supported. hidden makes a SCROLLABLE box:
   tab to a link that the crawl has carried half off the edge and the
   browser scrolls the strip sideways to reveal it, which shears the row
   out of the footer. clip cannot be scrolled, so there is nothing to
   shear. hidden stays first as the fallback. */
.msw-cred__vp  { overflow: hidden; overflow: clip; }
.msw-cred__trk { display: block; }
.msw-cred__run { display: flex; flex-wrap: wrap; justify-content: center;
                 gap: var(--s-3) var(--cred-gap);
                 margin: 0; padding: 0 var(--gutter); list-style: none; }
.msw-cred__it  { position: relative; flex: 0 0 auto; display: grid;
                 justify-items: center; gap: .5em; text-align: center; }

/* A hairline standing in the gap. Three names looping past on repeat read
   as a stutter without one and as a rhythm with one, which is the whole
   job it does. It sits at the midpoint of the gap, so it belongs to the
   space between two items rather than to either of them.

   Every item carries one when the strip is crawling, because in a loop
   every item is followed by another. Standing still, the last one would
   be a rule hanging in space, so it is dropped. */
.msw-cred__it::after {
  content: ""; position: absolute; top: 50%; right: calc(var(--cred-gap) / -2);
  transform: translateY(-50%);
  width: var(--hairline-w); height: 1.7em;
  background: var(--hairline-invert); }
.msw-cred:not(.is-live) .msw-cred__it:last-child::after { content: none; }

/* The small word says what KIND of credential it is, which is what lets
   three unlike things - a television credit, a retail partnership and a
   kitchen range - stand in one row without any of them being read as one
   of the others. Without it the strip is only a list of names. */
.msw-cred__kind { font-family: var(--body); font-size: var(--t-micro);
                  font-weight: 500; letter-spacing: var(--track-wide);
                  text-transform: uppercase; opacity: .78; }
.msw-cred__name { font-family: var(--display); font-weight: 600;
                  font-size: clamp(1rem, 1.7vw, 1.4rem); line-height: 1.1;
                  letter-spacing: -.012em; white-space: nowrap;
                  color: inherit; text-decoration: none;
                  border-bottom: var(--hairline-w) solid transparent;
                  padding-bottom: .12em;
                  transition: border-color var(--dur) var(--ease); }
a.msw-cred__name:hover         { border-bottom-color: var(--brass-invert); }
a.msw-cred__name:focus-visible { outline: 2px solid currentColor;
                                 outline-offset: 4px; }

/* -- The crawl * built by cred.js ------------------------------------
   The track holds AS MANY copies of the run as it takes to keep the window
   covered at the far end of the travel, which is more than two whenever a
   single run is narrower than the screen - the ordinary case here. Two
   copies travelling -50% is the marquee everyone writes and it leaves bare
   ground on a wide screen with a short list. cred.js counts the copies.

   The travel is an absolute LENGTH, one run plus its trailing gap, not a
   percentage. A percentage would have to be recomputed against the number
   of copies and would be wrong the moment that number changed; a length is
   the same distance whatever the track holds, so the copy behind lands
   exactly where the one in front started at any count. Seam measured at
   0px.

   The gap trails each run as a margin so that a run and its gap are one
   block, and the block is what travels. */
.msw-cred.is-live .msw-cred__trk {
  display: flex; width: max-content;
  animation: msw-cred-run var(--cred-dur, 30s) linear infinite; }
.msw-cred.is-live .msw-cred__run {
  flex-wrap: nowrap; padding: 0; margin-right: var(--cred-gap); }
@keyframes msw-cred-run {
  to { transform: translate3d(calc(-1 * var(--cred-travel, 100%)), 0, 0); } }

/* Pausable, and it has to be, because the items are links. A name you
   cannot click is worse than a name that never moved. */
.msw-cred.is-live:hover        .msw-cred__trk,
.msw-cred.is-live:focus-within .msw-cred__trk { animation-play-state: paused; }

/* Fade at the edges rather than a hard cut, so a name entering the strip
   arrives instead of appearing.

   A LENGTH, not a percentage, and that is the whole of the fix. At 7% the
   fade was 69px on a desktop and 26px on a phone - and a phone is exactly
   where the crawl always runs, because three items never fit on 375px.
   26px against a 140px name is not a fade, it is a hard edge with a
   gradient drawn on it, which is what it looked like. Clamped, the phone
   gets a fade wide enough to read as one and wide screens are still not
   swallowed by it. */
.msw-cred { --cred-fade: clamp(44px, 8%, 100px); }
.msw-cred.is-live .msw-cred__vp {
  -webkit-mask-image: linear-gradient(90deg, transparent 0,
                      #000 var(--cred-fade),
                      #000 calc(100% - var(--cred-fade)), transparent 100%);
          mask-image: linear-gradient(90deg, transparent 0,
                      #000 var(--cred-fade),
                      #000 calc(100% - var(--cred-fade)), transparent 100%); }

/* If the setting is flipped AFTER load, cred.js is no longer running and
   cannot undo itself, so the stylesheet has to be able to: stop the
   animation, hide the duplicate, and put the row back as it started. */
@media (prefers-reduced-motion: reduce) {
  .msw-cred.is-live .msw-cred__trk { animation: none; display: block;
                                     width: auto; }
  .msw-cred.is-live .msw-cred__run { flex-wrap: wrap; margin-right: 0;
                                     padding: 0 var(--gutter); }
  .msw-cred.is-live .msw-cred__vp  { -webkit-mask-image: none;
                                             mask-image: none; }
  .msw-cred [data-cred-clone]      { display: none; }
  .msw-cred .msw-cred__it:last-child::after { content: none; }
}

@media (max-width: 700px) {
  .msw-cred { margin-bottom: var(--s-4); padding-bottom: var(--s-3); }
  .msw-cred__name { font-size: 1rem; }
}

/* Visually hidden, still read aloud. The credentials strip uses it so it
   has a real heading without one being drawn. */
.msw-vh { position: absolute; width: 1px; height: 1px; margin: -1px;
          padding: 0; overflow: hidden; white-space: nowrap;
          clip-path: inset(50%); border: 0; }

/* The honeypot. Deliberately NOT display:none and NOT .msw-vh above:
   display:none is the first thing a form bot learns to skip, and .msw-vh
   is announced to screen readers, which would invite the one person on the
   site least able to see the trap to walk into it. So it is pushed out of
   the viewport and sealed off from assistive tech in the markup instead
   (aria-hidden, tabindex -1), leaving it visible only to something reading
   the DOM and filling every input it finds. */
.msw-trap { position: absolute; left: -9999px; top: auto;
            width: 1px; height: 1px; overflow: hidden; }
