/* ==========================================================================
   Systrox Solution — page styles
   --------------------------------------------------------------------------
   Design tokens live in tokens.css and are loaded first. No raw hex or rgba()
   colour literals belong in this file: use a semantic token, or compose an
   alpha from a channel triplet, e.g. rgb(var(--accent-ch) / .18).
   ========================================================================== */

* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  line-height: var(--body-leading);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
img { display: block; max-width: 100%; }

/* ---------- LAYOUT ---------- */
.container {
  max-width: 1280px;
  margin: 0 auto;
  padding: 0 32px;
}
@media (max-width: 720px) {
  .container { padding: 0 20px; }
}

section { position: relative; }

/* ---------- NAVBAR ---------- */
/* The bar holds one height for the whole page. It used to shrink 18px -> 12px
   of padding on scroll, which animated layout on a fixed element and, worse,
   left the bar 86px tall at rest and 74px scrolled -- so --nav-h (80px) matched
   neither. The hero reserved 80px for a bar that overlapped it by 6px, and the
   on-light observer derives its rootMargin from the measured height, so its
   threshold moved by 12px mid-transition. A constant height fixes all three;
   the scrolled state now reads through background, blur and rule alone, which
   is paint-only. */
.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 50;
  height: var(--nav-h);
  transition: background .35s ease, border-color .35s ease;
  border-bottom: 1px solid transparent;
  backdrop-filter: blur(0px);
}
.nav.scrolled {
  background: rgb(var(--black-ch) / 0.55);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
}

/* The page has two acts and the bar is fixed across both. Over the vellum
   section a translucent dark bar turns muddy grey and the wordmark loses its
   contrast, so the nav inverts to ink-on-paper while it is over the light act.
   Every value here is the light-act token, not a hand-mixed grey. */
.nav.on-light {
  background: rgb(var(--vellum-ch) / .82);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
}
.nav.on-light .brand-name { color: var(--ink); }
.nav.on-light .brand-sub  { color: var(--ink-dim); }
.nav.on-light .nav-links {
  background: rgb(var(--ink-ch) / .05);
  border-color: rgb(var(--ink-ch) / .16);
}
.nav.on-light .nav-links a { color: var(--ink-dim); }
.nav.on-light .nav-links a:hover {
  color: var(--ink);
  background: rgb(var(--ink-ch) / .11);
}
.nav.on-light .nav-cta,
.nav.on-light .lang-dd-btn {
  color: var(--ink);
  border-color: rgb(var(--ink-ch) / .26);
  background: rgb(var(--ink-ch) / .04);
}
.nav.on-light .nav-cta:hover { border-color: var(--accent-strong); color: var(--ink); }
.nav.on-light .nav-hamburger { border-color: rgb(var(--ink-ch) / .26); }
.nav.on-light .nav-hamburger span { background: var(--ink); }
.nav-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  height: 100%;
}
.brand {
  display: flex;
  align-items: center;
  gap: 12px;
  /* The language switcher competes for nav width on small screens; without
     this the brand column gets squeezed and .brand-sub wraps to two lines. */
  flex: none;
}
/* The mark keeps the logo's own 1.26 aspect instead of being boxed into a
   square: at the same bar height that is ~60% more pixels for a mascot that
   needs every one of them. The old tile — gradient fill, border, `cover` —
   cropped the horns off and framed art that carries its own silhouette. */
.brand-mark {
  width: 48px; height: 38px;
  display: grid; place-items: center;
  flex: none;
}
.brand-mark img { width: 100%; height: 100%; object-fit: contain; }
.brand-name {
  font-family: var(--font-display);
  font-variation-settings: 'wdth' var(--display-width);
  font-weight: 700;
  letter-spacing: 0.18em;
  font-size: 14px;
  color: var(--neutral-100);
}
/* The signed-off logo sets its final X in gold. The wordmark beside it is live
   text, so it has to earn the same treatment rather than inherit it. */
.brand-x { color: var(--accent); }
.brand-sub {
  display: block;
  font-family: var(--font-mono);
  font-size: 9px;
  color: var(--text-faint);
  letter-spacing: 0.32em;
  margin-top: 2px;
  white-space: nowrap;
}
.nav-links {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 5px;
  /* The capsule has to hold its own against the binary field running behind
     it, so it carries a real surface rather than a hint of one. */
  background: rgb(var(--white-ch) / .06);
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}
/* Language switcher + CTA travel together as the right-hand cluster. Without
   this they are separate flex children and `space-between` on .nav-inner
   spreads four items across the bar, stranding the switcher mid-gap. */
.nav-actions {
  display: flex;
  align-items: center;
  gap: 14px;
  flex: none;
}
.nav-links a {
  padding: 8px clamp(12px, 1.1vw, 18px);
  border-radius: 999px;
  font-size: 13px;
  color: var(--text-dim);
  letter-spacing: 0.02em;
  white-space: nowrap;
  transition: color .2s ease, background .2s ease;
}
.nav-links a:hover {
  color: var(--neutral-000);
  /* Against a capsule already at .06 white, a .08 pill is a 2% step and reads
     as nothing. The hover has to clear its own container. */
  background: rgb(var(--white-ch) / .14);
}
.nav-cta {
  padding: 10px 18px;
  border-radius: 999px;
  border: 1px solid var(--line-strong);
  font-size: 13px;
  color: var(--neutral-100);
  transition: all .25s ease;
  background: rgb(var(--white-ch) / 0.02);
}
.nav-cta:hover {
  border-color: var(--accent);
  color: var(--neutral-000);
}
@media (max-width: 860px) {
  .nav-links { display: none; }
  .nav-cta { display: none; }
}

/* ---------- MOBILE MENU ---------- */
.nav-hamburger {
  display: none;
  width: 40px; height: 40px;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: rgb(var(--white-ch) / 0.02);
  cursor: pointer;
  position: relative;
  z-index: 51;
}
.nav-hamburger span {
  position: absolute;
  left: 12px; right: 12px;
  height: 1.5px;
  background: var(--neutral-100);
  border-radius: 2px;
  transition: transform .35s cubic-bezier(.2,.8,.2,1), opacity .25s ease, top .35s cubic-bezier(.2,.8,.2,1);
}
.nav-hamburger span:nth-child(1) { top: 14px; }
.nav-hamburger span:nth-child(2) { top: 19px; }
.nav-hamburger span:nth-child(3) { top: 24px; }
.nav-hamburger.open span:nth-child(1) { top: 19px; transform: rotate(45deg); }
.nav-hamburger.open span:nth-child(2) { opacity: 0; }
.nav-hamburger.open span:nth-child(3) { top: 19px; transform: rotate(-45deg); }

