/* ============ tokens ============ */

:root {
  --bg: #121016;
  --surface: #1a1722;
  --text: #f1eef6;
  /* body/paragraph text — lightened for readability on phones in daylight.
     Headings use --text and accents use --accent, so those are unaffected. */
  --muted: #d4cfe1;
  --accent: #a855f7;
  /* "available / taking work" green — the one deliberate exception to the
     purple accent, so the live dot reads as a status and not as decoration. */
  --live: #3ddc84;
  --glow-live: 0 0 16px rgb(61 220 132 / 0.55);

  /* LED glow recipes — keep these the only glows on the page.
     Tighter bright core + wider falloff so the accent actually lands. */
  --glow-line: 0 0 6px rgb(168 85 247 / 0.85), 0 0 18px rgb(168 85 247 / 0.45), 0 0 40px rgb(168 85 247 / 0.2);
  --glow-soft: 0 0 28px rgb(168 85 247 / 0.22);
  --glow-text: 0 0 16px rgb(168 85 247 / 0.45);
  /* ring + halo for interactive accents (status pill, active tab) */
  --glow-accent: 0 0 0 1px rgb(168 85 247 / 0.35), 0 0 18px rgb(168 85 247 / 0.28);

  --font-display: "Bricolage Grotesque", "Alexandria", sans-serif;
  --font-body: "Instrument Sans", "Alexandria", sans-serif;
  /* Arabic/RTL typeface. The stylesheet is fetched by script.js only when
     Arabic is activated; "Alexandria" is the fallback until it loads.
     Readex Pro covers both Arabic and Latin, so mixed strings ("Firebase",
     "HTML") inside Arabic copy stay in one type family. */
  --font-arabic: "Readex Pro", "Alexandria", sans-serif;

  --gutter: clamp(1.5rem, 6vw, 6rem);
}

html[lang="ar"] {
  --font-display: var(--font-arabic);
  --font-body: var(--font-arabic);
}

/* Readex Pro's weight axis tops out at 700, so the 800 the Latin display face
   uses would be synthesised by the browser — faux bold smears Arabic strokes
   and thickens the joins unevenly. Cap the display elements at the real 700. */
html[lang="ar"] .logo,
html[lang="ar"] .hero-name,
html[lang="ar"] .section-title,
html[lang="ar"] .case-name,
html[lang="ar"] .card h3,
html[lang="ar"] .contact-email {
  font-weight: 700;
}

/* ============ reset ============ */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
}

html {
  scroll-behavior: smooth;
}

body {
  /* single fixed radial glow gives the page dark-purple depth instead of flat black */
  background:
    radial-gradient(1100px 600px at 50% -8%, rgb(168 85 247 / 0.12), transparent 60%),
    var(--bg);
  background-attachment: fixed;
  color: var(--text);
  font-family: var(--font-body);
  line-height: 1.6;
  overflow-x: clip;
  -webkit-font-smoothing: antialiased;
}

html[lang="ar"] body {
  line-height: 1.8;
}

img,
svg {
  display: block;
  max-inline-size: 100%;
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* ============ nav ============ */

.site-nav {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.1rem var(--gutter);
  z-index: 10;
  transition: background-color 0.3s, border-color 0.3s;
  border-block-end: 1px solid transparent;
}

.site-nav.scrolled {
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  border-block-end-color: rgb(168 85 247 / 0.12);
}

.logo {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.5rem;
  letter-spacing: -0.02em;
}

.nav-links {
  display: flex;
  gap: 1.8rem;
  font-size: 0.92rem;
  font-weight: 500;
}

.nav-links a {
  color: var(--muted);
  transition: color 0.2s;
}

.nav-links a:hover {
  color: var(--text);
}

.lang-toggle {
  font-weight: 600;
  font-size: 0.9rem;
  padding: 0.45em 1.1em;
  border: 1px solid color-mix(in srgb, var(--text) 30%, transparent);
  border-radius: 999px;
  transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
}

.lang-toggle:hover {
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: var(--glow-soft);
}

/* ============ hero ============ */

.hero {
  position: relative;
  min-height: 100svh;
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
  align-items: center;
  gap: 2rem;
  padding-inline: var(--gutter);
  /* the 3rem bottom padding used to reserve room for the scroll cue that sat
     inside the hero; the cue is now the divider below, so it's given back */
  padding-block: 6rem 1.5rem;
  overflow: hidden;
}

.hero-content {
  display: grid;
  gap: 1.4rem;
  justify-items: start;
}

/* availability status pill — top of hero, links to contact.
   white-space:nowrap keeps the dot and the label on one line: without it a
   narrow grid/flex parent can squeeze the pill into a vertical sliver. */
.status-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  inline-size: max-content;
  max-inline-size: 100%;
  white-space: nowrap;
  padding: 0.4em 0.9em;
  border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  box-shadow: var(--glow-accent);
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 500;
  transition: border-color 0.2s, color 0.2s, box-shadow 0.2s;
}

.status-pill:hover {
  border-color: var(--accent);
  color: var(--text);
  box-shadow: var(--glow-accent), var(--glow-soft);
}

