/* ============================================================
   flowork. — Design system  (brand-exact, self-hosted fonts)
   Fonts:  TT Ramillas (serif/display/wordmark, licensed 20-08) · Manrope (sans/body, SIL OFL)
   Colors: only the five brand tokens, always via variables.
   ============================================================ */

@import url("../assets/fonts/fonts.css");   /* must be first: defines --serif / --sans */

/* ===== Tokens ===== */
:root {
  /* Brand palette — exact hexes */
  --flow-green:   #687A61;
  /* [04-09, Abdullah] "any beige colour on the website should be white."
     --earthy-beige is the brand book's secondary and is kept here as the
     record, but it is worth knowing it paints NOTHING: grepped 04-09, var(--beige)
     has zero consumers anywhere in this file. What actually read as beige on
     screen was --off-white and --bg-2, and those are the two that changed. */
  --earthy-beige: #D1B8A4;
  /* [04-09, Abdullah] "any black thing is not pure black, it looks charcoal
     black, we want pure black, 000000." Was #231F20.

     This SUPERSEDES the 03-09 reasoning further down this file (the location
     prose block), which chose --ink over #000 on the grounds that "pure #000
     would be a palette change nobody asked for." He has now asked for it, by
     name and in hex, so that argument is closed. */
  --abaddon:      #000000;
  /* [04-09] Was #F5F1EC. Note this token is used two ways: as a SURFACE on
     cards and panels, and as FOREGROUND text on dark and green. Both wanted
     to stop being warm, so one flip covers both. */
  --off-white:    #FFFFFF;
  --white:        #FFFFFF;

  /* Working aliases + derived tints (kept in one place) */
  /* [08-05] Page canvas flipped white <-> off-white sitewide: the page
     background is now pure white, and surfaces that used to be white (cards,
     panels, buttons — see the swapped background: var(--off-white)/var(--white)
     declarations throughout this file) are now the warm off-white instead. */
  --bg:        var(--white);
  /* [04-09] Was #ECE5DB, the other half of the beige. Most of its uses are
     image wells (.card__media, .post__media, .phero__media, .troom, .imgband,
     .loccard__col) where the colour only shows while the photo is in flight, so
     white is strictly an improvement there. Its few real SURFACE uses -- .spenq
     and .newsletter -- now sit white on a white page and lose their separation.
     That is what was asked for; flagged to Abdullah rather than softened here,
     because inventing a grey to replace a beige he asked to remove would be
     answering a question he did not ask. */
  --bg-2:      #FFFFFF;
  --ink:       var(--abaddon);
  /* NOT changed 04-09, deliberately: this is a warm grey for muted captions,
     not a beige surface and not a black. Turning it white would erase caption
     text; turning it #000 would flatten the type hierarchy the location pages
     were tuned around on 03-09. */
  --ink-soft:  #6A625C;                 /* muted body / captions */
  --green:     var(--flow-green);
  --green-d:   #4E5C49;   /* [04-09] was #566349; Abdullah's mockup value */                 /* darker green for hovers */
  --green-l:   #8DA086;   /* [04-09] was #8A9A82; Abdullah's mockup value */
  --beige:     var(--earthy-beige);
  /* [04-09] Both were the old --abaddon inlined with alpha (rgba(0, 0, 0,*)).
     Left warm they would have been the only brown thing left on a page whose
     black is now pure, and borders are exactly where that shows. */
  --line:      rgba(0, 0, 0, .14);
  --line-soft: rgba(0, 0, 0, .08);

  /* Motion */
  --ease:    cubic-bezier(0.16, 1, 0.3, 1);   /* signature flowy ease */
  --ease-io: cubic-bezier(0.86, 0, 0.07, 1);
  --ease-o:  cubic-bezier(0.22, 1, 0.36, 1);
  /* [08-06, client] For CROSS-FADES only. --ease is built for things that
     travel: it is heavily front-loaded, reaching 49% in the first 10% of the
     duration and 90% at a third of it, which looks right on a curtain lifting
     and wrong on two images swapping. Ahmed on the Our Spaces hover: "it feels
     like it's going too fast", against a declared 935ms.

     This curve is symmetric, so 50% opacity lands at the true midpoint and the
     declared duration is the duration you actually see. Use it wherever one
     thing dissolves into another; keep --ease for movement. */
  --ease-fade: cubic-bezier(0.45, 0, 0.55, 1);

  /* [08-14, CEO via Abdullah] Hover feedback grows, it does not travel. On the
     Our Spaces list at [00:36] of the 14-08 call: "you see when I hover how it
     moves to the right... let's not do it... maybe a very subtle expand", and
     again at [07:16] "doesn't move left or right, just slightly bigger".

     Every hover that used to step sideways now scales by this instead. Paired
     with `transform-origin: left center` EVERY time: a centre-origin scale
     pushes the left edge outward, which is the sideways movement he vetoed,
     just in the other direction. */
  --hover-grow: 1.03;

  /* Layout */
  --pad-x: calc(clamp(20px, 5vw, 80px) + var(--up) * 0.0421);
  /* [08-06, client] Was a flat 1440px. Ahmed: "why is every page's content
     anchored to only the middle when I zoom in/out... it's coming off as a
     singular middle column." It was, and the wider the screen the worse: a
     1440 column left 240px of dead page each side at 1920, 560px at 2560 and
     720px at 2880, which is roughly a 50% browser zoom.

     The cap is 2000px and nothing else. Below that the container is simply the
     viewport and --pad-x provides the side margin, exactly as it always did,
     so 1440 and every width under it are untouched. Above it the layout stops
     growing rather than running to the glass on an ultrawide.

     A min(2000px, 92vw) was tried first and reverted before commit: 92vw is
     MORE restrictive than the 100% it replaced at any viewport below 2174px,
     so it silently shrank the column at the most common sizes, 1440 dropping
     from 1440 to 1325. --pad-x is already the margin; 92vw double-counted it.

     The cap is still here for a reason: line length. Sections whose content is
     long-form prose need their own narrower measure on top of this one, not a
     bigger container. --measure below is that number, and .about__lede uses
     it. Anything text-heavy added later should too. */
  /* ===== Very large screens =====
     [14-08, CEO via Abdullah at 11:03-11:53] He screen-shared onto a bigger
     display and the composition changed under him. "Under any circumstance,
     the screen, inshallah it's 100 inches, it will always be this way. Maybe
     the text will become bigger to adjust to the screen, but it doesn't
     change." Then: "apply that on the entire website."

     MEASURED FIRST, 17-08, 1440 -> 3840:

       - The layout never actually recomposes. The widest breakpoint in this
         whole file is 901px, so nothing re-flows on a big display.
       - What DOES change is that everything stops growing. Body copy is 15px
         at 1440 and still 15px at 3840. The hero title caps at 200px from
         1920. The home page's content column falls from 82% of the screen to
         31%. So the page does not change shape, it shrinks — which is exactly
         what "the composition changed" looks like from across a room.

     --up is the fix, and it is one number rather than re-tuning the forty-odd
     clamps individually. It is 0px below the threshold and grows 1:1 with the
     viewport above it, so anything written as `calc(<what it was> + var(--up)
     * <its own vw slope>)` carries on along the exact line it was already on.
     There is no discontinuity at the boundary BECAUSE --up is 0 there: at
     1900px every one of these evaluates to precisely what it evaluated to
     before this block existed. Sub-1900px rendering is untouched, which is
     every phone, every laptop and both common desktop sizes.

     A factor of X/100 continues an Xvw clamp exactly. --container is the one
     deliberate exception (0.6, not 1.0) — see its note below. */
  --wide-from: 1900px;
  --up: max(0px, 100vw - 1900px);

  /* [17-08] Was a hard 2000px stop. It now keeps growing above the cap at 60%
     of the viewport's own rate, so a bigger screen gets a bigger page instead
     of the same page with bigger margins. 0.6 rather than 1.0 on purpose: the
     cap was protecting against a row of content running edge to edge on a
     34-inch ultrawide, which is a real problem and not what the CEO was
     complaining about. At 2560 this gives 2336px, at 3840 it gives 3104px —
     81% of the glass, never touching it. --measure still governs long-form
     line length independently, so prose does not get wider with any of this. */
  --container: calc(2000px + var(--up) * 0.6);
  /* Comfortable reading measure. Tuned by measuring, not by theory: 68ch
     rendered ~83 characters per line at the 20px About body size, because a
     ch in this face is wider than the average glyph. 56ch lands near 70. */
  --measure: 56ch;
  --r-sm: 8px;
  --r-md: 16px;
  --r-lg: 28px;
  --r-xl: 40px;

  /* ===== Elevation =====
     Depth is built from TWO coupled layers, never one: a tight "contact"
     shadow that anchors the edge, plus a wide, very soft ambient shadow that
     does the floating. Both are tinted WARM (a brown-black, #48362A-ish) —
     a neutral/pure-black shadow turns the warm off-white ground grey and
     reads cheap. Opacities stay low: on this brand restraint is the signal,
     so surfaces should feel lifted, never stuck to a drop-shadow. */
  --sh-1:                                       /* barely raised: chips, fields */
    0 1px 2px rgba(0, 0, 0, .04),
    0 5px 12px -6px rgba(0, 0, 0, .07);
  --sh-2:                                       /* resting cards */
    0 1px 2px rgba(0, 0, 0, .05),
    0 10px 22px -10px rgba(0, 0, 0, .09),
    0 28px 54px -28px rgba(0, 0, 0, .10);
  --sh-3:                                       /* lifted / hover */
    0 2px 5px rgba(0, 0, 0, .06),
    0 18px 36px -14px rgba(0, 0, 0, .13),
    0 52px 92px -36px rgba(0, 0, 0, .18);
  --sh-4:                                       /* floating: modal, previews */
    0 4px 10px rgba(0, 0, 0, .07),
    0 30px 60px -20px rgba(0, 0, 0, .18),
    0 84px 140px -50px rgba(0, 0, 0, .28);
  --sh-green:                                   /* primary CTA, green-tinted */
    0 2px 6px rgba(0, 0, 0, .06),
    0 12px 26px -10px rgba(86, 99, 73, .40);
  --sh-green-lift:
    0 3px 8px rgba(0, 0, 0, .07),
    0 22px 44px -12px rgba(86, 99, 73, .52);
}

/* ===== Reset ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; scroll-behavior: auto; }
body {
  font-family: var(--sans);
  background: var(--bg);
  color: var(--ink);
  /* + var(--up): see --up in :root. 17px at 1900 is 0.895vw, so 0.009
     carries the same slope up. Below 1900 --up is 0 and this is 17px. */
  font-size: calc(17px + var(--up) * 0.009);
  line-height: 1.65;
  font-weight: 400;
  /* [04-08, client review] Ligatures off everywhere, inherited. Dany asked for
     it on "Office" in the headings; Abdullah then found the preloader still
     fusing, because .mark is a div on the wordmark font rather than an h1-h6
     and the rule scoped to headings could not reach it. Set on body instead of
     chasing selectors: the brand faces fuse fi/fl/ff/ffi, the wordmark itself
     is "flowork", and a third instance surfacing later on some element nobody
     listed is the predictable outcome of enumerating them. Manrope's own
     ligatures are negligible, so body copy loses nothing visible. */
  font-variant-ligatures: no-common-ligatures;
  /* clip (not hidden): still prevents horizontal scroll, but does NOT create a
     scroll container — so position:sticky (intro carousel) works vs the viewport */
  overflow-x: clip;
}
/* [04-08, client review] Abdullah: "when you open the website there is a random
   thin blue outlines on all edges... when the pop up shows up, and when the pop
   up closes."
   Nothing on this site is blue: the outline was rgb(0,95,204), Chrome's own
   default focus ring. The enquiry modal is a full-viewport overlay, and
   openModal() in main.js gives it tabindex="-1" and focuses it so the dialog
   reads correctly to a screen reader. Chrome then matched :focus-visible on it
   (confirmed: activeElement was div#enquiryModal with outline auto 1px
   rgb(0,95,204)) and painted the ring around the whole overlay, which is the
   four screen edges he saw.
   Suppressed for tabindex="-1" only. Those elements are not reachable by Tab,
   so no keyboard user loses a focus indicator; every genuinely focusable
   control keeps its own. Do not widen this to :focus generally. */
[tabindex="-1"]:focus { outline: none; }

img, video, svg { display: block; max-width: 100%; }
/* Images are wrapped in <picture> at build time to serve AVIF/WebP (see
   picturize() in build_site.py). display:contents removes the wrapper's own box
   so the <img> stays the flex/grid item it was before the wrapper existed, and
   every existing layout rule keeps applying unchanged. */
picture { display: contents; }
a { color: inherit; text-decoration: none; }
ul { list-style: none; }
button { font: inherit; background: none; border: none; cursor: pointer; color: inherit; }
input, textarea, select { font: inherit; color: inherit; }
::selection { background: var(--green); color: var(--off-white); }

/* Lenis */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: hidden; }
/* [04-09, Abdullah] The real scroll lock. See stopScroll() in main.js for why
   the Lenis rule above stopped firing and why this is position:fixed rather
   than overflow:hidden — iOS Safari scrolls the body regardless of the latter.
   `top` is set inline to the stashed offset so the page stays where it was. */
body.is-locked { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; }

.container {
  width: 100%;
  max-width: var(--container);
  margin: 0 auto;
  padding-left: var(--pad-x);
  padding-right: var(--pad-x);
}

/* ===== Typography ===== */
h1, h2, h3, h4, h5, h6 {
  font-family: var(--serif);
  font-weight: 500;
  line-height: 1.02;
  letter-spacing: -0.02em;
  /* [04-08, client review] Playfair Display's "fi" ligature reads as the two
     letters fused/curving into each other at display size — client, pointing
     at "Office": "it looks a bit weird because it's not like connecting...
     just make sure they're not connected." font-variant-ligatures (not
     font-feature-settings — that property is not additive, and
     .hero__stats strong below already owns its own font-feature-settings for
     lining numerals; a shared value here would silently replace it) turns
     off "fi"/"fl"/"ffi" etc. on every real heading tag site-wide. Scoped to
     h1-h6 only, not a global rule, so body copy, eyebrows and other
     --serif-set elements that aren't headings (prices, labels, captions)
     keep their normal ligatures — the complaint was specifically about large
     display type, not running text. */
  font-variant-ligatures: no-common-ligatures;
}
.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--green);
  font-weight: 600;
}
.eyebrow .dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 0 0 rgba(104,122,97,.5);
  animation: pulse 4.42s var(--ease) infinite;
}
@keyframes pulse {
  0%   { box-shadow: 0 0 0 0 rgba(104,122,97,.45); }
  70%  { box-shadow: 0 0 0 9px rgba(104,122,97,0); }
  100% { box-shadow: 0 0 0 0 rgba(104,122,97,0); }
}

/* Wordmark — trailing period is part of the mark. [03-08] The period matches
   "work" (the second half), not "flo" — it's just font-style:normal to cancel
   the <i> tag's default italic; color comes from the surrounding .mark. */
.mark { font-family: var(--mark); font-weight: 600; letter-spacing: -0.02em; white-space: nowrap; }
.mark em { font-style: normal; color: var(--green); }        /* "flo" */
.mark i  { font-style: normal; }                              /* trailing period, matches "work" */
.mark--on-dark { color: var(--white); }
.mark--on-dark em { color: var(--green); }
/* On the green banner: "flo" black, "work" + period white (brand rule) */
.mark--on-green { color: var(--white); }
.mark--on-green em { font-style: normal; color: var(--abaddon); }

/* ===== Section rhythm ===== */
section { position: relative; }
/* [08-05, confirmed with client] Re-tightened. The "CEO pass" note this
   replaces had loosened .section-pad back past its 27-07 tightening, on a
   claim that the CEO reviewed the tightened rhythm as "hectic and cramped" —
   but that reversal was never reflected in CLAUDE.md, which still carried
   "reduce gaps" as live, undisputed backlog (raised six times on the 27-07
   call: "there is space empty, they don't like it... free use of the
   space"). Asked directly: the 27-07 ask is the one that's current. Back to
   the same two numbers the 27-07 pass itself landed on — section 130 -> 86,
   section head 88 -> 58 — with min/vw scaled proportionally to match. */
.section-pad { padding-block: clamp(48px, 5.5vw, 86px); }
.section-head { max-width: 900px; margin-bottom: clamp(32px, 3.8vw, 58px); }
/* [08-06, client] The three "Inside ..." image bars centre their head after
   losing the sub line under it ([19:05]-[19:11]). margin-inline:auto as well as
   text-align, because the 900px cap above would otherwise leave the block
   itself pinned left and only the text inside it centred. */
.section-head--centred { text-align: center; margin-inline: auto; }
/* [07-08] The sub carries its own max-width, so text-align centres the words
   inside a box that is itself still pinned left. On the virtual tour that left
   it 410px off centre under a perfectly centred heading. Same fix as the block
   above, applied one level down. */
.section-head--centred .section-head__sub { margin-inline: auto; }
.section-head .eyebrow { margin-bottom: 26px; }
.section-head h2 {
  font-size: calc(clamp(38px, 6vw, 84px) + var(--up) * 0.0442);
}
.section-head h2 em { font-style: italic; color: var(--green); }
/* Meeting Room page: "Book the room that fits." (Dany's pick, 07-08, replacing
   "Two rooms, one standard.") wraps awkwardly inside the shared .section-head's
   900px column at large sizes — it's short enough to sit on one line, so drop
   the cap and hold it unwrapped from tablet width up. Below 760px nothing this
   long fits on one line at a legible size, so it falls back to the normal
   wrap. The replacement is a character shorter, so the threshold still holds. */
.rooms .section-head { max-width: none; }
@media (min-width: 761px) {
  .rooms .section-head h2 { white-space: nowrap; }
}
/* Coworking page: "Coworking, at both addresses." wraps the same way inside
   the shared 900px column — same fix as .rooms above. */
.loccards .section-head { max-width: none; }
@media (min-width: 761px) {
  .loccards .section-head h2 { white-space: nowrap; }
}
/* [04-08, client review] On Dubai Hills this sub broke as "...photographed as
   it is," / "every day." — two words stranded on a line of their own. Dany:
   "let's just have every day in the same line", and the same note again on
   Business Bay: "see what we put it in the same line, make it look like this."
   text-wrap: balance evens the lines out instead of filling the first and
   dumping the remainder, so short tails get pulled up. Fixed at the selector
   rather than by rewriting the two sentences, because the orphan is a function
   of the 620px measure, not of those particular words, and the next sub written
   at an awkward length would strand again. */
.section-head__sub {
  margin-top: 28px;
  max-width: 620px;
  color: var(--ink-soft);
  font-size: clamp(16px, 1.3vw, 19px);
  text-wrap: balance;
}

/* ===== Buttons ===== */
/* [changed — CEO pass] Was a hard-edged fill sliding up from behind (::after,
   translateY 101% -> 0) that read as an "invert" snap. Replaced with a plain
   background/color cross-fade: both properties ease together in place, so the
   button just settles into its hover state rather than being wiped by a panel. */
.btn {
  --btn-fg: var(--off-white);
  --btn-bg: var(--abaddon);
  --btn-fg-hover: var(--white);
  --btn-bg-hover: var(--green);
  display: inline-flex; align-items: center; gap: calc(12px + var(--up) * 0.007);
  /* + var(--up): see :root. A CTA that stays 17px beside a 400px title is
     the composition changing, which is the thing the CEO objected to. */
  padding: calc(17px + var(--up) * 0.00895) calc(28px + var(--up) * 0.0147);
  border-radius: 100px;
  font-family: var(--sans);
  /* 15px / 1900px — the factor that holds a ratio is the value AT the
     threshold over the threshold, not the original vw slope. */
  font-size: calc(15px + var(--up) * 0.0079); font-weight: 600; letter-spacing: 0.01em;
  color: var(--btn-fg); background: var(--btn-bg);
  position: relative;
  transition: color .7s var(--ease), background-color .7s var(--ease);   /* transform is GSAP's (magnetic) — no CSS fight */
}
.btn svg { width: 18px; height: 18px; position: relative; z-index: 2; transition: transform .6s var(--ease); }
.btn span { position: relative; z-index: 2; }
.btn:hover { color: var(--btn-fg-hover); background-color: var(--btn-bg-hover); }
.btn:hover svg { transform: translateX(4px); }

/* The primary CTA carries a green-tinted lift so it reads as the one raised
   thing on the page — the accent casts its own light, not a grey shadow. */
.btn--primary { --btn-bg: var(--green); --btn-fg: var(--white);
  --btn-bg-hover: var(--abaddon); --btn-fg-hover: var(--white);
  box-shadow: var(--sh-green); transition: box-shadow .8s var(--ease), color .7s var(--ease), background-color .7s var(--ease); }
.btn--primary:hover { box-shadow: var(--sh-green-lift); }

.btn--ghost {
  --btn-bg: transparent; --btn-fg: var(--ink);
  --btn-bg-hover: var(--abaddon); --btn-fg-hover: var(--off-white);
  border: 1px solid var(--line);
  transition: color .7s var(--ease), background-color .7s var(--ease), border-color .7s var(--ease);
}
.btn--ghost:hover { border-color: transparent; }

.btn--ghost-light {
  --btn-bg: transparent; --btn-fg: var(--white);
  --btn-bg-hover: var(--off-white); --btn-fg-hover: var(--abaddon);
  border: 1px solid rgba(255,255,255,.4);
  transition: color .7s var(--ease), background-color .7s var(--ease), border-color .7s var(--ease);
}
.btn--ghost-light:hover { border-color: transparent; }

/* Text link with arrow */
.link-arrow {
  display: inline-flex; align-items: center; gap: 9px;
  font-family: var(--sans); font-size: 15px; font-weight: 600;
  color: var(--ink);
  transition: gap 0.68s var(--ease), color 0.51s;
}
.link-arrow svg { width: 17px; height: 17px; transition: transform 0.765s var(--ease); }
.link-arrow:hover { color: var(--green); gap: 13px; }
.link-arrow:hover svg { transform: translateX(3px); }

/* ===== Preloader — flowing lines + wordmark + the period lands ===== */
.preloader {
  position: fixed; inset: 0; z-index: 10000;
  background: var(--abaddon);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px;
  pointer-events: none; overflow: hidden;
}
/* Flow lines — the brand's 5 parallel lines: straight past the title, then curve up.
   Left edge fades in for a smoother start. */