.mobile-menu {
  position: fixed;
  inset: 0;
  z-index: 49;
  background: rgb(var(--black-ch) / 0.88);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  opacity: 0;
  visibility: hidden;
  transition: opacity .35s ease, visibility 0s linear .35s;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 40px;
  padding: 0 32px;
}
.mobile-menu.open {
  opacity: 1;
  visibility: visible;
  transition: opacity .35s ease;
}
.mobile-menu::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(var(--accent-grid) 1px, transparent 1px),
    linear-gradient(90deg, var(--accent-grid) 1px, transparent 1px);
  background-size: 48px 48px;
  mask-image: radial-gradient(ellipse at center, black 30%, transparent 80%);
  pointer-events: none;
}
.mobile-menu a {
  font-family: var(--font-display);
  font-size: 32px;
  font-weight: 600;
  letter-spacing: -0.015em;
  color: var(--neutral-100);
  position: relative;
  padding: 6px 0;
  opacity: 0;
  transform: translateY(20px);
  transition: opacity .5s ease, transform .5s cubic-bezier(.2,.8,.2,1), color .25s ease;
}
.mobile-menu.open a {
  opacity: 1;
  transform: translateY(0);
}
.mobile-menu.open a:nth-child(1) { transition-delay: .15s; }
.mobile-menu.open a:nth-child(2) { transition-delay: .22s; }
.mobile-menu.open a:nth-child(3) { transition-delay: .29s; }
.mobile-menu.open .mobile-menu-cta { transition-delay: .36s; }
.mobile-menu a:hover { color: var(--accent); }
.mobile-menu a::after {
  content: "";
  position: absolute;
  left: 50%; bottom: 0;
  width: 60%; height: 1px;
  background: var(--accent);
  /* Held at final width and scaled open from the centre — compositor-only.
     The translate keeps it centred and has to stay ahead of the scale. */
  transform: translateX(-50%) scaleX(0);
  transition: transform .25s ease;
}
.mobile-menu a:hover::after { transform: translateX(-50%) scaleX(1); }
.mobile-menu-cta {
  padding: 14px 32px !important;
  border-radius: 999px;
  border: 1px solid var(--accent);
  background: linear-gradient(135deg, rgb(var(--accent-ch) / 0.15), rgb(var(--accent-ch) / .05));
  font-size: 16px !important;
  color: var(--neutral-100) !important;
  margin-top: 8px;
}
.mobile-menu-foot {
  position: absolute;
  bottom: 32px;
  left: 0; right: 0;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.32em;
  color: var(--text-faint);
  text-transform: uppercase;
}

@media (max-width: 860px) {
  .nav-hamburger { display: flex; }
}
/* Prevent body scroll when menu is open */
body.menu-open { overflow: hidden; }

/* ---------- HERO ---------- */
.hero {
  position: relative;
  min-height: 100vh;
  /* Reserve the fixed bar so the flex centring below measures the space the
     content can actually occupy, not the space the bar is sitting on. */
  padding-top: var(--nav-h);
  display: flex;
  align-items: center;
  overflow: hidden;
  z-index: 1;
}
/* The hero needs an explicit ground, not a transparent one: `screen` composites
   against its own stacking context's backdrop, and with nothing painted there
   the blend has no backdrop to preserve and the clip's box reads as a darker
   rectangle. The off-black token is enough -- `screen(0, x) = x`, so what keeps
   the box invisible is the clip's surround being crushed to true black in the
   encode, not the page being pure black underneath it. */
#top { background: var(--bg); }