.status-dot {
  position: relative;
  /* never let the flex line squash the dot into an oval */
  flex: 0 0 auto;
  inline-size: 8px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--live);
  box-shadow: var(--glow-live);
}

/* the pulsing ring; disabled under reduced-motion further down */
.status-dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--live);
  animation: status-pulse 2.4s ease-out infinite;
}

@keyframes status-pulse {
  0% {
    transform: scale(1);
    opacity: 0.55;
  }
  70%,
  100% {
    transform: scale(2.6);
    opacity: 0;
  }
}

.hero-eyebrow {
  color: var(--accent);
  font-weight: 600;
  font-size: 0.85rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
}

.hero-eyebrow::before {
  content: "";
  display: inline-block;
  inline-size: 1.6rem;
  block-size: 2px;
  background: var(--accent);
  box-shadow: var(--glow-line);
  margin-inline-end: 0.6rem;
  vertical-align: middle;
}

html[lang="ar"] .hero-eyebrow {
  letter-spacing: 0;
}

/* The name still leads, but it no longer swallows the hero: pulled down from
   10vw/7.5rem so it sits one clear step above the tagline instead of three. */
.hero-name {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(2.55rem, 6.4vw, 4.8rem);
  line-height: 1.05;
  letter-spacing: -0.02em;
  /* soft, wide accent halo so the name lands (not a hard glow) */
  text-shadow: 0 0 34px rgb(168 85 247 / 0.22);
}

html[lang="ar"] .hero-name {
  letter-spacing: 0;
  line-height: 1.25;
}

/* Reads as a statement, not a caption: bigger, weighted up, and lifted off the
   body-copy grey toward --text. Measure tightened to ~36ch so the extra size
   doesn't stretch the line length past comfortable reading. */
.hero-tagline {
  color: color-mix(in srgb, var(--text) 78%, var(--muted));
  font-weight: 500;
  font-size: clamp(1.2rem, 2.3vw, 1.7rem);
  line-height: 1.45;
  letter-spacing: -0.005em;
  max-inline-size: 36ch;
  text-wrap: pretty;
}

/* Arabic sets denser and needs the leading back; letter-spacing must be 0 or
   the cursive joins break apart. */
html[lang="ar"] .hero-tagline {
  letter-spacing: 0;
  line-height: 1.75;
  max-inline-size: 32ch;
}

.hero-cta {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  margin-block-start: 0.6rem;
  padding: 0.95em 1.7em;
  background: var(--accent);
  color: var(--bg);
  font-weight: 600;
  border-radius: 999px;
  transition: background 0.2s, box-shadow 0.2s;
}

.hero-cta:hover {
  background: #b975f9;
  box-shadow: var(--glow-soft);
}

.hero-cta .icon-flip {
  transition: translate 0.2s;
}

.hero-cta:hover .icon-flip {
  translate: 3px 0;
}

[dir="rtl"] .hero-cta:hover .icon-flip {
  translate: -3px 0;
}

/* directional icons mirror in RTL */
[dir="rtl"] .icon-flip {
  transform: scaleX(-1);
}

/* ============ hero scene (decorative, inline-end half) ============ */
/* Pure CSS composition; parallax offset comes from --px/--py set in JS. */

.hero-scene {
  position: relative;
  /* taller so the composition spreads across the whole inline-end side
     instead of clustering in the middle */
  block-size: min(86vh, 46rem);
  transform: translate(calc(var(--px, 0) * 18px), calc(var(--py, 0) * 18px));
  transition: transform 0.6s cubic-bezier(0.2, 0.6, 0.3, 1);
}

.scene-grid {
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(to right, rgb(168 85 247 / 0.16) 1px, transparent 1px),
    linear-gradient(to bottom, rgb(241 238 246 / 0.1) 1px, transparent 1px);
  background-size: 44px 44px;
  /* wider/taller reveal ellipse → grid fills more of the side before fading */
  mask-image: radial-gradient(ellipse 96% 92% at 50% 45%, black 52%, transparent 90%);
  -webkit-mask-image: radial-gradient(ellipse 96% 92% at 50% 45%, black 52%, transparent 90%);
  animation: grid-drift 24s linear infinite;
}

/* One faint LED that circles the grid — same motif as the section rules and
   the scroll cue, just slower and quieter. Only a short arc of the conic
   gradient is lit, so what reads is a single travelling light with a tail,
   never a glowing box. The reveal ellipse is the grid's own, so the light
   dissolves toward the corners instead of drawing a hard rectangle. */
@property --led-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

.scene-led {
  position: absolute;
  inset: 0;
  mask-image: radial-gradient(ellipse 96% 92% at 50% 45%, black 52%, transparent 90%);
  -webkit-mask-image: radial-gradient(ellipse 96% 92% at 50% 45%, black 52%, transparent 90%);
}