.flowlines { display: block; width: 100%; height: 100%; overflow: visible;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 15%, #000 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 15%, #000 100%); }
.fl-line { fill: none; stroke: var(--green-l); stroke-width: 5; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.preloader__flow { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.preloader__stack { position: relative; z-index: 2; display: flex; flex-direction: column;
  align-items: center; gap: clamp(6px, 0.9vw, 12px); }
.preloader__mark {
  position: relative; z-index: 2;
  /* [03-08] Was --serif (Playfair Display, the body/heading substitute) —
     this is the wordmark itself, so it belongs on --mark like every other
     .mark instance (nav, footer), not the general display face. */
  font-family: var(--mark); font-weight: 600;
  font-size: clamp(48px, 9vw, 132px); line-height: 1; letter-spacing: -0.03em;
  color: var(--white);
  display: flex; overflow: hidden;
}
.preloader__mark .ch { display: inline-block; transform: translateY(115%); }
.preloader__mark em { font-style: normal; color: var(--green); }
/* The period matches "work" (white), not "flo" (green) — see .mark i. */
.preloader__mark .ch--pd { transform: none; } /* the period animates separately — it drops in */
.preloader__mark .ch--pd i { display: inline-block; font-style: normal; opacity: 0; }
.preloader__tag {
  position: relative; z-index: 2;
  /* At a flat 13px/.18em this line measured 347px inside a 390px phone — 14px of
     air on one side, 28px on the other, which read as cramped before the flow
     lines even arrived. Both values are capped at the original, so anything
     wider than ~433px is unchanged. */
  font-family: var(--sans); font-size: clamp(11px, 3vw, 13px);
  letter-spacing: clamp(.12em, .5vw, .18em); text-transform: uppercase;
  color: rgba(255, 255, 255,.55); opacity: 0;
}

/* [03-08] The phone loading screen is back to centred, at the client's
   request. It used to left-anchor with the rise pinned to a fixed 57% of the
   width. runPreloader() in main.js reads its alignment live off
   getComputedStyle rather than from a hardcoded breakpoint, so removing the
   left-anchor here is enough on its own: the rise falls back to trailing the
   actual word, same as desktop, with no JS change needed.

   [08-06] Keep the @media wrapper. 92fef84 once restored an override like this
   one without it, so the phone clamp applied at every width and capped the
   desktop mark at 54px against the 132px base, which is the "title on the
   loading screen is much smaller than before" report. Same specificity as the
   base rule and it comes after, so unwrapped it wins everywhere. */
@media (max-width: 560px) {
  .preloader__mark { font-size: clamp(30px, 11vw, 54px); }

  /* ⚠️ DO NOT LEFT-ANCHOR THIS AGAIN WITHOUT ASKING. It has now been tried and
     rejected TWICE.

       03-08  left-anchor removed, "back to centred, at the client's request"
       21-08  left-anchor restored for Abdullah, seen live, reverted same day

     Centred is the standing answer. If someone asks for it again, the change
     itself is trivial — set `align-items: flex-start` on .preloader and
     .preloader__stack here, plus a 20px gutter, and runPreloader() picks it up
     on its own because it reads alignment live off getComputedStyle rather
     than re-testing this breakpoint. The work is not the problem; the
     back-and-forth is. Get it confirmed on a phone before it ships. */
}
/* ===== Site texture ===== */
/* Static film grain — texture without per-frame compositing cost (an animated
   full-screen blend layer caused visible frame drops during entries). */
.grain {
  position: fixed; inset: 0; z-index: 9600; pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
  opacity: .05; mix-blend-mode: overlay;
}
/* [21-08] THE FAST-SCROLL JITTER LIVED HERE.

   This used to be two <span class="amb"> children, each a filter: blur(64px)
   circle with transform: translateZ(0) promoting it to its own layer, drifting
   on a 44s / 54s infinite animation, inside this fixed full-viewport box. While
   those layers existed the compositor redid the whole viewport on every frame
   of every scroll. Measured on a real GPU (headed Chromium, home, 22 x 400px
   wheel), interleaved A/B, every run checked for window visibility and focus so
   throttling could not be mistaken for cost:

       as shipped, drifting          167.5ms per frame   (~6fps)
       drift removed, divs kept       83.4ms per frame
       divs gone, washes as gradient   16.7ms per frame   (60fps)
       .ambient removed entirely       16.7ms per frame

   The washes are now painted straight onto this element. Same two blobs, same
   colours, same positions, no extra layers, no blur filter, no animation.
   Geometry matches the old spans exactly: the green was a 56vw circle at
   left:-14vw top:-12vw, so centre (14vw, 16vw) radius 28vw; the beige was 48vw
   at right:-12vw bottom:-14vw, so centre (100%-12vw, 100%-10vw) radius 24vw.
   The stops run to 72% rather than the old 65% to stand in for the 64px blur
   that used to soften the edge.

   Do not reintroduce blurred or promoted children here, and do not "restore"
   the drift. Both were measured and both cost 5-10x the frame budget. Note
   also that headless Chromium rasterises in software and reports ALL of this
   as healthy 16.7ms, so any change to this block has to be measured headed. */
.ambient {
  position: fixed; inset: 0; z-index: 1; pointer-events: none;
  background:
    radial-gradient(circle 28vw at 14vw 16vw, rgba(104,122,97,.16), transparent 72%),
    radial-gradient(circle 24vw at calc(100% - 12vw) calc(100% - 10vw), rgba(255, 255, 255,.18), transparent 72%);
}
.preloader__curtain {
  position: fixed; left: 0; right: 0; z-index: 9999;
  height: 50vh; background: var(--abaddon);
}
.preloader__curtain--top { top: 0; }
.preloader__curtain--bottom { bottom: 0; }

/* ===== Page transition ===== */
.page-trans {
  position: fixed; inset: 0; z-index: 9800;
  background: var(--abaddon);
  transform: translateY(100%);
  display: grid; place-items: center;
  pointer-events: none;
  /* Parked, translateY(100%) puts this panel's top edge on exactly innerHeight —
     no clearance at all. That is invisible on desktop, but a phone browser hiding
     its toolbar grows the visual viewport before fixed elements are re-laid-out
     against it, and a flush black edge paints as a band across the bottom of the
     page. visibility is the belt to the transform's braces; main.js drives it via
     autoAlpha on every path that covers with or parks this overlay. Hidden is the
     safe default: if JS never runs, the overlay is unused anyway. */
  visibility: hidden;
}

/* ===== Nav ===== */
.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 900;
  display: flex; align-items: center; justify-content: space-between;
  padding: 22px var(--pad-x);
  /* [21-08] transform (hide/show) stays snappy. The solid treatment used to
     transition background + backdrop-filter + box-shadow on this element for a
     full second each. Transitioning backdrop-filter re-rasterises a blur of
     everything behind a fixed bar on every frame of that second, while the page
     scrolls underneath it — pure compositor cost, invisible to a rAF frame
     counter, and a measured contributor to the scroll jitter. It now lives on
     ::before and fades on opacity, which the compositor handles for free.
     padding stays here on purpose: it is a real 7px layout change and there is
     no transform that reproduces it (the logo's transform belongs to GSAP's
     magnetic handler and must not be fought). With the hysteresis added in
     main.js it fires once per threshold crossing instead of 4-6 times, which is
     what actually made it expensive. See tests/scroll-jitter.py. */
  transition: transform .5s var(--ease), padding 0.85s var(--ease);
}
/* Absolutely positioned, so it stays out of flow and never becomes a flex item
   of .nav. Carries everything that used to animate on the bar itself. */
.nav::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: rgba(255, 255, 255, .82);
  backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
  box-shadow: 0 1px 0 var(--line-soft), var(--sh-1);
  opacity: 0; transition: opacity 1.02s ease;
}
.nav.is-hidden { transform: translateY(-104%); }
.nav.is-solid { padding-top: 15px; padding-bottom: 15px; }
.nav.is-solid::before { opacity: 1; }
.nav__logo .mark { font-size: 27px; }
.nav__links { display: flex; align-items: center; gap: 4px; }
.nav__links > a, .nav__dropdown > button {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--sans); font-size: calc(15px + var(--up) * 0.0079); font-weight: 500;
  padding: calc(10px + var(--up) * 0.005) calc(15px + var(--up) * 0.008); border-radius: 100px; color: var(--ink);
  transition: background 0.51s, color 0.51s;
}
.nav__links > a:hover, .nav__dropdown > button:hover { color: var(--green); }
.nav__dropdown { position: relative; }
/* transparent bridge across the gap so hover isn't lost moving button -> panel */
.nav__dropdown::after { content: ""; position: absolute; top: 100%; left: 0; right: 0; height: 18px; }
.nav__dropdown > button svg { width: 15px; height: 15px; transition: transform 0.595s var(--ease); }
.nav__dropdown:hover > button svg { transform: rotate(180deg); }
.nav__panel {
  position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%) translateY(8px);
  min-width: 300px; padding: 12px;
  background: var(--off-white); border: 1px solid var(--line-soft);
  border-radius: var(--r-md); box-shadow: var(--sh-4);
  opacity: 0; visibility: hidden; transition: opacity 0.595s var(--ease), transform 0.595s var(--ease);
}
.nav__dropdown:hover .nav__panel { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
.nav__panel a {
  display: flex; flex-direction: column; gap: 2px;
  padding: 12px 14px; border-radius: var(--r-sm);
  font-size: 15px; font-weight: 500; transition: background 0.425s, color 0.425s;
}
.nav__panel a span { font-size: 13px; color: var(--ink-soft); font-weight: 400; }
.nav__panel a:hover { background: var(--bg); color: var(--green); }
.nav__cta { margin-left: 8px; }
.nav__burger { display: none; width: 44px; min-width: 44px; height: 44px; position: relative; flex: 0 0 44px; }
.nav__burger span { position: absolute; left: 10px; right: 10px; height: 1.6px; background: var(--ink); transition: transform 0.68s var(--ease), opacity 0.51s; }
.nav__burger span:nth-child(1) { top: 17px; }
.nav__burger span:nth-child(2) { bottom: 17px; }
/* Over a hero (nav transparent, before it turns solid on scroll) the page behind
   is dark, so the nav flips to its on-dark treatment: "work" + links + burger white. */
.nav:not(.is-solid) .nav__logo .mark { color: var(--white); }
.nav:not(.is-solid) .nav__links > a,
.nav:not(.is-solid) .nav__dropdown > button { color: rgba(255,255,255,.92); }
.nav:not(.is-solid) .nav__links > a:hover,
.nav:not(.is-solid) .nav__dropdown > button:hover { color: var(--green-l); }
.nav:not(.is-solid) .nav__burger span { background: var(--white); }
body.menu-open .nav__burger span:nth-child(1) { transform: translateY(5px) rotate(45deg); }
body.menu-open .nav__burger span:nth-child(2) { transform: translateY(-5px) rotate(-45deg); }
/* Over the dark menu, the header flips to its on-dark treatment:
   "flo" + period stay green, "work" turns white, burger turns white. */
/* [21-08] The solid treatment moved to .nav::before, so clearing it here has to
   move too. Left on .nav these four declarations would silently do nothing and
   the beige bar would show through the dark menu. */
body.menu-open .nav::before { opacity: 0; }
/* [21-08, Ahmed] "when I press the three dotted menu, why are there two
   Enquire Now buttons?"
   The navbar pill stays fixed on top of the open menu panel, and the panel
   carries its own full-width .mm-cta at the bottom — so opening the menu showed
   the same call to action twice, 900px apart.
   The panel's own button is the one that survives: it is the full-width one
   sized for a thumb, and it sits at the end of the menu where the decision is
   actually made. The navbar pill is only hidden WHILE the menu is open, so the
   07-25 rule that it stays on the page is untouched.
   visibility rather than display: the pill is inside the nav's own transform
   context and display:none would collapse the layout the burger sits in. */
body.menu-open .nav__cta { opacity: 0; visibility: hidden; pointer-events: none; }
body.menu-open .nav__logo .mark { color: var(--white); }
body.menu-open .nav__burger span { background: var(--white); }

/* Mobile menu.
   Two rules carry this: the link groups are the only thing that scrolls, and
   the CTA sits outside that scroller. Previously the whole panel was one
   scrolling column, so "Enquire Now" -- the one action the nav exists for --
   fell below the fold at every size we tested. 100svh (not vh) so mobile
   browser chrome can't push the CTA off either. */
/* Dims the page behind the sheet. Needed only since the panel stopped covering
   the screen -- without it the page reads as still-interactive underneath. */
.mm-scrim {
  position: fixed; inset: 0; z-index: 840;
  background: rgba(0, 0, 0, .55);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
  opacity: 0; visibility: hidden;
  transition: opacity 1.02s var(--ease), visibility 1.02s;
}
body.menu-open .mm-scrim { opacity: 1; visibility: visible; }

/* A sheet that drops from the nav and is only as tall as its contents, rather
   than a full-screen panel padded out with dead space. max-height keeps it
   escapable on a short screen; the groups scroll inside if it ever gets there. */
.mobile-menu {
  position: fixed; top: 0; left: 0; right: 0; z-index: 850;
  max-height: calc(100svh - 16px);
  background: var(--abaddon); color: var(--off-white);
  border-radius: 0 0 clamp(18px, 3vw, 28px) clamp(18px, 3vw, 28px);
  box-shadow: 0 26px 60px rgba(0, 0, 0, .45);
  /* top clears the fixed nav (44px controls + 22px padding each side = 88px) */
  padding: 92px var(--pad-x) clamp(18px, 2.6vw, 26px);
  display: flex; flex-direction: column;
  transform: translateY(-100%); transition: transform 1.19s var(--ease),
                                            visibility 0s linear 1.19s;
  /* [04-09, Abdullah] The closed panel is parked above the viewport by the
     transform above, but it was left painting: visibility visible, opacity 1.
     Rubber-band past the top of the page and the browser shows what is up
     there — a dark strip with the panel's Enquire Now button and its spinner,
     which is exactly what he screenshotted.
     translateY alone cannot fix that: the element still exists and is still
     painted, it is just outside the normal scroll area. So it is hidden when
     closed. The delay on the visibility transition is what keeps the CLOSING
     animation intact: visibility flips only after the 1.19s slide finishes,
     rather than snapping the panel away the instant the class is removed. */
  visibility: hidden;
}
body.menu-open .mobile-menu {
  transform: translateY(0);
  visibility: visible;
  transition: transform 1.19s var(--ease), visibility 0s linear 0s;  /* show immediately on open */
}

/* Two columns everywhere, including phones. The sheet's height is set by the
   tallest column, and 11 links at a 44px tap target is 484px in one column --
   taller than most of the screen no matter how small the type gets. Splitting
   them is the only thing that actually buys height back. */
.mm__cols {
  flex: 0 1 auto; min-height: 0;         /* min-height:0 lets a flex child scroll */
  overflow-y: auto; -webkit-overflow-scrolling: touch;
  display: grid; gap: clamp(14px, 2.4vw, 24px);
  grid-template-columns: 1.15fr 1fr; column-gap: clamp(14px, 5vw, 90px);
  align-content: start;
}
/* Spaces and Locations stack down the left; More holds the right column on its
   own. That puts 7 of the 11 links in the column the eye lands on first, so the
   sheet reads weighted to one side instead of splitting 5/6 either side of a
   hard vertical centre — which is also why the left track is the wider one. */
.mm__group:nth-child(1) { grid-column: 1; grid-row: 1; }
.mm__group:nth-child(2) { grid-column: 1; grid-row: 2; }
.mm__group:nth-child(3) { grid-column: 2; grid-row: 1 / span 2; }
/* Locations is the secondary list in that column — a step down in size keeps
   Spaces reading as the primary one rather than as two equal blocks. The 44px
   tap target below is untouched; only the type shrinks. */
.mm__group:nth-child(2) a { font-size: clamp(17px, 4.1vw, 22px); }
/* Below ~340px two columns would start truncating "Private Office". */
@media (max-width: 339px) {
  .mm__cols { grid-template-columns: 1fr; }
  .mm__group:nth-child(n) { grid-column: 1; grid-row: auto; }
}
.mm__group { display: flex; flex-direction: column; }
.mm__label {
  font-family: var(--sans); font-size: 10.5px; font-weight: 600;
  letter-spacing: .2em; text-transform: uppercase; color: var(--green-l);
  margin-bottom: 6px;
}
.mobile-menu a {
  font-family: var(--serif); font-weight: 500;
  font-size: clamp(19px, 4.6vw, 25px); line-height: 1.15;
  /* 44px is the accessible minimum tap target, and it is what sets the sheet's
     height now that the type is smaller -- don't trade it away for compactness */
  padding: 4px 0; min-height: 44px; display: flex; align-items: center;
  color: var(--off-white); letter-spacing: -.02em;
  transition: color 0.595s var(--ease);
}
.mobile-menu a:active { color: var(--green-l); }

/* Wide enough to be a laptop, not a phone: two columns, so the panel stops
   being one narrow strip of text with two-thirds of the screen left empty. */
/* Full-bleed is right on a phone; on a laptop it becomes a 900px pill. Cap it
   and let it sit at the left margin, in line with the first column. */
@media (min-width: 700px) {
  .mobile-menu .mm-cta { max-width: 320px; }
}

/* A real button, not a green text link: it is the only action in here.
   `.mobile-menu .mm-cta` already outranks `.mobile-menu a` on specificity, so
   these override cleanly without !important. */
.mobile-menu .mm-cta {
  flex: none;                             /* never shrinks, never scrolls away */
  margin-top: clamp(18px, 2.6vw, 26px);
  justify-content: center; gap: 10px;
  padding: 16px 24px; min-height: 54px;
  font-family: var(--sans); font-size: 15px; font-weight: 600;
  letter-spacing: .02em; line-height: 1;
  color: var(--off-white); background: var(--green);
  border-radius: 100px;
}
.mobile-menu .mm-cta:active { color: var(--off-white); background: var(--green-d); }

/* ===== Hero ===== */
.hero { position: relative; min-height: 100svh; display: flex; align-items: flex-end; overflow: hidden; }
.hero__photo, .hero__fallback {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: 0;
}
/* photo sits ABOVE the fallback gradient (fallback only shows if the photo
   fails to load); slight over-height so the exit-scale never reveals an edge */
.hero__photo { z-index: 1; top: -6%; height: 112%; }
/* [04-09] The fallback layer now also carries the 20px blur placeholder, set
   inline by home(). It was already the element sitting directly under the photo
   at inset:0, so it is the surface that reads grey while the photo loads;
   giving it the blur is what removes the grey rather than recolouring it.
   Its "photo failed to load" job is unchanged — a blurred hero behind the
   headline is a better failure state than a flat gradient anyway.
   -6%/112% mirrors .hero__photo exactly so the blur and the real photo occupy
   the same box and the swap has nothing to jump between. */
.hero__fallback { top: -6%; height: 112%; background-size: cover; background-position: center; }
/* [08-06] The 125%-wide, left-anchored frame is GONE, along with the sculpture
   that needed it. It existed only to push that subject clear of the headline;
   the coworking photograph that replaced it has an empty left third already, so
   the frame is back to the shared inset:0 / width:100% above.

   Kept as a warning rather than deleted outright: if a future hero photo ever
   needs the same treatment, `width` is the property to reach for — NOT
   transform or scale, which the hero-exit scrub overwrites — and it needs
   `right:auto` (inset:0 plus width over-constrains and the width is ignored)
   and `max-width:none` (the global reset sets img{max-width:100%}, which
   silently clamps it straight back and leaves the rule parsing fine while
   doing nothing). Both of those cost a debugging session each already. */
/* [08-05, client] Abdullah, on the hero composition generally, not just this
   photo: "behind the text it needs to be empty... if there is a main subject in
   the picture, it's going to be on the right of the text... so that it doesn't
   look cramped."

   The sculpture in g-in-5 sits across 33-48% of the frame, which is exactly
   where the headline lands. object-position cannot move it: the photo is 3:2
   inside a ~16:9 hero, so cover already fits it by width and there is no
   horizontal slack to pan. Zooming until the subject cleared the text needed
   about 1.75x on a 2000px master, which is a visible softening and throws away
   the bokeh that made the photo worth using.

   Mirroring costs nothing and puts the subject near 60%, clear of the text.
   Safe here specifically because the right-hand side is entirely out-of-focus:
   no signage, no legible text, nothing that reads wrong reversed. Check that
   again before reusing this trick on a different photograph.

   Done in the ASSET, as g-in-5-mirror.jpg, not in CSS. Both CSS routes were
   tried and both are wrong here. A `transform` mirror is overwritten outright
   by the hero-exit scrub at main.js:661. The standalone `scale` property
   survives that scrub, but GSAP derives its uniform scale from the transform
   matrix, and a negative scaleX makes it resolve asymmetrically: measured
   (-1.007, 0.898) at 840px of scroll against a clean (+1.007, +1.007), i.e.
   the photograph squashes ~12% vertically on the way out. Flipping the file
   keeps the element's transform untouched and the exit animation uniform. */
.hero__fallback {
  background:
    radial-gradient(120% 90% at 75% 15%, rgba(138,154,130,.55), transparent 60%),
    radial-gradient(120% 100% at 15% 90%, rgba(255, 255, 255,.55), transparent 55%),
    linear-gradient(160deg, #3c463a, #232a22 60%, #1a1f19);
}
/* Two coupled scrims so the white headline + stats stay crisp over a bright
   photo: a bottom-up gradient (anchors the text block) and a left-to-right one
   (darkens the headline column) — the photo stays visible on the right. */
.hero__overlay {
  position: absolute; inset: 0; z-index: 1;
  /* [08-05] Darkened another notch sitewide — client kept flagging that hero
     titles were hard to read against the photo. Bottom-up stops (where the
     title actually sits, .hero being flex-end) went from .86/.42/.20 to
     .92/.55/.28; the left-right column wash matched proportionally. */
  /* [08-05, later] "lower the opacity even more" — .92/.55/.28 -> .94/.66/.38.
     Weighted to the middle stop rather than applied flat: the bottom stop is
     already close to opaque, so adding there buys almost no contrast and only
     flattens the photograph into a black band. The 46% stop is where the
     headline actually sits, so that is the one that moves most (+.11). Paired
     with quieter photographs in build_site.IMG — the scrim was being asked to
     rescue frames that were too busy to carry type in the first place. */
  background:
    linear-gradient(to top, rgba(0, 0, 0,.94) 0%, rgba(0, 0, 0,.66) 46%, rgba(0, 0, 0,.38) 100%),
    linear-gradient(to right, rgba(0, 0, 0,.74) 0%, rgba(0, 0, 0,.36) 46%, rgba(0, 0, 0,0) 72%);
}
/* Top padding reserves the fixed nav (103px desktop / 89px phone) so the title
   can never run under the bar. The hero is min-height, not height, so on tall
   screens this is invisible slack — content stays bottom-aligned exactly as
   before — and on short screens the hero grows instead of overlapping.
   Mirrors the clamp .phero already uses. */
.hero__content { position: relative; z-index: 2; width: 100%; padding: clamp(128px, 17vh, 190px) var(--pad-x) clamp(48px, 7vh, 92px); color: var(--white); }
/* The four gaps down this column used to be flat px, so they cost the same on a
   937px-tall browser window as on a 1440px monitor. Every max here is the value
   that was hardcoded, and every vh coefficient is set to reach that max at about
   1080px of viewport — so a full-screen 1080p monitor and anything taller render
   byte-identically to before, and only genuinely short windows give ground. */
.hero__eyebrow { color: var(--white); margin-bottom: clamp(16px, 2.8vh, 30px); }
.hero__eyebrow .dot { background: var(--green); }
.hero__title {
  /* Width-driven type on a height-constrained box was the actual bug. 11vw on a
     1920-wide window asks for 211px (capped 200), and two lines of that is 368px
     — 39% of a 937px-tall viewport, which is what pushed the stats row onto the
     fold. The 19vh ceiling only binds once the window is shorter than ~1053px;
     above that 11vw is still the smaller of the two and nothing changes. */
  font-size: min(calc(clamp(58px, 11vw, 200px) + var(--up) * 0.105), 19vh);
  line-height: 0.92; font-weight: 500; letter-spacing: -0.03em;
  max-width: 15ch;
}
/* Mask lines carry breathing room so italic descenders/overhangs never clip,
   with negative margins cancelling the visual offset. */
.hero__title .line { display: block; overflow: hidden; padding: 0.06em 0.08em 0.16em 0.02em; margin: -0.06em -0.08em -0.16em -0.02em; }
.hero__title .line > span { display: inline-block; will-change: transform; }
.hero__title em { font-style: italic; color: var(--green-l); }
.hero__sub { margin-top: clamp(18px, 3.2vh, 34px); max-width: calc(560px + var(--up) * 0.15); font-size: calc(clamp(17px, 1.5vw, 21px) + var(--up) * 0.011); color: rgba(255,255,255,.85); }
.hero__ctas { margin-top: clamp(20px, 3.7vh, 40px); display: flex; flex-wrap: wrap; gap: 14px; }
.hero__stats {
  margin-top: clamp(48px, 6vh, 80px);
  display: flex; flex-wrap: wrap; gap: clamp(28px, 5vw, 72px);
  padding-top: clamp(18px, 3.2vh, 34px); border-top: 1px solid rgba(255,255,255,.18);
}
.hero__stats div { display: flex; flex-direction: column; gap: 4px; }
.hero__stats strong { font-family: var(--serif); font-size: calc(clamp(34px, 4vw, 54px) + var(--up) * 0.0284); font-weight: 500; line-height: 1; }
.hero__stats span { font-size: 13px; letter-spacing: .04em; color: rgba(255,255,255,.7); }
/* Short laptop screens (MacBook 14"/16" at default scaling land near 745-800px
   of viewport height). Once the nav clearance above is reserved, the stock
   rhythm no longer fits one screen and the stats row gets guillotined at the
   fold. Tighten the vertical spacing so the whole hero still reads as a single
   screen. Width-gated so the phone layout is untouched. */
@media (min-width: 900px) and (max-height: 860px) {
  .hero__content { padding-top: clamp(116px, 17vh, 190px); padding-bottom: clamp(36px, 5vh, 60px); }
  .hero__title   { font-size: min(clamp(58px, 8vw, 116px), 17vh); }
  .hero__sub     { margin-top: 18px; }
  .hero__ctas    { margin-top: 22px; }
  .hero__stats   { margin-top: 26px; padding-top: 20px; }
}
/* A second, tighter step. The rule above is a cliff, not a ramp: it fires at 860px
   and then gives nothing more however short the window gets, so a 1366x768 laptop
   showing a 625px viewport still overflowed by 41px and cut the labels off (air was
   -5px). Below 720px the figures give up some size too — the alternative is losing
   the row entirely, and a 40px Ramillas numeral still reads as the hero's statement.
   Phones never see this: both steps are gated at min-width 900px. */
@media (min-width: 900px) and (max-height: 720px) {
  .hero__content { padding-bottom: clamp(26px, 4vh, 60px); }
  .hero__title   { font-size: min(clamp(52px, 8vw, 116px), 15.5vh); }
  .hero__stats   { margin-top: clamp(20px, 3vh, 80px); }
  .hero__stats strong { font-size: clamp(28px, 3vw, 40px); }
}
/* scrollLine is still live — .phero__scroll span::after (inner-page heroes) uses it.
   The home hero's sideways "Scroll" cue that also used it was removed 2026-07-25. */
@keyframes scrollLine { 0%,100% { transform: scaleY(.35); opacity:.4;} 50% { transform: scaleY(1); opacity:1;} }

/* ===== Announcement banner (Business Bay) ===== */
/* Green band, lead copy above two real .loccard cards (photos + address +
   phone/map/tour actions) — was a pair of plain text link boxes with no
   imagery ("two small empty pills" in an earlier pass, still flat). */
/* Smaller than section-pad-sm on purpose — this banner reads as a compact
   strip between the hero and intro, not a full section. */
.banner { background: var(--green); color: var(--white); padding-block: clamp(32px, 4vw, 64px); }
/* Centered and full-width (no max-width) — with just the eyebrow + title
   left, a narrow left-aligned column read as lopsided against the two
   full-width cards below it; centering balances the whole band. */
.banner__lead { text-align: center; margin-bottom: clamp(20px, 2.4vw, 32px); }
.banner__eyebrow { color: rgba(255,255,255,.75); margin-bottom: 18px; }
.banner__eyebrow .dot { background: var(--white); }
/* Wider .banner__lead + a smaller clamp keeps "flowork. is open across
   Dubai." on one line down to small-tablet widths; it wraps back to two
   lines below that rather than truncating or forcing an overflow. */
.banner__title { font-size: clamp(26px, 3.4vw, 46px); color: var(--white); line-height: 1.04; }
.banner__title .ln { display: block; overflow: hidden; padding-bottom: .04em; }
.banner__title .ln > span { display: inline-block; }
/* .loccard is a white card by default (fine on white pages) — reads even
   better popping against the green band here, no overrides needed. */
.banner .loccard__strip { background: rgba(255,255,255,.25); }
/* Cards read a touch smaller in this compact banner than the full-size
   .loccards elsewhere (Coworking, Dubai Hills, Business Bay pages). */
.banner .loccard__strip { height: clamp(170px, 16vw, 220px); }
.banner .loccard__body { padding: clamp(18px, 1.8vw, 24px); }
.banner .loccard__body h3 { font-size: clamp(19px, 1.7vw, 24px); }
.banner .loccard__actions { margin-top: 14px; }

/* ===== Intro statement — editorial two-column with image panel ===== */
/* [08-05] No longer a scroll-pinned section — see the coverflow note above
   pages.py's home() intro block. Plain section-pad rhythm like every other
   section on the page; the visual side is the shared .coverflow component
   (auto-advancing, drag/swipe/click, defined near .introsplit below). */
.intro__grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: clamp(40px, 6vw, 96px); align-items: center; }
/* [08-06, client] The carousel is gone from this section, so the copy stands on
   its own and centres [02:02]-[02:16]. A measure cap is still wanted — 60ch of
   centred text is unreadable — so the block is capped and centred rather than
   allowed to fill the container.
   text-align on the wrapper reaches the eyebrow, the statement and the
   paragraph; the button is inline-flex, so it needs the parent's text-align to
   centre and does not need its own rule. .intro__text p carries max-width:46ch
   below and would sit hard left inside this block without margin-inline:auto. */
/* [08-06, client] "make that section take the appropriate page width to
   eliminate dead space on the sides" (Ahmed, 06-08). 880px left the statement
   wrapping early with a wide empty margin either side, which read as a narrow
   column floating in the section rather than as a centred block.
   The block now runs to 1180px so the statement can hold its line, while the
   paragraph keeps a readable measure of its own — full-width centred body copy
   is the thing that actually reads badly, not the heading. */
.intro__solo { max-width: calc(1180px + var(--up) * 0.35); margin-inline: auto; text-align: center; }
.intro__solo .intro__statement { max-width: 20ch; margin-inline: auto; }
.intro__solo .intro__text p { max-width: 62ch; margin-inline: auto; }
.intro__text .eyebrow { margin-bottom: 22px; }
.intro__statement { font-size: clamp(30px, 4.2vw, 60px); font-weight: 500; line-height: 1.08; letter-spacing: -0.02em; }
.intro__statement em { font-style: italic; color: var(--green); }
.intro__text p { margin-top: 28px; color: var(--ink-soft); max-width: 46ch; font-size: clamp(16px, 1.3vw, 18px); }
.intro__text .link-arrow { margin-top: 30px; }
.intro__text .btn { margin-top: 42px; }
.intro__visual { position: relative; }
.intro__emblem { position: absolute; left: -22px; bottom: -34px; width: clamp(110px, 13vw, 180px); aspect-ratio: 1;
  background: url("../assets/f-green.png") no-repeat center / contain;
  filter: drop-shadow(0 24px 44px rgba(0, 0, 0,.28)); animation: floatY 10.2s ease-in-out infinite; pointer-events: none; }
@keyframes floatY { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-14px); } }
@media (max-width: 900px) { .intro__grid { grid-template-columns: 1fr; gap: 52px; } .intro__emblem { left: -14px; } }

/* Stacked, the carousel landed dead last — under the paragraph and the button,
   so a full screen of copy sat between the statement and the only photography
   in the section. It belongs directly under the statement. The two can't be
   reordered as they stand because they live in different grid items, so
   display:contents dissolves .intro__text and its children become items of
   .intro__grid alongside .intro__visual, which is what `order` needs to slot
   the carousel between them. Consequences of that, each handled below:
   the 52px grid gap would now apply between every child, so it goes to 0 and
   the rhythm is margins only (tighter than 52px anyway — see "reduce gaps");
   grid items don't collapse margins, so the eyebrow's 22px bottom is dropped
   and the statement's inline 22px top carries that gap alone; and grid
   blockifies inline-flex, so .eyebrow and .btn need justify-self to keep
   hugging their content instead of stretching the full width. */
@media (max-width: 900px) {
  .intro__grid { gap: 0; }
  .intro__text { display: contents; }
  .intro__text .eyebrow { order: 1; justify-self: start; margin-bottom: 0; }
  .intro__statement { order: 2; }
  .intro__visual { order: 3; margin-top: 30px; }
  .intro__text p { order: 4; margin-top: 30px; }
  .intro__text .btn { order: 5; justify-self: start; margin-top: 30px; }
}

/* ---- Our Spaces on phone: compact card + swipe rail ----
   Stacked, the five showcase cards ran 4,056px — 4.8 phone screens, 37% of the
   whole home page. Two thirds of each 668px card was photo (267px) and feature
   chips (164px, wrapping onto 4-5 rows); the chips repeat verbatim on the space's
   own page, which the card already links to, so they earn nothing here. Below
   680px — the width where the grid already collapsed to one column — the photo
   shortens to 16:9, the chips drop, the description clamps to two lines, and the
   cards become a native scroll-snap rail with dots. Section: ~950px.
   Everything is scoped to the rail, so .card elsewhere (blog, cross-sell) and
   every width above 680px are untouched. */
.spaces__dots { display: none; }

@media (max-width: 680px) {
  .spaces__grid[data-spaces-rail] {
    display: flex; gap: 14px;
    overflow-x: auto; overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    /* bleed to the screen edges so the next card peeks, while the first still
       lines up with the section heading */
    margin-inline: calc(var(--pad-x) * -1);
    padding-inline: var(--pad-x);
    padding-block: 4px 8px;                 /* room for the card shadow */
  }
  .spaces__grid[data-spaces-rail]::-webkit-scrollbar { display: none; }
  .spaces__grid[data-spaces-rail] > .card {
    flex: 0 0 82%; max-width: 300px;
    scroll-snap-align: center;
  }
  .spaces__grid[data-spaces-rail] .card__media,
  .spaces__grid[data-spaces-rail] .card--tall .card__media { aspect-ratio: 16 / 9; }
  .spaces__grid[data-spaces-rail] .card__body { padding: 18px; gap: 8px; }
  .spaces__grid[data-spaces-rail] .card__feats { display: none; }
  .spaces__grid[data-spaces-rail] .card__body p {
    font-size: 15px;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .spaces__dots { display: flex; justify-content: center; gap: 4px; margin-top: 16px; }
  /* 24px button so the tap target clears the minimum; the visible dot is the ::after */
  .spaces__dot {
    width: 24px; height: 24px; padding: 0; border: 0; background: none;
    display: grid; place-items: center; cursor: pointer;
  }
  .spaces__dot::after {
    content: ""; width: 7px; height: 7px; border-radius: 100px;
    background: rgba(0, 0, 0, .25);
    transition: width 0.595s var(--ease), background 0.595s var(--ease);
  }
  .spaces__dot.is-on::after { width: 20px; background: var(--green); }
}

/* Word/line reveal base states (JS drives) */
.reveal-line { display: block; overflow: hidden; }
.reveal-line > * { display: inline-block; }

/* ===== Spaces / services grid ===== */
.spaces__grid {
  display: grid; grid-template-columns: repeat(12, 1fr); gap: clamp(18px, 2vw, 30px);
}
.card {
  grid-column: span 4;
  display: flex; flex-direction: column;
  background: var(--off-white); border: 1px solid var(--line-soft);
  border-radius: var(--r-lg); overflow: hidden;
  box-shadow: var(--sh-2);
  /* No transform transition — GSAP drives transform directly (quickTo tilt);
     a CSS transition here would lag every cursor update by half a second. */
  transition: box-shadow 1.02s var(--ease);
}
.card:hover { box-shadow: var(--sh-3); }
.card--wide { grid-column: span 8; }

/* ---- "Other spaces" cross-sell — real cards (image + title + a line of
   description), just noticeably smaller/lighter than the main grid, so it
   reads as a footnote to the page rather than a second competing section.
   Exactly 4 of the 5 SPACE_CARDS remain once the current page excludes
   itself, so span-3-of-12 tiles them cleanly in one row. ---- */
.section-pad-sm { padding-block: clamp(29px, 3.3vw, 53px); }   /* [08-05] re-tightened along with .section-pad above, same reversal */
.section-head--sm { margin-bottom: clamp(24px, 2.6vw, 38px); }
.section-head--sm h2 { font-size: calc(clamp(22px, 2.4vw, 34px) + var(--up) * 0.0179); }
.spaces__grid--sm .card--sm { grid-column: span 3; }
.card--sm .card__media { aspect-ratio: 4 / 3; }
.card--sm .card__body { padding: clamp(16px, 1.6vw, 22px); gap: 8px; }
.card--sm .card__body h3 { font-size: clamp(17px, 1.5vw, 20px); }
.card--sm .card__body p {
  font-size: 12.5px; line-height: 1.5; color: var(--ink-soft);
}
.card--sm .card__body .link-arrow { font-size: 13px; margin-top: auto; padding-top: 10px; }
@media (max-width: 900px) { .spaces__grid--sm .card--sm { grid-column: span 6; } }

/* Pills stay hidden at rest; hovering the PICTURE only (not the title/desc)
   blurs it and reveals them, sized/positioned to match .card__media exactly
   and self-clipped (own overflow+radius, not just the ancestor's) to avoid
   the backdrop-filter-bleeds-past-rounded-corners bug. That works on desktop
   where there's room, but the same small box on a phone can't fit 5-6 pills —
   so on touch the SAME markup instead becomes a bottom sheet (standard
   mobile pattern for overflow content that doesn't fit inline: popover on
   desktop, sheet on mobile), opened by a chevron button since there's no
   hover there to trigger it. */
.card--sm { position: relative; }
.card--sm .card__link { display: flex; flex-direction: column; flex: 1; color: inherit; text-decoration: none; }
.card--sm .card__overlay {
  position: absolute; top: 0; left: 0; right: 0; z-index: 3;
  aspect-ratio: 4 / 3; overflow: hidden;
  border-top-left-radius: var(--r-lg); border-top-right-radius: var(--r-lg);
  /* Belt-and-suspenders clip: overflow+border-radius alone isn't reliably
     honoured by backdrop-filter on every browser/GPU pipeline (reported: the
     blur not reaching the card's right edge on some machines) — clip-path is
     composited more consistently across engines. isolation gives the overlay
     its own stacking context so it doesn't inherit clipping quirks from the
     card's own transform (GSAP tilt + scroll-reveal both animate .card--sm's
     transform directly). */
  clip-path: inset(0 round var(--r-lg) var(--r-lg) 0 0);
  isolation: isolate;
  display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
  padding: 16px; text-align: center;
  background: rgba(104, 122, 97, .32);
  backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity 0.85s cubic-bezier(0.16,1,0.3,1), visibility 0s linear 0.85s;
}
.card--sm .card__overlay .card__feats { justify-content: center; margin: 0; gap: 8px; }
.card--sm .card__overlay .card__feats li,
.card--sm .card__overlay .card__feats li.card__cap {
  background: rgba(255, 255, 255, .92); border-color: var(--green); color: var(--green-d);
  font-size: 11.5px; padding: 4px 10px;
}
.card--sm .card__overlay .card__cap svg { width: 11px; height: 11px; }
@media (hover: hover) and (pointer: fine) {
  .card--sm:has(.card__media:hover) .card__overlay { opacity: 1; visibility: visible; transition-delay: 0s; }
}
.card--sm .card__link:focus-visible ~ .card__overlay {
  opacity: 1; visibility: visible; transition-delay: 0s;
}
.card--sm .card__info-btn {
  display: none;
  position: absolute; top: 10px; right: 10px; z-index: 4;
  width: 32px; height: 32px; border-radius: 50%;
  align-items: center; justify-content: center;
  background: var(--off-white); border: 1px solid var(--line);
  color: var(--ink); cursor: pointer;
}
.card--sm .card__info-btn svg { width: 14px; height: 14px; transition: transform 0.595s cubic-bezier(0.16,1,0.3,1); }
.card--sm .card__info-btn[aria-expanded="true"] svg { transform: rotate(180deg); }
.card--sm .card__sheet-backdrop { display: none; }

/* Touch: the picture-box overlay becomes a sheet that drops down from the
   top of the screen — full pill list, comfortably sized, no cramming.
   Gated to (hover:none) so it never competes with the hover overlay above
   on devices that have real hover. The same chevron button opens AND closes
   it (it already flips to point up via [aria-expanded="true"]), so its
   z-index is raised above the sheet/backdrop here — otherwise the sheet
   (position:fixed) would sit on top of it and swallow the tap. */
@media (hover: none) {
  .card--sm .card__info-btn { display: flex; z-index: 51; }
  /* The button is normally anchored to the CARD (top:10px;right:10px of
     .card--sm), but the sheet it opens is anchored to the VIEWPORT
     (position:fixed) — those are two different coordinate spaces, so
     wherever the card happened to be scrolled to when it opened, the
     button could land on top of the sheet's own first row of pills.
     Pinning the button to the viewport too while the sheet is open puts
     both in the same coordinate space, and the overlay's own padding-right
     below reserves a clear corner so no pill can wrap underneath it. */
  .card--sm.is-open .card__info-btn {
    position: fixed; top: calc(10px + env(safe-area-inset-top)); right: 10px;
  }
  .card--sm .card__overlay {
    position: fixed; inset: 0 0 auto 0; z-index: 50;
    aspect-ratio: auto; max-height: 65vh; overflow-y: auto;
    border-radius: 0; border-bottom-left-radius: var(--r-lg); border-bottom-right-radius: var(--r-lg);
    justify-content: center;
    /* Clears the 32px chevron (10px top offset + 32px + a 4px gap so the
       first pill row never touches it) — tightened now the button no longer
       renders at 44px. */
    padding: calc(46px + env(safe-area-inset-top)) 22px 22px 22px;
    /* [08-05] Was flipped to white by mistake in the sitewide creamy/white
       swap — every other solid card surface went the other way, to off-white. */
    background: var(--off-white);
    backdrop-filter: none; -webkit-backdrop-filter: none;
    box-shadow: 0 12px 40px rgba(0, 0, 0, .2);
    opacity: 1; visibility: visible; pointer-events: none;
    transform: translateY(-100%);
    transition: transform 0.765s cubic-bezier(0.16,1,0.3,1);
  }
  .card--sm .card__overlay .card__feats { justify-content: center; }
  .card--sm.is-open .card__overlay { transform: translateY(0); pointer-events: auto; }
  .card--sm .card__sheet-backdrop {
    display: block; position: fixed; inset: 0; z-index: 49;
    background: rgba(0, 0, 0, .4);
    opacity: 0; visibility: hidden; pointer-events: none;
    transition: opacity 0.68s ease, visibility 0s linear 0.68s;
  }
  .card--sm.is-open .card__sheet-backdrop {
    opacity: 1; visibility: visible; pointer-events: auto; transition-delay: 0s;
  }
}

@media (max-width: 560px) {
  .spaces__grid--sm .card--sm { grid-column: span 6; }
}

/* ===== Spaces index — the cross-sell as an editorial list, not cards =====
   Used wherever the spaces are a destination rather than the subject (locations,
   About). The rows carry the meaning; the photo frame is atmosphere. The frame
   is absolutely positioned in the right third so a row's text is never set over
   an image -- the photo reads as "behind" without ever costing legibility. */
/* [changed 27-07] Two real grid columns, not a list with an absolutely
   positioned frame beside it.

   The frame used to be `inset: 0 0 0 auto`, so its height WAS the row list's
   height — and the photo inside it is whatever the copy leaves over. A page that
   excludes its own space has four rows instead of five, so the frame lost ~93px
   and the photo took all of it: 139px against 232-270px elsewhere, a 3:1
   letterbox next to a landscape. At 1280 it hit its 96px floor, which means the
   frame had started cropping the panel's own copy.

   As a grid item the frame sizes from its WIDTH (aspect-ratio below) instead of
   from the row count, so the photo is identical on every page by construction.
   The grid row then takes the taller of list-or-frame and the rows share out any
   slack, which is what keeps a four-row page from leaving a hole beside the
   frame. Column 2 at min(34%, 420px) with a 44px gap reproduced the geometry
   the old `margin-right: calc(34% + 44px)` on the rows produced, to the pixel.
   Both numbers have since moved (38%/470px, and the gap now tracks --pad-x),
   so treat that equivalence as history, not as a constraint to preserve. */
/* align-items stays at its `stretch` default, which is what keeps the two columns
   ending level. The grid row takes the taller contribution — the frame's
   aspect-ratio height, or the list's natural height, whichever wins — and then
   both are stretched to it. On a four-row page that is the frame, so the list
   grows into it and the rows below share out the difference; on a five-row page
   the list already wins by a pixel and nothing moves. Setting `start` here
   instead left the frame hanging ~92px past the last row. */
.sindex__wrap {
  position: relative;
  /* [08-06, client] 34%/420 -> 38%/470: "make it bigger" (Ahmed, 06-08). The
     rows keep minmax(0,1fr) so they absorb the difference rather than
     overflowing, and the panel's own 9/10 spacer scales with the column. */
  display: grid; grid-template-columns: minmax(0, 1fr) min(38%, 470px);
  /* [08-06, client] The gap tracks the container's own side padding instead of
     being its own number. Ahmed, at 50% zoom: the card "sits closer to the list
     than to anything else". It did, measurably: 44px to the list on its left
     against --pad-x on its right, which is 72px at 1440 and 80px once the clamp
     maxes out. Two different numbers on two sides of the same panel.

     var(--pad-x) makes them the same value at every width by construction, so
     the panel is centred in its own gutters and cannot drift apart again if the
     clamp is ever retuned. The list column is minmax(0, 1fr), so it absorbs the
     extra 28-36px rather than pushing the panel or overflowing. */
  column-gap: var(--pad-x);
}
/* Both children are pinned to row 1. Naming only the COLUMN is not enough: with
   sparse auto-placement the frame takes row 1 / column 2, the cursor advances
   past it, and the list — which has no placement of its own — lands in row 2,
   column 1. Diagonal, not side by side. */
/* [08-06] The head is inside the grid now (see spaces_index in build_site.py),
   so the frame can span it. Row 1 is the head, row 2 the list, and the frame
   spans both. */
.sindex__head { grid-column: 1; grid-row: 1; }
.sindex__list { grid-column: 1; grid-row: 2; display: flex; flex-direction: column; }
/* Rows share any height the frame has over the list, so four of them fill the
   same column five would rather than stopping short of it. */
.sindex__list > .sindex__row { flex: 1 1 auto; }

.sindex__media {
  position: relative; grid-column: 2; grid-row: 1 / span 2;
  border-radius: var(--r-lg); overflow: hidden;
  background: var(--bg-2);
  pointer-events: none;                  /* the rows underneath stay clickable */
  /* [08-06, client] "add a bit of shadow just to create a separation between
     those two colours" [04:48]. The panel is warm off-white on a warm off-white
     page, so with no shadow its edge only reads where a photo happens to be
     dark. --sh-3 is the lifted step; --sh-2 is barely visible between two tones
     this close together. */
  box-shadow: var(--sh-3);
  /* [08-06, client, second pass] Stretched, not centred. Centring it left a
     522px panel floating beside an 849px section — Ahmed: "the box on the
     right's spacing is off, should be bigger and centered to this whole
     section". Stretching satisfies both at once: it fills head + list exactly,
     so its top edge sits on the eyebrow and its bottom on the last row, and it
     is 63% taller for free.
     The height goes to the PHOTO, not the copy: .sindex__panel is a flex column
     whose media is `flex: 1 1 auto` and whose body is content-sized. The 27-07
     note below is about the frame being too SHORT and letterboxing the photo —
     the opposite failure — so growing it is safe.
     No align-self at all: stretch is already the default for a grid item. */
}
/* The frame's height FLOOR, and the reason it is a spacer rather than an
   `aspect-ratio` on .sindex__media itself. Every panel is absolutely positioned,
   so without this the frame has no content and no natural height of its own.
   aspect-ratio on the frame would fix that, but it also makes the height
   DEFINITE, and a definite height cannot be stretched — five-row pages, where
   the list is the taller of the two, were left with the frame ending 15-21px
   short of the last row. As an in-flow spacer it sets the minimum and leaves the
   frame `height: auto`, so the grid row is max(list, floor) and both columns
   stretch to it either way.
   9/10 lands within a pixel or two of a five-row list at every desktop width, so
   pages that already looked right are unchanged, and four-row pages are lifted
   to match instead of the frame being cut down to their shorter list — which is
   what squeezed the photo to 139px. */
.sindex__media::before { content: ""; display: block; width: 100%; aspect-ratio: 9 / 10; }
/* One panel per space, stacked in the frame; only .is-on is visible. The frame
   never changes size, so moving between rows is a pure cross-fade with zero
   layout shift — the reason the detail lives here instead of expanding the rows.
   Photo takes whatever height the copy leaves it, with a floor so it can never
   collapse to nothing; .sindex__media already clips, so an unusually long body
   crops the image rather than spilling text out of the frame. */
.sindex__panel {
  position: absolute; inset: 0; z-index: 0;
  display: flex; flex-direction: column;
  opacity: 0;
  transition: opacity 0.935s var(--ease-fade);
}
.sindex__panel.is-on { opacity: 1; z-index: 1; }
/* The photograph may cross-fade. The copy may NOT.
   All five panels share one box, so mid-fade the outgoing panel's body was still
   being painted over the incoming one's — two grey text blocks at identical
   positions, blended. That reads as denser, heavier text which appears to
   un-bold the instant the fade lands. Worse, without z-index plain DOM order
   decided the stack, so hovering row 3 and then row 1 painted the OUTGOING panel
   on top of the incoming one. Dropping the outgoing body outright means only one
   is ever on screen; the incoming one still rides its panel's fade in. */
.sindex__panel:not(.is-on) .sindex__panelbody { visibility: hidden; }
/* [04-09, Abdullah] "the images are not the same size, he wants them the same."
   Measured at 1440 before changing anything: the panel is a fixed 642px for all
   five spaces, but the BODY under the photo varies with its pill count and
   description length (236 / 198 / 236 / 220 / 220), and the media was
   `flex: 1 1 auto` — it absorbed whatever the text left over, so the photo came
   out 406 / 444 / 406 / 422 / 422.

   Giving the media a fixed SHARE of the panel instead of the remainder makes
   every photo identical. 62% of 642 is ~398, leaving ~244 for the body, which
   clears the tallest measured body (236) so nothing is squeezed. A percentage
   rather than a px value so it tracks the panel height at every width. */
/* [04-09, Ahmed] 62% -> 66%, and 54% -> 60% below. The body is `flex: 1 1 auto`,
   so whatever the photo does not take becomes empty cream under the pills. The
   shares were set when the copy was smaller; with the larger type above they
   were handing the body more room than its content needs. Raising them is what
   actually closes the gap — the type bump alone left 78 to 180px. Photos get
   bigger, and stay identical to each other, which is Abdullah's 04-09 rule. */
.sindex__panelmedia { position: relative; flex: 0 0 62%; min-height: 96px; overflow: hidden; }
/* [04-09, Abdullah] "the Other Spaces section's card pills is overlapping."
   --short is set by spaces_index() whenever a slug is excluded, i.e. on every
   "You might also like" block, which lists four rows instead of five and so
   gets a panel a whole row shorter. The media kept its 62% of that smaller box
   and the body was left too little, so the last pills hung out of the bottom of
   the card: +10px at 1440 and +18px at 1024, on all five space pages.
   Measured at ed19938 too, so this long predates the 04-09 work.
   The photo gives the room back. Photos stay identical to each other WITHIN a
   section, which is Abdullah's rule; a shorter section's photo is smaller than
   a taller one's, exactly as it already is across breakpoints. */
.sindex__wrap--short .sindex__panelmedia { flex-basis: 52%; }
/* [04-09, Ahmed] Home only, same reason as the type above: home's panel is
   the tallest on the site, so it is the only one with room to give the photo.
   On the shorter cards a bigger photo squeezes the body and pushes pills out
   the bottom. */
[data-page="home"] .sindex__panelmedia { flex-basis: 66%; }
/* [04-09] The share has to come down as the panel narrows. The body's pills wrap
   onto more rows at smaller widths, so a flat 62% left the copy overflowing the
   card by 36px at 1024 — verified by measuring the body's bottom against the
   panel's. The photos stay identical to each other at every width, which is the
   requirement; only how tall they are changes with the viewport, which is true
   of everything else on the page. */
@media (max-width: 1340px) {
  .sindex__panelmedia { flex-basis: 54%; }
  /* Same correction at the narrow share. 1024 was the worse of the two. */
  .sindex__wrap--short .sindex__panelmedia { flex-basis: 44%; }
  [data-page="home"] .sindex__panelmedia { flex-basis: 60%; }
}
/* The subtle zoom lives on the photo, not the panel. A transform on a
   text-bearing element rasterises it into a composited layer for the length of
   the transition, which drops subpixel antialiasing and shifts the text's
   apparent weight on its own — a second, independent source of the same
   complaint. Scaling the image inside its clip box keeps the effect and spares
   the copy entirely. */
/* [04-09, Abdullah] The panel photo's settle-zoom is gone with the rest of the
   motion: "when hovering over the spaces they get slightly bigger, that needs to
   be removed." Was scale(1.02) -> scale(1) as a panel became active. */
.sindex__panelmedia .sindex__shuffle { transform: none; }
/* [04-09, Abdullah] "there is empty space under the text, why?"
   The panel is a fixed 642px box. The media now takes a fixed 62% so every photo
   is the same size (his previous note), and the body was `flex: 0 0 auto`, so it
   stopped at its own content — 236px for a six-pill space, 198px for Coworking.
   The shortfall exposed the PARENT's background, .sindex__media at #ECE5DB,
   which is a slightly darker cream than the body's #F5F1EC. That colour seam is
   what reads as "empty space": 46px of it under the Coworking pills.

   `flex: 1 1 auto` lets the body take the remainder, so the card reaches the
   bottom of the panel on every space. Short bodies simply carry more padding
   under the pills, in the same cream, which reads as the card breathing rather
   than as a gap. The photos are untouched and stay identical. */
.sindex__panelbody {
  flex: 1 1 auto;
  display: flex; flex-direction: column; gap: 10px;
  padding: clamp(16px, 1.5vw, 24px);
  background: var(--off-white);
}
.sindex__paneltitle {
  font-family: var(--sans); font-size: 11px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--green-d);
}
.sindex__paneltext { font-size: 13.5px; line-height: 1.6; color: var(--ink-soft); }
.sindex__panelbody .card__feats { gap: 6px; }
.sindex__panelbody .card__feats li { font-size: 12px; padding: 5px 10px; }