/* ---------- hero: binary rain, behind the animal ---------- */
.hero-rain {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  /* Cleared from under the bull. `screen` is additive, so anything behind the
     animal is added into its dark chrome and the metal reads grey. */
  -webkit-mask-image: linear-gradient(90deg, rgb(0 0 0 / .62) 0%, #000 18%, #000 46%, rgb(0 0 0 / .46) 64%, rgb(0 0 0 / .12) 80%, transparent 90%);
  mask-image: linear-gradient(90deg, rgb(0 0 0 / .62) 0%, #000 18%, #000 46%, rgb(0 0 0 / .46) 64%, rgb(0 0 0 / .12) 80%, transparent 90%);
}

/* ---------- hero: the bull ---------- */
.hero-canvas { position: absolute; inset: 0; }
.hero-video-wrap {
  z-index: 1;
  overflow: hidden;
  pointer-events: none;
  /* On the wrapper, not the video: `z-index` here opens a stacking context, and
     a blend applied inside it would only compose against the wrapper's own
     empty backdrop, leaving the clip's black rectangle visible. */
  mix-blend-mode: screen;
}
.hero-video {
  position: absolute;
  top: 50%; right: -5%;
  width: 60%; height: auto;
  /* The animal sits at 44% of its own frame, not 50%, so centring the video box
     leaves it riding ~48px above the text column's optical centre. The nudge is
     in vh so it tracks the viewport rather than one screen size. */
  translate: 0 calc(-50% + 7vh);
}
@media (max-width: 980px) {
  /* Its own nudge: the mobile block centres differently, and at 5vh the two
     midlines already agree to within 4px. */
  .hero-video { right: -14%; width: 122%; top: 50%; translate: 0 calc(-50% + 5vh); }
}

.hero-vignette {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(90deg, #000 0%, #000 34%, rgb(0 0 0 / .55) 43%, rgb(0 0 0 / .12) 49%, rgb(0 0 0 / 0) 53%),
    linear-gradient(180deg, transparent 62%, var(--bg) 100%);
}

.hero-inner {
  position: relative;
  z-index: 3;
  /* Symmetric, and padding-block rather than the shorthand: this element is
     also `.container`, whose `0 32px` gutter the shorthand wipes out. Above
     ~1344px the centred max-width masks that as margin, so it only showed at
     1280 and below, where the text ran flush to the viewport edge. */
  padding-block: 40px;
  width: 100%;
  pointer-events: none;
}
.hero-inner > * { pointer-events: auto; }
.hero-headline {
  font-family: var(--font-display);
  font-variation-settings: 'wdth' var(--display-width);
  font-size: var(--hero-size);
  line-height: var(--hero-leading);
  font-weight: 650;
  letter-spacing: var(--hero-tracking);
  max-width: 14ch;
  margin-bottom: 28px;
  color: var(--neutral-000);
}
/* Flat gold, no faux italic. The oblique was synthesised, and had to be disabled
   for Chinese anyway — which left EN and zh speaking with two different voices. */
.hero-headline .engineered {
  font-weight: 650;
  color: var(--accent);
}
.hero-sub {
  max-width: 52ch;
  color: var(--text-dim);
  font-size: clamp(15px, 1.4vw, 18px);
  margin-bottom: 36px;
}
.hero-ctas {
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
}
.btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 16px 26px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.01em;
  transition: all .3s ease;
  position: relative;
  overflow: hidden;
}
.btn-primary {
  background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
  color: var(--on-accent);
  font-weight: 600;
  box-shadow: 0 0 0 1px rgb(var(--accent-ch) / 0.35), 0 10px 28px -12px rgb(var(--accent-ch) / 0.35);
}
.btn-primary:hover {
  transform: translateY(-2px);
  box-shadow: 0 0 0 1px rgb(var(--accent-ch) / 0.55), 0 14px 36px -12px rgb(var(--accent-ch) / 0.45);
}
.btn-primary .arr { transition: transform .25s ease; }
.btn-primary:hover .arr { transform: translateX(4px); }
.btn-secondary {
  background: rgb(var(--white-ch) / 0.02);
  border: 1px solid var(--line-strong);
  color: var(--neutral-100);
}
.btn-secondary:hover {
  border-color: var(--neutral-300);
  background: rgb(var(--white-ch) / 0.05);
}

/* scroll cue */

/* On narrow viewports the bull centers behind the text, but stays visible. */
@media (max-width: 980px) {
  .hero-inner { text-align: center; }
  .hero-inner .hero-headline,
  .hero-inner .hero-sub,
  .hero-inner .hero-ctas {
    margin-left: auto;
    margin-right: auto;
  }
  .hero-inner .hero-headline { max-width: 100%; }
  .hero-inner .hero-sub { max-width: 56ch; }
  .hero-inner .hero-ctas { justify-content: center; }
  .hero-vignette {
    background:
      radial-gradient(ellipse at 50% 40%, transparent 0%, rgb(0 0 0 / .72) 72%),
      linear-gradient(180deg, transparent 62%, var(--bg) 100%) !important;
  }
}

/* ---------- SECTION HEADERS ---------- */
.section { padding: 140px 0; position: relative; z-index: 1; }
.section-title {
  font-family: var(--font-display);
  font-variation-settings: 'wdth' var(--display-width);
  font-size: clamp(32px, 4.4vw, 56px);
  line-height: 1.06;
  font-weight: 650;
  letter-spacing: var(--title-tracking);
  margin-bottom: 18px;
  color: var(--text);
  max-width: 22ch;
}
.section-lead {
  color: var(--text-dim);
  max-width: 62ch;
  margin-top: 20px;
  font-size: 16px;
}

/* ---------- SERVICES — the ledger ---------- */
/* Four rows on a rail, not four cards. Each row is a node on the system trace,
   which is what finally makes the 01-04 numbering encode something. A ledger
   also lets Bahasa Melayu's long service names take a full row instead of
   being squeezed into a fixed-width card. */
/* Stacked, not split. `space-between` with the lead as a second flex child is
   the "big headline left, small explainer right" pattern; with wrap on it fell
   to a second line anyway and left the whole right half of the band empty. */
.services-head {
  max-width: 62ch;
  margin-bottom: 40px;
}

/* Trace lead-in: the line arrives from the hero before the first node. */

/* The trace rail: one continuous gold line down the left of the ledger. */

/* The whole row answers the pointer, and the trace node it sits on brightens
   with it -- the same gold that runs down the left rail. */

/* The nudge is on the chip alone, via `translate` rather than padding: shifting
   the row would relayout it every frame and drag its own background edge with
   it. */

/* The node where the trace forks into this row. */

/* Stamped chip — an icon you can actually read. */

/* Silver strokes are the chassis; the single gold stroke is the signal path
   through it — the same semantics as the page trace. */

@media (max-width: 560px) {
  /* The rail has to keep meeting the node stubs. Hiding the stub here left the
     nodes floating free of the line. */
}
/* ---------- CONTACT ---------- */
.contact-section {
  padding: 140px 0;
  position: relative;
  overflow: hidden;
}
.contact-section::before {
  content: "";
  position: absolute;
  inset: 0;
  background:
    radial-gradient(ellipse at 80% 20%, rgb(var(--accent-ch) / 0.08) 0%, transparent 50%),
    radial-gradient(ellipse at 20% 80%, rgb(var(--neutral-ch) / 0.04) 0%, transparent 50%);
  pointer-events: none;
}
.contact-grid {
  display: grid;
  grid-template-columns: 1fr 1.2fr;
  gap: 80px;
  position: relative;
  /* Without this the form stretches to the height of the taller info column,
     leaving a large empty panel below the submit button. */
  align-items: start;
}
@media (max-width: 980px) {
  .contact-grid { grid-template-columns: 1fr; gap: 56px; }
}
.contact-info p {
  color: var(--text-dim);
  margin-bottom: 36px;
  font-size: 17px;
  max-width: 40ch;
}
/* Where the trace terminates. The rail returns alongside the contact rows and
   ends in a filled port — the same grammar as the service ledger nodes, closing
   the line that started under the bull. */
.contact-list {
  /* Keeps the positioning frame for ::before / ::after: those are the gold
     trace arriving from the hero and terminating beside the details, which is
     the committed direction's signature, not a decorative rail. */
  position: relative;
  display: grid;
  gap: 0;
  margin-top: 40px;
  padding-left: 28px;
}
.contact-list::before {
  content: "";
  position: absolute;
  left: 4px;
  top: 0;
  height: calc(100% - 26px);
  width: 2px;
  background: var(--accent);
  opacity: .55;
}
.contact-list::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 18px;
  width: 10px; height: 10px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 10px var(--accent-glow);
}
@media (max-width: 560px) {
  .contact-list { padding-left: 22px; }
}
.contact-row {
  display: grid;
  grid-template-columns: 92px 1fr;
  align-items: baseline;
  gap: 20px;
  padding: 18px 0;
  border-bottom: 1px solid var(--line);
}
.contact-row:first-child { border-top: 1px solid var(--line); }
.contact-row .lbl {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: var(--text-faint);
  width: 80px;
  flex-shrink: 0;
}
.contact-row .val {
  font-family: var(--font-display);
  font-size: 16px;
  color: var(--neutral-100);
  font-weight: 500;
}

/* An opaque plane, not glass. The form used to be a 45%-alpha wash sitting over
   a WebGL torus, which cost it both legibility and any sense of being a surface. */
.form {
  position: relative;
  padding-left: 40px;
  border-left: 1px solid var(--line);
}
.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-bottom: 18px;
}
@media (max-width: 600px) {
  .form-row { grid-template-columns: 1fr; }
  .form { padding: 28px; }
}
.field { display: flex; flex-direction: column; }
.field label {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--text-faint);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  margin-bottom: 6px;
}
.field input,
.field select,
.field textarea {
  width: 100%;
  padding: 12px 2px;
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--line-strong);
  border-radius: 0;
  color: var(--neutral-000);
  font-family: inherit;
  font-size: 15px;
  transition: border-color .25s ease;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
  outline: none;
  border-bottom-color: var(--accent);
}
.field input::placeholder,
.field textarea::placeholder { color: var(--text-faint); }
.field textarea { resize: vertical; min-height: 120px; }
.field textarea { resize: vertical; min-height: 120px; }
.field input:focus,
.field select:focus,
.field textarea:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-veil);
  background: rgb(var(--black-ch) / 0.7);
}
.field select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--neutral-500) 50%), linear-gradient(135deg, var(--neutral-500) 50%, transparent 50%); background-position: calc(100% - 18px) center, calc(100% - 12px) center; background-size: 6px 6px, 6px 6px; background-repeat: no-repeat; padding-right: 36px; }
.form .btn-primary { width: 100%; justify-content: center; margin-top: 8px; }