.scene-led::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 18px;
  /* this padding IS the rim thickness the two masks leave behind */
  padding: 1px;
  /* A conic sweep advances by ANGLE, so a long tail smears out across the wide
     edges and stays short on the narrow ones. Keeping the lit arc to ~22deg
     holds it to a compact head with a soft tail wherever it happens to be. */
  background: conic-gradient(
    from var(--led-angle, 0deg),
    transparent 0deg 338deg,
    rgb(168 85 247 / 0.16) 348deg,
    rgb(168 85 247 / 0.5) 356deg,
    rgb(226 200 255 / 0.72) 359.2deg,
    transparent 360deg
  );
  /* keep only the rim: subtract the content box out of the border box */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  /* bloom lands only where the arc is actually lit */
  filter: drop-shadow(0 0 4px rgb(168 85 247 / 0.4));
  animation: scene-led-run 18s linear infinite;
}

/* Without @property the angle can't animate: the arc simply rests at its
   start position as a faint static gleam, which is a harmless fallback. */
@keyframes scene-led-run {
  to {
    --led-angle: 360deg;
  }
}

.shape {
  position: absolute;
}

.shape-ring {
  inline-size: clamp(140px, 15vw, 210px);
  aspect-ratio: 1;
  border: 1.5px solid rgb(168 85 247 / 0.6);
  border-radius: 50%;
  inset-block-start: 9%;
  inset-inline-end: 11%;
  animation: float-a 11s ease-in-out infinite alternate;
}

.shape-square {
  inline-size: clamp(52px, 6.5vw, 78px);
  aspect-ratio: 1;
  border: 1.5px solid rgb(168 85 247 / 0.5);
  inset-block-end: 15%;
  inset-inline-start: 7%;
  animation: float-b 14s ease-in-out infinite alternate;
}

.shape-tri {
  inline-size: clamp(38px, 4.5vw, 54px);
  aspect-ratio: 1;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  background: rgb(168 85 247 / 0.28);
  inset-block-start: 66%;
  inset-inline-start: 44%;
  animation: float-a 9s ease-in-out infinite alternate-reverse;
}

.shape-dot {
  inline-size: 10px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: var(--glow-text);
  inset-block-start: 38%;
  inset-inline-end: 42%;
  animation: float-b 8s ease-in-out infinite alternate;
}

@keyframes grid-drift {
  from {
    background-position: 0 0;
  }
  to {
    background-position: 44px 44px;
  }
}

@keyframes float-a {
  from {
    transform: translateY(-10px);
  }
  to {
    transform: translateY(14px);
  }
}

@keyframes float-b {
  from {
    transform: translateY(8px) rotate(20deg);
  }
  to {
    transform: translateY(-14px) rotate(32deg);
  }
}

/* ============ hero divider ============ */
/* Separates the hero from the first section and signals "more below": a
   vertical LED hairline with a glowing pulse running down it.
   It sits in the flow BETWEEN the two sections — not absolutely positioned
   inside the hero — so it is never laid over the hero's grid and shapes, where
   it washed out to nothing on phones. Its own padding IS the gap between the
   two sections, which is why .section:first-of-type gives its top padding back.
   Centered and RTL-agnostic (grid + justify-items), decorative (aria-hidden). */

.hero-divider {
  display: grid;
  justify-items: center;
  padding-block: clamp(1.1rem, 3vh, 1.9rem);
}

.hero-divider-line {
  display: block;
  position: relative;
  inline-size: 2px;
  block-size: 46px;
  border-radius: 2px;
  overflow: hidden;
  /* stronger than the old 0.35 core: on plain page background this reads as a
     deliberate divider rather than a smudge, on phone screens included */
  background: linear-gradient(
    to bottom,
    rgb(168 85 247 / 0) 0%,
    rgb(168 85 247 / 0.6) 50%,
    rgb(168 85 247 / 0) 100%
  );
}

/* glowing pulse that travels down the line */
.hero-divider-line::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  margin-inline: auto;
  inline-size: 2px;
  inset-block-start: 0;
  block-size: 34%;
  border-radius: 2px;
  background: var(--accent);
  box-shadow: var(--glow-line);
  animation: hero-divider-run 2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}

/* Travels on `translate`, not `inset-block-start`. Animating a box offset runs
   layout → paint → composite every frame; translate and opacity are both
   compositor-only, so this runs off the main thread. Identical on screen: the
   percentages are re-based from the line's height onto the pulse's own (34% of
   it), so -40%/100% of the track becomes -118%/294% of the pulse. */
@keyframes hero-divider-run {
  0% {
    translate: 0 -118%;
    opacity: 0;
  }
  30% {
    opacity: 1;
  }
  70% {
    opacity: 1;
  }
  100% {
    translate: 0 294%;
    opacity: 0;
  }
}

/* ============ hero load animation ============ */
/* Final (resting) state is the default CSS; body.is-loaded runs the
   entrance sequence. Whole sequence finishes by ~1.45s. */

.name-mask {
  display: inline-block;
  position: relative;
  overflow: hidden;
  /* room for descenders inside the overflow mask */
  padding-block-end: 0.1em;
  margin-block-end: -0.1em;
}

.name-main {
  display: inline-block;
}

.is-loaded .name-main {
  animation: name-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both;
}

.is-loaded .status-pill {
  animation: rise 0.35s ease 0.55s both;
}

.is-loaded .hero-eyebrow {
  animation: rise 0.35s ease 0.65s both;
}

.is-loaded .hero-tagline {
  animation: rise 0.35s ease 0.78s both;
}