/* [04-09, Ahmed] "increase the text size in the card on the right so that it
   doesn't have empty space" — HOME ONLY, and the scoping is the whole point.
   The panel is as tall as the list beside it, and home's list is the tallest on
   the site: its section carries a two-line heading and an eyebrow the other
   pages do not, so the same five rows sit in a taller box. Everywhere else the
   card is shorter, and the larger type pushed the last two pills out through
   the bottom of it. I shipped that in 7e6df07 because I measured index.html and
   nothing else; Ahmed caught it on a space page.
   So: base sizes above are the original ones and are what the other seven pages
   get. Only [data-page="home"] takes the bigger copy and the bigger photo.
   Verified across all 8 pages carrying .sindex__panel — see the leftover table
   in the commit message; no card overflows at any tested width. */
[data-page="home"] .sindex__paneltitle { font-size: 12px; }
[data-page="home"] .sindex__paneltext { font-size: 15px; }
[data-page="home"] .sindex__panelbody .card__feats { gap: 7px; }
[data-page="home"] .sindex__panelbody .card__feats li { font-size: 13px; padding: 6px 11px; }

/* The frame is 34% of the container, so on a narrow desktop window it is only
   ~280px wide and the same copy needs far more lines. Tighten the body there so
   the photo keeps a usable share instead of being squeezed to its floor. */
@media (max-width: 1150px) {
  .sindex__panelbody { padding: 14px; gap: 8px; }
  .sindex__paneltext { font-size: 12.5px; line-height: 1.55; }
  [data-page="home"] .sindex__paneltext { font-size: 13.5px; }
  .sindex__panelbody .card__feats li { font-size: 11px; padding: 4px 9px; }
}

/* [04-09] Home's larger panel copy is a WIDE-SCREEN treatment and has to stop.
   The preview panel only exists at >=901px, and between 901 and 1024 it is at
   its narrowest while the copy wraps to the most lines. Measured at 901 with
   the bump on: +10px of pills hanging out of the card, against -108px at
   ed19938 before the bump existed. That one is mine, not inherited.
   Below 1025 home takes the base sizes and the base media share, i.e. exactly
   what every other page gets. Placed after the two blocks above so it wins. */
@media (max-width: 1024px) {
  [data-page="home"] .sindex__paneltitle { font-size: 11px; }
  [data-page="home"] .sindex__paneltext { font-size: 12.5px; }
  [data-page="home"] .sindex__panelbody .card__feats { gap: 6px; }
  [data-page="home"] .sindex__panelbody .card__feats li { font-size: 12px; padding: 5px 10px; }
  [data-page="home"] .sindex__panelmedia { flex-basis: 54%; }
}

/* The per-space shuffling image set. Reuses .loccard__col from the location
   cards — the stacked <img> pair and the opacity cross-fade are already
   defined there — so only the box it fills is described here. It is decorative
   in the panel (the frame is pointer-events:none) and part of the link in the
   expand variant, so it never wants the location card's own pointer cursor. */
.sindex__shuffle { position: absolute; inset: 0; }
.sindex__shuffle .loccard__col { position: absolute; inset: 0; cursor: inherit; }

/* ===== The open row — phone only =====
   [resolved 27-07] This was "variant B", the Business Bay half of the client A/B
   against the preview panel. Abdullah chose BOTH, split by size: the panel on
   desktop, this on phone. So it is no longer a variant you pick — it is what the
   same component renders below 900px, and the desktop half of it (the hover that
   opened a row on a wide screen, and the .sindex__wrap--exp / .sindex__row--exp
   hooks that drove it) is deleted rather than left to rot.

   Hidden, not merely collapsed, above the breakpoint: the panel already carries
   every description up there, and rendering both visibly would print each one
   twice. .sindex__more — the panel's old copy-only phone fallback — is gone for
   the same reason, since this supersedes it and brings a photo with it. */
.sindex__exp { display: none; }
.sindex__expinner { min-height: 0; }
.sindex__expmedia {
  position: relative;
  border-radius: var(--r-md); overflow: hidden; background: var(--bg-2);
}
.sindex__exptext { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.sindex__expdesc { line-height: 1.65; color: var(--ink-soft); max-width: 60ch; }

.sindex__row {
  position: relative; z-index: 1;
  /* [03-09, Abdullah] "all boxes must be exactly the same size."
     Measured before changing anything: rows rendered 93, 94 and 128px at
     1440px. The 128 was "Meeting & Boardrooms" alone — the only name long
     enough to wrap to two lines in a 1fr column, and the name is the tallest
     cell, so it sets the row height.

     Fixed by giving the name the room it needs (1.35fr against the
     description's 0.85fr) rather than by setting a min-height on the row.
     A min-height would have levelled everything UP to 128px, adding ~140px of
     empty space per section across four rows — the same dead space the perks
     section was rebuilt to remove in July. This levels down to 93 instead,
     which is what four of the five rows already were.

     The 93/94 pair was subpixel: one description wrapped to a hair over a whole
     number of line boxes. Equal columns fix that too. */
  display: grid; grid-template-columns: 46px minmax(0, 1.35fr) minmax(0, 0.85fr) 28px;
  align-items: center; gap: 20px;
  padding: clamp(20px, 2vw, 30px) 0;
  /* The rules used to be kept off the photo by `margin-right: calc(34% + 44px)`.
     The grid column does that now — the row box simply ends where column 1 does,
     so a rule can no longer reach the frame however long it gets. */
  border-top: 1px solid var(--line);
  color: var(--ink);
}
/* [03-09] The closing rule belongs to the LIST, not to its last item. It used to
   sit on .sindex__row:last-child, which made that one row exactly 1px taller
   than the other four (measured 104.39 against 103.39) — the last thing standing
   between these rows and "exactly the same size". Same pixel on screen, but now
   every row box is identical. */
.sindex__list { border-bottom: 1px solid var(--line); }
/* [08-14] The row used to step 14px right on hover (`padding-left`, 1.02s).
   This is THE hover the CEO asked us to kill — see --hover-grow. The name
   grows instead, and the green stays.

   The scale sits on the NAME, not on .sindex__row: the row carries the 1px
   rules above and below it and spans the whole grid column, so scaling it
   would thicken those rules and push a long name toward the photo panel.
   Animating `padding-left` also forced layout on every frame of a 1.02s
   transition, against our transform/opacity-only rule. */

.sindex__num {
  font-family: var(--sans); font-size: 12px; letter-spacing: .14em;
  color: var(--ink-soft); transition: color 0.85s var(--ease);
}
.sindex__name {
  font-family: var(--serif); font-size: clamp(22px, 2.2vw, 32px); line-height: 1.1;
  transform-origin: left center;
  transition: color 0.85s var(--ease), transform 0.85s var(--ease);
}
/* [04-09, Abdullah] "when hovering over the spaces in all the sections, they get
   slightly bigger. That needs to be removed." So the name no longer scales.
   The colour change on hover stays — the row is a link and still has to look
   like one, and he objected to the movement, not to the feedback.

   History, so this is not read as a regression: on 08-14 the CEO killed the
   row's 14px sideways step and the scale was what replaced it. Both are now
   gone, which is consistent with the 03-09 "zero animation" direction.

   :focus-visible keeps its own visible treatment through the colour rules
   below; it must not be left with no indicator at all. */
.sindex__row:hover .sindex__name,
.sindex__row:focus-visible .sindex__name { transform: none; }
/* [03-09, Abdullah] Second half of "all boxes must be exactly the same size".
   Widening the name column removed the 35px outlier, but the description then
   became the tallest cell and it wraps to one line on some rows and two on
   others, which reintroduced the same problem at a smaller scale (83 vs 99px at
   1280). Reserving exactly two lines makes every row identical at every width,
   and the clamp means a longer description can never reintroduce a third.
   line-height is stated rather than inherited so the reserved box and the
   rendered text cannot drift apart. */
.sindex__desc {
  font-size: 14px; color: var(--ink-soft);
  line-height: 1.6;
  min-height: 3.2em;                 /* exactly two 1.6em line boxes */
  /* [04-09, Abdullah] "not aligned with the text on the left or the arrow on the
     right." Reserving two line boxes (03-09, to make the rows equal) left a
     one-line description sitting at the TOP of a two-line box, while the name
     and the arrow are centred by the row's align-items — so short descriptions
     rode high. The reservation has to stay or the rows go unequal again, so the
     text is centred inside it instead.
     This replaces -webkit-line-clamp, which cannot coexist with flex centring:
     the clamp needs display:-webkit-box. overflow:hidden still caps a runaway
     third line, which is all the clamp was there for. */
  display: flex; align-items: center;
  overflow: hidden;
}
.sindex__arrow { display: flex; justify-content: flex-end; color: var(--ink-soft); }
.sindex__arrow svg {
  width: 19px; height: 19px;
  opacity: .45; transition: opacity 0.85s var(--ease), transform 0.85s var(--ease);
}
.sindex__row:hover .sindex__num,
.sindex__row:hover .sindex__name { color: var(--green); }
.sindex__row:hover .sindex__arrow { color: var(--green); }
.sindex__row:hover .sindex__arrow svg { opacity: 1; transform: translateX(4px); }

/* Above the rail there is nothing to page through, so the dots would be five
   buttons controlling nothing. Declared BEFORE the media query below, which
   turns them on — at equal specificity the later rule wins, so putting this
   after would hide them at every width including the one that needs them. */
.sindex__dots { display: none; }

/* ===== Tablet and below: the spaces become a swipe rail of cards =====
   [changed 28-07] What used to be here left every row open and stacked — photo,
   full copy and every pill, five times. It measured 2,828px on the home page:
   3.1 phone screens for one section, and 37% of the page. This is the rail
   Abdullah asked for at [03:33] of the 27-07 call ("should swipe, not scroll"),
   and it lands the same content in 863px — 0.96 of a screen.

   Rendered against the real pages before it was chosen; the comparison sheets
   are in Pictures/review/ (SPACES-PHONE-OPTIONS, -R2, -A2-REFORMAT).

   Four things carry the height saving, and each is a deliberate call:
     - The 01-05 number is dropped. Five spaces are a set, not a sequence, so
       the order carries nothing a reader needs, and on a card it costs a line.
       It also matches the [16:34] ask to replace 01/02 numbering with ticks.
     - Capacity leaves the feature list and becomes a chip on the photograph. It
       is a spec rather than a feature, and it is the first thing anyone filters
       on, so it earns the image's top-left corner and frees a row.
     - Name and arrow share a line. The whole card is one link; the arrow does
       not need a row to itself.
     - The description is clamped to three lines. This is what kills the dead
       space: a rail stretches every card to the tallest, so once each card's
       copy is within a line of the others there is almost nothing to equalise.

   The card is a GRID, not a column flex, so the name and arrow can sit side by
   side. The photo and copy live three wrappers deep inside .sindex__exp, so
   those wrappers go display:contents and the card lays their children out
   directly — plain `order` would move the media and the copy as one block, which
   is what forces the copy above the name. */
@media (max-width: 900px) {
  /* One column: the hover frame is gone, so the two-column grid above would
     leave its gutter and a dead second track behind. */
  .sindex__wrap { display: block; }
  .sindex__media { display: none; }

  .sindex__list {
    display: flex; flex-direction: row; align-items: stretch; gap: 14px;
    overflow-x: auto; overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    /* Bleed to the screen edges so the next card peeks, while the first still
       lines up with the section heading. */
    margin-inline: calc(var(--pad-x) * -1);
    padding-inline: var(--pad-x); padding-block: 4px 12px;
  }
  .sindex__list::-webkit-scrollbar { display: none; }

  /* Must out-specify `.sindex__list > .sindex__row { flex: 1 1 auto }` from the
     desktop rules, or every card shrinks to a sliver instead of scrolling. */
  .sindex__list > .sindex__row {
    flex: 0 0 76%; max-width: 292px; scroll-snap-align: center;
    position: relative;
    display: grid; grid-template-columns: minmax(0, 1fr) auto;
    /* start, not the row's inherited align-items:center — centred, the media
       shrinks to its content width and its aspect-ratio then yields zero height,
       so the photo disappears entirely. align-content keeps any height left over
       from the rail's equal-height stretch at the foot of the card rather than
       spreading it through the rows. */
    align-content: start; align-items: start;
    gap: 0; text-align: left;
    padding: 0 0 16px;
    border: 1px solid var(--line-soft); border-radius: var(--r-lg);
    background: var(--off-white); box-shadow: var(--sh-2); overflow: hidden;
    transition: box-shadow 1.02s var(--ease);
  }
  .sindex__list > .sindex__row:last-child { border-bottom: 1px solid var(--line-soft); }
  /* [08-14] The reset that used to live here cancelled the desktop hover
     indent, which "would shunt the card sideways under a thumb". The indent is
     gone sitewide now, so there is nothing left to cancel. The name scale that
     replaced it is hover-only and never fires on touch. */

  .sindex__exp, .sindex__expinner, .sindex__expgrid, .sindex__exptext { display: contents; }
  .sindex__expmedia {
    grid-column: 1 / -1; grid-row: 1;
    width: 100%; aspect-ratio: 3 / 2; border-radius: 0;
  }
  .sindex__num, .sindex__desc { display: none; }
  .sindex__name  { grid-column: 1; grid-row: 2; font-size: 20px; line-height: 1.15; padding: 14px 0 0 16px; }
  .sindex__arrow { grid-column: 2; grid-row: 2; align-self: center; padding: 13px 16px 0 8px; }
  .sindex__arrow svg { opacity: .8; }
  .sindex__expdesc {
    grid-column: 1 / -1; grid-row: 3; padding: 7px 16px 0;
    font-size: 13.5px; line-height: 1.55;
    display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
  }

  /* Features as a checked list, not pills. `.card__feats li` is white-space:
     nowrap and the card gives them 258px, so a 180px label and a 125px one
     cannot share a line — six pills tile into six rows and eat ~180px of the
     card. A tick plus a label that may wrap is denser and scans better, and the
     glyph is the same path .perk__check draws, so the site has one tick. */
  .sindex__exptext .card__feats {
    grid-column: 1 / -1; grid-row: 4;
    display: flex; flex-direction: column; gap: 0; padding: 11px 16px 0;
  }
  .sindex__exptext .card__feats li {
    display: flex; align-items: flex-start; gap: 8px;
    border: 0; border-radius: 0; padding: 3.5px 0; margin: 0;
    font-size: 12.5px; line-height: 1.42; white-space: normal; color: var(--ink-soft);
  }
  /* A box exactly one line-height tall with the tick centred inside it. The tick
     was a rotated 10x5 box on a hand-picked `top` offset, and a rotated box's
     visual centre is its box centre — so it sat ~4px above the text's and would
     drift again at any other font-size. This holds by construction, and stays on
     the first line when a label wraps to two. */
  .sindex__exptext .card__feats li::before {
    content: ""; flex: none; width: 12px; height: 1.42em;
    background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23687A61' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5 9.5 18 20 6.5'/%3E%3C/svg%3E") center / 11px 11px no-repeat;
  }
  /* Capacity is not a feature and must not inherit the tick or the stripped-back
     box. Anchored top-left of the photo: a fixed offset holds at any card width,
     where anything measured off the photo's height would not. Re-asserted AFTER
     the li rules, which would otherwise win on source order at equal specificity. */
  .sindex__exptext .card__feats li.card__cap::before { content: none; }
  .sindex__exptext .card__feats li.card__cap {
    position: absolute; top: 12px; left: 12px; z-index: 2; margin: 0;
    display: inline-flex; align-items: center; gap: 6px;
    background: rgba(255, 255, 255, .93); border-color: transparent; color: var(--green-d);
    -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
    box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
    border-radius: 100px; padding: 5px 11px; font-size: 11.5px; font-weight: 600;
    line-height: 1.3; white-space: nowrap;
  }

  /* Dots. Native scrolling does the swiping; initSpacesRail() only builds these
     and keeps the active one in sync. 24px button so the tap target clears the
     minimum, with the visible dot as the ::after. */
  .sindex__dots { display: flex; justify-content: center; gap: 6px; margin-top: 8px; }
  /* [21-08] Was 24x24, which is well under the 44px minimum touch target and
     measured as a real miss on six pages. The VISUAL dot is the 7px ::after and
     is unchanged; only the invisible hit box grows. Height goes to the full 44
     because nothing sits above or below it. Width goes to 32 rather than 44
     because these sit in a row and 44px boxes at this spacing would overlap,
     which trades a missed tap for a wrong tap. margin-top drops to 8 so the
     taller box does not add 20px of gap under the carousel. */
  .sindex__dot {
    width: 32px; height: 44px; padding: 0; border: 0; background: none;
    display: grid; place-items: center; cursor: pointer;
  }
  .sindex__dot::after {
    content: ""; width: 7px; height: 7px; border-radius: 100px;
    background: rgba(0, 0, 0, .25);
    transition: width 0.595s var(--ease), background 0.595s var(--ease);
  }
  .sindex__dot.is-on::after { width: 20px; background: var(--green); }
}

/* ===== Editorial photo gallery =====
   Fixed-height rows + explicit column ratios: tiles always align, never overlap,
   and every image covers its frame exactly. */
.gal { display: flex; flex-direction: column; gap: clamp(16px, 2vw, 28px); }
.gal__row { display: grid; gap: clamp(16px, 2vw, 28px); height: clamp(280px, 38vw, 520px); }
.gal__cell {
  margin: 0; overflow: hidden; border-radius: var(--r-lg);
  background: var(--bg-2); border: 1px solid var(--line-soft);
  box-shadow: var(--sh-2); transition: box-shadow 1.02s var(--ease);
}
.gal__cell:hover { box-shadow: var(--sh-3); }
.gal__cell img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  filter: grayscale(.22); transition: transform 1.53s var(--ease), filter 1.53s var(--ease);
}
.gal__cell:hover img { transform: scale(1.05); filter: grayscale(0); }
/* Dubai Hills / Business Bay only: .ibar.section-pad's own top padding
   (52-96px) stacks on .statband__grid's bottom padding (30-50px) right above
   it, leaving a bigger gap than anywhere else the two components meet. Scoped
   to the adjacency itself (not a page attribute) so it only fires where a
   .statband is the thing directly before it — About's .ibar has no statband
   above it and is untouched. */
.statband + .ibar.section-pad { padding-top: clamp(20px, 2.4vw, 36px); }
/* [03-08] Same over-gap on the About page, between the full-bleed image band
   and "Not a render. Our floor." — a fixed-height band carries no bottom
   padding of its own, so .ibar's full 52-96px top padding landed on top of
   it uncontested and read as dead space above the heading. */
.imgband + .ibar.section-pad { padding-top: clamp(20px, 2.4vw, 36px); }

/* "Open, and waiting for you." (Business Bay's Inside-the-floor heading)
   wraps inside the shared .section-head 900px cap the same way the podcast
   kit heading did — drop the cap and trim the font a bit from the shared
   84px max so a ~27-character line fits the full container instead. nowrap
   gated to 761px+ so phones keep their normal wrap. */
body[data-hero-theme="bay"] .ibar .section-head { max-width: none; }
body[data-hero-theme="bay"] .ibar .section-head h2 { font-size: clamp(34px, 5.4vw, 76px); }
@media (min-width: 761px) {
  body[data-hero-theme="bay"] .ibar .section-head h2 { white-space: nowrap; }
}

/* ---- Image bar: full-bleed strip on a seamless sideways loop ----
   Drifts on its own; hovering the outer third of either side steers it that
   way, so a visitor can go back to a photo without hunting for a scrollbar.
   Column width is intrinsic (not a fixed column count), so the strip simply
   shows fewer photos as the screen narrows -- no breakpoint juggling. */
.ibar__scroller {
  display: flex; gap: 3px;
  overflow-x: auto; overflow-y: hidden;
  height: clamp(300px, 32vw, 460px);
  margin-top: clamp(28px, 3vw, 48px);
  background: var(--line-soft);          /* shows through the gap as a hairline */
  scrollbar-width: none; -ms-overflow-style: none;
  overscroll-behavior-x: contain;        /* don't trigger browser back-swipe */
  touch-action: pan-x pan-y;
}
.ibar__scroller::-webkit-scrollbar { display: none; }
.ibar__scroller:focus-visible { outline: 2px solid var(--green); outline-offset: -2px; }
/* The cursor is the affordance -- it names the direction before you commit. */
.ibar__scroller.is-left  { cursor: w-resize; }
.ibar__scroller.is-right { cursor: e-resize; }
.ibar__scroller.is-drag  { cursor: grabbing; }

.ibar__item {
  flex: 0 0 clamp(200px, 19vw, 300px);
  margin: 0; position: relative; overflow: hidden; background: var(--bg-2);
}
.ibar__item img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  filter: grayscale(.22);
  transition: transform 1.53s var(--ease), filter 1.53s var(--ease);
  user-select: none; -webkit-user-drag: none;
}
.ibar__item:hover img { transform: scale(1.04); filter: grayscale(0); }
@media (max-width: 700px) {
  .ibar__scroller { height: clamp(240px, 52vw, 340px); }
}

/* ---- Photo stage: the location-page gallery (addendum A2) ----
   [16-09, Dany + Samir] Chosen from the second round of options, with one
   instruction: "without the animation ... just a normal still picture". So the
   photograph switches INSTANTLY -- no transition on .pstage__slide, no zoom
   keyframe on the image. Do not add one back without asking them: it is the
   single change they requested on a design they had otherwise approved.

   What moves is the row of bars: each fills over --pstage-hold and hands the
   floor to the next photo. That was theirs to keep, and it is also the only
   thing telling a visitor the photo is about to change. */
/* [16-09, round 3] The geometry here is the clip's own, read off the mockup
   that was recorded and sent (`.stg__frame`) and re-measured in the same
   1440x900 harness: inside .container on the page's normal gutter, 16/9 on a
   desktop and 4/5 on a phone. At 1440 that mockup measured 1282x721 in a
   1424px-wide page; .container gives 1296x729 at the same width.

   Two earlier passes moved away from it -- edge to edge first, then a clamped
   height so the whole section would fit one screen -- and both bought that by
   flattening the photograph: 820px at 1920 where 16/9 is 1080px. The section is
   taller than the window as a result, and that is what was approved; a
   full-bleed slideshow is a tall section.

   No height cap. The mockup carried one (100vh minus its own 64px sheet bar),
   but it never bit in the clip that was sent -- 721px of a possible 788 at
   1440x900 -- and on a real desktop it bites hard: a 1920x1080 screen gives a
   ~950px viewport, which would trim the photograph to 838px, 2.1:1 against the
   clip's 1.78:1. Flattening it is the exact complaint this section has already
   had twice, so the ratio holds at every width instead. */
.pstage__frame {
  position: relative; overflow: hidden;
  /* width:100% is load-bearing, and it is in the mockup for this reason. With
     width auto, the moment max-height clamps the height the browser derives the
     WIDTH back from the capped height (1721px = 968 x 16/9 at 1920) and
     left-aligns the narrower box inside .container, so the whole section looks
     shoved to the left with the slack on the right. Explicit width keeps the
     frame filling the container and lets the cap do what it is for: take height
     off the photograph, which object-fit:cover absorbs. */
  width: 100%;
  aspect-ratio: 16 / 9;
  background: var(--ink); color: var(--white);
  touch-action: pan-y;
}
/* The shared head caps at 900px, which is right for a heading with a sentence
   under it. This one is three words of display type over a photograph and has
   no sub-line, and at the shared 84px "Every corner, considered." needs about
   1050px to stay on one line. It was on one line in the clip, so the cap comes
   off here and text-wrap:balance keeps it honest if it ever does wrap. */
.pstage .section-head { max-width: none; }
.pstage__frame:focus-visible { outline: 2px solid var(--green); outline-offset: -2px; }
/* Taller than it is wide on a phone: a 16/9 band at 390px is a letterbox rather
   than a room. The mockup switched at a 720px container; with .container's
   gutter that is the same line as this 760px. */
@media (max-width: 760px) { .pstage__frame { aspect-ratio: 4 / 5; } }
.pstage__slide { position: absolute; inset: 0; margin: 0; cursor: zoom-in; }
.pstage__slide img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  user-select: none; -webkit-user-drag: none;
}
/* The clip's own shade, to the stop: 48% of the frame, a straight ramp from
   72% black. A deeper stepped one was tried while the frame was edge to edge
   and much shorter; at 16/9 the caption has the same room under it that it had
   in the recording. */
.pstage__shade {
  position: absolute; inset: auto 0 0 0; height: 48%; pointer-events: none;
  background: linear-gradient(to top, rgba(0, 0, 0, .72), rgba(0, 0, 0, 0));
}
/* Even inset on all four sides, as the clip had it (40px at 1440). The frame is
   inside .container now, so its own padding no longer has to stand in for the
   page gutter. */
.pstage__ui {
  position: absolute; inset: auto 0 0 0; z-index: 2;
  display: grid; gap: 18px;
  padding: clamp(18px, 3vw, 40px);
}
.pstage__row { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.pstage__meta { margin: 0; display: grid; gap: 8px; }
.pstage__room { font-family: var(--serif); font-size: clamp(28px, 4vw, 58px); line-height: 1; }
.pstage__alt { font-size: 13px; line-height: 1.4; color: rgba(255, 255, 255, .76); max-width: 46ch; }
.pstage__nav { display: flex; gap: 10px; flex: none; }
.pstage__arrow {
  width: 48px; height: 48px; display: grid; place-items: center; padding: 0;
  border: 1px solid rgba(255, 255, 255, .38); border-radius: 50%;
  background: rgba(255, 255, 255, .12); color: var(--white); cursor: pointer;
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  /* Grows, never travels -- the 14-08 hover rule, same as everything else. */
  transition: transform 1.02s var(--ease), background .3s var(--ease-fade);
  transform-origin: center;
}
.pstage__arrow:hover { transform: scale(var(--hover-grow)); background: rgba(255, 255, 255, .2); }
.pstage__arrow svg { width: 20px; height: 20px; }
.pstage__arrow:focus-visible { outline: 2px solid var(--white); outline-offset: 3px; }
@media (max-width: 560px) { .pstage__nav { display: none; } }
.pstage__bars { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
.pstage__bar {
  height: 16px; padding: 0; display: flex; align-items: center;
  border: 0; background: none; cursor: pointer;
}
.pstage__bar i { position: relative; display: block; width: 100%; height: 2px; overflow: hidden; background: rgba(255, 255, 255, .32); }
.pstage__bar i::after {
  content: ""; position: absolute; inset: 0; background: var(--white);
  transform: scaleX(0); transform-origin: left center;
}
.pstage__bar.is-done i::after { transform: scaleX(1); }
.pstage__bar.is-on i::after { animation: pstage-fill var(--pstage-hold, 6s) linear forwards; }
.pstage__bar:focus-visible { outline: 2px solid var(--white); outline-offset: 2px; }
/* [16-09] Nothing a visitor does to the gallery stops it any more. Hovering it
   held it at first, then only hovering the caption strip did; both read as
   broken, because a bar that is usually frozen looks like a bar that does not
   work. It runs from the moment it is on screen until it is not.
   Two holds are left and neither is something anyone can see happening: the
   frame off screen (set by initPhotoStage's observer, so the sequence starts at
   the first photo for whoever scrolls down to it) and the full-screen viewer
   open on top of it, where a photo swapping behind the overlay would mean
   closing it onto a different room than the one that was opened. */
.pstage__frame.is-paused .pstage__bar.is-on i::after,
body.lb-open .pstage__bar.is-on i::after { animation-play-state: paused; }
@keyframes pstage-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
/* Screen-reader only announcement of the photo that just came up. */
.pstage__live {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
/* No motion: the bars stop running the show and the photo waits to be asked
   for. .is-static is set by initPhotoStage() so this also covers the site's own
   no-motion switch, not just the OS setting. */
.pstage__frame.is-static .pstage__bar.is-on i::after { animation: none; transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) {
  .pstage__bar.is-on i::after { animation: none; transform: scaleX(1); }
  .pstage__arrow { transition: none; }
}

/* ---- Location cards (Coworking) — a quiet timed cross-fade at card scale
   (own component, .loccard__strip/.loccard__col — kept separate from
   image_bar()'s drag/steer scroller above, which doesn't suit a small card),
   paired with the branch address, phone, map link and a 360° tour button. ---- */
.loccards__grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: clamp(20px, 2.4vw, 32px); }
.loccard {
  border: 1px solid var(--line-soft); border-radius: var(--r-lg); overflow: hidden;
  /* [08-06, client] --sh-2 -> --sh-3, "same exact one" as the Our Spaces panel
     [19:18]-[19:24]. Both are warm off-white sitting on warm off-white, and
     --sh-2 does not separate two tones this close. Hover still steps up, so it
     goes to sh-3 with a lift instead of a shadow change. */
  background: var(--off-white); box-shadow: var(--sh-3);
  transition: box-shadow 1.02s var(--ease), transform 1.02s var(--ease), opacity 1.02s var(--ease);
}
/* Resting is --sh-3 now, so a shadow swap here would be a no-op. The hover
   affordance moves to a small lift instead — transform only, so it stays on the
   compositor and cannot cost a layout pass. */
.loccard:hover { box-shadow: var(--sh-3); transform: translateY(-4px); }
/* Spotlight: hovering one card pulls focus to it by easing the other back.
   !important is required here — GSAP's scroll-reveal (data-anim="rise") leaves
   an inline transform/opacity on .loccard once it has played in, which would
   otherwise outrank these on specificity (same fix as the reduced-motion rule
   for [data-anim] below). The hovered card's own rule repeats :has(.loccard:hover)
   so its specificity beats the dimming rule — plain .loccard:hover (3) loses to
   .loccards__grid:has(.loccard:hover) .loccard (4), which dimmed the hovered card too. */
.loccards__grid:has(.loccard:hover) .loccard { transform: scale(.96) !important; opacity: .55 !important; }
.loccards__grid:has(.loccard:hover) .loccard:hover { transform: scale(1.03) !important; opacity: 1 !important; z-index: 1; }
.loccard__strip {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px;
  height: clamp(200px, 20vw, 260px);
  background: var(--line-soft);
}
.loccard__col { position: relative; overflow: hidden; background: var(--bg-2); cursor: pointer; }
.loccard__col img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; filter: grayscale(.22);
  /* [21-08] The cross-fade was 4.42s. initLocCardShuffle in main.js ticks every
     1900-3200ms, so the fade was LONGER than the gap between swaps and one was
     always in flight: these image layers never settled and the compositor kept
     working on them through every scroll. Measured headed on coworking during a
     fast scroll — 4.42s: 92.5ms per frame, 2.0s: 99.5ms (a 2s fade still
     overlaps the 1900ms floor), 1.2s: 17.1ms, 0.8s: 16.7ms.
     Keep this comfortably under the 1900ms minimum tick. If the shuffle cadence
     in main.js is ever shortened, shorten this with it. tests/scroll-perf.py
     catches the regression, but only when run headed. */
  transition: opacity 1.2s ease-in-out, transform 1.53s var(--ease), filter 1.53s var(--ease);
}
.loccard__col img.is-on { opacity: 1; }
.loccard__col:hover img.is-on { transform: scale(1.05); filter: grayscale(0); }
/* Solo variant (room_cards()) — same timed cross-fade as the 3-up strip
   above (initLocCardShuffle in main.js already handles any column count),
   just a single full-width column instead of a 3-way split, since each
   room card shows one big photo rather than a triptych. The compound
   selector's specificity beats the 3-col and mobile 2-col rules regardless
   of source order, so it doesn't need its own media-query override. */
.loccard__strip.loccard__strip--solo { grid-template-columns: 1fr; }
/* Home page's location cards: a satellite Maps embed instead of the photo
   strip (see banner_locations(media="map") in build_site.py). Same footprint
   as .loccard__strip so the card shell doesn't jump. */
.loccard__map { height: clamp(200px, 20vw, 260px); background: var(--bg-2); }
.banner .loccard__map { height: clamp(170px, 16vw, 220px); }
.loccard__map iframe {
  display: block; width: 100%; height: 100%; border: 0;
  filter: grayscale(.15); transition: filter 1.02s var(--ease);
}
.loccard:hover .loccard__map iframe { filter: grayscale(0); }

/* ---- Editorial half/half intro (intro_split()) — eyebrow/heading/paragraph
   beside a photo panel. Text is vertically centred against the panel rather
   than sticky (prose__grid's stickiness earns its keep against long
   scrolling copy; this is one short paragraph, so the photo stays the
   co-star). */
/* The coverflow's slides are absolutely positioned and fan out well past the
   480px panel — by design, the side photos are meant to run to the edge. But
   nothing clipped them, so they extended the document instead: podcast-room
   was the only page on the site with a horizontal scrollbar, and because the
   ring keeps rotating, scrollWidth oscillated 1496-1649 against a 1440
   viewport. Clip at the section, which is full-bleed, so the slides still
   reach the viewport edge and only the part nobody can see is cut.
   clip, not hidden: hidden makes this a scroll container, which Lenis and
   ScrollTrigger both have opinions about. */
/* .introsplit is podcast-room-only, so this can just override .section-pad's
   shared padding directly rather than needing a page-scoped selector. The
   default clamp(52,6vw,96) stacked on top of .phero's own bottom padding read
   as a big empty band right under the hero. */
.introsplit.section-pad { padding-block: clamp(28px, 3vw, 48px); }
.introsplit { overflow-x: clip; }
.introsplit__grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: clamp(40px, 5vw, 88px); align-items: center; }
.introsplit__text .eyebrow { margin-bottom: 20px; }
.introsplit__text h2 { font-size: clamp(32px, 4.4vw, 62px); }
.introsplit__text h2 em { font-style: italic; color: var(--green); }
.introsplit__text p { margin-top: 20px; font-size: clamp(17px, 1.4vw, 20px); color: var(--ink-soft); }
.introsplit__cta { margin-top: 30px; }
@media (max-width: 900px) {
  .introsplit__grid { grid-template-columns: 1fr; gap: 32px; }
  .introsplit__media { order: -1; }
}