/* ---------- FOOTER ---------- */
footer {
  border-top: 1px solid var(--line);
  padding: 60px 0 40px;
  position: relative;
  z-index: 1;
}
.footer-inner {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 32px;
  flex-wrap: wrap;
}
.foot-brand { max-width: 380px; }
.foot-brand p {
  color: var(--text-faint);
  font-size: 13px;
  margin-top: 18px;
}
.foot-links {
  display: flex;
  /* Wraps because the link words are translated: the Malay row measures 315px
     of a 305px viewport at 320px wide and pushed the whole page sideways. */
  flex-wrap: wrap;
  gap: 28px;
}
.foot-links a {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-faint);
  letter-spacing: 0.24em;
  text-transform: uppercase;
  transition: color .2s ease;
}
.foot-links a:hover { color: var(--accent); }
.copyright {
  margin-top: 56px;
  padding-top: 24px;
  border-top: 1px solid var(--line);
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.16em;
  color: var(--text-faint);
  text-transform: uppercase;
}

/* ---------- TOAST ---------- */
.toast {
  position: fixed;
  bottom: 32px;
  right: 32px;
  z-index: 100;
  padding: 16px 22px;
  background: var(--plane);
  border: 1px solid var(--line);
  border-radius: 12px;
  color: var(--neutral-100);
  font-size: 13px;
  box-shadow: 0 20px 60px -10px rgb(var(--black-ch) / .7);
  transform: translateY(20px);
  opacity: 0;
  pointer-events: none;
  transition: all .35s cubic-bezier(.2,1,.3,1);
  display: flex;
  gap: 12px;
  align-items: center;
  max-width: 360px;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast .check {
  width: 22px; height: 22px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--on-accent);
  display: grid;
  place-items: center;
  flex-shrink: 0;
}
.toast .check svg { width: 12px; height: 12px; }
.toast strong { display: block; font-weight: 600; margin-bottom: 2px; }
.toast small { color: var(--text-dim); font-size: 11px; }

/* ---------- REVEAL ---------- */
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity .8s ease, transform .8s cubic-bezier(.2,.7,.2,1);
}
.reveal.in { opacity: 1; transform: translateY(0); }

/* ---------- DIVIDER ---------- */
.div-line {
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--line-strong) 30%, var(--line-strong) 70%, transparent);
  margin: 0 auto;
  max-width: 1280px;
}
/* ---------- PORTFOLIO — the vellum act ---------- */
/* The one light section on the page. Drafting paper carrying ink schematics:
   it breaks the dark tunnel, gives the page a second act, and is the only
   ground on which the architecture drawings are legible. */
.portfolio-section {
  background: var(--paper);
  color: var(--ink);
  padding: 140px 0;
  position: relative;
  z-index: 1;
}
/* Faint drafting grid on the paper itself. */
.portfolio-section::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(var(--paper-line) 1px, transparent 1px),
    linear-gradient(90deg, var(--paper-line) 1px, transparent 1px);
  background-size: 32px 32px;
  opacity: .45;
  pointer-events: none;
}
.portfolio-inner { position: relative; z-index: 2; }

.portfolio-section .section-title { color: var(--ink); }
.portfolio-section .section-lead { color: var(--ink-dim); }

/* ---------- PORTFOLIO SLIDER ---------- */
/* A scroll-snap track: native swipe and momentum on touch, arrows and keyboard
   on desktop, and it wraps around at both ends. Two cards visible on desktop so
   the screenshots stay legible; one on a phone. */
.port-slider { position: relative; }