.is-loaded .hero-cta {
  animation: rise 0.35s ease 0.9s both;
}

.is-loaded .hero-scene {
  animation: fade 0.5s ease 0.95s both;
}

.is-loaded .site-nav {
  animation: fade 0.4s ease 1s both;
}

.is-loaded .hero-divider {
  animation: fade 0.6s ease 1.15s both;
}

@keyframes name-in {
  from {
    transform: translateY(115%) skewY(5deg);
  }
  to {
    transform: translateY(0) skewY(0);
  }
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(14px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* ============ sections: shared ============ */

.section {
  position: relative;
  max-inline-size: 72rem;
  margin-inline: auto;
  padding-inline: var(--gutter);
  /* tightened from clamp(4.5rem,12vh,8rem) — sections were floating in dead space */
  padding-block: clamp(3rem, 7vh, 5rem);
  scroll-margin-block-start: 3.5rem;
}

/* The hero divider directly above already supplies the breathing room, so the
   first section doesn't pay for it twice — that double padding was the large
   empty stretch between the hero and Featured work. */
.section:first-of-type {
  padding-block-start: clamp(0.75rem, 2vh, 1.5rem);
}

/* faint accent hairline between sections (never above the first) */
.section:not(:first-of-type)::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  block-size: 1px;
  background: linear-gradient(90deg, transparent, rgb(168 85 247 / 0.22), transparent);
}

.section-head {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-block-end: clamp(1.5rem, 3vw, 2rem);
}

/* LED strip divider that "lights up" on reveal */
.led-line {
  inline-size: 3.5rem;
  block-size: 2px;
  border-radius: 2px;
  background: var(--accent);
  box-shadow: var(--glow-line);
  transform-origin: 0 50%;
}

[dir="rtl"] .led-line {
  transform-origin: 100% 50%;
}

.section-title {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(2rem, 4.5vw, 3.1rem);
  letter-spacing: -0.02em;
}

html[lang="ar"] .section-title {
  letter-spacing: 0;
}

/* ============ featured work ============ */

.work-grid {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
  gap: clamp(1.75rem, 4vw, 3rem);
  align-items: start;
}

.case {
  display: grid;
  gap: 1.5rem;
  justify-items: start;
}

.case-name {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.5rem;
  line-height: 1.3;
}

.case-label {
  color: var(--accent);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  margin-block-end: 0.4rem;
}

html[lang="ar"] .case-label {
  letter-spacing: 0;
}

.case-part p {
  color: var(--muted);
  font-size: 0.98rem;
}

/* tech chips under the case study — smaller than the skills chips so the row
   reads as a caption, not a second skills section */
.case-tech {
  gap: 0.5rem;
}

.case-tech .chip {
  padding: 0.4em 0.85em;
  font-size: 0.82rem;
}

.case-link {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.8em 1.5em;
  border: 1px solid rgb(168 85 247 / 0.5);
  border-radius: 999px;
  color: var(--accent);
  font-weight: 600;
  font-size: 0.95rem;
  transition: background 0.2s, color 0.2s, box-shadow 0.2s;
}

.case-link:hover {
  background: var(--accent);
  color: var(--bg);
  box-shadow: var(--glow-soft);
}

/* browser-frame showcase — two staggered frames.
   The rear frame (catalog) is laid out ABOVE the front frame and pushed to the
   inline-end edge; the front frame (home) is pulled back up by a negative block
   margin so it clips only the rear frame's lower inline-start corner. Roughly
   two-thirds of the rear frame stays fully uncovered, so both screenshots read
   as separate pages instead of one hiding behind the other.
   Both frames stay in normal flow (no absolute positioning), so the container
   height is always correct and the pair reflows cleanly on small screens. */

.showcase {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
}

.browser {
  position: relative;
  background: var(--surface);
  border: 1px solid rgb(241 238 246 / 0.08);
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 24px 60px rgb(0 0 0 / 0.45);
  /* --tilt is read by the transform here AND by the back-in keyframes, so the
     resting angle and the animated one can never drift apart. */
  transform: rotate(var(--tilt, 0deg));
}

/* rear frame: first in the visual stack, flush to the inline-end edge */
.browser-back {
  order: -1;
  z-index: 0;
  inline-size: 70%;
  justify-self: end;
  opacity: var(--shot-opacity, 1);
  --shot-opacity: 0.9;
  --tilt: 2.5deg;
}

[dir="rtl"] .browser-back {
  --tilt: -2.5deg;
}

/* front frame: overlaps upward into the rear frame's inline-start corner */
.browser-front {
  z-index: 1;
  inline-size: 84%;
  justify-self: start;
  margin-block-start: -16%;
}

.browser-bar {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.6rem 0.9rem;
  border-block-end: 1px solid rgb(241 238 246 / 0.07);
}

.b-dot {
  inline-size: 0.55rem;
  aspect-ratio: 1;
  border-radius: 50%;
  background: rgb(241 238 246 / 0.15);
}

.browser-url {
  flex: 1;
  block-size: 1.2rem;
  margin-inline-start: 0.5rem;
  border-radius: 999px;
  background: rgb(241 238 246 / 0.05);
}

.shot-img {
  inline-size: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  object-position: top;
  background: linear-gradient(160deg, var(--surface), #241f31);
}

/* Until the real screenshots land in images/, script.js flags the frame with
   .no-shot: the failed img is hidden (so no broken-image icon and no stray alt
   text) and this quiet on-palette panel stands in at the same 16/10 ratio, so
   the section keeps its intended shape rather than looking broken. */
.browser.no-shot .shot-img {
  display: none;
}

.browser.no-shot::after {
  content: "";
  display: block;
  aspect-ratio: 16 / 10;
  background:
    radial-gradient(70% 60% at 50% 38%, rgb(168 85 247 / 0.13), transparent 72%),
    linear-gradient(160deg, var(--surface), #241f31);
  border-block-start: 1px solid rgb(168 85 247 / 0.12);
}

/* ============ what you get ============ */

.cards {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.4rem;
}

.card {
  background: var(--surface);
  border: 1px solid rgb(241 238 246 / 0.08);
  border-radius: 14px;
  padding: 1.8rem;
  transition: border-color 0.25s, box-shadow 0.25s, transform 0.25s;
}

.card:hover {
  border-color: rgb(168 85 247 / 0.5);
  box-shadow: 0 0 0 1px rgb(168 85 247 / 0.25), var(--glow-soft);
  transform: translateY(-4px);
}

.card h3 {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.15rem;
  margin-block-end: 0.6rem;
}

.card p {
  color: var(--muted);
  font-size: 0.95rem;
}

/* ============ "this site" strip ============ */

.strip {
  position: relative;
  padding-block: 2rem;
  padding-inline: var(--gutter);
  text-align: center;
}

.strip::before,
.strip::after {
  content: "";
  position: absolute;
  inset-inline: 22%;
  block-size: 1px;
  background: linear-gradient(90deg, transparent, rgb(168 85 247 / 0.6), transparent);
  box-shadow: 0 0 12px rgb(168 85 247 / 0.4);
}

.strip::before {
  inset-block-start: 0;
}

.strip::after {
  inset-block-end: 0;
}

.strip p {
  color: var(--muted);
  font-size: 0.95rem;
  max-inline-size: 60ch;
  margin-inline: auto;
}

/* ============ skills ============ */

.skills {
  display: grid;
  gap: 1.5rem;
}

.skill-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem 1.6rem;
  border-block-end: 1px solid rgb(241 238 246 / 0.1);
}

.skill-tab {
  position: relative;
  /* bottom padding drops each tab onto the tablist border, where the LED sits */
  padding: 0.2em 0.1em 0.85rem;
  color: var(--muted);
  font-weight: 600;
  font-size: 0.95rem;
  transition: color 0.2s;
}

.skill-tab:hover {
  color: var(--text);
}

.skill-tab.is-active {
  color: var(--accent);
}

/* LED underline that grows under the active tab */
.skill-tab::after {
  content: "";
  position: absolute;
  inset-block-end: -1px;
  inset-inline: 0;
  block-size: 2px;
  border-radius: 2px;
  background: var(--accent);
  box-shadow: var(--glow-line);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}

[dir="rtl"] .skill-tab::after {
  transform-origin: 100% 50%;
}

.skill-tab.is-active::after {
  transform: scaleX(1);
}

/* inactive panels are removed from flow (overrides .chips display:flex) */
.skill-panel[hidden] {
  display: none;
}

.chips {
  list-style: none;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.8rem;
}

.chip {
  padding: 0.55em 1.1em;
  border: 1px solid rgb(241 238 246 / 0.14);
  border-radius: 999px;
  font-size: 0.92rem;
  font-weight: 500;
  color: var(--muted);
  transition: border-color 0.25s, color 0.25s, box-shadow 0.25s;
}

.chip:hover {
  border-color: rgb(168 85 247 / 0.55);
  color: var(--text);
  box-shadow: 0 0 14px rgb(168 85 247 / 0.15);
}

/* ============ about ============ */

.about-body {
  max-inline-size: 65ch;
  color: var(--muted);
  font-size: 1.05rem;
}

/* ============ contact ============ */

.contact-email {
  display: inline-block;
  position: relative;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.4rem, 4.5vw, 3rem);
  letter-spacing: -0.01em;
  text-shadow: 0 0 16px rgb(168 85 247 / 0.25);
  transition: text-shadow 0.25s;
}

.contact-email:hover {
  text-shadow: 0 0 22px rgb(168 85 247 / 0.55);
}

/* glowing LED underline */
.contact-email::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: -6px;
  block-size: 2px;
  border-radius: 2px;
  background: var(--accent);
  box-shadow: var(--glow-line);
  transform-origin: 0 50%;
}