/* ---- Coverflow: 3D ring math (positionCoverflow() in main.js), used by the
   homepage's own intro (.intro__visual) and every subpage's intro_split()
   panel alike. Auto-advances on a slow timer, never pins the page to a
   scroll-scrub. Always drag/swipe-able (pointer events, so mouse and touch
   both work) and pauses while the pointer is on it; a side photo can be
   clicked to jump the ring to it. Fills its (narrower) grid
   column rather than floating at a fixed size inside it — a hard cap here
   left dead space around the panel instead of reading as intentionally
   compact. */
/* width:100% is load-bearing, not decoration: a grid item with auto margins
   loses the default stretch-to-column behavior and falls back to
   shrink-to-fit — and since .coverflow's only content is absolutely
   positioned (zero normal-flow width), shrink-to-fit collapsed this to 0.
   Giving it an explicit width first is what lets max-width actually clamp
   it, with the auto margins then centering the clamped result. */
.introsplit__media { width: 100%; max-width: min(480px, 92%); margin-inline: auto; }
/* [07-08, client] The ring hugs its slides instead of sitting in a square.
   Abdullah on this section: "there is less white space here."

   The square was the whole problem, and none of it was padding: the section's
   own padding is 48px, but the slides are 58% of the width at 4:5, so the
   tallest of them is 0.725 of the width and a 1:1 box left ~130px of nothing
   above and below them. That taller box then set the whole section's height,
   and the text column, being shorter, centred itself against it with 142px of
   empty space above AND below the copy.

   0.80 clears the centre slide's 0.725 with room for its shadow and for the
   neighbours' rotation, and takes about 96px off the section. */
.coverflow { position: relative; aspect-ratio: 1 / 0.80; perspective: 1400px; touch-action: pan-y; cursor: grab; }
.coverflow.is-dragging { cursor: grabbing; }
.coverflow__slide { position: absolute; top: 50%; left: 50%; width: 58%; aspect-ratio: 4/5;
  border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--sh-3); cursor: pointer;
  transform: translate(-50%, -50%); transform-origin: center; will-change: transform, opacity;
  backface-visibility: hidden; }
.coverflow__slide img { width: 100%; height: 100%; object-fit: cover; display: block; user-select: none; -webkit-user-drag: none; pointer-events: none; }
.loccard__body { padding: clamp(22px, 2.2vw, 30px); }
.loccard__body h3 { font-size: clamp(22px, 2vw, 28px); color: var(--ink); }
/* Two lines are reserved whether or not the address uses both. Dubai Hills runs
   to two ("Business Park, Building 4" / "7th Floor", the split asked for at
   [29:50]); Business Bay is one. Without the reservation the cards sit side by
   side with their phone rows on different baselines. line-height is 1.55 here,
   so 2 lines is 2 * 14.5 * 1.55. */
.loccard__body p { margin-top: 6px; color: var(--ink-soft); font-size: 14.5px;
  line-height: 1.55; min-height: calc(2 * 14.5px * 1.55); }
.loccard__actions { margin-top: 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.loccard__phone { display: inline-flex; align-items: center; gap: 9px; font-family: var(--sans); font-size: 14px; font-weight: 600; color: var(--ink); transition: color 0.51s; }
.loccard__phone svg { width: 16px; height: 16px; color: var(--green); flex: none; }
.loccard__phone:hover { color: var(--green); }
/* ---- Location cards on phone: the card turns landscape ----
   Stacked portrait, two of these ran 392px each — 986px of banner on a 390px
   screen, two and a half viewports to say there are two branches, with the photo
   strip cropping each room to ~165px wide. Turned on its side the card is ~300px:
   one photo in a fixed 116px column, every line of text beside it, and both
   branches visible together without a scroll.

   TWO THINGS LOAD-BEARING HERE.

   1. The .banner rules above are 0-2-0 (`.banner .loccard__body` and friends). A
      media query adds no specificity, so the bare 0-1-0 rules below lose to them
      and the banner would keep its desktop padding, strip height and heading size
      while every other page changed. Each of those four is therefore repeated
      under .banner rather than left to source order.
   2. `.loccard__col` is NOT ours alone — .sindex__shuffle reuses it for the spaces
      image shuffle. Hiding the extra columns is scoped through .loccard__strip so
      it cannot reach that: verified on /dubai-hills, where all ten .loccard__col
      sit inside .sindex__shuffle, none inside a strip, and each shuffle holds
      exactly one child — so :nth-child(n+2) matches nothing there by construction.
      This replaces the old `.loccard__col:nth-child(3)`, which was unscoped and
      only escaped the same collision by luck of the child count. */
@media (max-width: 760px) {
  .loccards__grid { grid-template-columns: 1fr; gap: 12px; }
  .loccard { display: grid; grid-template-columns: 128px minmax(0, 1fr); align-items: stretch; }

  .loccard__strip,
  .banner .loccard__strip { height: auto; grid-template-columns: 1fr; }

  /* [21-08, Ahmed] "why is the map on the left not taking the full card length"
     .loccard__map carries a fixed clamp(200px, 20vw, 260px). On a phone the vw
     term collapses (20vw = 78px at 390px) so it pins to the 200px floor, while
     the body column next to it runs taller — leaving a block of empty card
     under the map. The card is already `align-items: stretch`, so the row has
     the height; the map just was not asked to fill it. */
  .loccard__map,
  .banner .loccard__map { height: 100%; min-height: 100%; }
  .loccard__map iframe { height: 100%; }
  /* Only the first photo survives the narrow column — the rest have no room to
     read, and the cross-fade still plays on the one that is left. */
  .loccard__strip .loccard__col:nth-child(n+2) { display: none; }

  .loccard__body,
  .banner .loccard__body { padding: 12px 13px; display: flex; flex-direction: column; justify-content: center; }
  .loccard__body h3,
  .banner .loccard__body h3 { font-size: 19px; }
  .loccard__body p { font-size: 12.5px; margin-top: 3px; line-height: 1.45; }

  /* Stacked, not inline: at ~200px of text column the phone number and "View on
     map" wrap into each other on the narrowest phones.
     Full-width rows rather than left-aligned links, because left-aligned they left
     a gutter: measured at 121 / 99 / 120px inside a 186px column, so 66 / 87 / 66px
     of the card's right edge was dead space running the height of the block. Giving
     each row the whole width and separating them with a hairline squares that edge
     off and reads as one list instead of three loose links. Vertical slack was
     already 0 — the emptiness was only ever horizontal, so nothing here is trying
     to buy height. */
  .loccard__actions,
  .banner .loccard__actions { margin-top: 10px; gap: 0; width: 100%; flex-direction: column; align-items: flex-start; }
  .loccard__phone { width: 100%; font-size: 12.5px; padding-block: 7px;
                    border-top: 1px solid var(--line-soft); }
  .loccard__actions .loccard__phone:first-child { border-top: 0; padding-top: 0; }
  /* [21-08, Ahmed] "why is the 360 tour button stuck to the other buttons"
     The pill had 9px above it and nothing below, so its bordered edge sat hard
     against the hairline of the "View on map" row beneath — two different
     shapes touching, which reads as a collision rather than a list. The rows
     below are borderless links separated by hairlines; the pill needs to be
     clearly its own object before that list starts. */
  .loccard__actions .btn { align-self: stretch; justify-content: center;
                           margin-top: 9px; margin-bottom: 10px;
                           font-size: 12.5px; padding: 9px 12px; }
  /* The row directly after the pill already has the pill's margin-bottom above
     it, so its own hairline would double the separation. */
  .loccard__actions .btn + .loccard__phone { border-top: 0; padding-top: 0; }

  /* `.card__feats li` is nowrap by default, which clips rather than wraps once the
     column narrows. Only the Meeting/Boardroom cards carry chips today (verified
     across all five pages that render .loccard) and those get their own treatment
     below — this stays as the guard so a chip added to a landscape card later wraps
     instead of running past the edge. Scoped to .loccard so the identical chips
     inside .sindex keep their own sizing. */
  .loccard .card__feats li { white-space: normal; }
}

/* ---- Meeting/Boardroom: the one card that is not an address ----
   Every other .loccard holds a name, an address and a phone number. These two hold
   a paragraph plus three feature chips — roughly four times the content — and in the
   landscape card's 195px column that ran to six lines of copy with two of the three
   chips wrapping. The column that suits an address is wrong for them, so these two
   go back to photo-on-top and spend the full width on the copy: four lines instead
   of six, and the features become a two-column checked grid rather than loose tags.

   Scoped on .loccard__strip--solo, which is an existing class and appears on
   meeting-room only — verified across index, contact, about, coworking and
   meeting-room, the five pages that render these cards. No new markup, and home,
   contact, about and coworking keep the landscape card untouched.

   The tick is drawn from two borders on a rotated box rather than shipped as an
   icon: it is 2 declarations against another HTTP request or another inline SVG in
   every card, and it inherits --green so it follows the palette. */
@media (max-width: 760px) {
  .loccard:has(.loccard__strip--solo) { grid-template-columns: 1fr; }
  .loccard:has(.loccard__strip--solo) .loccard__strip { height: 150px; }
  .loccard:has(.loccard__strip--solo) .loccard__body { padding: 15px 16px 16px; }
  .loccard:has(.loccard__strip--solo) .loccard__body h3 { font-size: 21px; }
  .loccard:has(.loccard__strip--solo) .loccard__body p { font-size: 13.5px; }

  .loccard:has(.loccard__strip--solo) .card__feats {
    display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; margin-top: 11px;
    padding-top: 11px; border-top: 1px solid var(--line-soft);
  }
  .loccard:has(.loccard__strip--solo) .card__feats li {
    border: 0; border-radius: 0; padding: 0 0 0 16px;
    font-size: 11.5px; line-height: 1.35; white-space: normal; position: relative;
  }
  .loccard:has(.loccard__strip--solo) .card__feats li::before {
    content: ""; position: absolute; left: 0; top: 5px;
    width: 8px; height: 4.5px;
    border-left: 1.6px solid var(--green); border-bottom: 1.6px solid var(--green);
    transform: rotate(-45deg);
  }
}

@media (max-width: 760px) {
  .gal__row { grid-template-columns: 1fr !important; height: auto; }
  .gal__cell { aspect-ratio: 4 / 3; }
}
.card--tall .card__media { aspect-ratio: 4/3; }
.card__media { position: relative; aspect-ratio: 5/4; overflow: hidden; background: var(--bg-2); }
.card__media img { width: 100%; height: 100%; object-fit: cover; filter: grayscale(.25); transition: transform 1.53s var(--ease), filter 1.53s var(--ease); }
.card:hover .card__media img { transform: scale(1.06); filter: grayscale(0); }
.card__tag {
  position: absolute; top: 16px; left: 16px; z-index: 2;
  font-family: var(--sans); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; font-weight: 600;
  padding: 7px 14px; border-radius: 100px;
  background: rgba(255, 255, 255,.9); color: var(--green); backdrop-filter: blur(6px);
}
/* placeholder media (no photos yet) */
.ph {
  position: relative; width: 100%; height: 100%;
  display: grid; place-items: center;
  background:
    radial-gradient(90% 80% at 80% 20%, rgba(138,154,130,.4), transparent 60%),
    radial-gradient(90% 90% at 15% 90%, rgba(255, 255, 255,.5), transparent 55%),
    var(--bg-2);
}
.ph::after { content: ""; position: absolute; inset: 0; background-image: url("../assets/f-green.png"); background-repeat: no-repeat; background-position: center; background-size: 64px; opacity: .16; }
.card__body { padding: clamp(24px, 2vw, 34px); display: flex; flex-direction: column; gap: 12px; flex: 1; }
.card__num { font-family: var(--sans); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--green); font-weight: 600; }
.card__body h3 { font-size: clamp(24px, 2vw, 32px); }
.card__body p { color: var(--ink-soft); font-size: 16px; }
.card__feats { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.card__feats li { font-size: 12px; white-space: nowrap; color: var(--ink); padding: 6px 11px; border: 1px solid var(--line); border-radius: 100px; }
/* capacity pill — same pill, led by a person icon so "1" reads as a people count */
.card__feats li.card__cap { display: inline-flex; align-items: center; gap: 6px; color: var(--green-d); border-color: var(--green); }
.card__cap svg { width: 13px; height: 13px; flex: none; }
.card__body .link-arrow { margin-top: auto; padding-top: 14px; }

/* ===== Google reviews — flowing marquee of cards ===== */
.reviews { overflow: hidden; background: var(--ink); }
.reviews__head { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 14px; margin-bottom: clamp(34px, 4vw, 60px); }
.reviews__head h2, .reviews__head .eyebrow { color: var(--off-white); }
.reviews__head h2 em { font-style: italic; color: var(--green); }
.reviews__note { font-size: 13px; color: rgba(255, 255, 255,.6); letter-spacing: .02em; }
.reviews__marquee { overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent); }
.reviews__track { display: flex; gap: clamp(18px, 2vw, 28px); width: max-content; will-change: transform; padding: 10px 8px; }
/* The rail transport (see initReviews/reviewsRail): the marquee is the scroll
   container and the drift moves scrollLeft instead of the track, so a finger,
   wheel, trackpad swipe or click-drag can all interrupt it. JS sets .is-rail
   on every input now, mouse included — a visitor has to be able to scroll the
   reviews themselves, not just watch them drift past. */
.reviews__marquee.is-rail { overflow-x: auto; overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.reviews__marquee.is-rail::-webkit-scrollbar { display: none; }
.reviews__marquee.is-rail .reviews__track { will-change: auto; }   /* nothing transforms it now */

/* ---- Rating badge (every breakpoint) ----
   The section showed eight glowing reviews and never once said what the rating
   actually was. The badge is a statement, not a control — there are two listings
   and it could only ever have pointed at one, so the two location links beneath
   it do the linking. */
.reviews__proof { display: flex; flex-direction: column; align-items: center; gap: 9px; margin-top: 2px; }
.reviews__rating {
  display: inline-flex; align-items: center; gap: 9px;
  padding: 9px 16px;
  border: 1px solid rgba(255, 255, 255,.25); border-radius: 100px;
  font-size: 14px; font-weight: 600; color: var(--off-white);
}
.reviews__g { width: 17px; height: 17px; flex: none; }
.reviews__rstars { display: inline-flex; gap: 2px; color: #D9A441; }
.reviews__rstars svg { width: 14px; height: 14px; }
.reviews__where { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255, 255, 255,.6); }
.reviews__where a { color: inherit; text-decoration: underline; text-underline-offset: 3px;
  text-decoration-color: rgba(255, 255, 255,.3); transition: color 0.595s var(--ease), text-decoration-color 0.595s var(--ease); }
.reviews__where a:hover { color: var(--green); text-decoration-color: currentColor; }

/* Whole card is a link into the reviews. The anchor is an overlay rather than a
   wrapper so <figure>/<blockquote>/<figcaption> keep their semantics. Phones get
   the explicit "Read all reviews" button instead, so the overlay is dropped
   there (display:none also takes it out of the tab order) — see the stack block. */
.rcard { position: relative; transition: box-shadow 0.85s var(--ease); }
.rcard__link { position: absolute; inset: 0; border-radius: inherit; z-index: 2; }
.rcard:hover { box-shadow: var(--sh-3); }

/* "Read all reviews" is phone-only — on a mouse the cards themselves are the link. */
.reviews__more { display: none; }

/* ---- Google reviews on phone: a static stack ----
   The drifting rail was the wrong shape here. A 290px card floating in a 390px
   viewport behind a mask gradient reads as a leftover desktop strip, every card
   was stretched to the tallest review (so short ones ended with a dead gap above
   the name), and NN/g's carousel research is unambiguous: auto-forwarding costs
   you the content — people read moving blocks as ads and skip them, and only the
   first slide gets looked at anyway. So below 680px nothing moves. The cards go
   full width, size to their own text, and three show with the rest behind a
   button. Every rule here is inside the media query, so nothing above 680px and
   nothing on the desktop marquee is affected. */
@media (max-width: 680px) {
  .reviews__marquee {
    overflow: visible;                                  /* no longer a scroller */
    -webkit-mask-image: none; mask-image: none;         /* the fade only made sense mid-scroll */
    padding-inline: var(--pad-x);                       /* .reviews__marquee sits outside .container */
  }
  .reviews__track {
    flex-direction: column; width: auto;
    gap: 12px; padding: 0; will-change: auto;
  }
  /* flex-basis would become a HEIGHT once the track is a column */
  .reviews__track > .rcard { flex: 0 0 auto; width: 100%; padding: 24px; }
  .rcard blockquote { font-size: 16px; }

  /* Three reviews, then out to Google — the marquee's second copy is never
     part of the stack. */
  .reviews__track > [data-clone] { display: none; }
  .reviews__track > .rcard:nth-of-type(n + 4) { display: none; }
  .rcard__link { display: none; }        /* the button below is the way out here */
  .rcard:hover { box-shadow: var(--sh-2); }   /* nothing to hover on a phone */

  .reviews__head { margin-bottom: 26px; }

  .reviews__more { display: flex; justify-content: center; margin-top: 22px; }
  .reviews__more .btn { padding: 15px 26px; font-size: 14px; }
}
.rcard { flex: 0 0 clamp(290px, 31vw, 396px); background: var(--off-white); border: 1px solid var(--line-soft);
  border-radius: var(--r-lg); box-shadow: var(--sh-2); padding: clamp(24px, 2.3vw, 34px);
  display: flex; flex-direction: column; gap: 18px; }
.rcard__top { display: flex; align-items: center; justify-content: space-between; }
.rcard__stars { display: inline-flex; gap: 3px; color: #D9A441; }
.rcard__stars svg { width: 16px; height: 16px; }
.rcard__g { width: 19px; height: 19px; flex: none; }
.rcard blockquote { font-size: 15.5px; line-height: 1.6; color: var(--ink); }
.rcard figcaption { display: flex; align-items: center; gap: 12px; margin-top: auto; }
.rcard__avatar { width: 42px; height: 42px; border-radius: 50%; background: var(--green); color: #fff;
  display: grid; place-items: center; font-family: var(--serif); font-size: 19px; flex: none; }
.rcard__who { display: flex; flex-direction: column; line-height: 1.35; }
.rcard__who strong { font-size: 15px; font-weight: 600; }
.rcard__who span { font-size: 12px; color: var(--ink-soft); }

/* ===== Latest news ===== */
.news__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; flex-wrap: wrap; margin-bottom: clamp(40px,5vw,72px); }
/* Category filter for the article list — sits in .news__head's second flex
   slot (space-between was already reserved for something here). Same pill
   language as .post__cat and .btn: rounded, border at rest, fills green and
   active on selection. */
.newsfilter { display: flex; gap: 10px; flex-wrap: wrap; padding-bottom: 3px; }
.newsfilter button {
  font-family: var(--sans); font-size: 13px; font-weight: 600; color: var(--ink-soft);
  background: transparent; border: 1px solid var(--line); border-radius: 999px;
  padding: 9px 18px; cursor: pointer; transition: color 0.51s var(--ease), border-color 0.51s var(--ease), background-color 0.51s var(--ease);
}
.newsfilter button:hover { border-color: var(--green); color: var(--ink); }
.newsfilter button.is-active { background: var(--green); border-color: var(--green); color: var(--white); }
@media (max-width: 700px) { .newsfilter { padding-bottom: 0; } }

/* Pagination below the article rows -- built by initBlogFilter() in main.js,
   which rebuilds it on every filter/page change (a category holds however
   many posts it holds, so the page count isn't fixed). Same pill language as
   .newsfilter's chips; the arrows are square-ish circles either side. */
.newspage { display: flex; align-items: center; justify-content: center; gap: 8px;
  margin-top: clamp(32px, 4vw, 56px); }
.newspage__num, .newspage__arrow {
  font-family: var(--sans); font-size: 13px; font-weight: 600; color: var(--ink-soft);
  background: transparent; border: 1px solid var(--line); border-radius: 999px;
  width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer; transition: color 0.51s var(--ease), border-color 0.51s var(--ease), background-color 0.51s var(--ease), opacity 0.51s;
}
.newspage__num:hover, .newspage__arrow:hover:not(:disabled) { border-color: var(--green); color: var(--ink); }
.newspage__num.is-active { background: var(--green); border-color: var(--green); color: var(--white); cursor: default; }
.newspage__arrow svg { width: 16px; height: 16px; }
.newspage__arrow:disabled { opacity: .35; cursor: default; }
/* ===== Articles: editorial rows =====
   One column, alternating sides for rhythm — but every row uses the same
   image ratio and the same media:text split, with a rule between each row.
   The previous version varied both the split (58/42, 50/50, 64/36) and the
   aspect ratio (3:2, 4:3, 16:9) per row to avoid settling into a grid; in
   practice that read as inconsistent sizing rather than intentional
   asymmetry, and gave no visible separation between articles. This keeps
   the alternating-side rhythm (still not a card grid) while fixing both. */
.news__rows { display: flex; flex-direction: column; }
/* Tightened 31-07: smaller thumbnails and less vertical air between rows --
   the picture is a marker for the row, not co-equal with the headline. */
.news.section-pad { padding-block: clamp(36px, 4vw, 64px); }
.news__head { margin-bottom: clamp(24px, 3vw, 40px); }

/* [04-08, client review] "I do think there's a lot of space between the
   picture and the text, but it doesn't look bad." Only the column gap (image
   <-> text) tightens here — row rhythm (padding-block, border-top) and every
   other section's negative space stay untouched; that space is deliberate
   elsewhere and the client didn't flag it. */
/* [07-08] row-gap only. A column-gap breaks the centring the picture now uses:
   justify-self centres it inside its TRACK, and the gap sits outside the track,
   so the whole gap landed on the inner side and pushed the picture 11.5px
   (half of it) toward the page edge. Measured at 1440 it read 64px to the page
   edge against 87px to the text, which is the lopsidedness Abdullah described.
   At zero the two tracks are exact halves, so centring in a track IS centring
   between the page edge and the text: 69.5px on both sides.

   It also puts the text back precisely on the centre line, which is what Dany
   asked for on 04-08 ("an invisible line that's in the middle of the page
   exactly"); the gap had been holding it 11.5px off. Nothing needs a column
   gap here because neither column's content reaches its own boundary. */
.post { display: grid; align-items: center; row-gap: clamp(14px, 1.6vw, 24px);
  grid-template-columns: 1fr 1fr;
  padding-block: clamp(18px, 2.4vw, 30px);
  border-top: 1px solid var(--line); }
/* Track is half the row now, so the picture is capped rather than stretching to
   fill it, and pushed to the OUTER edge of its half. The text is the thing that
   meets the centre line; the picture stays hard left or hard right. The gap
   that opens between them is the negative space Dany asked for twice. */
/* [08-06, client] 300px -> 420px -> a share of the track. At 16/9 the 300px box
   was only 169px tall, noticeably smaller than the 225px it had at 4/3, and
   Ahmed asked twice for the images to take more space. 420 was the first answer
   and it was still timid: it left 228px of empty track beside the picture and a
   240px gap to the text at 1440.

   80% of the track rather than a fixed width, because --container is fluid now
   and a flat pixel value shrinks in proportion the moment the row gets wider,
   which is the same complaint again on a bigger monitor. The 420px floor keeps
   the narrow end from going backwards: 80% of a 405px track at 900 would be
   324px, smaller than the 420 it replaces. min(100%) stops the floor
   overflowing a track narrower than 420 itself.

   Sized deliberately short of the track. Filling it kills the negative space
   Dany asked for twice ("keep negative space", "otherwise it looks cluttered")
   and breaks the 31-07 decision that the picture is "a marker for the row, not
   co-equal with the headline". Measured gap to the text at 1440: 150px here,
   40px if the picture ran to 620, 23px if it filled the track.

     viewport   track   before   after
      900        405     405      405
     1280        576     420      461
     1440        648     420      518
     1920        880     420      704
     2560        920     420      736     */
/* [07-08, client] Centred in its own half, not pinned to the outer edge.
   Abdullah, on the row measurements: "in row one, eighty on the left, nine
   hundred and fifty-four on the right... I need it centered, more centered
   between the extreme sides and the text." The picture was hard against the
   page edge with all of the slack dumped on the inner side, so the two rows
   read as lopsided in opposite directions rather than as a pattern.

   This does supersede the 04-08 note below, which had the picture "hard left
   or hard right" with the gap between it and the text as the negative space
   Dany asked for. The negative space survives, it is just split evenly either
   side of the picture now instead of all landing on one side. */
.post__media { width: min(100%, max(420px, 80%)); justify-self: center; }
/* Needed for the category filter: display:grid above always wins the
   cascade over the UA [hidden] default (author rules beat UA regardless of
   specificity), so without this, setting .hidden on a filtered-out post did
   nothing. Same pattern as .modal[hidden] / .tour[hidden] elsewhere. */
.post[hidden] { display: none; }
/* Even rows put the picture on the right; the columns swap so the media
   keeps its own width rather than inheriting the text column's. */
/* [03-08] Was :nth-child(even) — counts DOM position among ALL posts,
   including ones a category filter or a pagination page has hidden. Filtering
   left the visible posts carrying whatever odd/even parity they happened to
   have in the FULL unfiltered list, so the image side stopped alternating
   cleanly the moment a filter or page changed which posts were shown (client:
   "one blog on the right, three on the left"). initBlogFilter() in main.js
   now assigns .post--flip by VISIBLE position instead. */
/* [04-08, client review] The sides already alternated, but off a 32/68 split,
   so the seam between picture and text sat a third of the way across and the
   rows read as ragged rather than as a pattern. Dany drew what he wanted:
   "imagine when there is a line... an invisible line that's in the middle of
   the page exactly, and then you take it on the left and on the right side of
   that middle line. Like a table with alternating."
   So the tracks are now equal and the seam IS the page centre. The TEXT is what
   meets that line, from whichever side its row is on, and the picture stays out
   at the far edge: "have the text of the left-handed blogs on this line." The
   thumbnails stay small, because the 31-07 note deliberately shrank them,
   "the picture is a marker for the row, not co-equal with the headline", and
   splitting 50/50 by width would have undone that.
   This also answers what he was actually complaining about: six posts whose
   titles all open "2026 Guide" stacked flush left read as one repeating block.
   His words were "otherwise it looks cluttered" and, twice, "keep negative
   space". */
.post.post--flip { grid-template-columns: 1fr 1fr; }
.post.post--flip .post__media { order: 2; justify-self: center; }
.post.post--flip .post__text { align-items: flex-end; text-align: right; }

/* Stretches to fill its grid track (no max-width cap here) so the text block
   itself reaches the image on both alternating layouts -- capping it at a
   fixed ch width (as before) left the text flush against the row's start
   while the now-narrower media column sat stranded on the far side, with a
   dead gap between them. The paragraph below still caps its own line length
   for readability; only the container is left free to fill the row. */
.post__text { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }

/* [08-06, client] 4/3 -> 16/9 (Ahmed, 06-08). The images are object-fit:cover
   inside this box, so the crop tightens rather than the photo distorting — same
   width, less height, which also shortens each row and tightens the list. */
.post__media { border-radius: var(--r-md); overflow: hidden; background: var(--bg-2); aspect-ratio: 16 / 9; }
.post__media .ph::after { opacity: .12; }
.post__meta { display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--ink-soft); }

/* One column on phones and small tablets: alternating sides stop reading as a
   rhythm once each row is stacked, and the odd rows would simply look wrong. */
@media (max-width: 860px) {
  .news__rows { max-width: none; }
  .post, .post.post--flip { grid-template-columns: 1fr; gap: 14px; padding-block: clamp(18px, 4vw, 28px); }
  .post.post--flip .post__media { order: 0; justify-self: stretch; }
  /* [04-08] Undo the desktop centre-seam treatment: stacked, there is no seam
     to hug and no side to turn towards, so a right-aligned row would just look
     like a mistake. */
  .post.post--flip .post__text { align-items: flex-start; text-align: left; }
  .post__media { aspect-ratio: 16 / 9; width: 100%; justify-self: stretch; }
}
/* Category as a filled pill rather than plain coloured text — gives each row
   a clear at-a-glance marker instead of relying on the divider alone. */
.post__cat { display: inline-flex; align-items: center; color: var(--green-d); font-weight: 700;
  letter-spacing: .06em; text-transform: uppercase; font-size: 11px;
  background: rgba(104,122,97,.14); border-radius: 999px; padding: 6px 12px; }
/* A row gives the headline far more room than a card column did, so it can
   carry the display weight the rest of the site uses. */
.post h3 { font-size: clamp(19px, 1.75vw, 28px); line-height: 1.14; transition: color 0.51s; }
.post:hover h3 { color: var(--green); }
.post p { color: var(--ink-soft); font-size: 14.5px; max-width: 62ch; }

/* ===== Similar reads (end of a single blog post) ===== */
.relposts { border-top: 1px solid var(--line); }
/* [03-08] "Keep reading" read as crowded against the divider above it —
   section-pad-sm's own top padding wasn't enough breathing room specifically
   here, so this section gets a bit more of its own on top only. */
.relposts .news__head { margin-top: clamp(10px, 1.6vw, 22px); }
.relposts__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(20px, 2.6vw, 36px); }
.relcard { display: flex; flex-direction: column; gap: 14px; }
.relcard__media { border-radius: var(--r-md); overflow: hidden; background: var(--bg-2); aspect-ratio: 4 / 3; }
.relcard h3 { font-size: clamp(16px, 1.3vw, 19px); line-height: 1.25; transition: color 0.51s; }
.relcard:hover h3 { color: var(--green); }
.relcard:hover .media__img { filter: grayscale(0); }
@media (max-width: 720px) { .relposts__grid { grid-template-columns: 1fr; gap: 24px; } }

/* ===== Space enquiry (space_enquiry()) =====
   The end-of-page enquiry on the five space pages. The room's own photograph
   sits behind a warm cream scrim — cream on purpose, not the near-black the
   .enquiry band uses, because these pages already carry two or three dark
   sections and a fourth read as a slab. */
/* Sized to sit inside the viewport rather than fill it. At the original
   clamp(70px, 8vw, 128px) the section came to 754px against a 900px window —
   84% — which read fine zoomed out and overbearing at 100%. */
.spenq { position: relative; overflow: hidden; padding-block: clamp(44px, 4.4vw, 72px); background: var(--bg-2); }
.spenq__bg { position: absolute; inset: 0; }
.spenq__bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Two layers, not one: a light flat wash for overall consistency, then a
   directional gradient that is strong behind the heading, dips in the middle so
   the room actually shows, and lifts again under the form panel so the panel
   does not float on a dark patch.
   The first attempt was a .88 flat wash, which erased the photograph completely
   — the whole point of this treatment. The photographs themselves are
   brightness-normalised by assets/prepare-spenq-photos.py so one scrim reads
   the same on all five pages. */
.spenq__bg::before { content: ""; position: absolute; inset: 0; background: rgba(255, 255, 255,.28); }
.spenq__bg::after  { content: ""; position: absolute; inset: 0;
  background: linear-gradient(100deg, rgba(255, 255, 255,.96) 0%, rgba(255, 255, 255,.80) 32%, rgba(255, 255, 255,.28) 58%, rgba(255, 255, 255,.42) 100%); }
/* Dark-scrim experiment: replaces the cream gradient above with the same
   near-black ink (rgba(0, 0, 0,*)) used by .hero__overlay / .phero__scrim,
   matching the sitewide dark-gradient convention rather than inventing a new
   tone. Same alpha stops as the light version, color swapped. Applied via
   .spenq--dark, now added alongside .spenq on every page that uses this
   section (home + the nine space/location/contact pages). */
.spenq--dark .spenq__bg::before { background: rgba(0, 0, 0,.28); }
.spenq--dark .spenq__bg::after  {
  background: linear-gradient(100deg, rgba(0, 0, 0,.92) 0%, rgba(0, 0, 0,.72) 32%, rgba(0, 0, 0,.26) 58%, rgba(0, 0, 0,.46) 100%); }
.spenq--dark .spenq__copy .eyebrow { color: var(--green-l); }
.spenq--dark .spenq__copy h2 { color: var(--white); }
.spenq--dark .spenq__copy h2 em { color: var(--green-l); }
.spenq--dark .spenq__copy p { color: rgba(255, 255, 255,.78); }
.spenq--dark .spenq__tour { color: var(--white); border-color: rgba(255,255,255,.35); background: rgba(255,255,255,.12); }
/* Cross-fades to the off-white fill here (not abaddon) to match
   .btn--ghost-light, the same light-on-dark-photo pairing used elsewhere
   (e.g. "Tour this space" in the .phero CTAs) — text flips dark against the
   light fill instead of white. */
.spenq--dark .spenq__tour:hover { color: var(--abaddon); border-color: transparent; background-color: var(--off-white); }
.spenq--dark .spenq__contacts a { color: var(--white); }
.spenq--dark .spenq__contacts a:hover { color: var(--green-l); }

.spenq__inner { position: relative; display: grid; grid-template-columns: 1fr 1fr;
  gap: clamp(30px, 4vw, 64px); align-items: center; }
.spenq__copy { display: flex; flex-direction: column; align-items: flex-start; gap: 15px; }
.spenq__copy .eyebrow { color: var(--green); }
.spenq__copy h2 { font-size: clamp(30px, 3.2vw, 46px); color: var(--ink); line-height: 1.04; }
.spenq__copy h2 em { font-style: italic; color: var(--green); }
.spenq__copy p { color: var(--ink-soft); font-size: 15.5px; line-height: 1.55; max-width: 42ch; }
/* Same cross-fade as .btn (background/color ease together, no sliding panel),
   so "See it in 360 first" matches every other CTA's calmer hover. Kept as its
   own rule rather than switching the markup to .btn — this button's
   padding/font-size are deliberately smaller than .btn's for this compact spot. */
.spenq__tour { position: relative; display: inline-flex; align-items: center; gap: 10px; font-size: 14px; color: var(--ink);
  text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: 12px 22px;
  background: rgba(255,255,255,.62); cursor: pointer;
  transition: color .7s var(--ease), border-color .7s var(--ease), background-color .7s var(--ease); }
.spenq__tour span, .spenq__tour svg { position: relative; z-index: 2; }
.spenq__tour:hover { color: var(--white); border-color: transparent; background-color: var(--abaddon); }
.spenq__tour:hover svg { transform: translateX(4px); transition: transform .6s var(--ease); }
.spenq__tour svg { width: 16px; height: 16px; }
.spenq__panel { background: rgba(255,255,255,.80); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
  border: 1px solid rgba(255,255,255,.9); border-radius: 22px; padding: clamp(22px, 2.2vw, 32px);
  box-shadow: 0 24px 60px -38px rgba(0, 0, 0,.42); }

.spenqf { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 20px; }
.spenqf__field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.spenqf__field--full { grid-column: 1 / -1; }
.spenqf__lbl { font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-soft); }
.spenqf input, .spenqf select { font-family: var(--sans); font-size: 15px; color: var(--ink);
  background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0;
  padding: 5px 0 7px; width: 100%; transition: border-color 0.34s ease; }
.spenqf input::placeholder { color: rgba(106,98,92,.55); }
/* textarea was never styled here — .spenqf only ever had inputs and selects, so
   the contact page's Message field fell back to the browser's default box. */
.spenqf textarea { font-family: var(--sans); font-size: 15px; color: var(--ink);
  background: transparent; border: 0; border-bottom: 1px solid var(--line);
  padding: 7px 0; border-radius: 0; width: 100%; resize: none; line-height: 1.6; }
.spenqf textarea::placeholder { color: rgba(106,98,92,.55); }
/* [03-08] First pass tightened the outline to 1px/1px-offset; still read as a
   box, most noticeably on the Location dropdown. Dropped entirely now — the
   underline going green already IS the focus indicator, same as a plain
   :focus needs no separate :focus-visible treatment on top of it. */
.spenqf textarea:focus, .spenqf textarea:focus-visible { outline: none; border-bottom-color: var(--green); }
.spenqf input:focus, .spenqf select:focus,
.spenqf input:focus-visible, .spenqf select:focus-visible { outline: none; border-bottom-color: var(--green); }
/* The bare <select> rule below is the no-JS fallback only. initSelects() swaps
   in the same .fwsel control the popup uses, and .fwsel__native hides the
   original, so with JS on nothing here paints. */
.spenqf select { appearance: none; cursor: pointer;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236A625C' stroke-width='1.6'><path d='m6 9 6 6 6-6'/></svg>");
  background-repeat: no-repeat; background-position: right 2px center; }

/* Custom dropdown inside the end-of-page enquiry forms — and, via the same
   .spenqf__field class, the enquiry popup too. Underlined to match the text
   inputs sitting beside it. Values copied from `.spenqf input` above so the
   two rows sit on the same baseline. */
.spenqf__field .fwsel__trigger {
  background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0;
  padding: 5px 0 7px; font-size: 15px; color: var(--ink);
  transition: border-color 0.34s ease;
}
.spenqf__field .fwsel.is-open .fwsel__trigger,
.spenqf__field .fwsel__trigger:hover,
.spenqf__field .fwsel__trigger:focus-visible { border-bottom-color: var(--green); }
/* [03-08] Was outline: 1px solid + 1px offset — read as a rectangle sitting
   on top of the dropdown, most noticeable selecting a location. Removed in
   favour of the same green underline every other state on this trigger
   already uses (hover, open), so focus reads as consistent rather than as an
   extra box bolted on. */