.portfolio-grid {
  display: flex;
  gap: 20px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  /* The first and last card must be able to reach dead centre, or the browser
     snaps past them on load and the deck opens on card two. That needs side
     padding of exactly half the leftover space.
     Card width is set in vw/px, not %, so it does not depend on this padding —
     a percentage basis would resolve against the content box and make the two
     values circular. */
  --card-w: clamp(280px, 74vw, 560px);
  padding-block: 36px 40px;
  padding-inline: max(0px, calc((100% - var(--card-w)) / 2));
}
.portfolio-grid::-webkit-scrollbar { display: none; }
.portfolio-grid:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}
.portfolio-grid > .port-card {
  flex: 0 0 var(--card-w);
  scroll-snap-align: center;
}
@media (max-width: 1000px) { .portfolio-grid { --card-w: clamp(260px, 78vw, 600px); } }
/* On a phone only one card fits, so narrow it enough that the next one peeks —
   otherwise nothing signals that the deck scrolls. Needs the gap tightened too,
   or the neighbour lands just past the edge. */
@media (max-width: 640px) {
  .portfolio-grid { --card-w: clamp(240px, 71vw, 620px); gap: 12px; }
}

.port-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 18px;
  margin-top: 28px;
}
.port-arrow {
  width: 44px; height: 44px;
  display: grid; place-items: center;
  border: 1px solid var(--ink-dim);
  border-radius: 50%;
  color: var(--ink);
  background: rgb(var(--vellum-ch) / .7);
  transition: background .2s ease, border-color .2s ease;
}
.port-arrow svg { width: 20px; height: 20px; }
.port-arrow:hover { background: var(--paper-line); border-color: var(--ink); }
.port-arrow:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

.port-dots { display: flex; gap: 8px; }
.port-dots i {
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--paper-line);
  border: 1px solid var(--ink-dim);
  transition: background .2s ease;
}
.port-dots i.on { background: var(--ink); }

/* The card is the snap target and must never be transformed: scroll-snap areas
   are measured from the *transformed* border box, so animating the card itself
   moves its own snap position while scrolling — a feedback loop that made the
   track overshoot to the end. All motion lives on .port-inner instead. */
.port-card { background: none; border: 0; }

.port-inner {
  height: 100%;
  background: rgb(var(--vellum-ch) / .75);
  border: 1px solid var(--paper-line);
  border-radius: 2px;
  overflow: hidden;
  transition: border-color .3s ease;
}
.port-card:hover .port-inner { border-color: var(--ink-dim); }

/* The drawing sheet. */
.port-sheet {
  position: relative;
  aspect-ratio: 16 / 10;
  border-bottom: 1px solid var(--paper-line);
}

/* Engineering title block — absorbs the old redundant pill label. */
.port-titleblock {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--paper-line);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: var(--eyebrow-tracking);
  color: var(--ink-dim);
  text-transform: uppercase;
}

.port-body { padding: 20px 22px 24px; }
.port-title {
  font-family: var(--font-display);
  font-variation-settings: 'wdth' var(--display-width);
  font-size: 19px;
  font-weight: 650;
  color: var(--ink);
  letter-spacing: -0.01em;
  margin-bottom: 10px;
}
.port-desc {
  font-size: 14px;
  line-height: 1.6;
  color: var(--ink-dim);
  margin-bottom: 16px;
}
/* ---------- LANGUAGE SWITCHER ---------- */
/* Markup is generated by build.mjs (computed().langSwitcher) — see the
   {{langSwitcher}} token in src/partials/nav.html. */
.lang-dd { position: relative; }

.lang-dd-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 12px;
  border-radius: 999px;
  border: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.12em;
  color: var(--text-dim);
  transition: color 0.25s, border-color 0.25s, background 0.25s;
}
.lang-dd-btn:hover {
  color: var(--neutral-100);
  border-color: var(--accent-border);
  background: var(--accent-wash);
}
.lang-dd-btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px var(--accent-veil);
}
.lang-globe { width: 15px; height: 15px; flex: none; }
.lang-caret {
  width: 10px; height: 10px; flex: none;
  transition: transform 0.25s;
}
.lang-dd.open .lang-caret { transform: rotate(180deg); }

.lang-dd-menu {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  min-width: 168px;
  padding: 6px;
  margin: 0;
  list-style: none;
  border-radius: 12px;
  border: 1px solid var(--line-strong);
  background: linear-gradient(180deg, rgb(var(--surface-2-ch) / .98), rgb(var(--surface-ch) / .98));
  backdrop-filter: blur(18px);
  box-shadow: 0 24px 60px -18px rgb(var(--black-ch) / .8);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-6px);
  transition: opacity 0.22s, transform 0.22s, visibility 0.22s;
  z-index: 60;
}
.lang-dd.open .lang-dd-menu {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

.lang-dd-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 12px;
  border-radius: 8px;
  font-size: 13px;
  color: var(--text-dim);
  transition: color 0.2s, background 0.2s;
}
.lang-dd-item:hover { color: var(--neutral-100); background: var(--accent-wash); }
.lang-dd-item.is-current { color: var(--accent); }
.lang-dd-item svg { width: 14px; height: 14px; flex: none; }

/* In the mobile drawer the switcher sits inline, opening downward in flow. */
.mobile-menu .lang-dd { margin-top: 18px; }
.mobile-menu .lang-dd-menu { right: auto; left: 0; }

/* ==========================================================================
   PER-LOCALE TYPOGRAPHY
   Driven by <html lang> — one stylesheet serves all three locales, no JS.
   Values override the knobs declared in tokens.css.
   ========================================================================== */

/* ---------- Bahasa Melayu ---------- */
/* BM runs long ("Penyelesaian IT Sehenti", "Pembangunan Laman Web"). Archivo's
   width axis absorbs that directly: narrowing the display face buys horizontal
   room without the letter-spacing hacks the old face needed. */
:root:lang(ms) {
  --display-width: 104;
  --eyebrow-tracking: 0.1em;
  --nav-gap: 24px;
}
:lang(ms) .hero-headline { max-width: 18ch; }
:lang(ms) .section-title { max-width: 26ch; }
/* The hamburger has to appear earlier than in English or the nav crowds. */
@media (max-width: 1000px) {
  :lang(ms) .nav-links { display: none; }
  :lang(ms) .nav-cta { display: none; }
  :lang(ms) .nav-hamburger { display: flex; }
}

/* ---------- Simplified Chinese ---------- */
/* Hanzi are dense squares: negative tracking collides them, 0.98 leading clips
   them, and an equal point size reads visually larger than Latin. */