[dir="rtl"] .contact-email::after {
  transform-origin: 100% 50%;
}

.contact-links {
  display: flex;
  gap: 1.6rem;
  margin-block-start: 2rem;
}

.out-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--muted);
  font-weight: 500;
  transition: color 0.2s;
}

.out-link:hover {
  color: var(--text);
}

/* ============ scroll reveals — DESKTOP ONLY ============ */
/* One distinct entrance per section, driven by IntersectionObserver
   adding .in-view. Hidden states are gated behind html.js (so content
   stays visible without JS) and no-preference (so reduced-motion users
   never get hidden content).
   The `min-width: 761px` on this query is what makes the whole system
   desktop-only, and it is deliberately on the query rather than on a pile of
   per-element overrides further down. Every hidden starting state in here —
   `opacity: 0`, `scaleX(0)`, `clip-path: inset(0 100% 0 0)` — simply never
   applies below the breakpoint, so on a phone there is nothing hidden and
   therefore nothing to reveal: elements are just there, in their final state,
   from first paint. Overriding the animations alone would have left the hidden
   states behind and blanked the page. */

@media (prefers-reduced-motion: no-preference) and (min-width: 761px) {

  /* shared header motif: LED line lights up, title slides after it */
  html.js .reveal .led-line {
    transform: scaleX(0);
  }

  html.js .reveal .section-title {
    opacity: 0;
  }

  html.js .reveal.in-view .led-line {
    animation: led-grow 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
  }

  html.js .reveal.in-view .section-title {
    animation: title-slide 0.55s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
  }

  html.js [dir="rtl"] .reveal.in-view .section-title,
  html.js[dir="rtl"] .reveal.in-view .section-title {
    animation-name: title-slide-rtl;
  }

  /* 1 — WORK: browser wipes open, case study steps in from the side */
  html.js .reveal .browser-front {
    clip-path: inset(0 100% 0 0);
  }

  html.js[dir="rtl"] .reveal .browser-front {
    clip-path: inset(0 0 0 100%);
  }

  html.js .reveal .browser-back,
  html.js .reveal .case-name,
  html.js .reveal .case-part,
  html.js .reveal .case-link {
    opacity: 0;
  }

  html.js .reveal.in-view .browser-front {
    animation: shot-wipe 0.8s cubic-bezier(0.7, 0, 0.3, 1) 0.2s both;
  }

  html.js[dir="rtl"] .reveal.in-view .browser-front {
    animation-name: shot-wipe-rtl;
  }

  html.js .reveal.in-view .browser-back {
    animation: back-in 0.6s ease 0.7s both;
  }

  html.js .reveal.in-view .case-name {
    animation: case-slide 0.5s ease 0.25s both;
  }

  html.js .reveal.in-view .case-part:nth-of-type(1) {
    animation: case-slide 0.5s ease 0.35s both;
  }

  html.js .reveal.in-view .case-part:nth-of-type(2) {
    animation: case-slide 0.5s ease 0.45s both;
  }

  html.js .reveal.in-view .case-part:nth-of-type(3) {
    animation: case-slide 0.5s ease 0.55s both;
  }

  /* The tech chips reuse the shared chip-pop entrance (defined in the SKILLS
     block below) but pop after the three case parts have landed. The extra
     .case-tech class outranks the generic .chip:nth-child delays, so source
     order between the two blocks doesn't matter. */
  html.js .reveal.in-view .case-tech .chip:nth-child(1) { animation-delay: 0.62s; }
  html.js .reveal.in-view .case-tech .chip:nth-child(2) { animation-delay: 0.69s; }
  html.js .reveal.in-view .case-tech .chip:nth-child(3) { animation-delay: 0.76s; }
  html.js .reveal.in-view .case-tech .chip:nth-child(4) { animation-delay: 0.83s; }

  html.js .reveal.in-view .case-link {
    animation: case-slide 0.5s ease 0.9s both;
  }

  html.js[dir="rtl"] .reveal.in-view .case-name,
  html.js[dir="rtl"] .reveal.in-view .case-part,
  html.js[dir="rtl"] .reveal.in-view .case-link {
    animation-name: case-slide-rtl;
  }

  /* 2 — SERVICES: cards flicker on like LED tubes powering up */
  html.js .reveal .card {
    opacity: 0;
  }

  html.js .reveal.in-view .card:nth-child(1) {
    animation: flicker-in 0.9s linear 0.1s both;
  }

  html.js .reveal.in-view .card:nth-child(2) {
    animation: flicker-in 0.9s linear 0.28s both;
  }

  html.js .reveal.in-view .card:nth-child(3) {
    animation: flicker-in 0.9s linear 0.46s both;
  }

  /* 3 — STRIP: LED hairlines grow from the center, text follows */
  html.js .strip.reveal::before,
  html.js .strip.reveal::after {
    transform: scaleX(0);
  }

  html.js .strip.reveal p {
    opacity: 0;
  }

  html.js .strip.reveal.in-view::before,
  html.js .strip.reveal.in-view::after {
    animation: led-grow 0.9s cubic-bezier(0.16, 1, 0.3, 1) both;
  }

  html.js .strip.reveal.in-view p {
    animation: fade 0.6s ease 0.35s both;
  }

  /* 4 — SKILLS: chips spring-pop one by one */
  html.js .reveal .chip {
    opacity: 0;
  }

  html.js .reveal.in-view .chip {
    animation: chip-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }

  html.js .reveal.in-view .chip:nth-child(1) { animation-delay: 0.1s; }
  html.js .reveal.in-view .chip:nth-child(2) { animation-delay: 0.17s; }
  html.js .reveal.in-view .chip:nth-child(3) { animation-delay: 0.24s; }
  html.js .reveal.in-view .chip:nth-child(4) { animation-delay: 0.31s; }
  html.js .reveal.in-view .chip:nth-child(5) { animation-delay: 0.38s; }
  html.js .reveal.in-view .chip:nth-child(6) { animation-delay: 0.45s; }

  /* 5 — ABOUT: paragraph pulls into focus from a blur */
  html.js .reveal .about-body {
    opacity: 0;
  }

  html.js .reveal.in-view .about-body {
    animation: focus-in 0.9s ease 0.15s both;
  }

  /* 6 — CONTACT: email blooms like a neon sign, underline draws in */
  html.js .reveal .contact-email {
    opacity: 0;
  }

  html.js .reveal .contact-email::after {
    transform: scaleX(0);
  }

  html.js .reveal .out-link {
    opacity: 0;
  }

  html.js .reveal.in-view .contact-email {
    animation: bloom-in 0.9s ease 0.1s both;
  }

  html.js .reveal.in-view .contact-email::after {
    animation: led-grow 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.55s both;
  }

  html.js .reveal.in-view .out-link:nth-child(1) {
    animation: rise 0.5s ease 0.7s both;
  }

  html.js .reveal.in-view .out-link:nth-child(2) {
    animation: rise 0.5s ease 0.82s both;
  }

  html.js .reveal.in-view .out-link:nth-child(3) {
    animation: rise 0.5s ease 0.94s both;
  }
}