.spenqf__field .fwsel__trigger:focus-visible { outline: none; }
.spenqf__field .fwsel.is-empty .fwsel__value { color: rgba(106,98,92,.55); }
/* The menu is a light panel on a light form, so it needs its own elevation to
   separate from the frosted spenq panel behind it. */
.spenqf__field .fwsel__menu { box-shadow: var(--sh-4); }

/* Contact details in the homepage section's left column, where the room and
   address versions put their 360 pill. Deliberately NOT .enquiry__contacts:
   that block is coloured for the near-black .enquiry band (off-white text,
   --green-l icons) and would render at roughly 1.2:1 on this cream scrim. Same
   markup shape, light-band colours. */
.spenq__contacts { display: flex; flex-direction: column; gap: 12px; margin-top: 6px; }
.spenq__contacts a { display: inline-flex; align-items: center; gap: 11px; width: fit-content;
  font-size: 14.5px; color: var(--ink); transform-origin: left center;
  transition: color 0.51s, transform 0.68s var(--ease); }
/* [08-14] Was translateX(5px). See --hover-grow. */
.spenq__contacts a:hover { color: var(--green); transform: scale(var(--hover-grow)); }
.spenq__contacts svg { width: 17px; height: 17px; flex: none; color: var(--green); }

/* The "Or call" line for phones. .spenq__panel — which carries .spenqf__alt —
   is display:none under 900px, so without this a phone visitor on a location
   page had no number anywhere in the section. Hidden on desktop, where the
   panel's own copy of it is visible. */
.spenq__call { display: none; }
.spenq__call a { margin-left: 4px; color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--line); }
.spenq__call a:hover { border-bottom-color: var(--green); }
/* The service the page already knows: stated, not asked. */
.spenqf__locked { display: flex; align-items: center; gap: 10px; padding: 5px 0 7px; border-bottom: 1px solid var(--line); }
.spenqf__locked b { font-weight: 500; font-size: 15px; color: var(--ink); }
.spenqf__locked i { font-style: normal; font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--green); border: 1px solid rgba(104,122,97,.35); border-radius: 999px; padding: 3px 9px; }
.spenqf__submit { grid-column: 1 / -1; display: flex; align-items: center; gap: 18px; flex-wrap: wrap; margin-top: 4px; }
.spenqf__alt { font-size: 13.5px; color: var(--ink-soft); }
.spenqf__alt a { margin-left: 4px; color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--line); }
.spenqf__alt a:hover { border-bottom-color: var(--green); }
.spenqf .fw-status { grid-column: 1 / -1; }

/* Desktop shows the panel and hides the button; phones do the reverse. */
.spenq__mobcta { display: none; }

/* [21-08, Ahmed] Wrapper so the 360 link and Enquire Now can sit side by side
   on phones. display:contents means it is invisible to layout on desktop, so
   .spenq__copy's existing flex column is unchanged and .spenq__tour keeps
   behaving exactly as it did. */
.spenq__actions { display: contents; }

@media (max-width: 900px) {
  .spenq__inner { grid-template-columns: 1fr; }
  .spenqf { grid-template-columns: 1fr; }
  /* Stacked, the seven fields made this section 1049px against an 844px
     screen — past a full viewport before the visitor had chosen to engage.
     The panel comes out and the modal takes the job. */
  .spenq__panel { display: none; }
  /* [03-08] 4px, on top of .spenq__copy's 15px flex gap, read as crowded
     against a multi-line lede paragraph — virtual-tour's is 3 lines in this
     card. Client reported subtitle text sitting too close to a button on this
     page specifically; widened the gap here as the most likely spot. */
  /* [21-08, Ahmed] "2 buttons side by side… and we close the gap under."
     The wrapper becomes a real row here, so the two buttons share the width
     instead of stacking. `min-width: 0` is what lets them shrink to equal
     halves; without it the longer label keeps its content width and the pair
     wraps again, which is the thing being fixed.
     margin-top moves from the button to this row, and the button's own 18px
     is cleared — otherwise the two rules fight and the row sits too low. */
  .spenq__actions {
    display: flex; gap: 8px; align-items: stretch;
    width: 100%; margin-top: 18px;
  }
  .spenq__actions > .spenq__tour,
  .spenq__actions > .spenq__mobcta {
    flex: 1 1 0; min-width: 0; justify-content: center;
    padding-left: 12px; padding-right: 12px;
    font-size: 13px; gap: 6px; white-space: nowrap; margin-top: 0;
  }
  .spenq__actions > .spenq__mobcta svg { display: none; }   /* label needs the room */
  .spenq__actions > .spenq__tour svg { width: 14px; height: 14px; flex: none; }
  .spenq__mobcta { display: inline-flex; }
  /* The number that went out with the panel, put back. Location pages only —
     the space pages do not render .spenq__call.

     [22-08, Abdullah] Centred in the card on phone. "Or call" and the number
     already share one line and still do; what moves is the line itself.

     align-self, not just text-align. .spenq__copy is a flex column with
     align-items: flex-start, so this block is only as wide as its own text and
     sits hard against the left edge — text-align on a shrink-to-fit box centres
     nothing. Stretching it to the card first, then centring inside, is the same
     two-step the buttons below already use, so the card keeps one rule for
     "fill the width" rather than two competing ones.

     Left-aligned everywhere above 560px, and Contact is unaffected: it centres
     the whole card through its own [data-hero-theme="contact"] rule. */
  .spenq__call {
    display: block; font-size: 13.5px; color: var(--ink-soft); margin-top: 2px;
    align-self: stretch; text-align: center;
  }

  /* The copy gets the same frosted card the form panel wears on desktop.
     WHY A CARD AND NOT A STRONGER SCRIM. The scrim above is directional — heavy
     at the left edge, thinning from ~58% across so the room shows behind the
     panel. That aim is a DESKTOP fact: it assumes the copy occupies the left half.
     One column here means the copy spans the full width, so its right-hand end
     lands on the thin part. On Contact the heading crossed the flowork wall sign
     and stopped being readable.
     Re-aiming the gradient to run down the section instead of across it was the
     obvious fix and it does not work. Measured against all eleven background
     photos, every vertical ramp fails, because the premise — spend the bottom
     third on the photograph — is false here: the location pages carry .spenq__call
     to within 44px of the section bottom, so there is text in the band the ramp
     wants to give away. Sweeping the ramp's floor from .55 to .90 never clears it,
     and by .90 it is a flat wash in all but name.
     A flat wash does pass, at .90, but only by erasing the photograph — which is
     the outcome the note above ::before already rejected once.
     The card sidesteps the trade entirely: it carries the contrast under the text,
     and the photograph stays fully visible around all four edges. Measured on all
     eleven pages, worst pixel under every line: passes with +0.31 to spare at the
     tightest (virtual-tour). Desktop's gradient is untouched. */
  .spenq__copy {
    background: rgba(255,255,255,.82);
    backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
    border-radius: var(--r-lg);
    padding: clamp(22px, 5vw, 30px);
    box-shadow: var(--sh-2);
  }
  /* [03-08] Client asked for the Contact page's "Get in touch" card
     specifically to be centered on phone — left-aligned everywhere else,
     scoped to this one page rather than every .spenq card sitewide. */
  [data-hero-theme="contact"] .spenq__copy { align-items: center; text-align: center; }
  [data-hero-theme="contact"] .spenq__copy p { max-width: none; }

  /* [A8, 04-08] The buttons in this card, full width.

     Seif: "phone: get-in-touch forms not centre aligned", which took measuring
     to pin down because the card itself is centred to within 2px on every page.
     It is the buttons inside it. .spenq__copy is a flex column with
     align-items: flex-start, so each button is only as wide as its own label and
     sits hard against the left edge: at 390px the Enquire Now button measured
     176.8px inside a 350px column, 22px from the left and 151px from the right.
     Two buttons of different widths stacked against one edge with that much air
     beside them is what reads as "not aligned". On Contact the same button
     measured 86.6/86.6 — because of the rule above. That is exactly the
     difference between the page Omar fixed and the general case Seif is
     reporting.

     Stretching rather than centring, because centring would leave the same
     ragged pair of widths, just in the middle. Full width also matches
     .loccard__actions, which already renders its CTAs edge to edge on phone —
     so this is the pattern the site already uses, not a new one. And it works
     under the Contact rule too: centred text above a full-width button is the
     normal shape, and the tap target gets bigger.

     Belt and braces on the flex item AND the element: a flex item stretches
     only if its own width is auto, and .btn is an inline-flex whose intrinsic
     width would otherwise win. */
  .spenq__copy .btn,
  .spenq__copy .spenq__tour,
  .spenq__copy .spenq__mobcta { align-self: stretch; width: 100%; justify-content: center; }
  /* .spenq--dark's white-on-photo text (added for the desktop scrim) was
     leaking into this card: the card is the same light frosted white
     regardless of scrim variant, so white h2/p/contacts text landed on a
     near-white background here — unreadable. Put the light-card colours
     back for every .spenq--dark instance at this width; the dark scrim
     itself is untouched, it just sits behind a card these rules re-cover. */
  .spenq--dark .spenq__copy h2 { color: var(--ink); }
  .spenq--dark .spenq__copy h2 em { color: var(--green); }
  .spenq--dark .spenq__copy p { color: var(--ink-soft); }
  .spenq--dark .spenq__contacts a { color: var(--ink); }
  .spenq--dark .spenq__contacts a:hover { color: var(--green); }
  .spenq--dark .spenq__tour { color: var(--ink); border-color: var(--line); background: rgba(255,255,255,.62); }
  .spenq--dark .spenq__tour:hover { color: var(--white); background-color: var(--abaddon); }
}

/* ===== Enquiry form ===== */
.enquiry { background: var(--abaddon); color: var(--off-white); }
.enquiry__grid { display: grid; grid-template-columns: 0.9fr 1.1fr; gap: clamp(40px, 6vw, 96px); align-items: start; }
.enquiry__intro .eyebrow { color: var(--white); margin-bottom: 24px; }
.enquiry__intro h2 { font-size: clamp(34px, 4.4vw, 66px); color: var(--white); }
.enquiry__intro h2 em { font-style: italic; color: var(--green-l); }
.enquiry__intro p { margin-top: 24px; color: rgba(255, 255, 255,.66); max-width: 42ch; }
.enquiry__contacts { margin-top: 44px; display: flex; flex-direction: column; gap: 14px; }
.enquiry__contacts a { display: inline-flex; align-items: center; gap: 12px; color: rgba(255, 255, 255,.8); font-size: 15px; width: fit-content; transform-origin: left center; transition: color 0.51s, transform 0.68s var(--ease); }
/* [08-14] Was translateX(6px). See --hover-grow. */
.enquiry__contacts a:hover { color: var(--green-l); transform: scale(var(--hover-grow)); }
.enquiry__contacts svg { width: 18px; height: 18px; color: var(--green-l); }

/* The form was written for the dark .enquiry band, so its colours were baked
   in as light-on-dark. Now that the same markup also sits on the off-white
   pricing section, the four colours become custom properties that default to
   the light surface, and .enquiry overrides them back to what it always had. */
.form { display: grid; grid-template-columns: 1fr 1fr; gap: 18px;
  --f-text: var(--ink); --f-line: rgba(0, 0, 0,.22);
  --f-label: var(--ink-soft); --f-ph: rgba(0, 0, 0,.38); }
.enquiry .form { --f-text: var(--off-white); --f-line: rgba(255, 255, 255,.24);
  --f-label: rgba(255, 255, 255,.55); --f-ph: rgba(255, 255, 255,.35); }
.field { display: flex; flex-direction: column; gap: 8px; }
.field--full { grid-column: 1 / -1; }
.field label { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--f-label); font-weight: 600; }
.field input, .field select, .field textarea {
  background: transparent; border: none; border-bottom: 1px solid var(--f-line);
  padding: 12px 2px; color: var(--f-text); font-size: 16px; font-family: var(--sans);
  transition: border-color 0.595s var(--ease);
}
.field select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238A9A82' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 4px center; }
.field select option { color: var(--abaddon); }
.field input::placeholder, .field textarea::placeholder { color: var(--f-ph); }
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--green-l); }
.form .btn { margin-top: 8px; }
.form__submit { grid-column: 1 / -1; }

/* ===== Dropdowns =====
   A native <select> can be styled shut but never open: the option list is drawn
   by the OS, so tapping a considered dark form produced a stark white system
   menu in the system font. initSelects() builds a real listbox over each select
   and leaves the <select> itself in the DOM, invisible, still carrying the name,
   the value and the `required` constraint — so the form posts exactly as before
   and still works with JavaScript off.

   The menu is absolutely positioned INSIDE its field rather than in a top layer.
   That keeps it inside the dialog for `aria-modal`, and it means a scrolling
   form carries the open menu with it instead of leaving it stranded. */
.fwsel { position: relative; display: block; width: 100%; }
.fwsel__native {
  position: absolute; inset: 0; width: 100%; height: 100%; margin: 0;
  opacity: 0; pointer-events: none; appearance: none; border: 0;
}
.fwsel__trigger {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  width: 100%; text-align: left; cursor: pointer; position: relative; z-index: 1;
  font-family: var(--sans);
}
.fwsel__value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fwsel__chev { flex: none; width: 16px; height: 16px; color: var(--green);
  transition: transform 0.68s var(--ease); }
.fwsel.is-open .fwsel__chev { transform: rotate(180deg); }

.fwsel__menu {
  position: absolute; left: 0; top: calc(100% + 8px); z-index: 40;
  /* At least as wide as its field, but free to grow for a long option rather
     than wrapping "Co-working Space" onto two lines in a half-width column. */
  min-width: 100%; width: max-content; max-width: min(340px, 88vw);
  max-height: var(--fwsel-max, 300px); overflow-y: auto; overscroll-behavior: contain;
  /* No scrollbar track inside a six-item menu. Scrolling still works — by wheel,
     by drag, and by arrowing, which scrolls the active row into view — and a
     list long enough to need it cuts a row off at the edge, which reads as more
     below without a gutter having to say so. */
  scrollbar-width: none; -ms-overflow-style: none;
  padding: 6px; border-radius: 16px;
  background: var(--off-white); border: 1px solid var(--line);
  /* --sh-4, the modal's shadow, not --sh-3: an open menu overlaps the field
     above or below it, and at a lighter weight that partial cover reads as a
     glitch rather than as a layer sitting on top */
  box-shadow: var(--sh-4);
  opacity: 0; transform: translateY(-6px);
  transition: opacity 0.306s var(--ease), transform 0.408s var(--ease);
}
.fwsel__menu::-webkit-scrollbar { width: 0; height: 0; }
.fwsel__menu[hidden] { display: none; }
.fwsel__menu--up { top: auto; bottom: calc(100% + 8px); transform: translateY(6px); }
/* grown menus on a right-hand field would run off the edge, so they hang the
   other way instead */
.fwsel__menu--right { left: auto; right: 0; }
.fwsel__menu.is-in { opacity: 1; transform: none; }
.fwsel__opt {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 11px 12px; border-radius: 10px; cursor: pointer;
  font-size: 15px; line-height: 1.3; color: var(--ink);
  transition: background 0.34s, color 0.34s;
}
/* No min-width:0 here on purpose — zeroing the flex item's intrinsic size would
   collapse the menu's max-content width back to its field's, which is the whole
   thing width:max-content is there to avoid. The ellipsis is the last resort
   once max-width caps the menu. */
.fwsel__opt > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fwsel__opt svg { flex: none; width: 16px; height: 16px; color: var(--green); opacity: 0; }
/* is-active is the keyboard cursor; hover is the mouse. They look the same on
   purpose — arrowing down and hovering should not disagree about where you are. */
/* [04-09] Was the earthy beige at .3, which was the highlight on a white menu.
   The beige-to-white sweep would have made this white on white, i.e. a select
   with no visible hover and no visible selection at all. Neutral tint keeps the
   feedback without putting the beige back. */
.fwsel__opt.is-active, .fwsel__opt:hover { background: rgba(0, 0, 0,.06); }
.fwsel__opt[aria-selected="true"] { color: var(--green); font-weight: 500; }
.fwsel__opt[aria-selected="true"] svg { opacity: 1; }

/* Dark surface — the enquiry band inverts the form tokens, so the menu follows.
   Lifted a step off Abaddon rather than sitting on it, or the menu reads as a
   hole in the section instead of a layer above it. */
/* [04-09] Was #2C2726, a warm charcoal chosen to lift off the old #231F20
   section. The section is pure black now, so a warm lift is the one thing on it
   that would still read as charcoal. Neutral, and still clearly a raised
   surface rather than a hole in the black. */
.enquiry .fwsel__menu { background: #1A1A1A; border-color: rgba(255, 255, 255,.16); }
.enquiry .fwsel__opt { color: rgba(255, 255, 255,.84); }
.enquiry .fwsel__opt.is-active, .enquiry .fwsel__opt:hover { background: rgba(255, 255, 255,.09); }
.enquiry .fwsel__opt[aria-selected="true"] { color: var(--green-l); }
.enquiry .fwsel__opt svg { color: var(--green-l); }

/* Fingers need more than a mouse does — SC 2.5.8 wants 44px and these rows are
   the whole reason the native picker was worth replacing. */
@media (hover: none), (pointer: coarse) {
  .fwsel__opt { padding: 14px 14px; font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
  .fwsel__menu { transition: none; }
  .fwsel__chev { transition: none; }
}

/* --- the trigger inherits whichever field style its context already defines --- */
/* underline fields (enquiry band, pricing, contact) */
.field .fwsel__trigger {
  background: transparent; border: none; border-bottom: 1px solid var(--f-line);
  padding: 12px 2px; color: var(--f-text); font-size: 16px;
  transition: border-color 0.595s var(--ease);
}
.field .fwsel.is-open .fwsel__trigger,
.field .fwsel__trigger:focus-visible { outline: none; border-color: var(--green-l); }
.field .fwsel.is-empty .fwsel__value { color: var(--f-ph); }
.field .fwsel__chev { color: var(--green-l); }

/* ===== Newsletter ===== */
.newsletter { background: var(--bg-2); }
/* [04-09, Ahmed] "can we add like a mini line to separate both sections?"
   --bg-2 was #ECE5DB, so this band used to be a beige panel and the tone change
   WAS the boundary. It is white now, on a white page, and on About and Blog the
   section above it is also white, so the two ran together with nothing between
   them. This is the divider that tone used to do.

   It runs EDGE TO EDGE, 0 to viewport width. Measured, because .container sets
   padding-inline rather than a max-width, so putting the border on
   .newsletter__inner does NOT inset it to the gutters the way you would expect
   from the class name. That is the right result here anyway: this separates two
   full-width sections, so a full-bleed rule reads as a section break where a
   gutter-aligned one would read as content belonging to the newsletter. Worth
   knowing before reusing this trick somewhere it matters.

   var(--line) rather than --line-soft, to match the weight of the hairlines in
   the spaces list directly above it.

   NOT unconditional: on home, contact, virtual-tour and the location pages the
   newsletter follows .spenq--dark, which already ends in a hard black edge. A
   line there is at best invisible and at worst a seam. Suppressed by the
   sibling rule below, which is written as an opt-OUT so that any light section
   added above this one in future gets the divider automatically. */
.newsletter__inner { display: flex; align-items: center; justify-content: space-between; gap: 48px; flex-wrap: wrap; padding-block: clamp(60px, 8vw, 110px); border-top: 1px solid var(--line); }
.spenq--dark + .newsletter .newsletter__inner { border-top: 0; }
/* [04-09, Abdullah] The spaces card's shadow must not touch that line.
   It did, on About, which is the only page where .sindex sits directly above
   the newsletter. --sh-3's widest layer is `0 52px 92px -36px`, so it reaches
   about 62px below the card, and the measured clearance was 48px at 1440,
   42px at 1280 and 34px at 1024. The shadow crossed the rule at every width.

   margin, not padding: .sindex already carries section padding from
   section-pad-sm, and adding to that would mean knowing its value here and
   keeping the two in step. Margin just adds clearance.

   :has() so this follows the adjacency rather than the page name. About is the
   only page with it today, but a page-scoped rule would silently miss the next
   one, which is exactly how the divider above would have been got wrong. */
.sindex:has(+ .newsletter) { margin-bottom: clamp(40px, 3.5vw, 56px); }
.newsletter h2 { font-size: clamp(28px, 3.6vw, 52px); }
/* [08-12, Abdullah, round 3] 46ch -> 64ch. The measure is what decides whether
   the three stated lines can hold their shape: at 46ch the first line
   ("...coworking spaces, collaborations, and") is ~537px against a ~460px
   column, so it wrapped anyway and the forced breaks produced five lines
   instead of three. Widened until the longest stated line fits with headroom,
   not by eye — 58ch put the cap at 504px, exactly the length of the line, so
   any font fallback would have re-wrapped it. The flex row still gives the
   form its width; measured, the subscribe input does not shrink at any width
   above the 680px stack. */
.newsletter p { margin-top: 16px; color: var(--ink-soft); max-width: 64ch; }
/* [08-12, Abdullah, round 3] The diagonal rag, desktop only. See rag() in
   build_site.py for why the break is stated rather than left to the wrap, and
   why it has to disappear on a phone: a split measured against a 1440 column
   is wrong at 390, where the line already falls where he wants it. */
@media (max-width: 700px) { .ragbr { display: none; } }
/* [04-09, Abdullah] "when the window is a bit smaller than full screen the line
   for the email gets under the text... it needs to stay consistent on one line."

   The input and Subscribe were never the problem — they stay on one line at
   every width. What moved is the whole FORM, dropping below the copy.

   Measured at 1280: the copy block was 709px, the form 420px, the gap 48px.
   That is 1177 against ~1152 of usable container, so it missed by 25px and
   wrapped. The copy block could not give the 25px back because it is a bare
   div at `flex: 0 1 auto` sizing to its widest child — the h2, which sits on one
   line and sets the block's width.

   Letting it shrink lets the heading take a second line instead, which is the
   cheaper concession: a two-line heading reads fine, a form that jumps below the
   text does not. The comment above already claimed the form "does not shrink at
   any width above the 680px stack" — this makes that true again.

   The phone stack at max-width 680 sets flex-direction: column and is unaffected. */
.newsletter__inner > div { flex: 1 1 auto; min-width: 0; }
/* Above 1000px there is genuinely room for the copy and the form side by side,
   so the copy is capped to whatever is left after the form's 420px and the 48px
   gap. Capping is what prevents the wrap: flex-wrap breaks a line using each
   item's HYPOTHETICAL size, before flex-shrink applies, so merely making the
   copy shrinkable does nothing — measured, `flex: 1 1 auto` alone still wrapped
   at 1280.
   Below 1000px there is not room: a basis of 0 kept them on one line but
   squeezed the copy to 223px at 768, which is worse than stacking. So under
   1000 the cap lifts and flex-wrap stacks them, which is the honest layout. */
@media (min-width: 1000px) {
  .newsletter__inner > div { max-width: calc(100% - 480px); }
}
.newsletter__form { display: flex; align-items: center; gap: 10px; border-bottom: 1.5px solid var(--ink); padding-bottom: 10px; min-width: min(420px, 100%); }
.newsletter__form input { flex: 1; background: transparent; border: none; font-size: 18px; padding: 6px 0; }
.newsletter__form input:focus { outline: none; }
.newsletter__form button { font-weight: 600; color: var(--green); white-space: nowrap; display: inline-flex; align-items: center; gap: 8px; }
.newsletter__form button svg { width: 16px; height: 16px; }

/* ===== Footer ===== */
.footer { background: var(--abaddon); color: var(--off-white); padding-top: clamp(38px, 4vw, 58px); overflow: hidden; }
/* Five columns since the contact details left "Important Links". The two 1.15fr
   tracks are the ones with something long in them: Services carries
   "Meeting/Boardroom" (~175px, the longest link on the site) and Contact Us has
   to seat the 3-icon social row (3x38 + 2x10 = 134px) without wrapping it. */
.footer__top { display: grid; grid-template-columns: 1.7fr 1fr 1.15fr 1fr 1.15fr; gap: 40px; align-items: start; padding-bottom: clamp(26px,3vw,36px); }
/* line-height pulls the big mark up so its cap-height top-aligns with the column labels */
.footer__brand .mark { display: inline-block; font-size: clamp(36px, 3.4vw, 50px); line-height: .82; color: var(--white); }
.footer__brand p { margin-top: 16px; color: rgba(255, 255, 255,.6); font-size: 13.5px; }
.footer__locs { margin-top: 18px; display: flex; flex-direction: column; gap: 12px; }
.footer__loc { position: relative; padding-left: 16px; }
.footer__loc::before { content: ""; position: absolute; left: 0; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--green-l); }
.footer__loc strong { display: block; font-size: 13px; font-weight: 600; color: var(--off-white); }
.footer__loc span { font-size: 12.5px; color: rgba(255, 255, 255,.5); }
.footer__col .label { display: block; font-size: 11.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--green-l); margin-bottom: 14px; font-weight: 600; }
.footer__col a { display: block; padding: 5px 0; color: rgba(255, 255, 255,.72); font-size: 13.5px; transform-origin: left center; transition: color 0.51s, transform 0.51s var(--ease); }
/* [08-14] Was padding-left: 5px, which laid out on every frame. See --hover-grow. */
.footer__col a:hover { color: var(--white); transform: scale(var(--hover-grow)); }
.footer__social { display: flex; gap: 10px; margin-top: 18px; }
.footer__social a { width: 38px; height: 38px; padding: 0; display: grid; place-items: center; border: 1px solid rgba(255, 255, 255,.2); border-radius: 50%; color: rgba(255, 255, 255,.78); transition: color 0.51s, background 0.51s, border-color 0.51s, transform 0.51s; }
/* Keeps its own lift: this rule sits after .footer__col a:hover at equal
   specificity, so the translateY wins and a social circle never scales.
   [08-14] The `padding-left: 0` that used to be here existed only to cancel
   the .footer__col indent, which no longer exists. */
.footer__social a:hover { color: var(--abaddon); background: var(--off-white); border-color: transparent; transform: translateY(-2px); }
.footer__social svg { width: 17px; height: 17px; }
.footer__giant { text-align: center; line-height: .8; padding: clamp(20px,4vw,50px) 0; }
.footer__giant .mark { font-size: clamp(80px, 22vw, 340px); color: var(--white); }
.footer__bottom { display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; padding: 20px 0; border-top: 1px solid rgba(255, 255, 255,.14); font-size: 13px; color: rgba(255, 255, 255,.5); }
/* [changed 27-07] Not italic. This is the one place on the site that asked a
   SANS face for an italic, and Manrope ships none, so the browser would fake
   one by slanting the glyphs — which looks visibly wrong on a geometric sans,
   on the brand tagline, in the footer of every page. The green already carries
   the emphasis. Every other em on the site is display/serif and keeps its real
   italic from the Playfair italic file. */
.footer__bottom em { font-style: normal; color: var(--green-l); }

/* ===== WhatsApp FAB ===== */
/* [04-08] The branch chooser behind the WhatsApp button.
   The button keeps its own fixed position, so the wrapper only exists to give
   the menu something to anchor to and to catch outside clicks. */
.wachoose__menu {
  position: fixed; right: 26px; bottom: 96px; z-index: 801;
  width: 232px; padding: 14px; border-radius: var(--r-md);
  background: var(--off-white); border: 1px solid var(--line-soft);
  box-shadow: var(--sh-4);
  display: flex; flex-direction: column; gap: 4px;
  transform-origin: bottom right;
}
/* The attribute is the source of truth, and it is display:none in the UA
   sheet only — this restates it because .wachoose__menu sets display:flex and
   a class rule beats the attribute. Same trap as [hidden] elsewhere here. */
.wachoose__menu[hidden] { display: none; }
.wachoose__lead {
  font-family: var(--sans); font-size: 11px; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-soft); padding: 0 8px 6px;
}
.wachoose__menu a {
  display: flex; flex-direction: column; gap: 1px;
  padding: 9px 8px; border-radius: var(--r-sm); text-decoration: none;
  transition: background 0.425s var(--ease);
}
/* [04-09] --bg-2 is white now, and this menu sits on white, so the hover had
   become a no-op. Same neutral tint as .fwsel__opt above. */
.wachoose__menu a:hover { background: rgba(0, 0, 0,.05); }
.wachoose__menu a b {
  font-family: var(--sans); font-size: 14.5px; font-weight: 600; color: var(--ink);
}
.wachoose__menu a span {
  font-family: var(--sans); font-size: 13px; color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) { .wachoose__menu a { transition: none; } }

.fab {
  border: 0; padding: 0; font: inherit; cursor: pointer;
  /* [22-09] bottom was 26px. The glow below reaches ~20px past the disc
     (offset 16 + blur/2 16 - spread 12), which left 2-4px of floor clearance
     and tests/fab-glow.py measured 2.3px at 360x740 — under its 3px minimum,
     i.e. the exact straight-edge cut Abdullah reported, still there. Bought the
     clearance from the position rather than by re-tuning the shadow, because
     the shadow values below are deliberate and the comment on them says so. */
  position: fixed; right: 26px; bottom: 30px; z-index: 800;
  width: 60px; height: 60px; border-radius: 50%;
  background: var(--green); color: var(--white);
  display: grid; place-items: center;
  /* [22-08, Abdullah] NOT var(--sh-green-lift), and the difference is load-bearing.
     He reported the glow "cuts off randomly" on iPhone: a hard straight edge
     under the button with the footer carrying on below it.

     It is the same fixed-layer gap main.js already documents for the curtains
     (see the unmount note in runPreloader). A phone browser hiding its toolbar
     grows the VISIBLE page before fixed elements are re-laid-out, so the fixed
     layer ends part-way up the screen and anything painted past that boundary
     is cut with a straight edge. Measured on his screenshot: the cut sits at
     exactly the CSS viewport bottom, with ~35 CSS px of footer still painting
     below it, and the step only appears under the button — not across the page,
     which is what rules out .ambient and pins it on this shadow.

     The token overhangs. Its green layer is `0 22px 44px -12px`, so the shadow
     rect bottom lands 10px below the button (22 offset − 12 spread) and the
     blur carries ~22px further: 32px of paint below a button that sits 26px off
     the bottom, i.e. 6px past the edge. Off a phone that overhang is invisible
     because the clip and the screen edge are the same line.

     These numbers keep the same colour and weight but land the paint 20px below
     the button, 6px clear of the boundary in every toolbar state.
     tests/fab-glow.py asserts the invariant; do not restore the token here. */
  box-shadow:
    0 3px 8px rgba(0, 0, 0, .07),
    0 16px 32px -12px rgba(86, 99, 73, .52);
  /* transform belongs to GSAP (magnetic); breathing lives on the inner layers */
}
.fab__icon { display: grid; place-items: center; width: 100%; height: 100%; border-radius: 50%;
  animation: fabBreathe 5.78s ease-in-out infinite; }
.fab__icon svg { width: 28px; height: 28px; display: block; }
.fab__ring { position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--green);
  animation: fabRing 4.76s cubic-bezier(0.16,1,0.3,1) infinite; pointer-events: none; }
@keyframes fabBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.09); } }
@keyframes fabRing { 0% { transform: scale(1); opacity: .55; } 70% { opacity: 0; } 100% { transform: scale(1.75); opacity: 0; } }

/* ===== Scroll-reveal base (JS adds .in) ===== */
.fade-up { opacity: 0; transform: translateY(28px); transition: opacity 1.7s var(--ease), transform 1.7s var(--ease); }
.fade-up.in { opacity: 1; transform: none; }

/* ===== Responsive ===== */
@media (max-width: 1080px) {
  .nav__links { display: none; }
  .nav__cta { display: none; }
  .nav__burger { display: block; }
  .card, .card--wide { grid-column: span 6; }
  .intro__meta { grid-template-columns: 1fr; gap: 24px; }
  .enquiry__grid { grid-template-columns: 1fr; }
  /* Three-up, not the two-up this was before the fifth column arrived. Five
     children two-up is three rows however they are arranged — either the last
     one sits alone in a half-empty row, or the brand spans and pushes the pairs
     down. Both measured TALLER than the 4-column footer this replaces (803px at
     1080, against 703px), which is the wrong direction for a change whose brief
     was to cut padding. Three-up is two clean rows — brand/flowork/Services then
     Important Links/Contact Us — and comes out shorter than the original.
     32px gutters rather than 40: at the 681px floor of this range the tracks are
     ~186px, and "Meeting/Boardroom" sets ~175px. 40px gutters left 3px of slack
     there; 32 leaves ~11. */
  .footer__top { grid-template-columns: repeat(3, 1fr); gap: 36px 32px; }
}
@media (max-width: 680px) {
  body { font-size: 16px; }
  .card, .card--wide { grid-column: 1 / -1; }
  .form { grid-template-columns: 1fr; }
  .hero__stats { gap: 22px 40px; }

  /* ---- Newsletter, phone ----
     The band measured 401.66px at 390px: 48% of an iPhone 14 viewport for one
     email field, sitting between .enquiry (which already has its own 76px of
     bottom padding) and the near-black footer. The cut comes out of the
     padding, the gap and the paragraph's second sentence. The 28px Ramillas
     headline and the underlined input+Subscribe row — the two things that
     make this read as flowork rather than a generic signup — are left alone.
     401.66 -> 276.88 at 390px; 454.44 -> 303.27 at 320px. */
  .newsletter__inner {
    flex-direction: column;
    align-items: flex-start;
    padding-block: clamp(40px, 8vw, 60px);  /* was clamp(60px,8vw,110px). Ramps
                                               40 -> 54.4 across 320..680. The
                                               ceiling is 60 to match the desktop
                                               clamp's FLOOR of 60, so crossing
                                               the breakpoint can only ever step
                                               up (54.4 -> 60), never down. */
    gap: 22px;                              /* was 48px */
  }
  .newsletter p { margin-top: 10px; }       /* was 16px */

  /* Sentence two is instruction, not information — the placeholder already
     says "Enter your email" and the button already says "Subscribe". It costs
     two of the paragraph's four lines and says nothing the UI hasn't. */
  .newsletter p .nl-more { display: none; }

  /* initForms() appends <p class="fw-status"> INTO this flex row, so a
     successful subscribe becomes a third item beside the controls and squeezes
     them (measured at 390px: input 241.53 -> 191). Give it its own line. Costs
     0px while empty — .fw-status:empty is display:none.
     `min-width: 0` on the input is LOAD-BEARING: without it the input's
     automatic minimum size (173px min-content) exceeds the 171.53px the row
     can give it at 320px, the button drops to its own line, and the section
     GROWS to 357.27px. Ship both lines or neither. */
  .newsletter__form { flex-wrap: wrap; }
  .newsletter__form input { min-width: 0; }
  .newsletter__form .fw-status { flex: 1 0 100%; margin-top: 8px; }
}

/* ===== Footer — phone compaction =====
   The client's "scrolls forever". Measured at 390x844 with touch emulation the
   footer renders 1295.81px against an 844px viewport — 1.5 screens — and it
   measures the same at 320/360/390/412/430, so nothing about it is responsive
   below 680px today. 619.78px of that (47.8%) is 14 anchors, each pushed to
   44.27px by the tap-target block near the end of this file, stacked in ONE
   350px column holding text that averages ~110px wide.

   [updated 27-07] The long column no longer exists — the contact details moved
   into their own "Contact Us" column, so the four link columns are now two pairs
   rather than three short ones and a six-row outlier. The two-up band this block
   used to build for that outlier is gone; see the note further down.

   The height comes out of the ROW COUNT, never the target size. Pairing the two
   short columns and running the long one two-up takes 14 rows to 8. Every link
   stays visible, focusable and crawlable; every row keeps its 44.27px target.
   The same arithmetic was already done once for the burger menu — see the note
   at .mm__cols. The footer never got it.

   PLACEMENT IS DELIBERATE, and stated by selector rather than line number so it
   cannot rot: this block must sit AFTER the two earlier `.footer__top`
   grid-template-columns rules (the 1080px one and the base one) so it wins on
   source order, and BEFORE the `@media (hover: none), (pointer: coarse)` block
   near the end of this file. It is safe in that position because nothing here
   sets `padding` on `.footer__col a`, or padding/width/height on
   `.footer__social a` — the only two footer rules that live past this point.
   The tap targets survive by construction, not by ordering.

   Measured 390px: ~1296px -> ~745px. The footer fits one screen. */