:root:lang(zh) {
  --font-display: var(--font-cjk);
  --font-body: var(--font-cjk);
  --hero-size: clamp(34px, 5.6vw, 88px);
  --hero-leading: 1.15;
  --hero-tracking: 0;
  --title-tracking: 0;
  --body-leading: 1.75;
  --eyebrow-tracking: 0.2em;
}
/* Noto Sans SC has no variable width axis; neutralise the knob so the Latin
   default cannot leak in as a synthesised stretch. */
:lang(zh) .hero-headline,
:lang(zh) .section-title,
:lang(zh) .port-title,
:lang(zh) .brand-name { font-variation-settings: normal; }
:lang(zh) .hero-headline,
:lang(zh) .section-title,
:lang(zh) .hero-headline { max-width: 16ch; }
:lang(zh) .section-title { max-width: 20ch; line-height: 1.25; }
/* JetBrains Mono has no hanzi. Translated labels fall back to the CJK face, so
   give them the wide-tracked titling treatment rather than a broken mono look. */
:lang(zh) .field label {
  font-family: var(--font-cjk);
  font-weight: 500;
}
/* Structural chrome (port-meta years) stays Latin in every locale, so it keeps
   the mono face. */
:lang(zh) .port-meta,
:lang(zh) .brand-sub {
  font-family: var(--font-mono);
}

/* ==========================================================================
   REDUCED MOTION
   The hero clip and the reveal animations run by default. Honour the OS
   preference: app.js never assigns the video's src and settles the binary rain
   synchronously when this same query matches, so nothing is fetched and no
   frame is ever scheduled.
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }

  /* .reveal starts at opacity 0 and is animated in — without the animation it
     must be visible immediately, or the page reads as blank. */
  .reveal,
  .reveal-group > * {
    opacity: 1 !important;
    transform: none !important;
    /* The global block above zeroes duration but not delay, and the stagger
       adds up to 420ms of it. Without this the last card in a group would
       still wait, which is the delay being honoured on content that is
       supposed to be simply present. */
    transition-delay: 0s !important;
  }

  .port-card { transform: none !important; }

  /* The trace is drawn with stroke-dashoffset. Under reduced motion it must
     present already complete rather than frozen part-way. */
  .trace-draw {
    stroke-dasharray: none !important;
    stroke-dashoffset: 0 !important;
  }
}

/* ---------- reveal: order and weight ----------
   One move for every component made a grid resolve as a single flash: four
   tiles, seven cards and five footer rows all arrived on the same frame. The
   delay below turns each group into a sequence. JS writes --i per container,
   so every section starts its own count rather than inheriting a running
   total, and the cap stops a seven-card deck finishing half a second after a
   four-tile grid. */
/* The transition itself is declared at the very end of this file -- see
   "reveal transitions, declared last". */

/* Objects land, prose rises. The tiles and cards carry a little scale so they
   read as things arriving rather than text fading up; everything else keeps
   the plain rise, which is what suits a paragraph. */
.reveal--tile { transform: translateY(28px) scale(.985); }
.reveal--tile.in { transform: none; }

/* A list of rows wants less travel and less time, or the group reads as slow
   however short each step is. */
.reveal--line {
  transform: translateY(12px);
  transition-duration: .55s;
}
.reveal--line.in { transform: none; }

/* A group that reveals off its own arrival, not its children's.
   The deck scrolls horizontally, so five of its seven cards are never in the
   viewport when the section is reached -- observed individually they stayed at
   opacity 0 until someone swiped, and then faded up mid-swipe. Observing the
   track instead lets every card arrive together, still in sequence. */
.reveal-group > * {
  opacity: 0;
  transform: translateY(28px) scale(.985);
}
.reveal-group.in > * { opacity: 1; transform: none; }

/* Same guarantee for anyone reaching the page with JS disabled or failed:
   the reveal animation must never be what keeps content off the screen. */
.no-js .reveal,
.no-js .reveal-group > * {
  opacity: 1;
  transform: none;
  transition-delay: 0s;
}

/* WebGL failed to initialise (or was skipped for reduced motion) — the hero
   must not be left as an empty black box. */
/* A screenshot mounted where a drawing would otherwise sit. Same frame, same
   title block — a technical dossier carries both drawings and photo plates. */
/* The ratio belongs to the image, not to the padded sheet. Putting it on the
   sheet made the inner box 1.674 and cover clipped 10px off every screenshot. */
.port-sheet--shot {
  padding: 12px;
  background: rgb(var(--vellum-ch) / .6);
}
.port-sheet--shot img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  display: block;
  object-fit: cover;
  border: 1px solid var(--ink-dim);
}

/* ---------- SLIDER DEPTH ---------- */
/* Cards sit back and dim as they enter or leave the track, and come forward
   when they are comfortably in view. Driven by the scroll position itself
   (animation-timeline: view), so it runs on the compositor and schedules no
   animation frames — the reduced-motion guarantee is unaffected.

   Animates the individual `scale` and `translate` properties rather than
   `transform`, which leaves `transform` free for the pointer tilt below. The
   two compose instead of fighting.

   @supports keeps this to browsers that have scroll-driven animations
   (~85% today); everywhere else the slider stays flat and works exactly as now. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .portfolio-grid { perspective: 1400px; }

    .portfolio-grid > .port-card > .port-inner {
      animation: card-depth linear both;
      animation-timeline: view(inline);
      view-timeline-inset: 0;
      transform-style: preserve-3d;
      will-change: scale, translate, opacity;
    }

    /* view(inline) puts the element dead centre of the scrollport at 50%, so
       that is where the card is full size and fully opaque. Either side of it
       the siblings recede and dim, which is what reads as depth. */
    @keyframes card-depth {
      0%   { scale: .80; translate: 0 0 -180px; rotate: y  16deg; opacity: .35; }
      50%  { scale: 1;   translate: 0 0 0;      rotate: y   0deg; opacity: 1; }
      100% { scale: .80; translate: 0 0 -180px; rotate: y -16deg; opacity: .35; }
    }
  }
}

/* Pointer tilt — desktop only, and only for visitors who accept motion.
   JS writes the transform; see app.js. */
.port-inner {
  transition: transform .35s cubic-bezier(.2, .8, .2, 1), border-color .3s ease;
}
.port-card[data-tilting] .port-inner { transition: border-color .3s ease; }