@keyframes led-grow {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

@keyframes title-slide {
  from {
    opacity: 0;
    transform: translateX(-18px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes title-slide-rtl {
  from {
    opacity: 0;
    transform: translateX(18px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes shot-wipe {
  from {
    clip-path: inset(0 100% 0 0);
  }
  to {
    clip-path: inset(0 0 0 0);
  }
}

@keyframes shot-wipe-rtl {
  from {
    clip-path: inset(0 0 0 100%);
  }
  to {
    clip-path: inset(0 0 0 0);
  }
}

/* --tilt is resolved per element, so this one keyframe covers LTR, RTL and the
   untilted mobile stack — no mirrored copy needed. */
@keyframes back-in {
  from {
    opacity: 0;
    transform: translateY(24px) rotate(var(--tilt, 0deg));
  }
  to {
    opacity: var(--shot-opacity, 1);
    transform: translateY(0) rotate(var(--tilt, 0deg));
  }
}

@keyframes case-slide {
  from {
    opacity: 0;
    transform: translateX(-22px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes case-slide-rtl {
  from {
    opacity: 0;
    transform: translateX(22px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes flicker-in {
  0% {
    opacity: 0;
  }
  8% {
    opacity: 0.6;
  }
  14% {
    opacity: 0.1;
  }
  26% {
    opacity: 0.8;
  }
  34% {
    opacity: 0.3;
  }
  48% {
    opacity: 1;
  }
  100% {
    opacity: 1;
  }
}

@keyframes chip-pop {
  from {
    opacity: 0;
    transform: scale(0.5);
  }
  70% {
    opacity: 1;
    transform: scale(1.08);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes focus-in {
  from {
    opacity: 0;
    filter: blur(14px);
    transform: scale(1.02);
  }
  to {
    opacity: 1;
    filter: blur(0);
    transform: scale(1);
  }
}

@keyframes bloom-in {
  0% {
    opacity: 0;
    text-shadow: 0 0 42px rgb(168 85 247 / 0.9);
  }
  40% {
    opacity: 1;
    text-shadow: 0 0 30px rgb(168 85 247 / 0.75);
  }
  100% {
    opacity: 1;
    text-shadow: 0 0 16px rgb(168 85 247 / 0.25);
  }
}

/* ============ responsive ============ */

@media (max-width: 760px) {
  /* The page glow scrolls with the page rather than being pinned to the
     viewport. `fixed` holds it still while everything else moves, which is a
     backdrop travelling at its own rate — the thing this hero must not do. */
  body {
    background-attachment: scroll;
  }

  /* backdrop-filter re-blurs whatever is passing under the bar on every scroll
     frame — the most expensive thing that can be attached to a scroll handler
     on a phone GPU. A near-opaque bar reads the same over this dark palette. */
  .site-nav.scrolled {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background: color-mix(in srgb, var(--bg) 94%, transparent);
  }

  /* Single column: the scene no longer sits as a tall empty second row under
     the button (that was the black gap). It's pulled out of flow below and
     restyled into a full-bleed backdrop, so the copy fills a centered,
     100svh hero with the ambient composition sitting behind it. */
  .hero {
    grid-template-columns: 1fr;
  }

  /* copy sits above the ambient backdrop */
  .hero-content {
    position: relative;
    z-index: 1;
  }

  /* scene taken out of flow → no empty box under the button. inset:0 stretches
     it across the whole hero identically in LTR and RTL. */
  .hero-scene {
    display: block;
    position: absolute;
    inset: 0;
    z-index: 0;
    block-size: auto;
    /* Cancels the desktop cursor parallax: no --px/--py offset on touch.
       The translateZ(0) layer-promotion hint that was here is gone — a
       full-screen composited layer whose contents carry a mask-image is exactly
       the case where promotion can cost more than it saves, because the masked
       layer gets re-rasterized rather than cheaply cached. With every animation
       in the hero now dead, there is nothing left to promote away from. */
    transform: none;
    /* purely decorative: never intercept taps meant for the CTA */
    pointer-events: none;
  }

  /* dim the composition so it never competes with the copy. Opacity lives on
     the children, not .hero-scene, so the parent's fade-in entrance still runs. */
  .scene-grid {
    opacity: 0.55;
  }

  .shape {
    opacity: 0.6;
  }

  /* ---- the hero is frozen solid on phones ----
     One blanket rule instead of a list, so nothing can be missed here and
     nothing added later can reintroduce motion by accident. Every animation and
     every transition inside the hero — the grid's drift, the four shapes'
     floats, the status dot's pulse, the whole load-in sequence, the hover
     transitions — is off. The hero paints once and scrolls with the page as a
     single block, like plain text.
     `.hero *` covers pseudo-elements too: ::before/::after inherit the
     animation and transition properties they are given here through the
     element, and the explicit ::before/::after selectors below close the gap
     for the ones that declare their own.

     The `:not(.scene-led)` is the one carve-out: the LED rim keeps the desktop
     effect exactly, travelling arc included. Excluding the element also excludes
     its ::before, which is where that animation actually lives. */
  .hero,
  .hero *:not(.scene-led),
  .hero *:not(.scene-led)::before,
  .hero *:not(.scene-led)::after {
    animation: none !important;
    transition: none !important;
  }

  /* The load-in sequence is applied on body.is-loaded, which sits OUTSIDE .hero
     in the selector, so those rules are not caught by the blanket above on
     specificity alone — they are listed out. */
  .is-loaded .name-main,
  .is-loaded .status-pill,
  .is-loaded .hero-eyebrow,
  .is-loaded .hero-tagline,
  .is-loaded .hero-cta,
  .is-loaded .hero-scene {
    animation: none !important;
  }

  /* The divider's travelling pulse sits directly under the hero and runs
     forever; it goes static with everything else. */
  .hero-divider,
  .hero-divider-line,
  .hero-divider-line::after {
    animation: none !important;
    transition: none !important;
  }

  /* The pulsing ring on the status dot: an infinite transform+opacity animation.
     It is compositor-only and cheap in theory, but it keeps a compositor layer
     alive and ticking in the hero for the entire life of the page. The dot
     itself — colour, size, glow — is untouched. */
  .status-dot::after {
    display: none;
  }

  .scene-grid,
  .shape {
    animation: none;
  }

  .scene-grid {
    background-position: 0 0;
  }

  /* float-b held the square at 20–32deg, so freezing it flat would leave an
     axis-aligned box that reads as a different shape. Park it mid-swing. */
  .shape-square {
    transform: rotate(26deg);
  }

  /* NOTHING for .scene-led here on purpose. The LED rim inherits the desktop
     rule verbatim — the conic gradient, both masks, the drop-shadow bloom, the
     travelling arc, full opacity. Any declaration added in this block would by
     definition make the mobile border differ from the desktop one. */

  .nav-links {
    display: none;
  }

  .work-grid {
    grid-template-columns: 1fr;
  }

  /* DOM order stands on mobile: the case study reads first (name, then problem
     / what I built / result), and the screenshots follow it. The showcase used
     to be pulled above the heading with order:-1, which left the screenshots
     floating over the section with no title to introduce them. */
  .showcase {
    gap: 1.1rem;
  }

  /* No room to stagger on a phone: both frames go full width, upright, in DOM
     order (home, then catalog) with real space between them.
     Selector is `.showcase .browser` — deliberately two classes, so it outranks
     `[dir="rtl"] .browser-back` and the RTL tilt is actually cleared here. A
     one-class selector loses that cascade fight and leaves the rear frame
     rotated, which pushes it past the edge of the grid column. */
  .showcase .browser {
    order: 0;
    inline-size: 100%;
    justify-self: stretch;
    margin-block-start: 0;
    --shot-opacity: 1;
    --tilt: 0deg;
  }

  .cards {
    grid-template-columns: 1fr;
  }
}

/* ============ reduced motion ============ */

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  .is-loaded .name-main,
  .is-loaded .hero-eyebrow,
  .is-loaded .hero-tagline,
  .is-loaded .hero-cta,
  .is-loaded .hero-scene,
  .is-loaded .site-nav,
  .is-loaded .hero-divider,
  .is-loaded .status-pill,
  .status-dot::after,
  .scene-grid,
  .scene-led::before,
  .shape {
    animation: none;
  }

  /* no pulsing ring under reduced motion; keep the dot itself visible */
  .status-dot::after {
    display: none;
  }

  /* freeze the travelling pulse; the static hairline still reads as a divider */
  .hero-divider-line::after {
    display: none;
  }

  .hero-scene {
    transition: none;
  }

  .skill-tab::after {
    transition: none;
  }
}