@media (max-width: 680px) {
  .footer { padding-top: 28px; }

  /* Two real columns. 1.15fr on the right so "Board / Meeting Room", the
     longest link on the site, keeps headroom down to 320px. */
  .footer__top {
    grid-template-columns: 1fr 1.15fr;
    gap: 26px 16px;
    padding-bottom: 20px;
  }

  /* ---- Brand band: full width, tightened ----
     The two addresses sit BESIDE the wordmark rather than under it: the mark and
     the tagline take the left column, the locations take the right and span both
     rows. Nothing moves in the markup — .footer__locs stays inside
     .footer__brand, so desktop, where the whole band is one narrow 1.6fr column,
     is untouched.
     minmax(0, 1fr) twice, not `auto 1fr`: with `auto` the left track sizes to the
     TAGLINE's max-content (~227px), not the mark's, which would have starved the
     addresses. Equal tracks give each side ~163px at 390 and ~132px at 320.
     [03-08] Client asked for the locations to align with "the grid" — this
     inner 2-col split was an even 50/50, but .footer__top below it (the row
     the link columns sit in) is 1fr/1.15fr, so .footer__locs' left edge sat
     ~3-7px off the vertical line every column below it shares. Matching the
     same ratio here puts both grids' column line in the same place. */
  .footer__brand {
    grid-column: 1 / -1;
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
    column-gap: 16px;
    align-items: start;
  }
  .footer__brand .mark { grid-column: 1; grid-row: 1; }
  .footer__brand p     { grid-column: 1; grid-row: 2; }

  /* Services leads the pair on phones. Done with `order` rather than by moving
     the divs in build_site.py's FOOTER, because the markup is shared with
     desktop and desktop is not changing — the committed column order there
     (flowork, Services, Important Links) stays exactly as it is.
     All four children are ordered explicitly: `order` sorts the whole set, so
     leaving the brand and the last column at the default 0 would have floated
     them ahead of anything given a positive value. nth-child is safe here
     because every child of .footer__top is a div — if a column is ever added
     or moved in FOOTER, these three numbers move with it. */
  /* [03-08] Contact Us raised from the last row to pair with Services right
     under the brand band — client wanted it higher, not stranded at the very
     bottom of a footer that already runs long on phone. Important Links drops
     to the row it vacates. */
  .footer__brand                        { order: 1; }
  .footer__top > .footer__col:nth-child(5) { order: 2; }   /* Contact Us */
  .footer__top > .footer__col:nth-child(3) { order: 3; }   /* Services */
  .footer__top > .footer__col:nth-child(2) { order: 4; }   /* flowork */
  .footer__top > .footer__col:nth-child(4) { order: 5; }   /* Important Links */
  /* block, not inline-block: as an inline-block the 16px/1.65 body strut set
     the line box and quietly overrode the mark's own line-height. This both
     fixes that and reclaims ~4px — it is the one place the phone footer will
     look different rather than just shorter, so eyeball it against the lockup.
     clamp, not a flat 32px: "flowork." sets about 123px at 32px, and the left
     track is only ~132px at 320px. The clamp pulls it to 28px there (~108px) so
     the wordmark can never crowd or overrun its column on a small phone. */
  .footer__brand .mark { display: block; font-size: clamp(28px, 8vw, 32px); line-height: .9; }
  .footer__brand p { margin-top: 8px; font-size: 13px; line-height: 1.5; }

  /* Right of the wordmark, spanning both its rows. Single column, because the
     track is now ~163px wide rather than the full ~350px the earlier two-up
     version had — side by side in half the width would put every address on
     three lines. margin-top: 0 so the first branch starts level with the top of
     the mark instead of hanging below the tagline. */
  .footer__locs {
    grid-column: 2;
    grid-row: 1 / span 2;
    margin-top: 0;
    display: grid;
    grid-template-columns: 1fr;
    gap: 10px;
    align-content: start;
  }
  /* line-height on the ROW, not on strong/span: the sizes on `.footer__loc
     strong` / `.footer__loc span` are already small, it is the inherited
     16px/1.65 = 26.4px strut that was setting the line box. Leaving strong at
     13px and span at 12.5px keeps the hierarchy the desktop footer has. */
  .footer__loc { padding-left: 14px; font-size: 12.5px; line-height: 1.4; }
  .footer__loc::before { top: 5px; width: 5px; height: 5px; }

  /* ---- Link bands ---- */
  .footer__col .label { margin-bottom: 8px; line-height: 1.35; }

  /* The full-width two-up band that used to live here is gone with the column it
     served. It existed because "Important Links" was six rows long — three
     policies plus two numbers and an email — and running one 6-row list down half
     the screen was the tall part of the phone footer. Splitting the contact
     details out leaves two 3-row lists, which pair in the same 2-col grid as
     Services and flowork above them. Same row count as the two-up band, one less
     special case.
     Nothing targets :last-child by position any more, so adding a sixth column
     will not silently re-point a layout rule at it — only the `order` values
     above count children, and they are listed one per column. */

  /* The one width where the pair does not fit: at 320px each column is ~132px
     and three 44px coarse-pointer targets plus their gaps need 148px. Wrapping
     is the release valve — it engages under ~360px and nowhere else. */
  .footer__social { margin-top: 12px; gap: 8px; flex-wrap: wrap; }

  /* ---- Bottom bar ----
     The tagline is printed twice: once in `.footer__brand p` and once as the
     second span of `.footer__bottom` (both in build_site.py's FOOTER constant)
     — the same sentence. On desktop they are ~400px apart in different
     registers and read as a bookend; stacked in one left-aligned column ~700px
     apart they just read as a repeat. One tagline per phone footer — no
     information lost, ~22px saved. */
  .footer__bottom { padding: 14px 0; gap: 4px 16px; font-size: 12px; line-height: 1.5; }
  /* [17-08] Was `span + span`, which hid the tagline AND anything after it.
     The map credit is a third span now, so that selector silently dropped the
     ODbL attribution on every phone — a licence term, gone, on the majority of
     traffic. Targets the tagline itself instead, which is all it ever meant. */
  .footer__bottom span:nth-of-type(2) { display: none; }
}

/* ============================================================
   PAGE-LEVEL SECTIONS  (generated pages)
   ============================================================ */

/* ---- Universal element animation states ----
   Pre-hidden only to avoid a flash; GSAP drives the reveal via INLINE styles
   (inline always wins the cascade — no specificity/transition traps). */
/* No blanket will-change — pre-promoting dozens of layers caused start-up jank;
   GSAP promotes layers itself only while tweening. */
html.has-js [data-anim] { opacity: 0; }

/* ---- Hero entry base states ----
   These belong here rather than in JS for the same reason [data-anim] does: the
   curtain finishes lifting before pheroEntry/heroReveal run, so anything hidden
   only by a gsap.set() gets painted in its FINISHED state first, then yanked back
   and replayed. Measured at ~310ms of visible finished hero on every page open.
   [data-anim] was already covered — the title, the media and the ornament were
   the three that had been missed. Gated on has-js so a no-JS visitor sees
   everything; the reduced-motion block at the end of the file unsets them all. */
html.has-js .phero__title,
html.has-js .phero__orn,
html.has-js .hero__eyebrow,
html.has-js .hero__sub,
html.has-js .hero__ctas,
html.has-js .hero__stats { opacity: 0; }
/* both masked by an overflow:hidden parent, so this hides them without a fade */
html.has-js .hero__title .line > span { transform: translateY(110%); }
html.has-js .phero__media { clip-path: inset(0 0 100% 0); }

/* ---- Generic image (cards, media beds) ---- */
.media { position: relative; overflow: hidden; }
.media__img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1;
  filter: grayscale(.22); transition: transform 1.53s var(--ease), filter 1.53s var(--ease); }
.card:hover .media__img { transform: scale(1.06); filter: grayscale(0); }
/* Articles fade the grain but do not scale: the rows deliberately carry no
   lift, tilt or zoom. */
.post:hover .media__img { filter: grayscale(0); }
.card__media .media__img { z-index: 1; }

/* ---- Full-width parallax image band ---- */
.imgband { position: relative; height: clamp(340px, 46vw, 600px); overflow: hidden; background: var(--bg-2); }
.imgband--tall { height: clamp(440px, 62vw, 780px); }
/* Used where the photograph supports the section rather than opening it,
   so it should not cost 600px of scroll. */
.imgband--compact { height: clamp(220px, 26vw, 360px); }
.imgband--compact .imgband__cap { font-size: clamp(18px, 1.9vw, 28px); bottom: clamp(20px, 2.4vw, 32px); }
/* [08-11, Abdullah] The branch counts laid over the photograph instead of in a
   black block beneath it. Deliberately the SAME shape as .hero__stats on the
   home page — flex, wrap, a hairline rule above, no panel behind the figures —
   because he asked for "the way it is on the home page" and two different ways
   of putting numbers on a picture is what he was objecting to.
   Taller than a plain band: the numbers need room at the bottom without
   crowding whatever the photograph is doing in its own top half. */
.imgband--stats { height: clamp(430px, 54vw, 660px); }
/* The band's own scrim fades out at 55%, which is enough for one caption line
   and not enough for a row of figures plus their labels. Measured against the
   real photographs rather than guessed: band-hills and band-bay are both
   high-key, bright window light through most of the frame, and at the first
   strength I tried the green figures washed out completely against it. This
   holds near-solid through the bottom third, where the row actually sits, and
   is gone by two thirds up so the photograph still reads as a photograph.
   Abdullah allowed for this: "transparent, maybe opaque background".

   Doubled class deliberately. `.imgband::after` is declared further down this
   file at the same specificity, so a plain `.imgband--stats::after` here loses
   on source order and the scrim silently does nothing — which is exactly what
   happened on the first attempt, and it looks like a colour problem rather
   than a cascade one. */
.imgband.imgband--stats::after {
  background: linear-gradient(to top,
    rgba(0, 0, 0,.88) 0%, rgba(0, 0, 0,.84) 20%,
    rgba(0, 0, 0,.52) 36%, rgba(0, 0, 0,.18) 52%, transparent 68%);
}
/* [08-11, Abdullah, round 2] "numbers evenly spaced across the width and
   remove the line above. make em a bit bigger."

   Left-packed with a fixed gap, the five figures ended at 812px of a 1440px
   band and left the right-hand 44% of the photograph carrying nothing. They
   now spread edge to edge: `space-between` puts the first figure on the page's
   left padding and the last one on its right, so the row reads as the width of
   the band rather than as a cluster in one corner. The hairline that used to
   run above them is gone with it — it was there to echo .hero__stats on the
   home page, and once the row spans the full width it was drawing a line
   across the photograph for no reason. */
/* [08-12, Abdullah, round 3] "make the number bands in the location tabs
   centered better, the spacing from the left and right should be the same."

   This walks back half of round 2, deliberately. `space-between` pins the
   first figure to the container's left padding and the last to its right, so
   the two edge gaps are the container padding (72px at 1440) while the four
   inner gaps are whatever is left over (measured 150px+). That reads as a row
   pushed apart, not an evenly spaced one, which is what he is pointing at.

   `space-evenly` makes all six gaps one value. The row still spans the full
   band — it is the distribution that changes, not the width — so the round-2
   complaint it was fixing ("the right-hand 44% of the photograph carrying
   nothing") does not come back.

   The container padding goes with it. Left as it was, the two outer gaps would
   be padding + an equal share, i.e. bigger than the inner ones by exactly the
   thing this rule is removing. Edge to edge is what makes "even from the
   sides" true. */
.imgband__stats {
  position: absolute; left: 0; right: 0; bottom: clamp(24px, 3.2vw, 44px); z-index: 2;
  display: flex; flex-wrap: wrap; justify-content: space-evenly;
  /* column-gap MUST be 0. space-evenly divides the leftover space into equal
     shares, then a column-gap is added on top of the inner shares only — the
     two edge gaps never get one. Measured at 1920 with a 24px gap: 219 / 243 /
     243 / 243 / 243 / 219, i.e. the edges short by exactly the gap. Which is
     the thing he asked to fix, reintroduced by the property meant to fix it.
     row-gap still earns its keep for the wrapped rows between 701 and ~860. */
  column-gap: 0; row-gap: clamp(14px, 2vw, 24px);
  padding-inline: 0;
  color: var(--off-white);
}
/* Each figure sits over the centre of its own label rather than against its
   left edge. With the gaps equalised, a left-aligned "1" under a wide "Podcast
   Room" is the only thing left making the row look unevenly spaced. */
.imgband__stats > div { align-items: center; text-align: center; }
.imgband__stats > div { display: flex; flex-direction: column; gap: 4px; }
.imgband__stats strong {
  font-family: var(--serif); font-weight: 500; line-height: 1;
  /* [08-11, Abdullah, round 2] "make em a bit bigger": 30/3.6vw/52 -> 34/4.2vw/60.
     Sized against the room the space-between row leaves rather than raised
     until it looked right — five figures plus their labels still hold one line
     down to 760px, which is where the phone rules take over anyway. */
  font-size: clamp(34px, 4.2vw, 60px); color: var(--green-l);
  /* Lining numerals, same reason .hero__stats sets them: the serif's default
     old-style figures make 65+ and 40+ sit at different heights in a row. */
  font-feature-settings: "lnum" 1, "tnum" 1;
}
.imgband__stats span { font-size: 13px; letter-spacing: .04em; color: rgba(255,255,255,.72); }
/* Five figures will not sit on one line on a phone. Let them wrap to two rows
   and give the band the height to hold them, rather than shrinking the type
   until the labels are unreadable. */
@media (max-width: 700px) {
  .imgband--stats { height: clamp(470px, 96vw, 620px); }
  /* Five figures across a phone is three then two, and `space-between` on a
     wrapped flex row spreads that last pair to the two edges with a hole in
     the middle. A three-column grid gives the same even spacing the wide
     layout now has, and the short second row simply stops after two. */
  .imgband__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px 20px; }
  .imgband__stats strong { font-size: clamp(28px, 7.6vw, 38px); }
  .imgband__stats span { font-size: 11.5px; }
}
.imgband__media { position: absolute; inset: 0; overflow: hidden; }
.imgband__img { position: absolute; top: -11%; left: 0; width: 100%; height: 122%; object-fit: cover; will-change: transform; }
.imgband::after { content: ""; position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(to top, rgba(0, 0, 0,.55), transparent 55%); }
.imgband__cap { position: absolute; left: var(--pad-x); right: var(--pad-x); bottom: clamp(28px, 4vw, 52px); z-index: 2;
  /* [08-06, client] 22ch -> 34ch. "Fully furnished, lockable suites." is 32
     characters and was breaking after "lockable", which is the wrap Abdullah
     asked to remove ("make it in one line", [09:02]-[09:16], and again at
     [14:53] "it becomes more consistent"). 34ch still holds the longer captions
     to a readable measure rather than letting them run the full bleed. */
  /* [07-08, client] 34ch -> 48ch, and the breaks are explicit now. Every
     caption was wrapping wherever 34 characters happened to land rather than
     at a sentence boundary, which is what Abdullah kept flagging one caption at
     a time: "built for focus and community" split across two lines, "Walk in,
     hit record" orphaning "in, hit record.", "A private, secure community"
     splitting after "secure". Widening alone cannot fix it, because the wrap
     point is still whatever fits; and a <br> alone cannot either, because
     "Dubai Hills' first dedicated podcast studio." is 44 characters and would
     still wrap inside its own line at 34ch. Both are needed.

     48ch clears the longest intended line with room to spare. The measure is
     still doing its job as a backstop: nothing here runs the full bleed. */
  color: var(--white); max-width: 48ch; font-family: var(--serif); font-weight: 500; line-height: 1.14;
  font-size: clamp(22px, 2.8vw, 42px); letter-spacing: -0.02em; text-shadow: 0 2px 40px rgba(0,0,0,.35); }
/* The forced break is a desktop instruction. On a phone the caption box is
   only ~16ch wide, so the copy already wraps several times and an extra
   hard break just strands a word. */
.imgband__cap br { display: none; }
@media (min-width: 901px) { .imgband__cap br { display: inline; } }

/* ---- Page hero (interior pages) — image-based, white text ---- */
.phero { position: relative; min-height: 84vh; display: flex; align-items: flex-end; overflow: hidden; color: var(--white);
  padding-top: clamp(140px, 18vh, 220px); padding-bottom: clamp(48px, 8vh, 104px); }
/* [04-09] background-size/position are here rather than inline so the inline
   style carries only the data: URI. They must match .phero__img's object-fit:
   cover and its centred origin, or the blur sits somewhere the real photo does
   not and you see it jump on swap.
   The blur is the 20px LQIP from lqip_uri(); --bg-2 stays underneath it as the
   fallback for any hero that has no placeholder. */
.phero__media { position: absolute; inset: 0; z-index: 0; overflow: hidden;
  background: var(--bg-2); background-size: cover; background-position: center; }
/* Extra bleed top+bottom so parallax can never expose an edge (the grey-gap fix) */
.phero__img { position: absolute; top: -10%; left: 0; width: 100%; height: 120%; object-fit: cover; will-change: transform; }
/* [03-08, pushed further after client review] Client asked for the virtual
   office hero photo zoomed in and shifted right — scale lives on .phero__img
   itself (not .phero__media, which the scroll-exit scene in main.js already
   scales) so it can't fight that tween; GSAP's yPercent parallax only touches
   translateY and preserves this scale. transform-origin is biased right too,
   not left at the default center, so the zoom itself pushes further right
   instead of magnifying symmetrically around the middle of an already-shifted
   crop — object-position alone wasn't reading as "moved" at the first pass. */
[data-theme="virtual"] .phero__img { transform: scale(1.45); transform-origin: 88% center; object-position: 92% center; }
/* [03-08, deepened after client review] The first pass (text-shadow alone,
   scrim untouched) still wasn't reliably legible over brighter photos — a
   soft shadow reads as a glow, not as guaranteed contrast, when the patch of
   photo directly under a letter is already light. Darkened all three stops,
   not just added shadow on top: the middle stop (where the title sits) was
   the lightest at .34, which is the one that mattered most and moved the most. */
.phero__scrim { position: absolute; inset: 0; z-index: 2;
  /* [17-08] A decorative gradient must never intercept a click. It always sat
     above .phero__media harmlessly, because nothing under it was interactive —
     until the map pin moved INTO the media wrap so the labels could be
     positioned against the image. The scrim then covered the pin and ate every
     click on it, while the pin still looked perfectly fine. */
  pointer-events: none;
  /* [08-05] Same darkening pass as .hero__overlay — .94/.52/.7 -> .97/.62/.78. */
  /* [08-05, later] And again with it — .97/.62/.78 -> .97/.72/.85. The 0% stop
     stays put: at .97 it is already all but opaque, so raising it would only
     crush the bottom of the photograph to flat black without making a single
     letter more legible. The gain goes to the 52% stop, which is the band the
     title occupies. */
  background: linear-gradient(to top, rgba(0, 0, 0,.97) 0%, rgba(0, 0, 0,.72) 52%, rgba(0, 0, 0,.85) 100%); }
/* ---- Locations map hero ----
   [14-08, CEO] "the whole hero... the whole thing google maps". Same .phero
   skeleton as every other hero, so the entry animation and all the type rules
   apply unchanged; only what genuinely differs lives here.

   The map is already near-black (assets/maps/build-maps.py), so the scrim only
   has to protect the type at the bottom rather than darken a bright photograph.
   The photo scrim's .72 middle stop flattened the road network into the ground
   and undid the whole point of the levels lift in the map itself. */
.phero--map .phero__scrim {
  background: linear-gradient(to top, rgba(0, 0, 0,.9) 0%, rgba(0, 0, 0,.5) 34%,
                                      rgba(0, 0, 0,0) 66%);
}
/* [17-08, Ahmed] "any way we can make the hero of the locations pages full
   screen rather than have white at the bottom, kinda how the hero is on home
   page" — 84vh was letting 16vh of the next section show under it. This is the
   same 100svh the home hero uses (svh, not vh: on a phone, vh is the LARGE
   viewport and the hero would sit under the browser chrome). */
.phero--map { min-height: 100svh; }
/* ---- The map layer ----
   .phero__mapwrap carries the map's OWN aspect ratio and is sized to cover the
   hero, so the image is never cropped by object-fit — the wrap overflows the
   hero instead, and .phero__media clips it. That is what makes a percentage
   inside the wrap a percentage of the IMAGE, which is what the landmark labels
   need: put them on the hero instead and each one slides off its landmark the
   moment the window changes shape.

   min-width/min-height + aspect-ratio with auto sizing resolves to the
   smallest box that satisfies both minimums at that ratio, which IS cover. */
.phero--map .phero__media { overflow: hidden; }
.phero__mapwrap {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  aspect-ratio: 2400 / 1350;        /* == OUT_W/OUT_H in build-maps.py */
  min-width: 100%; min-height: 100%;
}
/* Fills the wrap exactly, so there is no crop left for object-fit to do. */
.phero--map .phero__img { position: absolute; inset: 0; top: 0; width: 100%; height: 100%; object-fit: fill; }
.phero__labels { position: absolute; inset: 0; z-index: 2; pointer-events: none; }

/* Two tiers, the way a printed map separates a point from a region: .mlab--place
   gets a dot and a name, .mlab--area is a district set wider and quieter with no
   dot, .mlab--road is quieter still and italic. Sized off the WRAP (cqw) rather
   than the viewport, so the labels scale with the map they sit on instead of
   drifting across it. */
.phero__mapwrap { container-type: inline-size; }
.mlab {
  position: absolute; transform: translate(-50%, -50%);
  font-family: var(--sans); white-space: nowrap;
  font-size: clamp(10px, 0.85cqw, 17px);
  color: rgba(255, 255, 255,.72);
  text-shadow: 0 1px 6px rgba(0,0,0,.9), 0 0 18px rgba(0,0,0,.7);
}
/* Nudged off the anchor so the text sits beside the point, not on top of it. */
.mlab.is-left  { transform: translate(-100%, -50%); padding-right: 14px; }
.mlab.is-right { transform: translate(0, -50%); padding-left: 14px; }
.mlab--place::before {
  content: ""; position: absolute; top: 50%; width: 5px; height: 5px;
  border-radius: 50%; background: rgba(255, 255, 255,.72); transform: translateY(-50%);
}
.mlab--place.is-left::before  { right: 4px; }
.mlab--place.is-right::before { left: 4px; }
.mlab--area {
  color: rgba(255, 255, 255,.42);
  letter-spacing: .16em; text-transform: uppercase;
  font-size: clamp(9px, 0.68cqw, 14px);
}
.mlab--road {
  /* Brighter than a district label rather than dimmer, despite ranking lower:
     the road points sit low in the frame, which is where .phero__scrim is
     heaviest, and at .34 the names Ahmed asked for were being eaten by it. */
  color: rgba(255, 255, 255,.52); font-style: italic;
  font-size: clamp(9px, 0.66cqw, 13px);
}
@media (max-width: 700px) {
  /* A phone crop shows roughly the middle third of the map, so most labels are
     off-frame anyway and the ones left collide with the title. */
  .phero__labels { display: none; }
}

/* ---- The pin ----
   Markup, not baked into the map, so it can be clicked through to the branch's
   Google listing. translate(-50%, -100%) puts the POINT of the teardrop on the
   coordinate rather than the middle of its head. */