/* ---------- THE TRACE DRAWS ITSELF ---------- */
/* The gold line is the page's signature: it leaves the hero, forks a node into
   each service row, and terminates beside the contact details. Static, it is
   just a rule. Drawn by the scroll position, it reads as a circuit being
   traced — which is the whole idea.

   Scroll-driven, so it runs on the compositor and schedules no animation
   frames. Animates `scale` (the individual property) rather than `transform`,
   leaving transform free and avoiding any conflict.

   Without scroll-driven animation support, or with reduced motion on, the line
   is simply drawn in full from the start — the design still reads. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .contact-list::before {
      transform-origin: top center;
      animation: trace-draw linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 60%;
    }

    /* The ledger publishes one timeline that both the rail and its nodes read,
       so each node lights up exactly as the line sweeps past it. On separate
       view() timelines they fired on their own entry instead, and the last node
       lit before the line had reached it. */

    .contact-list::after {
      animation: node-arrive linear both;
      animation-timeline: view();
      animation-range: entry 40% cover 55%;
    }

    
/* ==========================================================================
   HERO — wordmark + responsive
   Appended last so these win over the base declarations above without needing
   specificity hacks; media queries do not raise specificity on their own.
   ========================================================================== */

/* Same face, weight and gold X as the header logo, at hero scale. Only the
   tracking opens up — 0.18em reads as precision at 14px and as a gap at 118px.
   Capped so the X clears the animal. */
.hero-wordmark {
  /* 118px put the wordmark at 8.2vw and 6.6:1 against the sub, and its ink ran
     to x831 while the animal's began at x763 — a 68px overlap. At 6.4vw the
     ratio settles to ~5:1 and the two clear each other by ~90px. */
  font-size: min(clamp(32px, 6.4vw, 92px), 15vh);
  letter-spacing: 0.05em;
  line-height: 1;
  font-weight: 700;
  text-transform: uppercase;
  max-width: none;
  /* Archivo's S carries a 0.045em left side bearing; body copy carries almost
     none. Without this the wordmark reads as indented against the paragraph
     below it even though both boxes start on the same pixel. */
  margin-left: -0.045em;
  margin-bottom: 20px;
}
/* The wordmark is a proper noun, so it must not follow the per-locale font
   swap. `:root:lang(zh)` reassigns --font-display to the CJK face, so naming
   that variable here would resolve to Noto; the Latin alias cannot be. */
.hero-wordmark { font-family: var(--font-display-latin); }
:lang(zh) .hero-wordmark { font-variation-settings: 'wdth' 118; }

/* The base hero pads 140/100, a third of a phone viewport spent on air, and the
   wordmark stops shrinking at its clamp floor exactly when the screen needs it
   to. Measured before this: 320x700 overflowed the fold by 281px, 360x740 by
   131px. Everything vertical scales together below. */
@media (max-width: 860px) {
  .hero-inner { padding-block: clamp(24px, 4vh, 44px); }
  .hero-wordmark {
    font-size: min(clamp(30px, 11.5vw, 78px), 14vh);
    letter-spacing: 0.035em;
    margin-bottom: 14px;
  }
  .hero-sub { margin-bottom: 26px; }
  .hero-ctas { gap: 10px; }
}

/* Short-and-wide — laptops at 1280x720, split screens. Height is the scarce
   axis there, not width, so the vh caps do the work rather than the vw ones. */
@media (min-width: 861px) and (max-height: 760px) {
  .hero-inner { padding-block: 32px; }
  .hero-wordmark { font-size: min(clamp(32px, 6.4vw, 92px), 13vh); margin-bottom: 14px; }
  .hero-sub { margin-bottom: 26px; }
}

@media (max-width: 340px) {

  /* Items size to their own text. Forcing a percentage basis gave them a width
     their content could not wrap into, and "Cloud · On-prem · Hybrid" ran off
     the edge instead of breaking. */
}

/* Very short viewports — an iPhone SE is 568px tall and the nav claims ~78px of
   that. The top padding cannot go below the nav's height without the eyebrow
   sliding under it. */
@media (max-height: 720px) {
  .hero-inner { padding-block: clamp(14px, 2.5vh, 28px); }
  .hero-wordmark { font-size: min(clamp(28px, 11.5vw, 78px), 12vh); margin-bottom: 14px; }
  .hero-ctas { gap: 8px; }
  /* At 320px the sub runs to six lines; trimming the leading is worth more here
     than trimming any single margin. */
  .hero-sub { font-size: 13px; line-height: 1.45; margin-bottom: 24px; max-width: 40ch; }
}

/* 320px phones. The bar's three groups total 327px of content where 265px
   exists, so the hamburger sat 26px off the right edge and could not be tapped
   at all -- the mobile menu was unreachable on an SE-class screen. The language
   pill is not the thing to drop: the mobile menu carries no locale switch, so
   hiding it would strip a trilingual site of two of its languages on the
   smallest phones. Everything is tightened instead, which buys ~72px.

   The bound is 380 rather than 320: the three groups are a fixed 327px, so with
   the 20px gutters they stop fitting below 367px wide. At 360 the bar did not
   push the page sideways, it just spilled ~7px into the right gutter, which is
   why only the 320 case looked broken. The bound is 400 rather than 367 because
   the Chinese lockup is wider than the Latin one and still spilled at 381. */
@media (max-width: 400px) {
  .container { padding: 0 14px; }
  .nav-inner { gap: 10px; }
  .brand { gap: 8px; }
  .brand-mark { width: 38px; height: 30px; }
  .brand-name { font-size: 12.5px; letter-spacing: 0.12em; }
  .nav-actions { gap: 8px; }
  .lang-dd-btn { padding: 7px 9px; gap: 5px; }
  .foot-links { gap: 12px 16px; }
}

/* Below ~340px even the tightened lockup does not fit, so the brand drops to
   mark plus wordmark. The descriptor is the right thing to lose: the wordmark
   above it already says the name, and the footer still carries it in full. */
@media (max-width: 340px) {
  .container { padding: 0 12px; }
  .brand-sub { display: none; }
  .brand-mark { width: 32px; height: 26px; }
  .brand-name { font-size: 11.5px; letter-spacing: 0.10em; }
  .nav-inner { gap: 8px; }
}