.phero__pin {
  position: absolute; left: 50%; top: 50%; z-index: 3;
  /* [21-08] lower bound was 30px, under the 44px touch minimum on phones */
  /* [03-09, Abdullah] "scale the location icon up accordingly." Was a flat
     clamp(44px, 3.2vw, 44px) — the two bounds were equal, so it rendered 44px
     at every width and the vw term did nothing. Now it genuinely scales: 44px
     stays the floor, which is the 44px touch minimum the 21-08 note raised it
     to, and it grows to 72px on a desktop hero. */
  width: clamp(44px, 4.6vw, 72px);
  transform: translate(-50%, -100%);
  display: block; line-height: 0;
  filter: drop-shadow(0 6px 14px rgba(0,0,0,.55));
}
.phero__pin svg { width: 100%; height: auto; display: block; overflow: visible; }
.phero__pin-body { fill: var(--green-l); transition: fill 0.51s var(--ease); }
.phero__pin-dot { fill: #000000; }
/* Grows from its own point, so the tip never leaves the coordinate. This is the
   14-08 "no movement on hover" rule applied literally: the pin gets bigger and
   the thing it points at does not move. */
.phero__pin:hover, .phero__pin:focus-visible {
  transform: translate(-50%, -100%) scale(1.14);
}
.phero__pin { transition: transform 0.51s var(--ease); transform-origin: 50% 100%; }
.phero__pin:hover .phero__pin-body { fill: var(--white); }
.phero__pin:focus-visible { outline: 2px solid var(--green-l); outline-offset: 6px; border-radius: 4px; }
/* Named on hover, because a bare pin does not say it is clickable. */
.phero__pin-tip {
  position: absolute; left: 50%; bottom: calc(100% + 10px);
  transform: translateX(-50%);
  padding: 7px 12px; border-radius: 999px; white-space: nowrap;
  font-family: var(--sans); font-size: 12px; letter-spacing: .02em;
  background: rgba(0, 0, 0,.9); color: var(--off-white);
  border: 1px solid rgba(255, 255, 255,.16);
  opacity: 0; pointer-events: none;
  transition: opacity 0.4s var(--ease);
}
.phero__pin:hover .phero__pin-tip, .phero__pin:focus-visible .phero__pin-tip { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .phero__pin:hover, .phero__pin:focus-visible { transform: translate(-50%, -100%); }
}
/* .phero__media's shared placeholder is --bg-2, a warm cream, which is right
   behind a photograph and wrong behind this: pheroEntry holds the media under
   a clip-path until the opener finishes, so any moment the preloader is not
   covering (a route swap, JS still parsing) flashes cream where a black map is
   about to be. */
.phero--map .phero__media { background: #000000; }
/* [17-08, Ahmed] "the Dubai Hills title can be a bit smaller" — approved at
   this size on the rendered comparison. Scoped to the map hero: every other
   hero keeps the sitewide 168px ceiling. */
.phero--map .phero__title { font-size: calc(clamp(46px, 6.2vw, 92px) + var(--up) * 0.0484); line-height: 0.96; }
/* ODbL attribution for the map tiles. [17-08, Ahmed] "make it quieter and at
   the farrrr corner of the site footer" — so it is the last item in
   .footer__bottom, pushed to the end of the row and set below the weight of
   everything around it. It is not removable: the tiles are OpenStreetMap data
   under ODbL and the credit is a licence condition, not a styling choice.
   Quiet is fine. Absent is not, which is why tests/map-hero.py asserts it is
   present, rendered and legible rather than merely in the markup. */
.footer__credit {
  margin-left: auto;
  font-size: 11px;
  color: rgba(255, 255, 255,.32);
  letter-spacing: .01em;
}
@media (max-width: 700px) {
  /* The bottom bar wraps on a phone, so `margin-left: auto` would leave it
     stranded on its own line pretending to be a third column. */
  .footer__credit { margin-left: 0; width: 100%; }
}
@media (max-width: 700px) {
  /* NO object-position override here. There used to be a `center 30%` crop
     bias, from when the pin was painted into the map and the two moved
     together. The pin is markup at a fixed 50%/50% now, so biasing the image
     slides the map underneath a pin that cannot follow it — the pin then
     points at the wrong building, on phones only, and every other assertion
     still passes. tests/map-hero.py checks both axes for exactly this. */
  .phero__credit { font-size: 10px; bottom: 6px; }
  .phero__pin-tip { display: none; }     /* no hover on a touch screen */
}

.phero__inner { position: relative; z-index: 3; width: 100%; }
/* Two-layer shadow, not one: a tight near-black pass anchors contrast right at
   the letterform edge (the part a soft-only glow leaves weakest), the wider
   soft pass behind it does the general separation from the photo. Kept as-is
   on the eyebrow (small uppercase label, never read as an outline at this
   size) — see .phero__title below for why the title itself moved off this
   pattern. */
.phero__eyebrow { color: var(--white); margin-bottom: 26px;
  text-shadow: 0 1px 2px rgba(0,0,0,.85), 0 2px 24px rgba(0,0,0,.55); }
.phero__eyebrow .dot { background: var(--green-l); }
/* letter-spacing: -0.015em, tighter than the brand book's -0.02em (CLAUDE.md
   §3) was still colliding the "f" and "i" in "Office" and similar pairs, so
   this went slightly looser than spec rather than hold a value that keeps
   producing a visible defect. font-kerning is explicit insurance, not the
   fix itself — the real fix is below, on italic runs specifically. */
/* [04-08, client review] The tight near-black pass above (1px offset, 3px
   blur, .8 opacity) is legible at a glance but at hero size it renders as a
   hard outline around every letterform — client: "there's drop shadow...
   make it like a glow effect instead of a stroke... something a little bit
   more diffused instead of it looking like a 3D font." Dropped to one
   symmetric, zero-offset, large-blur, low-opacity pass: no directional
   "shadow" read, no letterform-edge ring, just enough of a dark halo behind
   the white glyphs to keep them off a bright patch of photo. The scrim
   (.phero__scrim above) still carries the real contrast job — this shadow is
   a finishing touch now, not the anchor, which is why it can afford to be
   this quiet. */
.phero__title { font-size: calc(clamp(52px, 10vw, 168px) + var(--up) * 0.0884); line-height: 0.94; letter-spacing: -0.015em; font-kerning: normal; max-width: 16ch; color: var(--white);
  text-shadow: 0 0 34px rgba(0,0,0,.4); }
/* [03-08] Client asked for a smaller blog hero title specifically — every
   other hero keeps the 168px ceiling. */
[data-theme="blog"] .phero__title { font-size: clamp(48px, 7.5vw, 120px); }
/* [03-08, root cause] Italic glyphs lean into the space the uniform negative
   tracking above was already taking away — that compounding, not the base
   tracking alone, is what was still colliding letters after the first pass
   loosened it. Italic runs get NO extra negative tracking; letter-spacing:0
   here, not just "less negative", because a slanted "ff" needs its natural
   spacing back, not a smaller deduction from it. Covers both the raw <em> (a
   no-JS/first-paint fallback) and the .w.is-em span splitWords() replaces it
   with once the reveal animation runs — see splitWords() in main.js. */
.phero__title em { font-style: italic; color: var(--green-l); letter-spacing: 0; }
.phero__title .w { display: inline-block; overflow: hidden; vertical-align: top;
  padding: 0.06em 0.08em 0.16em 0.02em; margin: -0.06em -0.08em -0.16em -0.02em; }
.phero__title .w > span { display: inline-block; will-change: transform; }
.phero__title .w.is-em > span { font-style: italic; color: var(--green-l); letter-spacing: 0; }
.phero__sub { margin-top: 30px; max-width: 56ch; font-size: clamp(17px, 1.5vw, 21px); color: rgba(255,255,255,.85); }
/* [07-08, client] The podcast sub on one line, not two: "A dedicated podcast
   room, offering unmatched service quality and top-tier facilities." At 56ch it
   broke mid-word, orphaning "tier facilities." on a line of its own.

   Scoped to this page rather than raising the shared measure. It is the only
   sub short enough to fit at 85 characters; every other one runs 111 to 144,
   so a global change would just make seven other pages wrap at a longer,
   harder-to-read measure without putting any of them on one line either. */
body[data-hero-theme="podcast"] .phero__sub { max-width: 88ch; }
/* [08-12, Abdullah, round 3] "private office: merge meeting rooms with the
   line before." Same shape of fix as the podcast sub above and for the same
   reason: at 56ch the standard private-office line broke after "professional
   amenities" and left "and meeting rooms." alone underneath, 689px over 192px.

   This is the line the 07-24 meeting fixed the wording of, so it is a measured
   width problem and not a copy one — hence a max-width and not a rewrite. The
   full string is ~890px at the 21px ceiling, which the container holds down to
   about 1000px wide; below that it wraps to two lines again, which is correct.
   Raising the shared 56ch instead would push seven other subs out to a measure
   none of them needs. */
body[data-hero-theme="office"] .phero__sub { max-width: 96ch; }
/* Sits above .phero__title now, in the spot the repetitive .phero__eyebrow
   used to occupy (eyebrow just restated the title in small caps). Bumped from
   13px/.72 alpha to read clearly as a standalone lead-in rather than a footnote. */
.phero__locations { margin-bottom: 22px; display: inline-flex; align-items: center; gap: 8px; font-family: var(--sans); font-size: 14.5px; font-weight: 500; letter-spacing: .02em; color: rgba(255,255,255,.9);
  text-shadow: 0 1px 2px rgba(0,0,0,.85), 0 2px 24px rgba(0,0,0,.55); }
.phero__locations svg { width: 15px; height: 15px; flex: none; color: var(--green-l); }
/* .phero__bottom holds the CTAs + the scroll hint on one flex line, so the
   hint is always vertically aligned with the buttons — not a fixed distance
   from the section's bottom edge, which drifted out of sync with them
   whenever the hero content's height changed. */
/* The scroll indicator used to sit in this flex row, so it pushed the buttons
   64px in from the left while the eyebrow, headline and sub-line all began at
   the container edge. Buttons now start on the same line as the text (Ahmed,
   27-07) and the indicator is taken out of flow beside them, keeping its
   position on the page without occupying a column. */
.phero__bottom { margin-top: 40px; position: relative; display: flex; align-items: center;
  gap: clamp(22px, 3vw, 38px); flex-wrap: wrap; }
.phero__bottom .phero__scroll { position: absolute; left: -38px; top: 50%; transform: translateY(-50%); }
.phero__ctas { display: flex; flex-wrap: wrap; gap: 14px; }

/* [21-08, Abdullah] Hero CTAs share one row on phones instead of wrapping.
   His objection, verbatim: the green button is big, then one to its right,
   then one on the line below. Three buttons at content width overflowed 390px
   and wrapped 2-then-1, which read as a mistake rather than a layout.
   `flex: 1 1 0` + `min-width: 0` is what makes them EQUAL rather than merely
   unwrapped: without min-width:0 a flex item refuses to shrink below its
   content, so the longest label would still push the row wide and re-wrap.
   Sizing here is deliberately tighter than the base .btn (28px side padding x
   6 across three buttons alone exceeds the viewport). Vertical padding is
   untouched, so the tap target keeps its height.
   Virtual Tour stays green: it is the primary CTA sitewide per the 07-24
   punch list. "Same size" was the ask, not "same style". */
@media (max-width: 700px) {
  .phero__ctas { flex-wrap: nowrap; gap: 8px; width: 100%; }
  .phero__ctas .btn {
    flex: 1 1 0; min-width: 0; justify-content: center;
    padding-left: 10px; padding-right: 10px;
    font-size: 13px; gap: 6px; white-space: nowrap;
  }
  .phero__ctas .btn svg { width: 14px; height: 14px; flex: none; }
  /* The arrow is decoration on a button that is now barely wide enough for its
     label, so it goes rather than squeezing the words. */
  .phero__ctas .btn--primary svg { display: none; }
}
.phero__scroll { position: relative; z-index: 3; width: 26px; height: 40px; flex: none; }
@media (max-width: 700px) { .phero__bottom .phero__scroll { display: none; } }
.phero__scroll span { position: absolute; inset: 0; margin: auto; width: 1px; height: 100%; background: rgba(255,255,255,.3); overflow: hidden; }
.phero__scroll span::after { content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 40%; background: var(--green-l); animation: scrollLine 4.08s var(--ease) infinite; }

/* Hero ornament (themed SVG watermark, entry + parallax drawn by JS).
   --orn-o is the settle target — JS animates TO this, never to full opacity. */
.phero__orn { --orn-o: .16; position: absolute; top: 50%; right: clamp(-40px, -2vw, 20px); transform: translateY(-50%); z-index: 2; width: clamp(280px, 34vw, 540px); aspect-ratio: 1; color: var(--white); opacity: var(--orn-o); pointer-events: none; }

.phero__orn .orn { width: 100%; height: 100%; overflow: visible; }
.orn [class^="o-"], .orn [class*=" o-"] { stroke: currentColor; }
.orn circle[class^="o-"], .orn .o-node, .orn .o-seat, .orn .o-core, .orn .o-pin-dot, .orn .o-qd { fill: currentColor; stroke: none; }
.orn .o-ring, .orn .o-qc, .orn .o-blob, .orn .o-table, .orn .o-stamp, .orn .o-pin, .orn .o-doc, .orn .o-line, .orn .o-edge, .orn .o-txt, .orn .o-contour, .orn .o-qm, .orn .o-check { fill: none; stroke-width: 1.4; }
.orn .o-bar, .orn .o-tower { fill: currentColor; }
.orn .o-blk { fill: none; }
@media (max-width: 820px) { .phero__orn { opacity: .12; width: 74vw; right: -16vw; } }

/* ---- Prose (editorial two-column) ---- */
/* [07-08, client] The body COLUMN is the measure, not a share of the row.
   Ahmed: "it feels like there is more white space on the right rather than on
   the left." It did, and only on a wide screen. --measure caps the paragraphs
   at 56ch for readability, but the 1.1fr column kept growing past them, so at
   a 2000px container the text stopped at 1644 while the column ran to 1920:
   356px of dead page on the right against 0 on the left.

   Sizing the column to the measure hands that slack to the heading column
   instead of leaving it empty, so the block still spans the container edge to
   edge and both margins are the page padding. minmax(0, ...) so it can still
   shrink below the measure on a narrow screen rather than overflow. */
/* [08-11, Abdullah, round 2] "private office: bring title and body closer."
   The tracks were `1fr minmax(0, var(--measure))`, and with a fixed second
   track that arithmetic pins the body to the container's right edge at every
   width: bodyLeft = containerRight - measure, independent of the gap. So the
   whole of the slack landed between the two — 154px of empty page between the
   end of "Modern Businesses" and the first word of the paragraph at 1440, and
   765px at 2560. Shrinking the gap alone would have moved nothing; the 1fr
   simply grows to eat whatever the gap gives up.

   Now the heading column is sized to the heading and the slack goes to the
   body track, which the paragraphs do not fill because they are already capped
   at the measure. Same amount of empty page, moved off the middle and out to
   the right margin, where it reads as a margin rather than as a hole.

   fit-content(46%), not max-content: private office breaks its heading with
   explicit <br>s so its max-content is the widest of the three lines (489px at
   1440) and the track hugs it exactly, which is what locks that heading to
   three lines by construction rather than by luck. The about page's heading
   has no breaks, so its max-content is one 800px line; the 46% cap makes it
   wrap the way it already does instead of starving the paragraph column.

   Side effect worth knowing: --measure is 56ch, and ch resolves against the
   element it is used on. As a track it was computed on the grid container at
   17px (580px); as `max-width` on .prose__body p it computes at the paragraph's
   own 20px (649px). The track used to be the tighter of the two and silently
   won, so the paragraphs ran at about 50 characters. They now run at the 56 the
   token actually asks for. */
.prose__grid { display: grid; grid-template-columns: fit-content(46%) minmax(0, 1fr); gap: clamp(32px, 4vw, 64px); align-items: start; }
/* [08-11, Abdullah] The location pages only. One column, both halves centred.
   Their heading is three words against a single paragraph, so the two-column
   grid left a wide empty gutter and the sticky heading hung beside nothing.
   The measure still caps the paragraph, so centring it does not turn it into a
   96-character line on a wide screen; it just centres the block that measure
   produces. `position: static` undoes the sticky above, which has nothing to
   stick against once the two are stacked. */
.prose__grid--centred { grid-template-columns: minmax(0, 1fr); justify-items: center; text-align: center; }
.prose__grid--centred .prose__head { position: static; }
.prose__grid--centred .prose__body p { margin-left: auto; margin-right: auto; }
/* [08-11, Abdullah, round 2] The other half of "too much gap": the section's
   own padding, which stacked on top of the row gap below. A heading and a
   single paragraph do not earn the full 86px section rhythm on both sides. */
.prose--tight.section-pad { padding-block: clamp(32px, 3.4vw, 54px); }
/* [08-11, Abdullah, round 2] "locations: too much gap." Stacked, the grid's
   gap is vertical, so the 64px above buys nothing here — it is just white
   between a three-word heading and the paragraph it introduces. The two belong
   to each other and now read as one block. */
.prose__grid--centred { row-gap: clamp(18px, 2vw, 30px); }
.prose__head { position: sticky; top: 120px; }
.prose__head .eyebrow { margin-bottom: 20px; }
.prose__head h2 { font-size: clamp(32px, 4.4vw, 62px); }
.prose__head h2 em { font-style: italic; color: var(--green); }
/* [08-06] The reading measure, not the grid track, sets how long these lines
   get. This is the one page whose body is genuinely long-form, so when
   --container went fluid it was the only place that suffered: the paragraphs
   went from 651px to 959px at a 2560 viewport, about 65 characters per line to
   96, which is well past comfortable. The grid still spends the extra width on
   the layout; the prose just stops growing into it. */
.prose__body p { font-size: clamp(17px, 1.4vw, 20px); color: var(--ink-soft); margin-bottom: 24px;
  max-width: var(--measure); }
.prose__body p:last-child { margin-bottom: 0; }
/* [03-09, Abdullah] Location pages only, two asks that turned out to be the same
   paragraph. "Make the black text true black, not a dark grey" — it was
   --ink-soft (#6A625C), a warm grey, which is what he was seeing. And "increase
   the paragraph text size", plus, on the call, wanting it to span wider so it
   stops running to seven lines.

   --ink rather than #000: --ink IS the brand black (#231F20, and Abdullah's own
   mockup uses #1f1b1c, so neither reference is pure black). Against the #6A625C
   it replaces, this is unambiguously "true black" — pure #000 would be a
   palette change nobody asked for.

   ⚠️ [04-09] THAT LAST SENTENCE IS NOW WRONG, and it is left standing because
   the reasoning is worth keeping visible. Abdullah came back the next day and
   asked for pure black by name and in hex: "any black thing is not pure black,
   it looks charcoal black, we want 000000." So --abaddon IS #000000 now and
   this rule still says --ink, which now resolves to pure black. The line below
   did not need editing; the palette moved underneath it.

   ⚠️ The measure is widened AGAINST the reasoning in the comment above, which
   caps these lines at ~65 characters on purpose. Abdullah asked for the wider
   span directly, so it is widened here and scoped to [data-page="location"] so
   the genuinely long-form pages (About, the blog) keep the comfortable measure.
   Line count is verified by measurement, not by arithmetic — raising the font
   size eats most of the width gain, so the two have to be tuned together. */
[data-page="location"] .prose__body p {
  font-size: clamp(18px, 1.55vw, 22px);
  color: var(--ink);
  max-width: 82ch;
}
.pullquote { margin-top: 20px; padding: 30px 0 4px; border-top: 1px solid var(--line); font-family: var(--serif); font-size: clamp(24px, 2.6vw, 36px); line-height: 1.2; color: var(--ink); }
.pullquote em { font-style: italic; color: var(--green); }
/* [08-11] 900px -> 1200px. The second track is the reading measure, which does
   not shrink, so between 901 and 1199 it took nearly the whole container and
   left the heading column at 176px (measured at 901px) while the heading was
   still rendering at 40px. Nothing fits in that, and it is why the private
   office heading came out on five lines at 1024 when it holds three at every
   other width. Below 1200 the two halves now stack, which is the layout those
   widths wanted anyway: a sticky heading has nothing to earn its keep against
   when its column is narrower than one word. */
@media (max-width: 1200px) { .prose__grid { grid-template-columns: 1fr; gap: 32px; } .prose__head { position: static; } }

/* ---- Perks (what a space offers) — editorial numbered list, no cards ---- */
/* ---- Perks: heading left, features as a two-column grid right ----
   Was one full-width column of numbered rows, which left the right-hand half of
   the section empty on every space page. The heading moving into that space is
   what removes the gap; the two-column list is what lets 8 features sit in four
   rows instead of eight. Row count follows item count, so no page needs its own
   layout. See perks() in build_site.py for the call timestamps. */
.perks__grid {
  display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  gap: clamp(32px, 5vw, 80px); align-items: start;
}
/* [08-12, Abdullah, round 3] The sticky is GONE. "It's too small of a space...
   the animation is good but it's too short, so it's better to remove it."

   He is describing a heading that pins, shifts a few pixels and unsticks again
   — movement short enough to read as a fault rather than as an effect. The
   measurements say he is right, and that it was never earning its keep:

     travel = grid row height - heading height, i.e. how far it can move
     before it unsticks.

     private office  1920 3px · 1440 25px · 1280 25px · 1024 28px
     coworking       1920 0px · 1440  0px · 1280  8px · 1024 22px
     meeting room    1920 0px · 1440  0px · 1280  0px · 1024  0px

   28px is the most it ever travelled, on any page at any width, and on two of
   the three pages at the widths most people see it does nothing at all — the
   headings are taller than their own lists, so there is no room to slide.
   A sticky column pays for itself when a short heading sits beside a long
   scrolling list; here the two sides are the same height by construction,
   because perks() sizes the grid to the item count.

   Removed rather than raised to a page-specific threshold: one rule for every
   space page is what this component is built around, and a property that does
   nothing on two pages and glitches on the third is not worth a special case.
   The 900px rule below already turned it off on phones for the same reason. */
.perks__head { position: static; }
.perks__head .eyebrow { margin-bottom: 22px; }
/* The heading used to sit in .section-head and inherit the display scale from
   it. Moving it into its own column dropped it to the browser's default h2,
   25.5px against 84px, and the em lost its green. Restated here at a size that
   suits the narrower column rather than reusing the full 84px. */
.perks__head h2 { font-size: clamp(30px, 3.6vw, 52px); }
.perks__head h2 em { font-style: italic; color: var(--green); }
.perks__lead {
  margin-top: clamp(18px, 2vw, 26px); max-width: 42ch;
  font-family: var(--sans); font-size: clamp(15px, 1.15vw, 17px);
  line-height: 1.65; color: var(--ink-soft);
}
/* [A2, 04-08] Centred against the heading column rather than sharing its top
   edge. Abdullah's note was unreadable ("make it premium meeting in one line the
   other part in the 2nd line, and the last"); Seif's reading was that the block
   looks wrong and the list should sit lower, and the measurements say why.

   Only three pages use perks(), and heading-vs-list heights at 1440 are:
   private office 154/179, coworking 277/261, meeting room 314/196. The first
   two are within 26px, which reads as level. The meeting room's heading is
   118px taller than its list, so with both columns top-aligned the list stops
   well short and leaves an empty rectangle under it — that is the whole
   complaint, and it is specific to this page's longer heading and lead.

   align-self on the list, not align-items on the grid, so .perks__head keeps
   its own alignment. It moves the meeting room's list down 59px and the other
   two by 13px and 8px, which is below noticing. One property, and no page needs
   a special case — the same thing perks() itself is built around.

   [08-12] This used to read "keeps its own alignment AND its sticky behaviour".
   The heading no longer sticks at any width; see the note on .perks__head. */
.perks__list {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(20px, 2.4vw, 40px);
  align-self: center;
}
.perk {
  display: flex; align-items: flex-start; gap: clamp(12px, 1.2vw, 16px);
  padding: clamp(15px, 1.5vw, 20px) 0;
  border-bottom: 1px solid var(--line-soft);
  transition: border-color 0.85s var(--ease);
}
/* Both cells of the first row get the top rule, not just the first item. */
.perk:nth-child(-n+2) { border-top: 1px solid var(--line-soft); }
.perk:hover { border-color: var(--green); }
.perk__check {
  flex: none; width: clamp(19px, 1.5vw, 22px); height: clamp(19px, 1.5vw, 22px);
  margin-top: .1em; color: var(--green);
  transition: color 0.85s var(--ease);
}
/* The tick draws itself in when the row scrolls into view, rather than simply
   appearing. 30 is a little over the path length so nothing shows at rest. */
.perk__check path { stroke-dasharray: 30; stroke-dashoffset: 30; }
.perk.is-drawn .perk__check path { animation: perkDraw 0.85s var(--ease) forwards; }
@keyframes perkDraw { to { stroke-dashoffset: 0; } }
.perk:hover .perk__check { color: var(--green-d); }
.perk__t {
  font-family: var(--sans); font-size: clamp(15px, 1.15vw, 17px); line-height: 1.45; color: var(--ink);
  transform-origin: left center;
  transition: transform 0.85s var(--ease);
}
/* [08-14] Was translateX(5px). Grows in place now — see --hover-grow. */
.perk:hover .perk__t { transform: scale(var(--hover-grow)); }

/* One column below the split. The heading used to be un-stuck here too — there
   was nothing beside it to stick against — but as of round 3 it no longer
   sticks at any width, so that declaration has gone with it rather than being
   left behind as a rule that overrides nothing. */
@media (max-width: 900px) {
  .perks__grid { grid-template-columns: 1fr; gap: clamp(26px, 5vw, 40px); }
  .perks__list { grid-template-columns: 1fr; }
  .perk:nth-child(-n+2) { border-top: 0; }
  .perk:first-child { border-top: 1px solid var(--line-soft); }
}
/* Coworking only — .perks is shared with private-office and meeting-room, so
   scope through the page's data-hero-theme rather than touching .perks__grid/
   __head/__list directly.
   "Flexible Coworking Space<br>in Dubai." needs "Flexible Coworking Space" to
   hold one line: at the shared 0.85fr head column and 52px font-size, that
   string (~24 characters) runs wider than the column at desktop sizes, so it
   was wrapping a second time on its own and pushing "in Dubai." to a third
   line. Widening the column to an even 1fr/1fr split gives it the room, and
   knocking the cap from 52px to 46px adds margin so it holds even where the
   font is close to maxed. Widening the head column also reads as the list
   moving right relative to it — the explicit margin-left adds a bit more on
   top since that was asked for as its own thing, not just a side effect. */
@media (min-width: 901px) {
  body[data-hero-theme="coworking"] .perks__grid { grid-template-columns: 1fr 1fr; }
  body[data-hero-theme="coworking"] .perks__head h2 { font-size: clamp(28px, 3.3vw, 46px); }
  body[data-hero-theme="coworking"] .perks__list { margin-left: clamp(12px, 2vw, 32px); }
}

/* Nothing draws itself when motion is reduced; the ticks are simply there. */
@media (prefers-reduced-motion: reduce) {
  .perk__check path { stroke-dashoffset: 0; }
  .perk.is-drawn .perk__check path { animation: none; }
}

/* "Everything's already set up for you." wraps to 2 lines inside the shared
   .section-head 900px cap — .kit is podcast-room-only, so drop the cap and
   shrink the font (shared clamp maxes at 84px, which this ~37-character line
   would overflow the container at) rather than scoping through a page
   attribute. nowrap is gated to 761px+ like .rooms/.loccards do it above —
   below that even the smaller floor here is wider than a phone screen, so
   mobile keeps its normal 2-line wrap. */
/* [08-11, Abdullah] Rebuilt as heading-left / photographed cards right, the
   third of the perks redesigns. Replaces .kit__list + .kit__row + the fixed
   .kit__preview that floated a photograph beside the cursor: that preview was
   mouse-only, so on a phone the six kit photographs were unreachable and had
   never been seen there. The photograph now lives in the card, so there is no
   interaction to miss.
   Same two-column shape and the same head sizing as .perks__grid, because
   these two sections are the same idea on neighbouring pages. */
.kit__grid {
  display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  gap: clamp(32px, 5vw, 80px); align-items: start;
}
.kit__head { position: sticky; top: clamp(96px, 12vh, 140px); }
.kit__head .eyebrow { margin-bottom: 22px; }
.kit__head h2 { font-size: clamp(30px, 3.6vw, 52px); }
.kit__head h2 em { font-style: italic; color: var(--green); }
.kit__items {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(12px, 1.3vw, 18px);
}
/* [07-09, Abdullah] "Before, each image plus description had a little beige box
   around it. Since they asked for beige to be removed -- could we make a little
   box and add shadow maybe, and see it float?"

   The box never went anywhere. This rule already said background: var(--bg-2),
   but --bg-2 was the beige #ECE5DB and became #FFFFFF on 04-09, so the box has
   been painting white on a white page ever since -- present in the box model,
   invisible to the eye. Nothing was deleted; the colour that drew it was.

   Edge AND lift, which is option E of the 07-09 sheet and Seif's pick. The
   hairline does the work the beige used to do (it states where the box ends);
   --sh-1 is the lightest step on the ladder and supplies the float he asked
   for without the six of these reading as a wall of drop-shadows -- at this
   density --sh-2 was too much.

   border-box is global (line 192), so the 1px border eats into the existing
   padding instead of growing the card, and the two-column grid does not
   reflow. Verified at 1440, 1280, 1024 and 900. */
.kit__item {
  display: flex; align-items: center; gap: clamp(12px, 1.2vw, 18px);
  padding: clamp(10px, 1vw, 14px);
  background: var(--bg-2); border-radius: var(--r-md);
  border: 1px solid var(--line-soft); box-shadow: var(--sh-1);
}
/* Fixed square so a portrait and a landscape source crop to the same shape;
   without it the rows in a column stopped lining up. */
.kit__thumb {
  flex: 0 0 auto; width: clamp(54px, 5vw, 68px); aspect-ratio: 1;
  border-radius: calc(var(--r-md) - 4px); overflow: hidden; background: var(--line);
}
.kit__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.kit__name {
  font-family: var(--sans); font-size: clamp(14px, 1.05vw, 16px);
  line-height: 1.35; color: var(--ink);
}
/* One column of cards under the heading on a phone: two 68px thumbnails plus
   two labels do not fit side by side at 390px without the names going to three
   lines each. */
@media (max-width: 900px) {
  .kit__grid { grid-template-columns: minmax(0, 1fr); }
  .kit__head { position: static; }
}
@media (max-width: 560px) {
  .kit__items { grid-template-columns: minmax(0, 1fr); }
}
@media (hover: none) { .kit__hint, .kit__preview { display: none; } }

/* subtle matching hover accent for the feature-list rows */
.fl__row { position: relative; }
.fl__row::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--green); transform: scaleY(0); transform-origin: top; transition: transform 0.85s var(--ease); }
.fl__row:hover::before { transform: scaleY(1); }

/* ---- Feature list ---- */
/* [07-08, client] Head beside the rows, matching perks__grid on the
   meeting/boardroom page. Abdullah: the three cards should "sit beside the
   heading instead of under it". Same column split and gap as perks so the two
   sections read as one system. */
.flist__wrap {
  display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  gap: clamp(32px, 5vw, 80px); align-items: start;
}
/* Three across was right at full width. Beside a heading the column is roughly
   half as wide, so the rows stack instead — one per line, each keeping its
   label and full sentence on a comfortable measure. */
.flist__grid { display: grid; grid-template-columns: 1fr; gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; }
@media (max-width: 900px) {
  /* Stacked below the tablet breakpoint, same as perks__grid. */
  .flist__wrap { grid-template-columns: 1fr; }
  .flist__head { position: static; }
}
.fl__row { background: var(--bg); padding: clamp(26px, 2.6vw, 40px); display: flex; flex-direction: column; gap: 10px; transition: background 0.68s var(--ease); }
.fl__row:hover { background: var(--off-white); }
.fl__row h3 { font-size: clamp(19px, 1.6vw, 24px); }
.fl__row p { color: var(--ink-soft); font-size: 15px; }
@media (max-width: 900px) { .flist__grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 620px) { .flist__grid { grid-template-columns: 1fr; } }

/* ---- Stat band ---- */
/* --green-l, not --green: the band sits on --abaddon, and #687A61 on that
   ground is 3.5:1 — under AA for the 13px labels. #8A9A82 is 5.4:1 and is
   already the site's green-on-dark (hero em, nav hover). */
.statband { background: var(--abaddon); color: var(--green-l); }
.statband__grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 30px; padding-block: clamp(30px, 3.4vw, 50px); }
/* Numbers + labels were left-aligned by default inside their grid cells —
   fine when every cell's content happened to be the same width, but "65+"
   vs "1" leaves the short ones hugging the left edge instead of sitting
   under their own label. Centering the whole cell keeps both lines
   centered on each other regardless of digit count.
   [03-08, deepened] text-align only centered the TEXT; the block itself was
   still top-anchored inside its grid cell, so a cell whose grid row got
   stretched taller by a neighbour's 2-line label (see .statband__midbreak)
   sat higher/lower than a cell with a 1-line label instead of matching it.
   Flex + justify-content:center centers the whole number+label group
   vertically within whatever height the row actually is. */
.statband__grid > div { text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; }
/* [A3, 04-08] Lining figures. This is the whole of "the + is higher than it
   should be, not aligned with the numbers", and it was never a layout bug —
   every cell measures identical height and identical top.

   Playfair Display's DEFAULT numerals are old-style: measured off
   playfair-var.woff2 with fontTools, 3 4 5 7 9 descend to -148/1000 and 0 1 2
   stop at ~529 while 6 and 8 reach ~723. So "65+" sets a tall 6, a descending
   5, and a '+' that spans 105 to 606 — which lands 6px above the optical centre
   of the digits at 86px, and reads as a superscript. Old-style figures are
   right in running prose, which is what they are for. They are wrong for a
   display statistic, where the digits are the subject.

   The font ships lnum, so this is one declaration rather than markup. Lining
   figures seat every digit on the baseline at a uniform 708-758, which moves
   the '+' to 1.4px BELOW that centre with its top 13px under the tallest digit.
   It also settles the second complaint — the ragged tops were most of why the
   space above the row read as bigger than the space below it.

   Do not "fix" this by wrapping the suffix in a span: .statband span and
   .hero__stats span style the LABEL, so a new span inherits display:block and
   13px and the '+' drops onto its own line. That was tried on 03-08 and
   reverted. It would also collide with the count-up in js/main.js, which
   assigns el.textContent and would destroy any child on the first frame. */
.statband strong,
.hero__stats strong { font-variant-numeric: lining-nums; font-feature-settings: "lnum" 1; }

.statband strong { display: block; font-family: var(--serif); font-size: clamp(44px, 6vw, 88px); font-weight: 500; line-height: 1; }
.statband span { display: block; margin-top: 10px; font-size: 13px; letter-spacing: .04em; color: var(--green-l); }
@media (max-width: 900px) { .statband__grid { grid-template-columns: repeat(2, 1fr); gap: 34px 20px; } }

/* ---- Pricing / package cards ---- */
.pricing__grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: clamp(20px, 2vw, 32px); max-width: 960px; }
.pricing__grid--3 { grid-template-columns: repeat(3, 1fr); max-width: none; }
.pricecard { display: flex; flex-direction: column; gap: 22px; background: var(--off-white); border: 1px solid var(--line-soft); border-radius: var(--r-lg); padding: clamp(28px, 3vw, 44px); box-shadow: var(--sh-2); transition: box-shadow 1.02s var(--ease); }
.pricecard:hover { box-shadow: var(--sh-3); }
.pricecard--featured { background: var(--abaddon); color: var(--off-white); }
.pricecard--featured .pricecard__price .per, .pricecard--featured .pricecard__feats li { color: rgba(255, 255, 255,.7); }
.pricecard--featured .pricecard__feats li::before { color: var(--green-l); }
.pricecard--featured .pricecard__feats li { border-color: rgba(255, 255, 255,.14); }
.pricecard__tag { display: inline-block; font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--green); font-weight: 600; margin-bottom: 12px; }
.pricecard__head h3 { font-size: clamp(23px, 2vw, 30px); }
.pricecard__price { margin-top: 16px; font-family: var(--serif); font-size: clamp(40px, 4.4vw, 62px); font-weight: 500; line-height: 1; }
.pricecard__price .cur { font-size: .4em; color: var(--green); vertical-align: middle; letter-spacing: .04em; }
.pricecard__price .per { font-family: var(--sans); font-size: 15px; color: var(--ink-soft); font-weight: 400; margin-left: 8px; }
.pricecard__feats { display: flex; flex-direction: column; gap: 12px; flex: 1; }
.pricecard__feats li { position: relative; padding-left: 26px; font-size: 15px; padding-bottom: 12px; border-bottom: 1px solid var(--line-soft); }
.pricecard__feats li::before { content: "→"; position: absolute; left: 0; color: var(--green); }
.pricecard .btn { margin-top: 8px; }
@media (max-width: 980px) { .pricing__grid, .pricing__grid--3 { grid-template-columns: 1fr; max-width: 560px; } }

/* ===== Virtual office price =====
   One package, two payment terms, an identical feature list. So: one number at
   the site's own price size, the discount stated on the toggle rather than
   found after clicking it, and the other term's equivalent printed underneath
   so the two are comparable without switching. No card, no border, no shadow.

   Real radios rather than buttons, so arrow-key navigation and the radiogroup
   role come from the platform. The input is visually hidden but never
   display:none, which would drop it out of the focus order. */
/* This section is one price and six lines of copy, so it does not need the
   full section rhythm. Measured at 1048px before: 259px of it was padding and
   309px was an 84px heading over two lines, against 479px of actual content.
   All of it is scoped to .pricing; no other section's head or padding moves. */
.pricing.section-pad { padding-block: clamp(35px, 3.7vw, 53px); }   /* [08-05] re-tightened along with .section-pad above, same reversal */
.pricing .section-head { margin-bottom: clamp(26px, 3vw, 44px); }
.pricing .section-head h2 { font-size: clamp(26px, 3.2vw, 46px); }

/* The price card, alone and centred. It used to sit in a two-column grid with
   an enquiry form beside it; that form moved to space_enquiry() at the foot of
   the page, so the card no longer has a partner to line up against. */
/* The column is constrained, not the card. Centring the card inside an
   auto-sized column does nothing: the implicit column takes the max-content
   width of the feature chips (1102px here), so justify-content centred a column
   far wider than the card and the card stayed pinned to its left edge. */
.vo__grid { display: grid; grid-template-columns: min(560px, 100%); justify-content: center;
  gap: clamp(32px, 5vw, 80px); align-items: start; --vo-inset: clamp(24px, 2.6vw, 38px); }

/* [added 27-07] Two packages side by side. The single-column rule above keeps
   its explicit track for the same reason it was written: leaving the column
   implicit lets it take the max-content width of the feature chips (1102px),
   which centres a 1102px column around a 560px card and pins the card left.
   The pair uses two equal tracks capped with minmax(0,1fr) so the chips wrap
   inside the card rather than widening the track. */
.vo__grid--pair { grid-template-columns: repeat(2, minmax(0, 1fr));
  max-width: 980px; margin-inline: auto; gap: clamp(20px, 2.6vw, 36px); align-items: stretch; }
.vo__grid--pair .vo { display: flex; flex-direction: column; height: 100%; }
/* Push the chips to the bottom so both cards' feature lists line up even when
   one carries a saving line and the other does not. */
.vo__grid--pair .vo__feats { margin-top: auto; padding-top: 22px; }
/* [removed 30-07] A 720px rule used to collapse the pair into one column. That
   was written when each card carried its own copy of the six features and was
   too tall to sit beside its twin. The features moved to the shared band below,
   so the cards fit side by side all the way down to 390px and the pair no
   longer stacks at any width. The phone sizing is in the 760px block further
   down. */
/* [rebuilt 30-07] The card is now the same white surface every other card on
   this site uses: .card's edge, .card's radius, .card's resting shadow. The two
   gradient washes it had before were the only place on the site where a card
   announced itself by tinting, and at 58% they ran out mid-card and left a hole
   in each one, which is the empty space the client raised six times.

   Ranking moved to a 3px rule along the top and the filled button. That ranks
   the package without making one card a different colour from every other card
   on the page. */
/* One card, photograph beside the body, which is what .card and the Other
   Spaces panel already are. */
.vo { text-align: left; overflow: hidden; max-width: 1040px; margin-inline: auto;
  display: grid; grid-template-columns: 0.82fr 1.18fr; align-items: stretch;
  border: 1px solid var(--line-soft); border-radius: var(--r-lg);
  background: var(--off-white); box-shadow: var(--sh-2);
  transition: box-shadow 1.02s var(--ease); }
.vo:hover { box-shadow: var(--sh-3); }

/* The photo fills its column at whatever height the body sets, so the card has
   no slack in it at any width. */
.vo__media { position: relative; min-height: 100%; background: var(--bg-2); }
.vo__media img { position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block; }
/* Where the address is, stated on the photograph the way .card__cap states
   capacity on the spaces cards. */
.vo__cap { position: absolute; left: 14px; bottom: 14px; z-index: 2;
  padding: 8px 13px; border-radius: 100px;
  background: rgba(255,255,255,.86); backdrop-filter: blur(9px);
  font-family: var(--sans); font-size: 11.5px; font-weight: 600;
  letter-spacing: .06em; text-transform: uppercase; color: var(--ink); }

.vo__body { padding: clamp(24px, 2.8vw, 40px); display: flex; flex-direction: column; }
.vo__eyebrow { font-family: var(--sans); font-size: 12px; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-soft); font-weight: 600; }
.vo__title { font-family: var(--serif); font-size: clamp(21px, 2vw, 28px);
  font-weight: 500; line-height: 1.15; margin: 7px 0 6px; }
.vo__lead { font-family: var(--sans); font-size: 14.5px; color: var(--ink-soft);
  line-height: 1.55; margin: 0 0 20px; }

/* The two terms, side by side inside the one card. */
.vo__terms { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.vterm { padding: 15px 16px; border-radius: var(--r-md);
  border: 1px solid var(--line-soft); background: rgba(255, 255, 255,.10); }
.vterm--best { border-color: rgba(104,122,97,.55); background: rgba(104,122,97,.09); }
.vterm__head { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; }
.vterm__label { font-family: var(--sans); font-size: 12px; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase; color: var(--ink-soft); }
.vterm__price { font-family: var(--serif); font-size: clamp(24px, 2.3vw, 31px);
  font-weight: 500; line-height: 1; }
.vterm__price .cur { font-size: .42em; color: var(--green); vertical-align: middle;
  letter-spacing: .04em; }
.vterm__per { font-family: var(--sans); font-size: 12px; color: var(--ink-soft);
  line-height: 1.4; margin-top: 7px; }

/* The head stays centred. The cards under it are left-aligned now, but the
   approved design centres the heading over the pair and that is what was
   signed off. Scoped to .pricing — .section-head is shared. */
.pricing .section-head { text-align: center; margin-inline: auto; }
/* .billing / .billing__opt deleted 30-07 with the phone toggle they drove.
   .billing__pct stays: it is the saving badge, and the card head still uses it. */
.billing__pct { font-style: normal; font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
  background: var(--green); color: var(--white); padding: 3px 8px; border-radius: 100px;
  white-space: nowrap; }

/* The CTA sits on the floor of the body so the card bottom lines up with the
   photograph beside it however the copy above reflows. */
.vo .btn { margin-top: auto; align-self: flex-start; }
.vo__body .btn { margin-top: 22px; }

/* A checked list, not pills. "Prestigious Business Address" is a ~200px pill, so
   six of them could never pair up and tiled into six full-width rows. The tick is
   the same path .perk__check draws and .sindex__exptext .card__feats reuses, so
   the site keeps having exactly one tick.

   Two columns, not three: this list lives inside the card's right-hand column
   now rather than in a full-width band, so there is roughly half the width. */
.vo__feats { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 18px;
  margin-top: 20px; padding-top: 17px; border-top: 1px solid var(--line-soft); }
.vo__feats li { display: flex; align-items: flex-start; gap: 9px; padding: 4px 0;
  font-family: var(--sans); font-size: 13.5px; line-height: 1.5; color: var(--ink); }
.vo__feats li::before {
  content: ""; flex: none; width: 13px; height: 1.5em;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23687A61' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5 9.5 18 20 6.5'/%3E%3C/svg%3E") center / 12px 12px no-repeat;
}
/* Below 900 the form drops under the price rather than beside it. */
/* Tightened only here. The same .form appears on the contact page, the home
   enquiry band and in the popup, and none of those were asked to change. */
.pricing .form { gap: 13px; }
.pricing .field input, .pricing .field select { padding: 9px 2px; font-size: 15px; }

/* ---- Packages on phone ----
   [rewritten 30-07] The 28-07 toggle is gone with the pair it switched between.
   One card cannot hold a photograph beside its body at 390px, so the photo goes
   on top and the body under it, which is exactly what .card does when it stacks.
   The two term boxes stay side by side: they are a price and a label each, and
   both fit. Nothing is hidden behind a tap. */
@media (max-width: 860px) {
  .vo { grid-template-columns: 1fr; }
  .vo__media { min-height: 0; aspect-ratio: 16 / 9; }
  .vo__media img { position: static; }
}
@media (max-width: 760px) {
  .vo__terms { grid-template-columns: 1fr 1fr; gap: 8px; }
  .vterm { padding: 12px 11px; }
  .vterm__head { flex-wrap: wrap; gap: 5px; margin-bottom: 7px; }
  .vterm__price { font-size: 22px; }
  .vterm__per { font-size: 11px; line-height: 1.35; }
  .vo__feats { grid-template-columns: 1fr 1fr; gap: 0 12px; }
  .vo__body .btn { width: 100%; justify-content: center; margin-top: 18px; }
  .vo__feats li { font-size: 12.5px; line-height: 1.45; }
}

/* ---- FAQ ---- */
.faq__grid { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: clamp(40px, 6vw, 90px); align-items: start; }
.faq__aside { position: sticky; top: 120px; }
.faq__aside .eyebrow { margin-bottom: 20px; }
.faq__aside h2 { font-size: clamp(30px, 3.6vw, 52px); }
.faq__aside h2 em { font-style: italic; color: var(--green); }
.faq__aside p { margin-top: 20px; color: var(--ink-soft); }
.faq__aside .link-arrow { margin-top: 24px; }
.faq__item { border-top: 1px solid var(--line); }
.faq__item:last-child { border-bottom: 1px solid var(--line); }
.faq__q { display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; text-align: left; padding: 28px 4px; font-family: var(--serif); font-size: clamp(19px, 1.8vw, 26px); color: var(--ink); }
.faq__icon { position: relative; flex-shrink: 0; width: 20px; height: 20px; }
.faq__icon i { position: absolute; top: 50%; left: 50%; width: 16px; height: 1.6px; background: var(--green); transform: translate(-50%,-50%); transition: transform 0.765s var(--ease); }
.faq__icon i:last-child { transform: translate(-50%,-50%) rotate(90deg); }
.faq__item.open .faq__icon i:last-child { transform: translate(-50%,-50%) rotate(0); }
.faq__a { overflow: hidden; height: 0; }
.faq__a p { padding: 0 4px 30px; max-width: 60ch; color: var(--ink-soft); font-size: 16px; }
@media (max-width: 900px) { .faq__grid { grid-template-columns: 1fr; gap: 28px; } .faq__aside { position: static; } }

/* Honeypot. Deliberately NOT display:none — the cheap bots skip anything that
   is display:none or hidden, and the whole point is that they fill it in.
   Off-canvas plus aria-hidden plus tabindex -1 keeps it away from humans and
   screen readers while still looking like a real field to a scraper. */
.fw-hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

/* Cloudflare Turnstile [17-09]. The widget draws itself inside an iframe it
   sizes on its own, so the only job here is placement.
   Both enquiry layouts are two-column grids, and an unplaced child lands in one
   column at half width beside whichever field happens to precede it. */
.fw-ts { grid-column: 1 / -1; margin-top: 4px; min-height: 65px; }
/* The height is reserved rather than left to the iframe to claim when it
   arrives. api.js is async, so without this the submit button jumps down a
   full widget height mid-read — a layout shift on the one element the page
   exists to get clicked. 65px is the managed widget's own height. */

/* The newsletter is a flex row sharing one underline, not a grid. Same intent,
   different mechanism: take a whole row under the email input.

   flex-wrap is set HERE and not left to the mobile breakpoint that already
   declares it. Without it this row does not wrap at desktop, so a 100%-basis
   item sits alongside the input and the button and pushes the row 230px past
   the viewport — measured at 1920, and it reached production before it was
   caught. Mobile looked correct throughout precisely because wrap was already
   on down there. */
.newsletter__form { flex-wrap: wrap; }
.newsletter__form .fw-ts { flex: 1 0 100%; margin: 12px 0 2px; }

/* Form status line, shared by every form on the site. */
.fw-status { margin-top: 12px; font-size: 14px; line-height: 1.5; }
.fw-status:empty { display: none; }
.fw-status[data-state="error"] { color: #b4432f; }
.fw-status[data-state="ok"] { color: var(--green); }
/* .newsletter was in this list by mistake. That band is --bg-2 (#ECE5DB), so
   near-white rendered "Subscribed. Thanks for joining." at about 1.14:1 —
   invisible, at every width, since the band stopped being dark. .hero is a
   genuinely dark band and keeps the override. The .newsletter case falls
   through to the --green rule above. Colour only: nothing moves. */
.hero .fw-status[data-state="ok"] { color: rgba(255,255,255,.9); }
form[data-fw-form].is-busy { opacity: .7; pointer-events: none; }

/* ---- Legal ---- */
.legal__wrap { max-width: 820px; }
.legal__intro { margin-bottom: 40px; }
/* The standfirst. Only the two policy pages emit this block, via legal() in
   pages.py — one hand-written lead paragraph, deliberately larger and in
   full-strength ink.

   [22-09, Samir] Blog posts used to emit it too: _article_body() put every
   paragraph before the first <h2> in here, so ordinary article copy opened
   three points larger and in black against the --ink-soft body below it.
   Fixed at the source — posts now emit .legal__sec for that run — so this rule
   needs no page scoping to stay off them, and .legal__intro still means one
   thing. Do NOT re-scope it by body[data-page]: that hides the ambiguity
   instead of removing it, and silently drops the standfirst on any new policy
   page whose data-page is not "legal". tests/article-typography.py asserts
   both halves. */
.legal__intro p { font-size: clamp(17px, 1.4vw, 20px); color: var(--ink); margin-bottom: 18px; }
.legal__sec { margin-bottom: 40px; }
.legal__sec h2 { font-size: clamp(22px, 2.2vw, 32px); margin-bottom: 18px; }
/* Blog articles reuse .legal wholesale for the body copy (same long-form
   prose need as the policy pages) but go one level deeper — h3 subheadings
   within a section — which the policy pages never needed. */
.legal__sec h3 { font-size: clamp(18px, 1.6vw, 22px); margin: 26px 0 12px; color: var(--ink); }
.legal__sec p { color: var(--ink-soft); margin-bottom: 14px; }
.legal__sec strong { color: var(--ink); font-weight: 600; }
.legal__ul, .legal__ol { padding-left: 22px; display: flex; flex-direction: column; gap: 10px; color: var(--ink-soft); }
.legal__ul li { list-style: disc; }
.legal__ol li { list-style: decimal; }
/* Imported blog bodies arrive as plain WordPress markup with no classes on
   their ul/ol/a (unlike the hand-authored legal pages, which add legal__ul /
   legal__ol themselves) -- these un-classed fallbacks give them the same
   list markers, and the same visible affordance on inline links, which
   otherwise inherit the global `a { text-decoration: none }` reset and
   vanish into the surrounding body copy. */
.legal__sec h4, .legal__intro h4 { font-size: clamp(17px, 1.4vw, 20px); margin: 22px 0 10px; color: var(--ink); }
.legal__sec ul, .legal__sec ol, .legal__intro ul, .legal__intro ol {
  padding-left: 22px; margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; color: var(--ink-soft); }
.legal__sec ul li, .legal__intro ul li { list-style: disc; }
.legal__sec ol li, .legal__intro ol li { list-style: decimal; }
.legal__sec a, .legal__intro a { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.legal__sec a:hover, .legal__intro a:hover { color: var(--green); }

/* Blog article "back to all articles" — same .link-arrow used for "Read
   more", reversed: icon leads, and the hover nudge points left instead of
   right since it's a back link, not a forward one. */
.blogback { margin-bottom: clamp(28px, 4vw, 44px); }
.blogback:hover svg { transform: translateX(-3px); }

/* .aboutcta is now only a section wrapper — its cards are .loccard/.loccards__grid,
   shared with the homepage banner, so nothing About-specific is styled here. */

/* ---- Contact channels (contact page) ----
   Replaces .contactinfo's three cards. Those printed one value per card across
   the cream ground and left the right half of the section doing nothing, which
   is the dead space the 27-07 call kept objecting to. This is the editorial
   half/half the brand book asks for: heading and lede left, the three ways to
   reach flowork stacked right on hairlines.
   .visit / .visit__* went with it — the two addresses now use the same green
   .banner + .loccard boxes the homepage opens with, so a location renders
   identically wherever it appears. */
.cchan__grid { display: grid; grid-template-columns: 0.9fr 1.1fr; gap: clamp(32px, 6vw, 96px); align-items: start; }
.cchan__text h2 { font-family: var(--serif); font-size: clamp(30px, 3.4vw, 52px); line-height: 1.05; letter-spacing: -.02em; margin: 22px 0 16px; }
.cchan__text h2 em { font-style: italic; color: var(--green); }
.cchan__text p { color: var(--ink-soft); font-size: 16px; line-height: 1.7; max-width: 40ch; }
/* min-width: 0 on the value lets a long address shrink instead of pushing the
   row past its track — the same iPhone overflow .visit__card p guarded against. */
.cchan__row { display: grid; grid-template-columns: 92px minmax(0, 1fr) auto 22px; align-items: center;
  gap: 18px; padding: 22px 0; border-top: 1px solid var(--line); }
.cchan__row:last-child { border-bottom: 1px solid var(--line); }
/* [08-14] Was padding-left: 10px on the row. Same treatment as .sindex__row and
   for the same reasons: the scale goes on the name so the 1px rules stay 1px.
   See --hover-grow. */
.cchan__row strong { font-family: var(--serif); font-size: clamp(19px, 1.7vw, 25px); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow-wrap: anywhere;
  transform-origin: left center; transition: transform 0.765s var(--ease); }
.cchan__row:hover strong { transform: scale(var(--hover-grow)); }
.cchan__k { font-size: 10px; letter-spacing: .2em; text-transform: uppercase; color: var(--ink-soft); }
.cchan__n { font-size: 13px; color: var(--ink-soft); }
.cchan__row svg { width: 20px; height: 20px; color: var(--green); }
@media (max-width: 860px) {
  .cchan__grid { grid-template-columns: 1fr; }
  .cchan__row { grid-template-columns: 76px minmax(0, 1fr); }
  .cchan__row .cchan__n, .cchan__row svg { display: none; }
}

/* ---- Enquiry popup ---- */
.modal { position: fixed; inset: 0; z-index: 9500; display: grid; place-items: center; padding: clamp(14px, 3vw, 40px); }
.modal[hidden] { display: none; }
.modal__backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0,.58); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
.modal__card { position: relative; z-index: 2; width: min(1020px, 100%); max-height: min(88vh, 780px); overflow: auto;
  display: grid; grid-template-columns: 0.68fr 1.32fr; border-radius: var(--r-lg);
  /* [08-05] Missed in the sitewide creamy/white flip — every other card
     surface (.card, .loccard, .pricecard, .nav__panel) went to off-white. */
  background: var(--off-white); box-shadow: var(--sh-4); will-change: transform; }
/* The button sits at the top-right of the CARD, which on desktop is the light
   form panel — so it has to be styled for a light surface. It was off-white on
   a 12%-white wash, i.e. invisible there; only Esc and the backdrop closed the
   popup. The on-dark treatment it used to carry is correct once the layout
   stacks and the dark aside moves under it, so it moves into the query below. */
.modal__close { position: absolute; top: 16px; right: 16px; z-index: 3; width: 44px; height: 44px; border-radius: 50%;
  display: grid; place-items: center; background: rgba(0, 0, 0,.05); color: var(--ink);
  border: 1px solid var(--line);
  transition: background 0.51s, color 0.51s, border-color 0.51s, transform 0.85s var(--ease); }
.modal__close svg { width: 20px; height: 20px; }
.modal__close:hover { background: var(--green); color: var(--white); border-color: var(--green); transform: rotate(90deg); }
.modal__aside { background: var(--abaddon); color: var(--off-white); padding: clamp(28px, 3.4vw, 52px);
  display: flex; flex-direction: column; gap: 22px; border-radius: var(--r-lg) 0 0 var(--r-lg); }
.modal__aside .mark { font-size: 24px; }
.modal__aside h2 { font-size: clamp(27px, 2.8vw, 42px); color: var(--green-l); margin-top: auto; max-width: 12ch; }
.modal__aside h2 em { font-style: italic; color: var(--green-l); }
.modal__aside p { color: rgba(255, 255, 255,.66); font-size: 15px; max-width: 34ch; margin-bottom: 8px; }
.modal__form { padding: clamp(24px, 3vw, 48px); align-content: start; }
/* The close button lives at the card's top-right, which on this layout is over
   the form panel — it was clipping the corner of the Full name field by 17px at
   1440 and 22px at 1280. The button is 44px at a 16px offset, so the first row
   has to start below 60px; this reserves that band rather than letting the two
   fight for the corner. Only needed while the columns sit side by side. */
.modal__form { padding-top: 72px; }
/* Field styling (labels, underlines, focus states, the fwsel trigger) comes
   from .spenqf on the form and .spenqf__field on each row — the same classes
   the space and location pages' end-of-page forms use, so the popup is no
   longer a visually distinct third form on the site. */
.modal__submit { grid-column: 1 / -1; justify-content: center; margin-top: 6px; }
.modal__form.is-sent .spenqf__field { opacity: .35; pointer-events: none; transition: opacity 0.68s; }
.modal__form.is-sent .modal__submit { pointer-events: none; }
@media (max-width: 860px) {
  .modal__card { grid-template-columns: 1fr; max-height: 92vh; }
  .modal__aside { border-radius: var(--r-lg) var(--r-lg) 0 0; padding-bottom: 22px; }
  .modal__aside h2 { margin-top: 6px; }
  /* Stacked, the close button is over the aside instead, so the form gets its
     normal top padding back. */
  .modal__form { grid-template-columns: 1fr; padding-top: clamp(24px, 3vw, 48px); }
  /* Stacked, the close button sits over the dark aside, so it flips back to the
     on-dark treatment the base rule used to carry. */
  .modal__close { background: rgba(255, 255, 255,.12); color: var(--off-white); border-color: rgba(255, 255, 255,.18); }
  .modal__close:hover { background: var(--green); color: var(--white); border-color: var(--green); }
}

/* ---- Enquiry popup · phones ----
   The centred dialog does not survive a 390px screen: measured on an iPhone 12
   it ran 885px of content inside a 753px scroll box, so the intro block ate the
   whole first screen, "Enquire Now" sat ~120px below the fold, and the close
   button — absolutely positioned inside the card, which was itself the scroll
   container — scrolled away with the content.

   On phones it becomes a bottom sheet with a photograph of the floor for a
   header. Structurally that means: the CARD stops scrolling and the FORM
   becomes the only scrolling region, which is what pins the header and the
   close button in place. Everything fits without scrolling at 390x844 and,
   with the short-phone block below, at 375x667 too.

   The header photo is a purpose-built 1000x444 crop rather than the 133 KB
   band image — this loads on a tap, not on page load, but the client flagged
   mobile image weight and 17 KB of AVIF is the honest answer. It sits on
   .modal__aside as a background because a background is the only way to swap
   the panel's surface without touching the builder's markup. */
@media (max-width: 680px) {
  .modal {
    padding: 0;
    align-items: end;
    /* JS fills --kb with the on-screen keyboard's height (see initModal). iOS
       does not shrink the layout viewport for the keyboard, so without this the
       sheet stays pinned behind it. */
    padding-bottom: var(--kb, 0px);
  }
  .modal__card {
    width: 100%;
    max-height: 94vh;
    max-height: calc(94dvh - var(--kb, 0px));
    grid-template-rows: auto minmax(0, 1fr);
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    overflow: hidden;
    /* the sheet slides and springs from its bottom edge, never its centre */
    transform-origin: 50% 100%;
  }

  /* --- photo header --- */
  .modal__aside {
    position: relative;
    height: 172px;
    padding: 15px 20px 16px;
    gap: 0;
    justify-content: space-between;
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    background-color: var(--abaddon);
    background-image:
      linear-gradient(to top, rgba(0, 0, 0,.92) 2%, rgba(0, 0, 0,.58) 46%, rgba(0, 0, 0,.72)),
      url("/assets/photos/enquiry-header.jpg");
    background-size: cover;
    background-position: center;
    /* the header is the drag handle, so the browser must not claim the gesture */
    touch-action: none;
  }
  @supports (background-image: image-set(url("/assets/photos/enquiry-header.avif") type("image/avif"))) {
    .modal__aside {
      background-image:
        linear-gradient(to top, rgba(0, 0, 0,.92) 2%, rgba(0, 0, 0,.58) 46%, rgba(0, 0, 0,.72)),
        image-set(url("/assets/photos/enquiry-header.avif") type("image/avif"),
                  url("/assets/photos/enquiry-header.webp") type("image/webp"),
                  url("/assets/photos/enquiry-header.jpg") type("image/jpeg"));
    }
  }
  /* grab handle — says "this can be flicked away", which it now can */
  .modal__aside::before {
    content: ""; position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
    width: 38px; height: 4px; border-radius: 100px; background: rgba(255, 255, 255,.55);
  }
  .modal__aside .mark { font-size: 19px; text-shadow: 0 1px 14px rgba(0, 0, 0,.6); }
  /* over photography the heading leads in white and the accent carries the green,
     rather than the all-green it uses on the flat dark panel */
  .modal__aside h2 {
    margin-top: auto; max-width: 15ch;
    font-size: 26px; line-height: 1.1; color: var(--white);
    text-shadow: 0 1px 18px rgba(0, 0, 0,.5);
  }
  .modal__aside p { display: none; }
  .modal__close {
    top: 14px; right: 15px; width: 42px; height: 42px;
    background: rgba(0, 0, 0,.45); border-color: rgba(255, 255, 255,.22); color: var(--white);
    backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  }
  .modal__close svg { width: 18px; height: 18px; }

  /* --- form --- */
  .modal__form {
    min-height: 0;
    overflow-y: auto;
    /* keeps a flick at the end of the list from scrolling the page behind */
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    gap: 9px;
    padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
  }
  /* 16px is the threshold below which iOS Safari zooms the page on focus — at
     the old 15px, tapping any field threw the sheet half off-screen.
     [21-08] This fix used to stop at .modal__form, which left every OTHER form
     on the site at 15px: the end-of-page enquiry forms (.spenqf) on all nine
     space and location pages, and the pricing forms. Those are where most
     enquiries are actually typed, so the bug was live on the forms that matter
     most and fixed only on the popup. Measured across 12 pages on a 390x844
     iPhone viewport before this change: input[text], input[email], input[tel]
     and select all computed to 15px.
     iOS zooms IN on focus and does not zoom back out, so one tap on a field
     leaves the visitor stranded at 1.3x for the rest of the session. The rule
     below is deliberately broad: on a phone there is no control that benefits
     from being under 16px. tests/mobile-audit.py guards it. */
  .modal__form .spenqf__field input,
  .modal__form .spenqf__field select,
  .modal__form .spenqf__field .fwsel__trigger,
  .spenqf input, .spenqf select, .spenqf textarea, .spenqf .fwsel__trigger,
  .field input, .field select, .field textarea,
  .pricing .field input, .pricing .field select { font-size: 16px; }

  /* [21-08] Touch targets. Measured on 390x844 and 360x740: buttons came out
     39-41px tall and inline contact links 101x19, both under the 44px minimum
     that stops a tap landing next to the thing rather than on it. Padding is
     left alone and a floor is set instead, so nothing reflows on desktop and
     the visual size only changes where it was already too small. Phone and
     email links get vertical padding rather than a min-height because they sit
     inline in running text, where a min-height would not centre them. */
  .btn { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
  a[href^="tel:"], a[href^="mailto:"] { padding-block: 12px; }
  .newspage__arrow { min-width: 44px; min-height: 44px; }
  .modal__submit { margin-top: 5px; padding: 15px 24px; }
}

/* Short phones (iPhone SE, older Androids). At 667px the sheet above overflows
   by ~25px and clips the button, which is the one control that must never need
   scrolling. Trimming the header and the field rhythm buys back ~60px. */
@media (max-width: 680px) and (max-height: 740px) {
  .modal__aside { height: 140px; padding: 13px 20px 14px; }
  .modal__aside .mark { font-size: 18px; }
  .modal__aside h2 { font-size: 23px; }
  .modal__close { top: 12px; width: 40px; height: 40px; }
  .modal__form { gap: 8px; padding: 14px 20px calc(14px + env(safe-area-inset-bottom)); }
}

/* ============================================================ 360° VIRTUAL TOUR */
/* Fullscreen viewer overlay */
.tour { position: fixed; inset: 0; z-index: 9700; background: var(--abaddon); }
.tour[hidden] { display: none; }
.tour__pano { position: absolute; inset: 0; width: 100%; height: 100%; background: var(--abaddon); }
.tour__top { position: absolute; top: 0; left: 0; right: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between;
  padding: 20px clamp(16px, 3vw, 34px); background: linear-gradient(to bottom, rgba(0, 0, 0,.66), transparent); pointer-events: none; }
.tour__brand { display: inline-flex; align-items: center; gap: 14px; pointer-events: auto; }
.tour__brand .mark { font-size: 24px; }
.tour__badge { font-family: var(--sans); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; font-weight: 600; color: var(--off-white);
  border: 1px solid rgba(255, 255, 255,.3); border-radius: 100px; padding: 6px 12px; }
.tour__close { pointer-events: auto; width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center;
  background: rgba(255, 255, 255,.12); color: var(--off-white); backdrop-filter: blur(6px); transition: background 0.51s, transform 0.85s var(--ease); }
.tour__close svg { width: 20px; height: 20px; }
.tour__close:hover { background: var(--green); transform: rotate(90deg); }
.tour__caption { position: absolute; left: clamp(16px, 3vw, 34px); bottom: clamp(96px, 13vh, 128px); z-index: 5; color: var(--off-white); pointer-events: none; text-shadow: 0 2px 20px rgba(0,0,0,.5); }
.tour__caption strong { display: block; font-family: var(--serif); font-weight: 500; font-size: clamp(24px, 3vw, 38px); }
.tour__caption span { font-family: var(--sans); font-size: 13px; color: rgba(255, 255, 255,.75); }
.tour__rooms { position: absolute; left: 0; right: 0; bottom: clamp(40px, 6vh, 58px); z-index: 5; display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; padding: 0 16px; }
.troombtn { font-family: var(--sans); font-size: 13px; font-weight: 600; color: var(--off-white); letter-spacing: .01em;
  padding: 11px 18px; border-radius: 100px; background: rgba(0, 0, 0,.5); border: 1px solid rgba(255, 255, 255,.24); backdrop-filter: blur(8px);
  transition: background 0.51s, color 0.51s, border-color 0.51s, transform 0.51s var(--ease); }
.troombtn:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255,.5); }
.troombtn.is-active { background: var(--green); border-color: var(--green); color: var(--white); }
/* Dubai Hills / Vision Tower toggle — only on rooms that exist in both locations */
.tour__variants { position: absolute; left: 50%; transform: translateX(-50%);
  top: clamp(72px, 11vh, 104px); z-index: 6; display: inline-flex; gap: 4px; padding: 4px;
  background: rgba(0, 0, 0,.55); border: 1px solid rgba(255, 255, 255,.18); border-radius: 999px;
  backdrop-filter: blur(10px); pointer-events: auto; }
.tour__variants[hidden] { display: none; }
.tvar { font-family: var(--sans); font-size: 12.5px; font-weight: 600; letter-spacing: .03em;
  color: rgba(255, 255, 255,.72); padding: 8px 18px; border-radius: 999px; border: 0; background: transparent;
  cursor: pointer; transition: color 0.51s, background 0.51s; white-space: nowrap; }
.tvar:hover { color: var(--off-white); }
.tvar.is-active { background: var(--green); color: var(--white); }
.tour__note { position: absolute; right: clamp(16px, 3vw, 34px); bottom: 14px; z-index: 5; font-family: var(--sans); font-size: 10.5px; letter-spacing: .04em; color: rgba(255, 255, 255,.42); pointer-events: none; }
/* Only ever visible when a panorama has been loading far too long, or failed.
   Centred over the viewer and above pannellum's own chrome, because it is the
   only thing on screen worth reading at that moment. Sits on top of the
   preview if one arrived, which is why it carries its own scrim. */
.tour__stall {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  z-index: 8; max-width: min(88vw, 380px); padding: 22px 26px; text-align: center;
  border-radius: var(--r-lg); background: rgba(0, 0, 0,.86);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  border: 1px solid rgba(255, 255, 255,.14);
}
.tour__stall[hidden] { display: none; }
.tour__stall p {
  margin: 0 0 14px; font-family: var(--sans); font-size: 14px; line-height: 1.5;
  color: rgba(255, 255, 255,.86);
}
body.tour-open { overflow: hidden; }
@media (max-width: 700px) { .tour__caption { bottom: clamp(120px, 20vh, 150px); } .tour__note { display: none; } }

/* ===== Photo lightbox =====
   Opens from an image_bar() strip. Same layer as the tour: the two are both
   full-screen takeovers and can never be open at once.

   The track holds prev / current / next and slides; each frame keeps its own
   -100 / 0 / +100 position, so a drag moves one element and stays on the
   compositor. `picture { display: contents }` (line ~165) is what lets .lb__img
   be the grid item directly, which is what makes max-height: 100% resolve. */
.lb { position: fixed; inset: 0; z-index: 9700; background: rgba(0, 0, 0,.95); }
.lb[hidden] { display: none; }
.lb__backdrop { position: absolute; inset: 0; }
.lb__stage {
  position: absolute; inset: 0; overflow: hidden;
  cursor: grab;
  /* Ours, both axes: the page underneath is scroll-locked, and letting the
     browser claim the horizontal gesture would eat the swipe between photos. */
  touch-action: none;
}
.lb__stage.is-drag { cursor: grabbing; }
.lb__track { position: absolute; inset: 0; }
/* flex, NOT grid. The photo is constrained by max-height: 100%, and a
   percentage max-height on a GRID item resolves against its row track -- which
   here is an implicit auto row sized from the content, so the percentage is
   circular, resolves to none, and the image renders at its intrinsic size. It
   looked like an upscaled blur: a 900x1200 master painted 1238x1651 inside a
   950px-tall viewport. A flex item's percentage resolves against the
   container's definite height instead, which is what inset: 0 gives it. */
.lb__frame {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  padding: clamp(56px, 9vh, 104px) clamp(14px, 7vw, 116px);
}
.lb__frame[data-off="-1"] { transform: translateX(-100%); }
.lb__frame[data-off="1"]  { transform: translateX(100%); }
.lb__img {
  max-width: 100%; max-height: 100%;
  display: block;
  user-select: none; -webkit-user-drag: none;
  box-shadow: 0 30px 90px rgba(0,0,0,.5);
}
.lb__close, .lb__nav {
  position: absolute; z-index: 3;
  display: grid; place-items: center;
  border-radius: 50%; border: 1px solid rgba(255, 255, 255,.2);
  background: rgba(255, 255, 255,.08); color: var(--off-white);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  cursor: pointer;
  transition: background 0.51s var(--ease), border-color 0.51s var(--ease), transform 0.51s var(--ease);
}
.lb__close { top: clamp(14px, 2.4vw, 26px); right: clamp(14px, 2.4vw, 26px); width: 46px; height: 46px; }
.lb__close svg { width: 20px; height: 20px; }
/* Rotates rather than travels, same as .tour__close. The 14-08 "no movement on
   hover" ruling is about elements stepping sideways under the pointer; a round
   icon button turning in place is not that. */
.lb__close:hover { background: var(--green); border-color: transparent; transform: rotate(90deg); }
.lb__nav {
  top: 50%; margin-top: -27px; width: 54px; height: 54px;
}
.lb__nav svg { width: 22px; height: 22px; }
.lb__nav--prev { left: clamp(10px, 2vw, 26px); }
.lb__nav--next { right: clamp(10px, 2vw, 26px); }
.lb__nav[hidden] { display: none; }
.lb__nav:hover { background: var(--green); border-color: transparent; transform: scale(var(--hover-grow)); }
.lb__close:focus-visible, .lb__nav:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
.lb__bar {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
  display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
  padding: 18px clamp(16px, 3vw, 34px) clamp(16px, 2.4vw, 26px);
  pointer-events: none;
  background: linear-gradient(to top, rgba(0, 0, 0,.82), rgba(0, 0, 0,0));
}
.lb__cap {
  margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
  color: rgba(255, 255, 255,.82); max-width: 62ch;
}
.lb__count {
  font-family: var(--sans); font-size: 12px; letter-spacing: .16em;
  text-transform: uppercase; color: rgba(255, 255, 255,.6); white-space: nowrap;
}
/* Announced, never drawn. There is no sitewide visually-hidden utility to lean
   on, and one class used once does not earn one. */
.lb__live {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}
body.lb-open { overflow: hidden; }
@media (max-width: 700px) {
  .lb__frame { padding: clamp(56px, 9vh, 104px) 10px clamp(84px, 14vh, 120px); }
  .lb__nav { top: auto; bottom: clamp(16px, 3vh, 26px); margin-top: 0; width: 46px; height: 46px; }
  .lb__nav--prev { left: 50%; margin-left: -56px; }
  .lb__nav--next { right: 50%; margin-right: -56px; }
  .lb__bar { flex-direction: column; align-items: flex-start; gap: 4px; padding-bottom: clamp(74px, 12vh, 96px); }
  .lb__cap { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
  .lb__close:hover, .lb__nav:hover { transform: none; }
}

/* Pannellum hotspots — branded */
.pnlm-hotspot.tour-hs { width: 34px; height: 34px; border-radius: 50%; background: rgba(104,122,97,.9); border: 2px solid var(--off-white);
  box-shadow: 0 6px 20px rgba(0,0,0,.4); transition: transform 0.51s var(--ease), background 0.51s; }
.pnlm-hotspot.tour-hs:hover { transform: scale(1.15); background: var(--green); }
.pnlm-hotspot.tour-hs--go { animation: hsPulse 4.08s var(--ease) infinite; }
.pnlm-hotspot.tour-hs--info { background: rgba(0, 0, 0,.8); }
@keyframes hsPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(104,122,97,.5), 0 6px 20px rgba(0,0,0,.4); } 60% { box-shadow: 0 0 0 14px rgba(104,122,97,0), 0 6px 20px rgba(0,0,0,.4); } }
.pnlm-tooltip span { font-family: var(--sans) !important; font-size: 13px !important; }
.pnlm-load-box { background: var(--abaddon) !important; font-family: var(--sans) !important; }
.pnlm-lmsg, .pnlm-load-box p { color: var(--off-white) !important; }

/* Homepage / tour-page promo band */
.tourpromo__rooms { display: grid; grid-template-columns: repeat(5, 1fr); gap: clamp(12px, 1.2vw, 18px); }
/* .tourpromo is virtual-tour-only and had no rules of its own — it was
   riding .section-pad's default padding-block (52-96px) both above and
   below, reading as too much air right under the hero. Cut it down and drop
   the .section-head 900px cap for "Step inside before you visit." (30
   characters), which was wrapping the same way the other one-line fixes on
   this page were — nowrap gated to 761px+ so phones keep wrapping normally. */
.tourpromo.section-pad { padding-block: clamp(20px, 2.6vw, 40px); }
.tourpromo .section-head { max-width: none; }
.tourpromo .section-head h2 { font-size: clamp(30px, 4.6vw, 64px); }
@media (min-width: 761px) {
  .tourpromo .section-head h2 { white-space: nowrap; }
}

.troom { position: relative; display: block; border-radius: var(--r-lg); overflow: hidden; aspect-ratio: 3/4; background: var(--bg-2); cursor: pointer; box-shadow: var(--sh-2); transition: box-shadow 1.02s var(--ease); }
.troom:hover { box-shadow: var(--sh-3); }
.troom__media { position: absolute; inset: 0; }
.troom__media .media__img { filter: grayscale(.15) brightness(.82); transition: transform 1.36s var(--ease), filter 1.36s var(--ease); }
.troom:hover .media__img { transform: scale(1.07); filter: grayscale(0) brightness(.92); }
.troom::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, rgba(0, 0, 0,.7), transparent 55%); }
.troom__badge { position: absolute; top: 12px; left: 12px; z-index: 2; font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .06em;
  color: var(--white); background: rgba(104,122,97,.85); border-radius: 100px; padding: 5px 10px; }
.troom__name { position: absolute; left: 16px; right: 16px; bottom: 16px; z-index: 2; font-family: var(--serif); font-size: clamp(16px, 1.3vw, 20px); font-weight: 500; color: var(--white); text-align: left; }
.tourpromo__cta { margin-top: clamp(28px, 3vw, 44px); display: flex; justify-content: center; }
@media (max-width: 900px) { .tourpromo__rooms { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 520px) { .tourpromo__rooms { grid-template-columns: 1fr 1fr; } }

/* Two padded sections back to back stack a full bottom padding on top of a full
   top padding, so the gap between them comes out at twice every other break on
   the page — 260px against the site's 130px. That doubled band is the empty
   space the client keeps flagging. Collapse the pair to a single gap; the
   earlier section's bottom padding carries the rhythm on its own.

   Measured before this rule (1440px): home spaces→reviews, contact
   contactinfo→visit, virtual-tour tourpromo→flist all sat at 260px, and
   about ibar→aboutcta→sindex at 260 then 202, while every space page ran at
   130 because an image band or the sand-coloured enquiry block interrupts the
   stack. This is what "uniform spacing, space pages as the reference" means.

   Excluded are the padded sections that paint their own surface. There the
   top padding is not adding to a gap, it is holding content off a colour edge:
   .enquiry is Abaddon black, .banner is Flow Green, .reviews is Abaddon black
   again (added [08-05] per the client: "Hear it from our community" reading
   as cramped against the section above it was this same padding-top:0 rule
   zeroing out the one padded section on the site dark enough to need the
   clearance back). The other surface-painting sections (.imgband, .spenq,
   .statband, .newsletter) carry no section-pad, so they never match in the
   first place.

   Replaces a body[data-hero-theme="meeting"] rule that did exactly this for one
   page; the general form covers that stack (rooms → perks → prose) unchanged. */
.section-pad + .section-pad:not(.enquiry):not(.banner):not(.reviews),
.section-pad + .section-pad-sm:not(.enquiry):not(.banner):not(.reviews) { padding-top: 0; }

/* The "Other Spaces" cross-sell used to zero its own top padding, which was
   right while it followed another section on the same surface. On the space
   pages it follows .spenq, and .spenq paints its own sand-coloured block — so
   the zero put the "Other Spaces" eyebrow hard against that colour edge with no
   breathing room at all, while every other break on the site had 86px.

   It now carries the standard .section-pad like any section after a colour
   break. Where it does follow a same-surface section, the general sibling rule
   above still collapses it, so nothing regresses to a doubled gap. */

/* ===== Tap targets: the WCAG 2.2 SC 2.5.8 floor, every pointer =====
   SC 2.5.8 is not conditional on input device, so these two cannot sit in the
   coarse-pointer block below: a trackpad user is exactly who the criterion is
   for. Both were under the 24px floor with a mouse.

   .loccard__phone    23px at every width.
   .reviews__where a  21px on tablet and desktop. The 7px of padding used to
                      live inside the max-width:680px block, so only phones got
                      it. Moved here, which is also why it reads 35px on phones
                      exactly as before. It stays padding rather than
                      min-height because the anchors are inline, and min-height
                      does not apply to non-replaced inline boxes.

   Held at the floor, not 44, so desktop layout does not shift. */
.loccard__phone { min-height: 24px; }
.reviews__where a { padding-block: 7px; }

/* ===== Touch tap targets =====
   Gated to coarse pointers so mouse-driven layouts do not move a pixel: the
   client reviews on desktop and none of this should show up there.

   WCAG 2.2 SC 2.5.8 (AA) sets a 24px floor; SC 2.5.5 (AAA) and Apple's HIG
   both ask for 44. Two selectors were under the AA floor and are handled
   unconditionally in the block above. Everything here already cleared AA and
   missed 44, which is a thumb-accuracy problem on the tap-to-call, subscribe
   and enquiry paths rather than a conformance one.

   Deliberately NOT changed: .spaces__dot, already sized to 24px on purpose;
   see the note at its own rule. */

/* ===== Phone figures — .hero__stats + .statband__grid ========================
   Both blocks were failing on phones for the same reason: a column model the item
   count does not divide into. The hero is a bare flex-wrap with content-sized
   items, so its "2x2" was not a grid at all — column two started at 161px on row
   one and 132px on row two, which is the misalignment that read as accidental.
   The band was worse and deterministic: five cells into repeat(2,1fr) orphans
   "Podcast Room" alone on a third row at every width below 900px.

   They land on DIFFERENT layouts because the counts are different, and that is
   deliberate rather than an inconsistency. Four halves into two columns; five
   does not, so five goes in a row. Each count gets the arrangement it divides
   into evenly, and neither leaves a ragged last row.

   Two details are load-bearing:

   - SPECIFICITY, NOT SOURCE ORDER. `.hero__content .hero__stats` and
     `.statband .container.statband__grid` both land at 0-3-0, beating the rules
     they replace (0-2-0 and 0-1-0). Ordering would have worked too, but a rule
     that wins only because of where it sits in the file breaks silently the next
     time anything is inserted above it — and this file is edited from more than
     one place.
   - THE TWO BREAKPOINTS ARE THE COMPONENTS' OWN, and they differ on purpose. The
     hero's wrap misbehaves from 680px down (it goes 3+1 between ~420 and ~600
     before it ever reaches 2x2). The band's orphan starts at 900px. Putting both
     at 680 would leave iPad portrait (768px) still showing the orphaned band. */
@media (max-width: 680px) {
  /* The hero's four figures as a real 2x2: equal 1fr halves, so the second column
     starts at the same x on both rows, with a hairline cross dividing the cells.
     The old block looked like this and was not — its columns were content-sized,
     which is the whole reason it read as ragged.
     No FAB reservation needed here, unlike the band's full-width row: the cells
     are half-width and left-aligned, so the bottom-right label ends ~24px short
     of the floating WhatsApp button (fixed, bottom:26px, 60px tall). Measured: no
     ink overlap at 320-680px. */
  .hero__content .hero__stats {
    display: grid; grid-template-columns: 1fr 1fr; gap: 0;
    margin-top: clamp(22px, 3.4vh, 44px); padding-top: 0; border-top: 0;
  }
  .hero__content .hero__stats > div { gap: 4px; padding-block: clamp(11px, 1.7vh, 15px);
    border-top: 1px solid rgba(255,255,255,.16); }
  .hero__content .hero__stats > div:nth-child(2n) { padding-left: clamp(12px, 4.4vw, 20px);
    border-left: 1px solid rgba(255,255,255,.16); }
  .hero__content .hero__stats strong { font-size: clamp(26px, 8vw, 34px); }
  /* One line per label, always. At 1fr of a 390px phone each cell has ~155px and
     the widest label ("Offices available") measures ~113px at 12px, so nothing
     wraps and no min-height reservation is needed — that was only ever required by
     the five-across band, where cells are 70px. */
  .hero__content .hero__stats span { font-size: clamp(10.5px, 3vw, 12px); letter-spacing: .01em; }
}

/* Off by default — the 900px rule below turns it into a real line break once the
   band is five-across and every cell is ~70px.

   [moved 04-08] This declaration used to sit AFTER that media query. Both rules
   are `.statband__midbreak`, so both are specificity 0-1-0, a media query adds
   none, and the later one wins at every width — `display: none` always. The
   break has therefore never fired on any screen since it was added, and because
   the markup carried no space beside the <br>, the desktop band has been reading
   "PhoneBooths" and "PodcastRoom". The space is now in build_site.py's _label()
   and the order here is right, so both widths get what was intended. */
.statband__midbreak { display: none; }

@media (max-width: 900px) {
  .statband .container.statband__grid {
    grid-template-columns: repeat(5, 1fr); gap: 0;
    padding-block: clamp(26px, 5vw, 44px);
  }
  /* Tighter inline padding than the hero's: five columns inside 390px leave 70px
     each, and every px of padding comes straight out of the label. At these
     values the widest label ("Coworking Spaces") clears its cell by 1-5px from
     360px up. */
  .statband .container.statband__grid > div { padding-inline: clamp(3px, 1vw, 12px);
    border-left: 1px solid rgba(138,154,130,.26); }
  .statband .container.statband__grid > div:first-child { border-left: 0; padding-left: 0; }
  .statband .container.statband__grid > div:last-child { padding-right: 0; }
  .statband .container.statband__grid strong { font-size: clamp(21px, 5.4vw, 46px); }
  .statband .container.statband__grid span { margin-top: 6px; font-size: clamp(9px, 2.4vw, 12px);
    line-height: 1.3; min-height: 2.6em; letter-spacing: 0; }
  .statband__midbreak { display: block; }
}

@media (hover: none), (pointer: coarse) {
  /* The one AA failure: 23px tap-to-call and "View on map" on location cards. */
  .loccard__phone { min-height: 44px; }

  /* Tap-to-call and tap-to-email in the enquiry panel, 25px. */
  .enquiry__contacts a,
  .spenq__contacts a { min-height: 44px; }

  /* 14 nav links on every one of the 15 pages. Padding rather than min-height
     so the text stays put and only the hit area grows: the line box is
     22.275px, so 11px either side lands at 44.3.
     The social icons match this selector too but carry a fixed 38px box, so
     they need resizing rather than padding, and the padding above would only
     squeeze the 17px glyph inside an unchanged border-box. */
  .footer__col a { padding: 11px 0; }
  .footer__social a { padding: 0; width: 44px; height: 44px; }

  /* Input was 42px, button 26px. Matching both keeps the underline row level. */
  .newsletter__form input,
  .newsletter__form button { min-height: 44px; }

  /* Chevron that opens the card sheet; only ever rendered on touch anyway.
     Visible circle stays at the 32px desktop size — a 44px white disc read
     as oversized against the card photo — and the 44px AAA tap target comes
     instead from an invisible ::before hit-area (below), same box either way. */
  .card--sm .card__info-btn::before {
    content: ""; position: absolute; inset: -6px;
  }

  /* Only where the link-arrow IS the target. 32 of the 33 in the built pages
     are <span> inside a card that is itself the anchor, so padding those adds
     height to every card and buys no hit area. */
  a.link-arrow { min-height: 44px; }
}

/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }

  /* [08-14] The `*` rule above only kills the DURATION, so a hover grow would
     still snap to its new size instantly, which is worse than not moving at
     all. Cancel the six --hover-grow scales outright. The colour change on
     each of them survives and carries the hover on its own. */
  .sindex__row:hover .sindex__name,
  .sindex__row:focus-visible .sindex__name,
  .perk:hover .perk__t,
  .spenq__contacts a:hover,
  .enquiry__contacts a:hover,
  .footer__col a:hover,
  .cchan__row:hover strong { transform: none !important; }
  .fade-up, html.has-js [data-anim] { opacity: 1 !important; transform: none !important; }
  /* Undo the hero entry base states — nothing animates here, so nothing may
     stay hidden waiting for an animation that will never play. */
  html.has-js .phero__title,
  html.has-js .hero__eyebrow, html.has-js .hero__sub,
  html.has-js .hero__ctas, html.has-js .hero__stats,
  html.has-js .hero__title .line > span { opacity: 1 !important; transform: none !important; }
  html.has-js .phero__media { clip-path: none !important; }
  .preloader, .preloader__curtain { display: none; }
  /* !important: the has-js base rule above is more specific and would otherwise
     win and leave the watermark invisible. Stays at .12, never full — the
     white-block bug. */
  .phero__orn { opacity: .12 !important; }
  .grain { animation: none; }
  .fab__icon, .fab__ring { animation: none; }
  .intro__emblem { animation: none; }
  .pnlm-hotspot.tour-hs--go { animation: none; }
}