/* Stacked hero. Below 980px the text column is already centred, which put the
   wordmark directly on top of the animal -- the sub and both CTAs sat over the
   bull's chrome and neither read. The clip leaves the absolute layer here and
   becomes the first row of a column, so the ox is above the title rather than
   behind it. The markup already orders video before .hero-inner, so nothing
   moves in the template. */
@media (max-width: 980px) {
  .hero { flex-direction: column; justify-content: center; }

  .hero-video-wrap {
    position: relative;
    inset: auto;
    flex: 0 0 auto;
    width: 100%;
    height: clamp(180px, 34vh, 340px);
  }
  /* Sized off the stage's height, not the viewport's width: the animal has to
     fill the row it was given, and the empty sides of the 16:9 frame are the
     part worth losing. */
  .hero-video {
    top: 50%; left: 50%; right: auto;
    width: auto; height: 100%;
    translate: -50% -50%;
  }

  /* The rain's clear-out runs left-to-right because the bull sat on the right.
     Once it is above the text that gradient erases the wrong half and adds rain
     straight into the animal's dark chrome, which is what makes the metal read
     grey under `screen`. Turned 90deg to follow it. */
  .hero-rain {
    -webkit-mask-image: linear-gradient(180deg, transparent 0%, rgb(0 0 0 / .14) 12%, rgb(0 0 0 / .5) 28%, #000 48%);
    mask-image: linear-gradient(180deg, transparent 0%, rgb(0 0 0 / .14) 12%, rgb(0 0 0 / .5) 28%, #000 48%);
  }

  .hero-inner { flex: 0 0 auto; }
}

/* ==========================================================================
   SERVICES + CONTACT — responsive
   Appended so these win over the base declarations without specificity hacks.
   ========================================================================== */

/* The form's left rule exists to separate two columns. Once the grid stacks it
   separates nothing and only costs gutter on an already narrow screen. */
@media (max-width: 980px) {
  .form { padding-left: 0; border-left: 0; }
}

@media (max-width: 600px) {
  /* A 92px label column plus a 26-character email does not fit 390px. Stacking
     the label over its value lets the row read at any width. */
  .contact-row {
    grid-template-columns: 1fr;
    gap: 4px;
    padding: 14px 0;
  }
  .contact-row .val { font-size: 15px; overflow-wrap: anywhere; }
}

/* ==========================================================================
   SERVICES — image bento
   Four disciplines, four cells, spans alternating 7/5 then 5/7 so the grid has
   a rhythm instead of four identical cards. The imagery is generated for these
   four subjects specifically and shares one lighting world with the hero clip:
   chrome and gunmetal, gold as the only accent, on black.
   ========================================================================== */

.services-bento {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 24px;
}
.svc-tile--wide   { grid-column: span 7; }
.svc-tile--narrow { grid-column: span 5; }

.svc-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  background: var(--plane);
  overflow: hidden;
  transition: border-color .35s ease;
}
.svc-tile:hover { border-color: rgb(var(--accent-ch) / .4); }

/* A fixed height, not a ratio. With 7/5 spans a shared ratio gives the two
   tiles in a row images of different heights (436 vs 307 at 1440), which drops
   their text to different baselines and leaves ~130px of dead space under the
   shorter one. Equal heights let the crop absorb the difference instead. */
.svc-shot {
  height: clamp(190px, 22vw, 320px);
  overflow: hidden;
  background: var(--warm-1000);
}
.svc-shot img {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
  transition: scale .6s cubic-bezier(.2,.8,.2,1);
}
.svc-tile:hover .svc-shot img { scale: 1.04; }

.svc-body {
  padding: 26px 28px 30px;
  border-top: 1px solid var(--line);
}
.svc-num {
  display: block;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: var(--eyebrow-tracking);
  color: var(--accent);
  margin-bottom: 12px;
}
.svc-name {
  font-family: var(--font-display);
  font-variation-settings: 'wdth' var(--display-width);
  font-size: clamp(19px, 1.6vw, 23px);
  font-weight: 600;
  letter-spacing: var(--title-tracking);
  color: var(--neutral-100);
  margin-bottom: 10px;
  transition: color .3s ease;
}
.svc-tile:hover .svc-name { color: var(--neutral-000); }
.svc-desc {
  color: var(--text-dim);
  font-size: 14.5px;
  line-height: 1.65;
  max-width: 52ch;
}

/* Two up, then one up. Below 620 the spans collapse rather than squeezing a
   16:10 image into half a phone. */
@media (max-width: 1000px) {
  .services-bento { gap: 20px; }
  .svc-tile--wide, .svc-tile--narrow { grid-column: span 6; }
}
@media (max-width: 620px) {
  .svc-tile--wide, .svc-tile--narrow { grid-column: span 12; }
  .svc-body { padding: 22px 20px 24px; }
  /* One column means each tile arrives on its own, and an index delay is no
     longer sequence -- it is a wait before the single thing being looked at.
     The stagger only means something while tiles share a row.

     The step is zeroed rather than the index: --i is written by JS as an inline
     style, and no rule in a stylesheet can outrank that. --stagger-step is set
     only here, so CSS keeps control of it. */
  .services-bento { --stagger-step: 0ms; }
}

/* The gold trace draws itself as the section arrives, and its terminal node
   lands once the line has reached it. */
@keyframes trace-draw { from { scale: 1 0; } to { scale: 1 1; } }
@keyframes node-arrive { from { scale: 0; opacity: 0; } to { scale: 1; opacity: 1; } }


/* ---------- reveal transitions, declared last ----------
   `transition` is a shorthand: it replaces the whole list rather than adding to
   it. .svc-tile sets `transition: border-color .35s ease` 600 lines after
   .reveal sets its opacity/transform pair, so the tiles silently lost both the
   fade and the stagger and were snapping from hidden to shown. Any component
   that declares a transition of its own would do the same.

   Declaring the reveal transition after every component rule makes it the last
   word instead of the first, and border-color rides along in the list so the
   components that wanted it keep it. The reduced-motion block above still wins
   over all of this -- it is !important. */
.reveal,
.reveal-group > * {
  transition:
    opacity .8s ease,
    transform .8s cubic-bezier(.2, .7, .2, 1),
    border-color .35s ease;
  transition-delay: min(calc(var(--i, 0) * var(--stagger-step, 80ms)), 420ms);
}
.reveal--line { transition-duration: .55s; }
