/* Typography lives in css/fonts.css (Dana) and css/tokens.css (the stack).
   This file used to open with @font-face rules for Gofteh and IRANYekanX,
   both single static weights, declared as `font-weight:400 900`. A static face
   claiming a range is served unchanged for every weight, so all eight weights
   the design system asks for rendered as the same regular face — no bold
   headings anywhere on the homepage. The language overrides that followed also
   named Inter and Noto Sans SC, which nothing in the project ever declared. */
  /* No per-language font override: the same family renders every locale, so
     switching language can no longer change the typeface. font-synthesis
     stays off so a missing weight shows a real fallback, never a smeared one. */
  html[data-lang="fa"], html[data-lang="ar"]{ font-synthesis:none; }
  h1,h2,h3,h4,.hero-name,.h-title,.hop-role,.svc-title,.proj-title,.class-subject{font-family:var(--font-display);}
  h1,h2,h3,h4,.hero-name,.h-title,.svc-title,.proj-title,.class-subject,.hop-role{text-wrap:balance;}
  p{text-wrap:pretty;}
  html[data-lang="fa"] h1,html[data-lang="ar"] h1,
  html[data-lang="fa"] h2,html[data-lang="ar"] h2,
  html[data-lang="fa"] h3,html[data-lang="ar"] h3,
  html[data-lang="fa"] h4,html[data-lang="ar"] h4,
  html[data-lang="fa"] .hero-name,html[data-lang="ar"] .hero-name,
  html[data-lang="fa"] .h-title,html[data-lang="ar"] .h-title,
  html[data-lang="fa"] .hop-role,html[data-lang="ar"] .hop-role,
  html[data-lang="fa"] .svc-title,html[data-lang="ar"] .svc-title,
  html[data-lang="fa"] .proj-title,html[data-lang="ar"] .proj-title,
  html[data-lang="fa"] .class-subject,html[data-lang="ar"] .class-subject,
  html[data-lang="fa"] .eyebrow,html[data-lang="ar"] .eyebrow{
    font-weight:900;
    letter-spacing:0;
  }
  html[data-lang="en"] h1,html[data-lang="en"] h2,html[data-lang="en"] h3,html[data-lang="en"] h4,html[data-lang="en"] .hero-name,html[data-lang="en"] .h-title,html[data-lang="en"] .hop-role,html[data-lang="en"] .svc-title,html[data-lang="en"] .proj-title,html[data-lang="en"] .class-subject,
  html[data-lang="fr"] h1,html[data-lang="fr"] h2,html[data-lang="fr"] h3,html[data-lang="fr"] h4,html[data-lang="fr"] .hero-name,html[data-lang="fr"] .h-title,html[data-lang="fr"] .hop-role,html[data-lang="fr"] .svc-title,html[data-lang="fr"] .proj-title,html[data-lang="fr"] .class-subject{ letter-spacing:-.015em; }
  *{box-sizing:border-box; margin:0; padding:0;}
  html{-webkit-tap-highlight-color:transparent;}
  html{scroll-behavior:smooth; color-scheme:dark; scrollbar-width:none; -ms-overflow-style:none;}
  html::-webkit-scrollbar{display:none; width:0; height:0;}
  body{
    /* font-family comes from the shared base rule in css/tokens.css. */
    background-color:var(--ink); color:var(--paper);
    line-height:1.75; -webkit-font-smoothing:antialiased; font-optical-sizing:auto; text-rendering:optimizeLegibility;
    -webkit-text-size-adjust:100%; text-size-adjust:100%; font-synthesis-weight:none;
    transition:background .25s ease, color .25s ease;
  }
::selection{background-color:var(--signal); color:var(--on-accent);}
  /* Visually hidden but still read out. Used where the same string is also
     rendered visually by something that has to stay out of the a11y tree (the
     typewriter). clip-path rather than the legacy clip:inset(), so it works
     unprefixed where clip-path is supported. */
  .sr-only{
    position:absolute !important; width:1px !important; height:1px !important;
    margin:-1px !important; padding:0 !important; overflow:hidden !important;
    clip-path:inset(50%) !important; white-space:nowrap !important; border:0 !important;
  }
  /* ---------- focus indicator ----------
     Catch-all first, so nothing on the page can end up with no visible focus.
     The old rule used `outline:none` plus a box-shadow, which is a real WCAG
     2.4.7 failure in forced-colors / Windows High Contrast: that mode strips
     box-shadow, so keyboard users were left with nothing at all. A real
     outline survives forced-colors, so it is the primary indicator and the
     box-shadow is only decoration. */
  :focus-visible{outline:2px solid var(--signal); outline-offset:2px;}
  a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible,
  select:focus-visible, summary:focus-visible, [tabindex]:focus-visible{
    outline:2px solid var(--signal); outline-offset:2px;
    box-shadow:0 0 0 3px color-mix(in srgb, var(--signal) 40%, transparent); border-radius:4px;
  }
  /* Keep the ring readable on the signal-coloured surfaces (buttons, links on
     the gradient) where a same-colour outline would disappear. */
  .btn:focus-visible, button[type="submit"]:focus-visible{
    outline-color:var(--ink); box-shadow:0 0 0 3px var(--grad);
  }

  /* ---------- skip link ---------- */
  .skip-link{
    position:fixed; z-index:9999; top:16px; inset-inline-end:16px; transform:translateY(-500%); background-color:var(--signal); color:var(--on-accent);
    padding:10px 18px; border-radius:8px; font-size:13.5px; font-weight:600; transition:transform .2s ease;
  }
  .skip-link:focus{transform:translateY(0);}

  /* ---------- preloader ---------- */
  /* The preloader is an opaque full-screen overlay that only JS ever removes
     (by adding .hide), so it must not exist at all without scripting —
     otherwise the visitor stares at a loading screen forever. `.js` is set by
     js/theme-init.js before first paint. */
  #preloader{
    position:fixed; inset:0; z-index:9998; background-color:var(--ink);
    display:none; flex-direction:column; align-items:center; justify-content:center; gap:16px;
    /* Same discrete-property treatment as .fab-mini: visibility flips at once
       when hiding so the fade can run, and is only delayed when coming back, so
       the overlay never keeps eating clicks for a second after it fades out. */
    transition:opacity .5s ease, visibility 0s linear .5s;
  }
  .js #preloader{display:flex;}
  #preloader.hide{opacity:0; visibility:hidden; pointer-events:none; transition:opacity .5s ease, visibility 0s;}
  .pl-spinner{
    width:42px; height:42px; border-radius:50%; border:3px solid var(--line); border-top-color:var(--signal);
    animation:pl-spin .8s linear infinite;
  }
  @keyframes pl-spin{to{transform:rotate(360deg);}}
  .pl-label{font-family:var(--mono); direction:ltr; font-size:12px; color:var(--muted-2); letter-spacing:.04em;}
  @media (prefers-reduced-motion: reduce){
    .pl-spinner{animation:none; border-top-color:var(--line);}
  }
  a{color:inherit; text-decoration:none;}
  img{max-width:100%; display:block;}
  .wrap{max-width:1080px; margin:0 auto; padding:0 20px;}
  @media(min-width:768px){.wrap{padding:0 32px;}}

  /* Scroll-reveal is progressive enhancement, so the hiding rule is scoped to
     .js (set by the inline bootstrap in <head>). Without JS nothing ever adds
     .in, and an unscoped `opacity:0` would leave the whole page invisible —
     which silently breaks the static no-JS content in index.html. */
  .js [data-reveal]{opacity:0; transform:translateY(14px); transition:opacity .6s ease, transform .6s ease;}
  .js [data-reveal].in{opacity:1; transform:none;}
  @media (prefers-reduced-motion: reduce){.js [data-reveal]{opacity:1; transform:none; transition:none;} *{animation:none !important;}}

  /* ---------- header (glass) ---------- */
  header.topbar{
    /* 20px here is the mirror of the fixed footer's 20px below its card. The
       base value was 14px and only the 768px breakpoint raised it to 20, so on
       a phone the header's band was 68px and the footer's 74px — the bars were
       the same 54px but the two framed differently, which is what "the header
       is bigger" looks like. Same number everywhere now; the 768px rule below
       only changes the horizontal padding. */
    position:sticky; top:calc(20px + env(safe-area-inset-top)); z-index:50;
    max-width:none; width:100%; margin:0 auto;
    /* --gutter, and only --gutter. The side inset is what lines the header up
       with the text under it, so it has to be the same number the content
       container pads with. This was a literal 12px here and 20px above 768px
       against a flat 16px in every content container, which put the bar 4px
       outside the text on a phone and 4px inside it on a tablet — the two ends
       of the same bug, in opposite directions. */
    padding:0 var(--gutter);
    /* The wrapper itself carries no paint today, so rounding it changes
       nothing on screen. It is set anyway so a background or border added
       here later cannot reintroduce a square header. */
    border-radius:999px;
  }
  /* The pill takes --band-w, the same width as the footer card and, since
     --band-w is now --page-w minus both gutters, the same width as the page's
     content column. It used to be the full width of the page, which is what
     made the pair look mismatched once the card was sized down: a 1560px bar
     above an 1080px card. --head-inset is this same inset, published so the
     floating action button can align to the band's edge. */
  :root{--head-inset:var(--gutter);}
  .topbar-inner{
    display:flex; align-items:center; justify-content:space-between; height:var(--band-h); gap:10px;
    width:var(--band-w); max-width:100%; margin-inline:auto;
    padding:0 12px;    /* Full pill, not a rounded rectangle: 999px resolves against the --band-h bar
       height, so every corner is a true half-circle. The old value came from
       --radius-xl, which capped the corners well short of that and read as a
       square with softened edges. */
    border-radius:999px;
    background-color:var(--header-bg); backdrop-filter:var(--glass-blur); -webkit-backdrop-filter:var(--glass-blur);
    border:1px solid var(--header-border);
    box-shadow:var(--header-shadow);
  }
  /* The vertical offset is no longer restated per breakpoint — it is 20px at
     every width, set once on the rule above. */
  .brand{display:flex; align-items:center; gap:8px; font-weight:700; font-size:14px; flex:none; white-space:nowrap;}
  .brand .dot{width:8px; height:8px; border-radius:50%; background-color:var(--signal); box-shadow:0 0 0 3px var(--signal-dim);}
  .head-controls{display:flex; align-items:center; gap:8px; flex:none;}
  .lang-btn-flag{display:inline-flex; align-items:center;}
  .flag-icon{width:18px; height:13px; border-radius:2px; display:block; box-shadow:0 0 0 1px var(--line);}
  .flag-ic{display:inline-flex; border-radius:3px; overflow:hidden; box-shadow:0 0 0 1px var(--line); flex:none;}
  .flag-ic svg{display:block;}
  .icon-btn{
    display:flex; align-items:center; justify-content:center; width:44px; height:44px; border:1px solid var(--line);
    border-radius:999px; background:transparent; color:var(--paper); cursor:pointer; transition:border-color .15s, color .15s; flex:none;
  }
  .icon-btn:hover{border-color:var(--signal); color:var(--signal);}
  .icon-btn:active{transform:scale(.95);}
  .lang-wrap{position:relative;}
  /* A fixed square footprint, not padding-derived: the width was auto, so the
     button sized to the 18x13 flag plus 8px padding and came out 36x44. A
     999px radius on a non-square box clamps to a pill, never a circle. width
     and height now match, so the flag sits in a real 44px circle. */
  .lang-btn{display:flex; align-items:center; justify-content:center; gap:6px; width:44px; height:44px; padding:0; border:1px solid var(--line); border-radius:999px; background:transparent; color:var(--paper); font-size:12.5px; font-family:var(--font-body); cursor:pointer;}
  .lang-btn:hover{border-color:var(--signal);}
  .lang-btn:active{transform:scale(.98);}
  .lang-menu{
    position:absolute; inset-inline-end:0; top:calc(100% + 8px); background-color:var(--panel); border:1px solid var(--line);
    border-radius:16px; padding:6px; min-width:52px; display:none; z-index:60; box-shadow:0 12px 30px rgba(0,0,0,.35);
  }
  .lang-menu.open{display:block; animation:menuPop .18s ease-out;}
  @keyframes menuPop{from{opacity:0; transform:translateY(-6px);} to{opacity:1; transform:none;}}
  .lang-opt{display:flex; align-items:center; justify-content:center; width:100%; min-height:44px; padding:8px; border-radius:var(--radius-sm); background:none; border:none; color:var(--paper); cursor:pointer; text-align:start;}
  .lang-opt .lo-flag{display:inline-flex; align-items:center;}
  .lang-opt:hover{background-color:var(--panel-3);}
  .lang-opt.active{color:var(--signal);}
  .mob-btn{display:flex; align-items:center; justify-content:center; width:44px; height:44px; border:1px solid var(--line); border-radius:999px; background:transparent; color:var(--paper);}
  .mob-btn:hover{border-color:var(--signal); color:var(--signal);}
  .mob-btn:active{transform:scale(.95);}
  @media(min-width:1024px){.mob-btn{display:none;}}
  #mobile-nav{display:none; margin-top:8px; padding:10px 18px; border-radius:20px; background-color:var(--header-bg); backdrop-filter:blur(24px) saturate(200%) brightness(1.08); border:1px solid var(--line); box-shadow:0 12px 30px -8px rgba(0,0,0,.35);}
  #mobile-nav.open{display:block; animation:menuPop .22s ease-out;}
  #mobile-nav a{display:block; padding:10px 4px; font-size:14px; color:var(--muted); border-bottom:1px solid var(--line-soft); transition:color .15s;}
  #mobile-nav a.nav-active{color:var(--signal);}
  @media(max-width:360px){
    .topbar-inner{gap:7px; padding:0 12px;}
    .head-controls{gap:6px;}
    /* Shrinks as a set, so the language button stays a circle instead of
       drifting back to a pill once it is no longer square. */
    .icon-btn,.mob-btn,.lang-btn{width:36px; height:36px; padding:0;}
  }

  /* No border-radius here on purpose. This base rule used to declare
   var(--radius) and the page layer below declared the same property twice more
   for the same selector — the card family at --radius-lg, then this one box
   promoted to --radius-xl. Three values for one property on one selector,
   where only the last one is ever rendered. The page layer owns the corner. */
.panel-chrome{background-color:var(--panel); border:1px solid var(--line); overflow:hidden;}
  .panel-bar{display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--line); background-color:var(--panel-2);}
  .panel-bar .knob{width:8px; height:8px; border-radius:50%; background-color:var(--line);}
  .panel-bar .path{font-family:var(--mono); direction:ltr; font-size:11px; color:var(--muted-2); margin-inline-start:6px;}
  .panel-body{padding:26px 20px;}
  @media(min-width:768px){.panel-body{padding:34px 30px;}}

  section{padding:64px 0; scroll-margin-top:100px;}
  @media(min-width:768px){section{padding:88px 0;}}
  .eyebrow{font-family:var(--font-body); font-size:11px; letter-spacing:.02em; color:var(--signal); margin-bottom:10px;}
  .eyebrow::before{content:'// ';}
  /* Sections whose eyebrow line reads as redundant against the heading right
     below it. The `// ` prefix comes from the pseudo-element above, so hiding
     the element removes the whole line rather than leaving a dangling slash.

     These nine were: مسیر حرفه‌ای / آموزش / خدمات طراحی وب / نمونه‌کارها /
     دانلود / تحصیلات / گواهی‌نامه‌ها / سوالات پرتکرار / در ارتباط باشیم.
     The section, its <h2> and all its content stay exactly as they were — only
     the label line goes. `display:none` rather than a visually-hidden clip
     because the element is decorative: the <h2> beside it is the real heading
     and is what gives the section its accessible name, so nothing is lost for
     a screen reader. The //about/ and //skills/ eyebrows are deliberately NOT
     in this list; they were kept. */
  .eyebrow-off{display:none;}
  h2.h-title{font-size:clamp(1.5rem, 1.15rem + 1.25vw, 2rem); font-weight:800; letter-spacing:-.01em;}
  .section-head{margin-bottom:34px; max-width:640px;}

  .hero{padding-top:52px; margin-bottom:28px;} @media(min-width:768px){.hero{padding-top:70px; margin-bottom:36px;}}
  .hero-eyebrow{display:inline-flex; align-items:center; gap:8px; font-family:var(--font-body); font-size:12px; color:var(--amber); border:1px solid var(--signal-dim); background-color:var(--panel-2); border-radius:999px; padding:5px 12px; margin-bottom:22px;}
  .hero-eyebrow .dt{width:6px; height:6px; border-radius:50%; background-color:var(--amber); animation:ping 1.8s ease-in-out infinite;}
  @keyframes ping{0%,100%{opacity:1;} 50%{opacity:.25;}}
  /* NB: this was `h1.hero-name`, which matched nothing — the hero name is a
     <div>, so the display size, weight and tracking below were never applied
     at all. Keep it unprefixed so it cannot silently stop matching again. */
  /* 3.625rem (58px) was visually shouting. A name is a label, not a headline:
   it should read as the page's anchor without becoming the only thing on it.
   2.625rem keeps it clearly the largest element while letting the section
   headings and the work below it hold their own. */
.hero-name{font-size:clamp(1.875rem, 1.2rem + 2.9vw, 2.625rem); font-weight:800; line-height:1.18; letter-spacing:-.02em;}
  .hero-role{font-family:var(--font-body); color:var(--signal); font-size:13.5px; margin-top:12px; font-weight:600;}
  .hero-intro{color:var(--muted); font-size:16px; max-width:52ch; margin-top:20px; text-align:start;}
  /* Justified text is a Persian convention: it evens out the rag without the
     inter-word rivers you get in English, so it is scoped to RTL only. The
     previous unconditional `text-align:justify` was stretching the English
     copy into visible gaps at every line break. */
  [dir="rtl"] .hero-intro{text-align:justify; text-align-last:start;}
  .hero-cta{display:flex; flex-wrap:wrap; gap:12px; margin-top:30px;}
  .hero-avail{display:flex; flex-wrap:wrap; gap:8px; margin-top:14px;}
  .avail-chip{display:inline-flex; align-items:center; gap:7px; font-family:var(--font-body); font-size:11.5px; color:var(--muted); background-color:var(--panel-2); border:1px solid var(--line); border-radius:999px; padding:5px 12px;}
  .avail-chip svg{flex:none; color:var(--signal);}
  .btn{display:inline-flex; align-items:center; gap:8px; padding:12px 22px; border-radius:8px; font-size:14.5px; font-weight:600; transition:transform .15s ease, background .15s ease, border-color .15s ease;}
  .btn:active{transform:scale(.98);}
  .btn-primary{background-color:var(--signal); color:var(--on-accent);}
  .btn-primary:hover{filter:brightness(1.1);}
  .btn-ghost{border:1px solid var(--line); color:var(--paper);}
  .btn-ghost:hover{border-color:var(--signal); color:var(--signal);}
  .hero-stats{display:flex; gap:0; margin-top:44px; border-top:1px solid var(--line);}
  .hero-stats div{flex:1; padding:18px 10px 0; border-inline-start:1px solid var(--line);}
  .hero-stats div:first-child{border-inline-start:none;}
  .hero-stats b{display:block; font-size:22px; font-weight:800;}
  .hero-stats span{font-size:12px; color:var(--muted-2);}

  .trusted-strip{display:flex; flex-wrap:wrap; align-items:center; gap:12px; padding:28px 0 52px; border-bottom:1px solid var(--line-soft); margin-bottom:60px;}
  .ts-label{font-size:12.5px; color:var(--muted-2); white-space:nowrap;}
  .ts-orgs{display:flex; flex-wrap:wrap; gap:8px; align-items:center;}
  .ts-logo-card{display:flex; align-items:center; gap:8px; border:1px solid var(--line); border-radius:8px; padding:6px 12px; background-color:var(--panel-2);}
  .ts-logo-card img{width:22px; height:22px; object-fit:contain; border-radius:4px; flex:none;}
  .ts-logo-card span{font-size:12.5px; color:var(--paper); white-space:nowrap;}

  .about-body{color:var(--muted); font-size:var(--text-base); max-width:64ch; text-align:start;}
  .tag-row{display:flex; flex-wrap:wrap; gap:9px; margin-top:24px;}
  .tag{font-family:var(--font-body); font-size:12.5px; color:var(--signal); border:1px solid var(--signal-dim); border-radius:6px; padding:6px 11px;}

  .hop{display:grid; grid-template-columns:auto 1fr; gap:16px; padding:20px 0; border-bottom:1px solid var(--line-soft);}
  .hop:last-child{border-bottom:none;}
  .hop-num{font-family:var(--mono); color:var(--muted-2); font-size:13px; padding-top:3px; min-width:34px;}
  .hop-head{display:flex; flex-wrap:wrap; align-items:baseline; gap:10px 14px;}
  .hop-role{font-weight:700; font-size:16.5px;}
  .hop-org{color:var(--signal); font-size:13.5px;}
  .hop-meta{font-family:var(--font-body); color:var(--muted-2); font-size:12px; margin-top:4px;}
  .hop-points{margin-top:12px; display:grid; gap:7px;}
  .hop-points li{list-style:none; color:var(--muted); font-size:14px; padding-inline-start:18px; position:relative;}
  .hop-points li::before{content:'›'; position:absolute; inset-inline-start:0; color:var(--signal);}

  .rack-grid{display:grid; gap:20px;} @media(min-width:640px){.rack-grid{grid-template-columns:1fr 1fr;}}
  .rack{background-color:var(--panel-2); border:1px solid var(--line); border-radius:var(--radius); padding:18px 20px;}
  .rack-title{font-family:var(--font-body); font-size:12.5px; font-weight:600; color:var(--muted-2); margin-bottom:12px; text-align:start;}
  .rack-items{display:flex; flex-wrap:wrap; gap:8px;}
  .rack-item{display:flex; align-items:center; gap:6px; font-size:13px; border:1px solid var(--line); border-radius:6px; padding:6px 10px;}
  .rack-item .p{width:5px; height:5px; border-radius:50%; background-color:var(--signal); flex:none;}
  .rack-full{grid-column:1/-1;}

  .classes-grid{display:grid; gap:16px;} @media(min-width:640px){.classes-grid{grid-template-columns:1fr 1fr;}} @media(min-width:960px){.classes-grid{grid-template-columns:1fr 1fr 1fr;}}
  .class-card{background-color:var(--panel); border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; transition:border-color .2s, transform .2s, box-shadow .2s;}
  .class-card:hover{border-color:var(--signal); transform:translateY(-2px); box-shadow:0 12px 28px -12px rgba(0,0,0,.3);}
  .class-banner{width:100%; aspect-ratio:16/9; object-fit:cover; display:block; background-color:var(--panel-2);}
  .class-body{padding:18px 20px;}
  .class-subject{font-weight:700; font-size:15.5px; margin-bottom:8px;}
  .class-hours{font-family:var(--font-body); font-size:12.5px; color:var(--muted-2); margin-bottom:14px; display:flex; align-items:center; gap:6px;}
  .class-watch{display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; font-size:13px; font-weight:600; color:var(--signal); padding:8px 14px; border:1px solid var(--signal-dim); border-radius:8px; transition:background .15s, color .15s;}
  .class-watch:hover{background-color:var(--signal); color:var(--on-accent);}

  .svc-tabs{display:flex; flex-wrap:wrap; gap:8px; margin-top:22px;}
  .svc-tabs:empty{display:none;}
  .svc-tab{appearance:none; font-family:var(--font-body); font-size:13px; font-weight:600; color:var(--muted); background-color:transparent; border:1px solid var(--line); border-radius:999px; padding:8px 15px; min-height:40px; cursor:pointer; transition:color .15s, border-color .15s, background-color .15s;}
  .svc-tab:hover{color:var(--paper); border-color:var(--muted-2);}
  .svc-tab.active{color:var(--on-accent); background-color:var(--signal); border-color:var(--signal);}
  .svc-grid{display:grid; gap:16px; margin-top:22px;} @media(min-width:640px){.svc-grid{grid-template-columns:1fr 1fr;}} @media(min-width:1000px){.svc-grid{grid-template-columns:repeat(3,1fr);}}
  .svc-card{background-color:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:24px; display:flex; flex-direction:column; height:100%; transition:border-color .2s, transform .2s, box-shadow .2s;}
  .svc-card:hover{border-color:var(--signal); transform:translateY(-2px); box-shadow:0 12px 28px -14px rgba(0,0,0,.35);}
  .svc-card[hidden]{display:none;}
  .svc-icon{align-self:center; display:inline-flex; align-items:center; justify-content:center; width:52px; height:52px; border-radius:16px; background-color:var(--signal-dim); color:var(--signal); margin-bottom:18px;}
  .svc-icon svg{width:24px; height:24px; display:block;}
  .svc-title{font-weight:700; font-size:16.5px; text-align:center;}
  .svc-desc{color:var(--muted); font-size:13.8px; line-height:1.75; margin-top:8px; text-align:center; flex:1;}
  .svc-tags{display:flex; flex-wrap:wrap; justify-content:center; gap:6px; margin-top:16px;}
  .svc-tags li{list-style:none; font-family:var(--font-body); font-size:11.5px; color:var(--muted-2); border:1px solid var(--line); border-radius:999px; padding:4px 10px;}
  .svc-cta{margin-top:20px; display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; padding:10px; border:1px solid var(--line); border-radius:10px; font-size:13.5px; font-weight:600; background:none; color:var(--paper); cursor:pointer; width:100%; transition:border-color .15s, color .15s, background .15s;}
  .svc-cta svg{width:15px; height:15px;}
  .svc-cta:hover{border-color:var(--signal); color:var(--signal);}
  .svc-cta.active-cta{background-color:var(--signal); color:var(--on-accent); border-color:var(--signal);}

  /* ---------- contact popover ---------- */
  #contact-popover{position:fixed; z-index:80; width:240px; background-color:var(--panel); border:1px solid var(--line); border-radius:16px; padding:8px; box-shadow:0 20px 40px rgba(0,0,0,.4); display:none;}
  #contact-popover.open{display:block;}
  #contact-popover .cp-title{font-size:12px; color:var(--muted-2); padding:8px 10px 4px;}
  #contact-popover a{display:flex; align-items:center; gap:10px; padding:10px; border-radius:var(--radius-sm); font-size:13.5px; font-weight:500; color:var(--paper); transition:background .15s;}
  #contact-popover a:hover{background-color:var(--panel-2);}

  .proj-grid{display:grid; gap:16px;} @media(min-width:720px){.proj-grid{grid-template-columns:1fr 1fr;}}
  .proj-card{background-color:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:24px; transition:border-color .2s, transform .2s, box-shadow .2s;}
  .proj-img{width:100%; aspect-ratio:16/9; object-fit:cover; border-radius:8px; margin-bottom:16px; border:1px solid var(--line);}
  .proj-card:hover{border-color:var(--signal); transform:translateY(-2px); box-shadow:0 12px 28px -12px rgba(0,0,0,.3);}
  .proj-tag{font-family:var(--font-body); font-size:12px; font-weight:600; color:var(--amber);}
  .proj-title{font-weight:700; font-size:17px; margin-top:10px;}
  .proj-desc{color:var(--muted); font-size:14px; margin-top:10px; text-align:start;}
  .proj-impact{display:flex; align-items:center; gap:7px; font-size:12px; color:var(--signal); margin-top:10px; font-family:var(--font-body);}
  .proj-link{display:block; margin-top:16px; font-family:var(--font-body); font-size:12.5px; color:var(--muted-2); border-top:1px dashed var(--line); padding-top:10px;}
  .proj-actions{display:flex; flex-wrap:wrap; gap:10px; margin-top:16px; border-top:1px dashed var(--line); padding-top:10px;}
  .proj-actions .proj-link{display:inline-flex; align-items:center; gap:7px; min-height:44px; margin:0; border:1px solid var(--line); background-color:var(--panel-2); color:var(--paper); padding:8px 14px; border-radius:8px; font-weight:600; transition:border-color .15s, color .15s, background .15s;}
  .proj-actions .proj-link:hover{border-color:var(--tag-ink); color:var(--tag-ink); background-color:var(--signal-dim);}
  .proj-link-none{color:var(--muted-2);}

  .resume-grid{display:grid; gap:14px;} @media(min-width:640px){.resume-grid{grid-template-columns:1fr 1fr;}}
  .resume-card{display:flex; align-items:center; justify-content:space-between; gap:14px; background-color:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px 20px; transition:border-color .2s, transform .2s, box-shadow .2s;}
  .resume-card:hover{border-color:var(--signal); transform:translateY(-2px); box-shadow:0 12px 26px -10px rgba(0,0,0,.28);}
  .resume-card b{font-size:14.5px;}
  .resume-card span.dl{font-family:var(--font-body); font-size:12.5px; font-weight:600; color:var(--signal);}

  .rec-grid{display:grid; gap:14px;} @media(min-width:640px){.rec-grid.certs{grid-template-columns:repeat(3,1fr);}}
  .rec-card{background-color:var(--panel-2); border:1px solid var(--line); border-radius:var(--radius); padding:18px 20px;}
  .rec-card b{font-size:14.5px; display:block;}
  .rec-card .org{color:var(--signal); font-size:13px; margin-top:4px;}
  .rec-card .meta{font-family:var(--font-body); color:var(--muted-2); font-size:12.5px; margin-top:8px;}

  .faq-body{display:flex; flex-direction:column; gap:12px;}
  .faq-item{border-bottom:1px solid var(--line-soft);}
  .faq-item:last-child{border-bottom:none;}
  .faq-item summary{
    list-style:none; cursor:pointer; padding:16px 4px; display:flex; align-items:center; justify-content:space-between;
    font-weight:600; font-size:14.5px; gap:12px;
  }
  .faq-item summary::-webkit-details-marker{display:none;}
  .faq-item summary .chev{flex:none; transition:transform .2s; color:var(--muted-2);}
  .faq-item[open] summary .chev{transform:rotate(180deg); color:var(--signal);}
  .faq-item p{color:var(--muted); font-size:14px; padding:0 4px 18px; line-height:1.8;}

  .contact-grid{display:grid; gap:32px;} @media(min-width:900px){.contact-grid{grid-template-columns:1fr 1fr;}}
  .contact-line{display:flex; align-items:center; gap:12px; padding:12px 0; border-bottom:1px solid var(--line-soft);}
  .contact-line .ic{width:34px; height:34px; border-radius:8px; border:1px solid var(--line); display:flex; align-items:center; justify-content:center; flex:none;}
  .contact-line span.v{font-family:var(--mono); direction:ltr; text-align:start; font-size:13.5px;}
  .contact-line span.v.v-local{font-family:var(--font-body); direction:inherit; text-align:start;}
  .ports-row{display:flex; flex-wrap:wrap; gap:8px; margin-top:18px;}
  .port{width:44px; height:44px; border-radius:8px; border:1px solid var(--line); display:flex; align-items:center; justify-content:center; transition:border-color .15s, color .15s;}
  .port:hover{border-color:var(--signal); color:var(--signal);}
  form.contact-form{display:flex; flex-direction:column; gap:14px;}
  .field label{display:block; font-size:12.5px; color:var(--muted-2); margin-bottom:6px; font-family:var(--font-body);}
  .field input, .field textarea{width:100%; background-color:var(--panel-2); border:1px solid var(--line); border-radius:8px; color:var(--paper); padding:11px 14px; font-family:var(--font-body); font-size:16px;}
  .field textarea{resize:vertical; min-height:96px;}
  .field.honeypot{position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; margin:0; padding:0;}
  .field.honeypot input{font-size:0; width:1px; height:1px; border:0; padding:0;}
  button.submit-btn{background-color:var(--signal); color:var(--on-accent); border:none; border-radius:8px; padding:12px; font-weight:700; font-size:14.5px; cursor:pointer;}
  button.submit-btn:hover{filter:brightness(1.1);}

  /* ---------- floating quick-contact ----------
     Reworked to the previous site's own quick-contact, scaled to 70% and
     re-anchored to this layout's band system. Three changes, in order of how
     much they alter the look.

     1. The wrap is no longer a glass pill. It was, and there was a lot of
        careful arithmetic holding it up: --fab-h animating 80 -> 196, 11px of
        padding on each side, overflow:hidden to clip the two hidden buttons, and
        a flex-end that had to match them or the orange circle fell out of the
        panel. All of that existed to hide two circles inside a panel. The live
        widget has no panel — the wrap is a bare flex column and only the three
        circles are ever visible — so the pill, the padding, the border, the
        background, the blur, the shadow, the fixed height and the clipping are
        all gone, and the --fab-h arithmetic goes with them. The circles keep the
        live site's own shadows and the same #28A9E8 / #25D366 fills.

     2. Thirty per cent smaller, measured off this site's current size rather
        than the live one: the main circle 56 -> 39, the minis 46 -> 32, the
        stack gap 12 -> 8, and the icons scaled by the same factor (24 -> 17,
        20 -> 14, 19 -> 13) so the artwork keeps its proportion inside the
        smaller disc instead of crowding it. Shadows are scaled too — a shadow
        is not a size, but at 70% a full-size one reads as a smudge under a
        button this small. The open/closed motion keeps the live site exactly:
        translateY(12px) scale(.7) in, translateY(0) scale(1) out. The 12px
        becomes 8px because it is a gap, and the gaps are what shrank.

        A 32px circle is under the 44px comfortable touch target even though it
        clears the 24px that WCAG 2.2 requires, so each button carries a
        transparent ::after that extends its hit area 3px past the disc: 45px for
        the main button, 38px for the minis. The neighbours are 8px apart, so
        those hit areas stop 2px short of touching and cannot steal each other's
        clicks — the overlap that makes "add padding to the target" backfire.

     3. The right edge is aligned to the footer card instead of floating at a
        fixed 22px. Every other band on this site is --band-w wide and centred,
        and the footer card's inset from the viewport edge is exactly
        max(--head-inset, (100% - --band-w) / 2): the head-inset term at narrow
        widths, where the band is clamped to the padded width, and the halving
        term from 1168px up, where the card is genuinely centred and the extra
        head padding is split evenly by the centring. The live site uses 22px
        because nothing on it is inset; here that put the button 114px to the
        right of the card it is supposed to be paired with.

     --foot-fixed-h still does the vertical job it was written for: this site
     has a fixed footer and the live one does not, so the widget clears the band
     instead of sitting 22px off the viewport edge on top of it. */
  #fab-wrap{
    position:fixed; z-index:70;
    bottom:calc(var(--foot-fixed-h, 88px) + 16px + env(safe-area-inset-bottom));
    inset-inline-end:calc(max(var(--head-inset, 12px), (100% - var(--band-w)) / 2) + env(safe-area-inset-right));
    display:flex; flex-direction:column; align-items:center; gap:8px;
    /* The live layout keeps both hidden minis in the flow, so the column is
       39+8+32+8+32 tall with the main button at the bottom and ~72px of
       invisible box above it. That box is a real hit target on the live site and
       silently swallows clicks on whatever scrolls under it, so the wrap opts out
       of pointer events and each button opts back in. Identical pixels, no dead
       zone. */
    pointer-events:none;
  }
  #fab-main{
    width:39px; height:39px; border-radius:50%; border:none; cursor:pointer; flex:none;     position:relative; pointer-events:auto;
    /* background-color and color are bound to --signal / --on-accent further
       down, with the rest of this page's token bindings. */
    display:flex; align-items:center; justify-content:center;
    box-shadow:0 7px 18px -4px rgba(0,0,0,.45); transition:transform .2s;
  }
  #fab-main::after,
  .fab-mini::after{content:''; position:absolute; inset:-3px; border-radius:50%;}
  #fab-main:hover{transform:scale(1.06);}
  .fab-mini{
    width:32px; height:32px; border-radius:50%; display:flex; align-items:center; justify-content:center; flex:none;
    position:relative; pointer-events:none;
    box-shadow:0 6px 14px -3px rgba(0,0,0,.4); opacity:0; transform:translateY(8px) scale(.7); visibility:hidden;
    /* `visibility` is transitioned with a 0s duration and a delay that equals
       the fade, rather than a .2s duration of its own. `visibility` is
       discrete, so transitioning it over the full duration leaves the element
       unfocusable for that whole window — and .focus() on a visibility:hidden
       element is silently ignored, so opening this widget and moving focus into
       it only worked by accident. Flip it instantly on open, and hold it until
       the fade-out has finished on close so it is not cut off mid-animation. */
    transition:opacity .2s ease, transform .2s ease, visibility 0s linear .2s;
  }
  #fab-telegram{background-color:#28A9E8;}
  #fab-whatsapp{background-color:#25D366;}
  #fab-wrap.open .fab-mini{opacity:1; transform:translateY(0) scale(1); pointer-events:auto; visibility:visible; transition:opacity .2s ease, transform .2s ease, visibility 0s;}
  /* The icons carry width/height attributes in the markup; these scale the
     rendered box. #fab-main svg outranks the bare id on the close icon, so that
     one needs the doubled id to win. */
  #fab-main svg{width:17px; height:17px;}
  #fab-main svg#fab-icon-close{width:14px; height:14px;}
  .fab-mini svg{width:13px; height:13px;}
  @media(max-width:640px){#fab-wrap{bottom:calc(var(--foot-fixed-h, 88px) + 14px + env(safe-area-inset-bottom));}}

  /* ---------- print ---------- */
  @media print{
    body{background:#fff !important; color:#111 !important; background-image:none !important;}
    /* The atmosphere is the body's own background-image, already zeroed above.
       body::after is the grain. body::before no longer exists — it used to carry
       the atmosphere, and leaving it here is a selector that matches nothing. */
    .skip-link, #preloader, header.topbar, #mobile-nav, #fab-wrap,
    #contact-popover, .hero-eyebrow .dt, form.contact-form, .svc-cta,
    .ports-row, .resume-card span.dl{ display:none !important; }
    section{padding:18px 0 !important;}
    .panel-chrome, .svc-card, .proj-card, .rec-card, .rack, .resume-card{
      border:1px solid #ccc !important; background:#fff !important; box-shadow:none !important; break-inside:avoid;
    }
    .panel-bar{background:#f3f3f3 !important;}
    h1, h2, .hop-role, .svc-title, .proj-title, b{color:#111 !important;}
    .about-body, .hero-intro, .svc-desc, .proj-desc, .hop-points li, p{color:#333 !important;}
    .eyebrow, .tag, .rack-item .p{color:#000 !important;}
    a{color:#111 !important; text-decoration:underline;}
    .trusted-strip{border-color:#ccc !important;}
  }

:root{
  /* Palette moved to css/tokens.css — that is now the single source of truth.
     main.css keeps only what is genuinely page-specific: the type scale below. */

  /* 17px body was on the large side. 15.5px is the compromise: still
     comfortable for Persian letterforms, but the page stops feeling oversized.
     Nothing below 11px exists in the scale — small labels bottom out at 11. */
  --text-base:clamp(.9375rem,.91rem + .12vw,.96875rem);
}

/* ======================= page atmosphere =======================
   The previous background was three stacked clichés: a 42px grid, a dot
   stamped into every one of its cells, and two coloured radial "blobs" in the
   corners. That combination is the stock dev-portfolio wallpaper — it reads as
   generated rather than designed, and the corner glows fight the hero text for
   attention instead of supporting it.

   Three separated concerns now live in three separated layers, so each can be
   tuned or switched off without disturbing the others:

     body            — flat colour only. No pattern at all.
     body::before    — atmosphere: one wide, very low-contrast light source that
                       behaves like light falling across the page, plus a faint
                       hairline grid that dissolves as you scroll. The mask is
                       the important part: a full-strength grid everywhere is
                       wallpaper, a grid that fades out reads as structure.
     body::after     — grain. A 3% fractal-noise wash. This is the single
                       change that does most of the work: large smooth gradients
                       band visibly on 8-bit panels, and noise dithers them away.
                       It is also what stops flat fills looking like plastic.

   Both layers are fixed, pointer-events:none and behind content, so they cost
   nothing to scroll. The grid is drawn with --line as before, so it still
   follows the active theme. */
/* The atmosphere — the warm key light, the cool fill and the vignette — now
   lives in css/tokens.css, so all six page types get the identical background.
   It used to live here on body::before, which meant the blog, the 24 post
   pages, the 404 and the privacy page each had to carry their own copy, and
   they did not match.

   body::after stays here for now: it is the homepage's grain, and the grain is
   decorative rather than part of the design system. If the other pages should
   have it too, it belongs in tokens.css beside the atmosphere, not duplicated
   per page. */
body::after{
  content:""; position:fixed; inset:0; z-index:-1; pointer-events:none;
  /* `overlay` keeps it invisible on flat areas and only bites where there is a
     gradient to dither, so the page does not get a grey wash. */
  opacity:.035; mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:160px 160px;
}
/* Grain. `overlay` keeps it invisible on flat areas and only bites where there
   is a gradient to dither, so the page does not get a grey wash. */
body::after{
  content:""; position:fixed; inset:0; z-index:-1; pointer-events:none;
  opacity:.035; mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:160px 160px;
}

/* The bottom padding is the fixed footer's full band — the card plus the 20px
   inset under it, not the card alone. The 110px that was here cleared a footer
   sitting in normal flow at the end of the document. This one is position:fixed,
   so it covers the bottom of the viewport rather than the end of the page: the
   document's last pixel meets the bottom of the viewport at full scroll, so the
   band has to be matched exactly or the last section sits under the card. */
/* The top padding is --head-fixed-h, the header's full sticky cost, for the
   same reason the bottom one is --foot-fixed-h: both bands are position:fixed
   or sticky and therefore cover the viewport rather than sitting in the flow.
   The 24px that was here only cleared the band's own top inset, so the first
   line of the page started 56px too high and slid under the pill on scroll. */

/* --page-w wide and padded by --gutter, so the content column is exactly
   --band-w — the same number as the header pill above it and the footer card
   below it. This cap was a literal 1180px while the bands stayed at 1168px,
   which put the content 10px narrower than the two bars that frame it on every
   screen wide enough for the cap to bind: the header and footer both overhung
   the text on each side, and nothing overflowed so no browser complained. At
   1168 the reading column and the two bands land on the same edges, and the
   sidebar plus the text inside it are what change width, not the frame. */
.template01-main{max-width:var(--page-w); margin:0 auto; padding:var(--head-fixed-h, 100px) var(--gutter) var(--foot-fixed-h, 88px); display:grid; gap:22px; grid-template-columns:1fr;}
@media (min-width:1024px){
  .template01-main{grid-template-columns:300px minmax(0,1fr); align-items:start;}
}
.template01-content{min-width:0; display:grid; gap:22px; grid-template-columns:minmax(0,1fr); overflow-x:clip;}
.template01-content section{padding:0;}
.template01-content .wrap{max-width:none; padding:0;}
.template01-content .section-head{margin:0 auto 26px; text-align:center; max-width:100%;}
.template01-page .wrap{margin-bottom:22px;}
.template01-page .wrap:last-child{margin-bottom:0;}

/* The gradient hairline that used to run across the top of this card is gone.
   Every box on the site carried one — the sidebar, the hero card, all six card
   families, the blog cards and the single-post card — so the top edge of every
   object on every page was the same accent stripe. That is a border, not a
   hierarchy: it says "this is a box" on eleven components at once and so says it
   about none of them. The cards are already separated by their own surface,
   border and shadow, which is what makes them read as boxes; the stripe was
   competing with them.
   overflow:hidden was only ever there to clip that stripe's corners to the
   22px radius. It is kept, because a scroll container needs it to keep the
   internal scrollbar's corners square with the card. */
.template01-sidebar{position:relative; overflow:hidden; text-align:center; background:linear-gradient(165deg, var(--panel), var(--panel-2)); border:1px solid var(--line); border-radius: var(--radius-xl); box-shadow:var(--shadow-1); padding:30px 22px 26px;}
/* .template01-sidebar::after decorative gradient square removed */
/* .template01-sidebar::before accent hairline removed */
.template01-sidebar .avatar-box{position:relative; z-index:1; width:128px; height:128px; margin:14px auto 18px; border-radius:50%; padding:4px; background-color:var(--grad); box-shadow:var(--shadow-2);}
.template01-sidebar .avatar-box img{width:100%; height:100%; border-radius:50%; object-fit:cover; border:3px solid var(--panel); display:block;}
/* Discourage the one-click "save image as" on the profile photo.
   -webkit-touch-callout:none is the one that matters in practice: it removes the
   long-press save menu on iOS and Android, which is how the photo actually gets
   taken on a phone. -webkit-user-drag:none stops drag-to-desktop.
   pointer-events:none makes the image inert — it is not a hit-test target, so
   a real right-click over the avatar is delivered to .avatar-box. That is why
   the contextmenu handler in js/main.js is bound to the wrapper rather than to
   the <img>; bound to the image it would never fire.
   None of this is protection. View-source, devtools and screenshots all still
   work, and anyone determined is not stopped. The actual limit is that the
   served file is 320px wide (image/photo-display.jpg) while the master is
   denied in .htaccess. */
.template01-sidebar .avatar-box img{-webkit-user-drag:none; user-select:none; -webkit-touch-callout:none; pointer-events:none;}
/* The name and the role are two lines of one h1 now, so the heading stacks them
   itself. Left as a plain block the role pill, being inline-block, would have
   sat on the same line as the name instead of below it. */
.template01-sidebar .name{margin:0; font-family:var(--font-display); font-size:24px; font-weight:900; color:var(--paper); display:flex; flex-direction:column; align-items:center;}
.template01-sidebar .title{display:inline-block; margin:10px 0 0; padding:5px 18px; border-radius:999px; border:1px solid var(--line); background-color:var(--signal-dim); color:var(--tag-ink); font-size:13px; font-weight:600;}
.info_more-btn{display:none;}
.template01-sidebar .info-more{display:grid; gap:18px;}
.template01-sidebar .separator{height:1px; background:linear-gradient(90deg, transparent, var(--line) 18%, var(--line) 82%, transparent);}
.template01-sidebar .contacts-list{list-style:none; margin:0; padding:0; display:grid; gap:12px; text-align:start;}
/* The social row below the contact list. Not :last-child, because the info_more
   button follows it on mobile and is display:none on desktop. */
.template01-sidebar .template01-socials{display:flex; align-items:center; justify-content:center; gap:6px; margin-top:20px;}
.template01-sidebar .contact-item{display:flex; align-items:center; gap:11px; min-width:0;}
/* Contact and social icons share one 30px rhythm. They were 34px and 44px,
   which made the social row wrap to 4+3 inside the 300px column and turned the
   box into the tallest thing on the page. 7 x 30 + 6 x 6 = 246px, which fits
   the 256px inner width on one line with room to spare. 30px is still above the
   24px WCAG 2.5.8 minimum for a target. */
.template01-sidebar .contact-item .icon-c{flex:none; width:30px; height:30px; border-radius:var(--radius-sm); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; color:var(--signal); background-color:var(--panel-2);}
.template01-sidebar .contact-item .icon-c svg{width:15px; height:15px;}
.template01-sidebar .template01-link{font-size:13px; color:var(--paper); overflow-wrap:anywhere; text-decoration:none; min-width:0; line-height:1.6; transition:color .2s;}
.template01-sidebar .template01-link:hover{color:var(--signal);}
/* 30px to match .icon-c above, 7 x 30 + 6 x 6 = 246px against a 256px inner
   width, so the row stays on one line. --brand/--brand-ink come from the
   [data-platform] rules in tokens.css, so these pick up the same per-network
   colours as the footer without naming a platform here. */
.template01-sidebar .template01-socials a{
  width:30px; height:30px; flex:none;
  display:flex; align-items:center; justify-content:center;
  /* 10px, matching .foot-grid a. These are the same buttons as the footer's at a
     different size, and 9px against the footer's 10px meant the two social rows
     on one screen were a pixel apart in shape. */
  border-radius:var(--radius-sm); border:1px solid var(--line);
  color:var(--brand-ink);
  background-color:var(--panel-2);
  transition:color .15s, background-color .15s, border-color .15s;
}
.template01-sidebar .template01-socials a svg{width:15px; height:15px;}
.template01-sidebar .template01-socials a:hover,
.template01-sidebar .template01-socials a:focus-visible,
.template01-sidebar .template01-socials a:active{
  color:var(--brand-on);
  background-color:var(--brand);
  border-color:var(--brand);
}
/* Sticky again. The rule was already here, but the 100dvh shell further down the
   file set position:static on the same selector and won on source order, so the
   offset never took effect and `top` sat there inert. With the shell gone it
   applies, so the number has to be right: the header is a pill pinned 20px from
   the top and --band-h tall, putting its lower edge at 80px, and 152px left the
   card hanging 72px below it for no reason. 94px clears the bar with 14px of
   air. It is 4px under html{scroll-padding-top} in tokens.css, which is the
   other side of the same clearance: that one reserves room when a link jumps to
   an anchor, this one parks the sidebar. Both clear the 80px lower edge, and
   they do not have to be the same number. */
/* The height is part of the condition, not an afterthought. A pinned card has to
   fit between the header and the footer with its own content intact — 94 + 475
   + 88 = 657px. Below that, pinning the card means clamping it, and a clamped
   card scrolls internally, which puts the social links below the card's own fold:
   visible, and not clickable. So on a window too short to hold the card, the
   card is left in the flow and scrolls with the page, where the reader can reach
   all of it by scrolling the page the way they scroll everything else. Below
   660px of height there is no pinning, at any width. */
@media (min-width:1024px) and (min-height:660px){
  /* Sticky alone is not enough to keep the card in one place.

     position:sticky pins the top edge and does nothing about the bottom one, so
     it only holds the card still when the card fits between the sticky offset and
     the fixed footer. The card is 492px tall, which needs a 674px-tall viewport
     once the 94px offset and the 88px footer are taken off — and a 1366x620
     laptop window is 446px short of that. There the card kept its top pinned at
     94px and its last 54px sat underneath the footer: the social links were
     still painted, but the footer was on top of them, and a hit test at the
     centre of a social icon came back as .foot-inner. The links looked present
     and were not clickable.

     max-height plus overflow-y:auto keeps the whole card inside the band at any
     window height, and the card scrolls internally only when it has to. dvh is
     the unit that tracks a mobile browser's collapsing address bar, where vh
     would size the card against a viewport the user is not looking at.

     The cap is the gap between the pin and the footer: 100dvh - 94 - 88.
     Bounding it by the resting offset instead takes 160px off the top rather
     than 94, which clamps the card 66px earlier than it has to and drops the
     social links into the card's own scroller.

     The reasoning that pointed at the resting offset was not wrong about the
     measurement, and it is worth keeping: at 1366x620 the card rests at 160px
     with the footer starting at 540px, so only 380px is clear at rest, while
     sizing from 94px allowed 446px, and the last of the card sat behind the
     footer until the reader scrolled. But a smaller cap is not the fix for
     that — a smaller cap is the very thing that moves the social links into a
     nested scroller, where they are painted and still cannot be clicked. The
     fix is to not pin a card the window cannot hold, which is what the 660px
     in the query above does. With the query in place the card is in the flow
     at 620, and the overlap at rest cannot arise.

     At the 660px the query asks for, 660 - 94 - 88 = 478px is the height of the
     card, so this cap does not bind on any window that can show the card whole.
     It is a guard for when the content grows, not a clamp in normal use. */
  .template01-sidebar{
    position:sticky; top:94px;
    max-height:calc(100dvh - 94px - var(--foot-fixed-h, 88px));
    overflow-y:auto; overscroll-behavior:contain;
  }
}
@media (max-width:1023px){
  .template01-sidebar .info-more{display:none;}
  .template01-sidebar.active .info-more{display:grid;}
  .info_more-btn{display:flex; align-items:center; justify-content:center; min-height:44px; margin:16px auto 0; border:1px solid var(--line); background-color:var(--panel-2); color:var(--signal); font-size:13px; font-weight:700; padding:10px 20px; border-radius:999px; cursor:pointer; transition:background .2s, border-color .2s;}
  .info_more-btn:hover{background-color:var(--signal-dim); border-color:var(--tag-ink); color:var(--tag-ink);}
}

.topbar-inner{height:var(--band-h); display:flex; align-items:center; justify-content:space-between; gap:10px; padding:0 12px;}

.template01-navbar{display:flex; align-items:center; gap:2px; flex:1 1 auto; justify-content:center; overflow-x:auto; scrollbar-width:none; -ms-overflow-style:none; padding:0 4px; white-space:nowrap;}
  .template01-navbar::-webkit-scrollbar{display:none;}
  .template01-navbar .nb{flex:none; display:inline-flex; align-items:center; gap:4px; padding:8px 12px; border-radius:999px; border:1px solid transparent; background:transparent; color:var(--muted); font-size:13.5px; font-weight:600; font-family:var(--font-body); cursor:pointer; text-decoration:none; white-space:nowrap; transition:color .18s, background .18s, box-shadow .18s, transform .12s;}
.template01-navbar .nb:hover{color:var(--paper);}
.template01-navbar .nb:active{transform:scale(.95);}
.template01-navbar .nb.active{color:var(--on-accent); background-color:var(--signal); box-shadow:0 8px 18px -10px rgba(76,194,255,.45);}
  @media (max-width:1023px){ .template01-navbar{display:none;} }
  .template01-pages{display:block;}
.template01-page{display:none;}
.template01-page.active{display:block; animation:template01FadeUp .38s cubic-bezier(.2,.7,.3,1) both;}
@keyframes template01FadeUp{from{opacity:0; transform:translateY(12px);} to{opacity:1; transform:none;}}
@media (prefers-reduced-motion: reduce){.template01-page.active{animation:none;}}
#mobile-nav .nb{display:flex; align-items:center; width:100%; min-height:44px; text-align:start; padding:10px 4px; font-size:14px; color:var(--muted); border:none; border-bottom:1px solid var(--line-soft); background:none; cursor:pointer; font-family:var(--font-body);}
#mobile-nav .nb:hover{color:var(--paper);}
#mobile-nav .nb.active{color:var(--signal);}

.hero{padding:0;}
.hero-card{position:relative; overflow:hidden; background:linear-gradient(165deg, var(--panel), var(--panel-2)); border:1px solid var(--line); border-radius: var(--radius-xl); box-shadow:var(--shadow-1); padding:30px;}
/* .hero-card::before accent hairline removed — see .template01-sidebar for why
   the stripe came off every box at once. overflow:hidden stays: it is what keeps
   the card's own background inside its 22px radius. */
.hero-eyebrow{display:inline-flex; align-items:center; gap:8px; padding:6px 14px; border-radius:999px; border:1px solid var(--line); background-color:var(--panel-2); font-family:var(--font-body); font-size:12px;}
.hero-stats{margin-top:22px; padding-top:20px; border-top:1px solid var(--line);}
.hero-stats div{border-inline-start-color:var(--line);}
.hero-stats b{color:var(--paper); font-variant-numeric:tabular-nums;}

.wrap.trusted-strip{margin:0 0 60px; padding:16px 20px; border:1px solid var(--line); border-bottom:1px solid var(--line); border-radius: var(--radius-lg); background-color:var(--panel-2); box-shadow:var(--shadow-1);}
.ts-logo-card{border:1px solid var(--line); background-color:var(--panel); border-radius:var(--radius-sm);}

/* The six content cards share one surface and one corner. The panel used to be
   in this list at --radius-lg and then overridden on the next line to
   --radius-xl, so one selector carried two corners and only the second was
   ever painted. It has its own rule below instead. */
.rack,.svc-card,.proj-card,.rec-card,.resume-card,.class-card{
  border-radius: var(--radius-lg);
  border:1px solid var(--line);
  background:linear-gradient(165deg, var(--panel), var(--panel-2));
  box-shadow:var(--shadow-1);
}
/* The panel is the page's outermost surface, so it takes the largest corner in
   the scale. Same material as the cards above, one step larger. */
.panel-chrome{
  border-radius: var(--radius-xl);
  border:1px solid var(--line);
  background:linear-gradient(165deg, var(--panel), var(--panel-2));
  box-shadow:var(--shadow-1);
}
.rack,.svc-card,.proj-card,.rec-card,.resume-card,.class-card{position:relative; overflow:hidden; transition:transform .25s, border-color .25s;}
.rack:hover,.svc-card:hover,.proj-card:hover,.rec-card:hover,.resume-card:hover,.class-card:hover{transform:translateY(-2px); border-color:var(--signal);}
/* The hover hairline that used to wipe in across the top of these six cards is
   gone with the always-on one. It duplicated the border-colour change the same
   hover already makes: the card went blue and a blue stripe went with it, so the
   two signals said the same thing twice. The lift and the border now carry the
   whole hover. */
.proj-cover{width:100%; aspect-ratio:16/9; border-radius:8px; margin-bottom:16px; border:1px solid var(--line); display:flex; align-items:center; justify-content:center; background:linear-gradient(160deg, var(--panel-2), var(--panel)); color:var(--signal);}
.proj-cover svg{width:34px; height:34px; opacity:.8;}
.panel-bar{border-bottom:1px solid var(--line); background-color:var(--panel-2);}

.section-head{padding-bottom:4px;}
#page-resume > section{padding-bottom:0;}
#page-resume > section + section{padding-top:80px; padding-bottom:0;}
#page-resume > section + section .section-head{padding-bottom:0; margin-bottom:26px;}
/* The 46x4px gradient dash that used to sit under every section title is gone.
   It was a second accent on a page that already had one on the top of every box,
   so the eye read the dash and the stripe as a pair and neither said anything
   about the section. A heading at this size carries its own weight, and the
   space below it now does the work the dash was doing. position:relative stays:
   the title is the anchor for its own block, and removing the pseudo does not
   remove the need for it to be a positioned box. */
.h-title{position:relative;}
/* .h-title::after accent dash removed */

.btn,button,input,textarea{transition-timing-function:cubic-bezier(.2,.7,.3,1);}
.btn{border-radius:12px; font-weight:700;}
/* Filled controls use a flat accent, so the colour shift on hover is a real
   state rather than a brightness filter on a gradient. The hover colour is a
   token, not a literal: it used to be a hard-coded #6FD0FF, which is lighter
   than the light theme's own accent, so hovering a dark-blue button in light
   mode dropped white label text to roughly 1.6:1. */
.btn-primary{background-color:var(--signal); color:var(--on-accent); border:1px solid transparent; box-shadow:0 10px 24px -14px color-mix(in srgb, var(--signal) 60%, transparent);}
.btn-primary:hover{background-color:var(--signal-hover); transform:translateY(-1px);}
.btn-ghost{border:1px solid var(--line); color:var(--paper); background-color:var(--panel-2);}
.btn-ghost:hover{border-color:var(--signal); color:var(--signal);}
.submit-btn{background-color:var(--signal); color:var(--on-accent); border:1px solid transparent; border-radius:12px; box-shadow:0 10px 24px -14px color-mix(in srgb, var(--signal) 60%, transparent);}
.submit-btn:hover{background-color:var(--signal-hover);}
.svc-cta.active-cta{background-color:var(--signal); color:var(--on-accent); border-color:transparent;}
.class-watch{border-radius:999px; color:var(--signal); border:1px solid var(--line);}
.class-watch:hover{border-color:transparent; background-color:var(--signal); color:var(--on-accent);}

.icon-btn,.mob-btn,.lang-btn,.port{border-radius:999px; border:1px solid var(--line); color:var(--paper);}
.icon-btn:hover,.mob-btn:hover,.lang-btn:hover,.port:hover{background-color:var(--signal-dim); color:var(--tag-ink); border-color:transparent; transform:translateY(-2px);}
.port{width:44px; height:44px; border-radius:12px; color:var(--brand-ink);}
.port:hover,.port:focus-visible,.port:active{background-color:var(--brand); color:var(--brand-on); border-color:transparent;}

.tag{background-color:var(--signal-dim); color:var(--tag-ink, var(--signal)); border:1px solid transparent; border-radius:999px; font-weight:600;}
.tag:hover{border-color:var(--signal); background:transparent;}
.rack-item{border:1px solid var(--line); background-color:var(--panel-2); border-radius:999px; padding:6px 14px;}
.rack-item .p{background-color:var(--grad);}
.rec-card .org{color:var(--signal);}

.hop,.svc-card,.proj-card,.class-card,.resume-card{position:relative;}
.hop{padding-inline-start:20px;}
.hop::before{content:""; position:absolute; inset-inline-start:0; top:4px; bottom:4px; width:3px; border-radius:3px; background-color:var(--grad); opacity:.35;}
.hop-num{color:var(--muted-2); font-family:var(--mono);}

.name-title,.proj-link{color:var(--signal);}
.proj-link{border-top:1px dashed var(--line);}

.faq-item{border:1px solid var(--line); background-color:var(--panel-2); border-radius:16px; overflow:hidden;}
.faq-item[open]{border-color:var(--signal);}
.faq-item summary .chev{color:var(--signal);}

.contact-line .ic{background-color:var(--panel-2); border:1px solid var(--line); border-radius:var(--radius-sm); color:var(--signal);}
.contact-line .v{color:var(--paper);}
.field input,.field textarea{background-color:var(--panel-2); border:1px solid var(--line); border-radius:12px;}
/* The transparent outline is here only so the box-shadow ring does not add to
   the element's layout box, but a transparent outline is *invisible* — and in
   forced-colors it is not transparent, it is dropped, so a keyboard user would
   be left with the ring and nothing else. Give it a real colour and let
   forced-colors repaint it. */
.field input:focus,.field textarea:focus,.field select:focus{
  border-color:var(--signal); box-shadow:0 0 0 3px var(--signal); outline:2px solid var(--signal); outline-offset:1px;
}
.field input:focus-visible,.field textarea:focus-visible,.field select:focus-visible{
  box-shadow:0 0 0 3px color-mix(in srgb, var(--signal) 40%, transparent); outline:2px solid var(--signal);
}
.field input:-webkit-autofill,.field input:-webkit-autofill:hover,.field input:-webkit-autofill:focus,
.field textarea:-webkit-autofill{-webkit-box-shadow:0 0 0 1000px var(--panel-2) inset;-webkit-text-fill-color:var(--paper);caret-color:var(--signal);transition:background-color 9999s ease-out,-webkit-box-shadow 9999s ease-out;}

#fab-main{background-color:var(--signal); color:var(--on-accent);}
#fab-main:hover{filter:brightness(1.08);}
.fab-mini{background-color:var(--panel);}

.eyebrow{color:var(--signal);}
::selection{background-color:var(--signal); color:var(--on-accent);}

@media (prefers-reduced-motion: reduce){
  .rack, .svc-card, .proj-card, .rec-card, .resume-card, .class-card{transition:none !important;}
  .rack:hover,.svc-card:hover,.proj-card:hover,.rec-card:hover,.resume-card:hover,.class-card:hover{transform:none !important;}
  /* The touch rule that used to suppress the card hover hairline goes with the
     hairline. Nothing to suppress now that it is not in the sheet at all. */
  .template01-navbar .nb:active,.icon-btn:active,.mob-btn:active,.lang-btn:active{transform:none;}
  /* The quick-contact is the one widget on the page whose open state is a move:
     the two channels slide up out of the button. Under reduced motion they
     still appear and disappear — `visibility` is a discrete property, so its
     flip is not motion, and dropping the transition there would break the
     focus handling that depends on it — but they no longer travel, and the
     hover scale goes with them. Both states end at transform:none, so the
     channels simply fade in place instead of rising into it. */
  #fab-main,.fab-mini{transition:none !important;}
  #fab-main:hover,.fab-mini{transform:none !important;}
}

/* Desktop is now an ordinary document that happens to be two columns — the
   same model the twenty-four article pages already used, and the same one the
   base rules above were written for. This block used to lock the body to
   100dvh with overflow:hidden and make .template01-content the only scrollable
   region. Three things were wrong with that:

     - the page itself never scrolled, so the scrollbar sat inside the layout
       instead of at the window edge, and the page read as a dashboard;
     - the footer was pinned to the bottom of the viewport forever, eating 88px
       of every screen instead of sitting at the end of the document;
     - in-page anchors were dead. The browser only scrolls the window, never an
       inner container, so #contact resolved to window.scrollY 0 and
       .template01-content.scrollTop 0 — a link that silently did nothing.

   The only thing this block still needs to do is pick the two-column grid,
   which the base rule leaves as a single column. Everything else — the sidebar
   at its natural height, the vertical rhythm, the sticky header — is the base
   stylesheet's default again. */
@media (min-width:1024px){
  .template01-main{grid-template-columns:300px minmax(0,1fr); align-items:start;}
}
@media (min-width:1024px) and (max-width:1499px){
  .topbar-inner{flex-wrap:nowrap; height:var(--band-h); padding:0 12px; row-gap:0;}
  .template01-navbar{order:0; flex-basis:auto; justify-content:center; min-width:0; flex-shrink:1;}
  .template01-navbar .nb{padding:6px 7px; font-size:12.5px; gap:3px;}
}

#contact-popover{background-color:var(--panel); border:1px solid var(--line);}

#hero-intro{max-width:none; margin-top:16px;}
#about-title{margin-bottom:18px;}
#classes{display:none;}
#svc-subtitle, #cnt-intro{color:var(--muted); margin-top:10px; font-size:14.5px;}
#contact-form .panel-body{display:flex; flex-direction:column; gap:14px;}
#fab-icon-close{display:none;}
a,button,input,textarea,summary,label{touch-action:manipulation;}
/* The nine header variants this file used to carry — minimal, solid, underline,
   center, float-card, bordered, glow, split, compact — are gone. No page sets
   data-header on <header class="topbar">, so all nine were unreachable: they
   cost nothing at runtime and a lot to read, and four of them contradicted the
   glass spec (solid and bordered switch the film off and square the corners,
   split sets height:auto, compact drops the band to 50px). A variant that cannot
   be selected is a promise the stylesheet does not keep, and the one place it
   looked like a real option was a trap for whoever tried to use it later. If a
   variant is ever wanted it has to arrive with the page that sets it. */

/* ======================= reading progress bar ======================= */
.progress-bar{
  position:fixed; top:0; left:0; right:0; height:3px; z-index:2000;
  background-color:var(--grad); transform-origin:left center; transform:scaleX(0);
  pointer-events:none; will-change:transform; border-radius:0 3px 3px 0;
}
[dir="rtl"] .progress-bar{ transform-origin:right center; border-radius:3px 0 0 3px; }

/* ======================= typed hero caret =======================
   The caret lives on the aria-hidden inner span that the typewriter actually
   writes into (`.hero-typed`), not on the outer `.hero-name` / `.hero-role`.
   `.done` is added to that same span when typing finishes, so the caret always
   disappears — when it sat on the outer element, the animated path left the
   caret blinking forever because only the inner span ever got `.done`. */
.hero-name .hero-typed:not(.done)::after,
.hero-role .hero-typed:not(.done)::after{
  content:""; display:inline-block; width:.1em; height:1.05em;
  margin-inline-start:3px; vertical-align:-1px; background-color:var(--signal);
  animation:caret-blink 1s steps(1,end) infinite;
}
@keyframes caret-blink{50%{opacity:0;}}

/* ======================= empty states ======================= */
.empty-state{
  display:flex; align-items:center; gap:10px; padding:22px 18px;
  border:1px dashed var(--line); border-radius: var(--radius-md);
  color:var(--muted-2); font-size:14px; line-height:1.7;
}
.empty-state svg{flex:none; opacity:.7;}
.rack-note{display:inline-block; color:var(--muted-2); font-size:12.5px; padding:2px 0;}

/* ======================= contact form inline validation ======================= */
#contact-form .field{position:relative;}
#contact-form .field.invalid input,
#contact-form .field.invalid textarea{border-color:var(--danger); box-shadow:0 0 0 3px var(--danger-ring);}
.field-error{
  display:block; overflow:hidden; max-height:0; opacity:0;
  font-size:12px; line-height:1.5; color:var(--danger);
  transition:opacity .2s ease, max-height .2s ease;
}
#contact-form .field.invalid .field-error{max-height:40px; opacity:1; margin-top:5px;}
.form-status{min-height:18px; margin-top:2px; font-size:13px; line-height:1.6; font-family:var(--font-body);}
.form-status.error{color:var(--danger);}
.form-status.success{color:var(--success);}
.form-status.warn{color:var(--warn);}
#contact-form .submit-btn:disabled{opacity:.75; cursor:wait;}

/* ======================= hero avatar skeleton ======================= */
.avatar-box{overflow:hidden;}
.avatar-box img{position:relative; z-index:1; opacity:1;}
.avatar-box::after{
  content:""; position:absolute; top:0; bottom:0; left:0; width:160%; z-index:0;
  background:linear-gradient(100deg, transparent 35%, color-mix(in srgb, var(--paper) 16%, transparent) 50%, transparent 65%);
  transform:translateX(-125%); opacity:0; transition:opacity .3s ease;
  animation:shimmer-sweep 1.5s linear infinite; pointer-events:none;
}
.js .avatar-box:has(img:not(.loaded))::after{opacity:1;}
/* The photo is visible from the very first paint. An earlier version started it
   at opacity:0 and only revealed it once JS added .loaded, so a frozen
   transition (an occluded tab), a stale cached main.js or any script error left
   the avatar permanently blank. The sweep now runs *behind* an opaque photo
   instead of masking it: it still fades out when .loaded lands, and an opacity
   that never transitions simply leaves it covered. */
@keyframes shimmer-sweep{0%{transform:translateX(-125%);} 100%{transform:translateX(125%);}}

/* ======================= micro-interactions ======================= */
.btn,.submit-btn,.svc-cta,.class-watch,.proj-actions .proj-link,.mob-btn,.lang-btn,
.nb,.info_more-btn,.topbar button,.hero-cta{
  transition:transform .12s ease, filter .2s ease, background-color .25s ease,
             border-color .25s ease, box-shadow .25s ease, color .25s ease;
}
.btn:active,.submit-btn:active:not(:disabled),.svc-cta:active,.class-watch:active,
.proj-actions .proj-link:active,.mob-btn:active,.lang-btn:active,
.nb:active,.info_more-btn:active{transform:scale(.96);}
body,.template01-content,.topbar-inner,.field input,.field textarea{
  transition:background-color .3s ease, border-color .3s ease, color .3s ease;
}
@media (prefers-reduced-motion: reduce){
  html,.template01-content{scroll-behavior:auto;}
  *{transition:none !important;}
  .hero-name .hero-typed:not(.done)::after,.hero-role .hero-typed:not(.done)::after{animation:none;}
  .avatar-box::after{animation:none; opacity:0;}
}

/* ======================= high contrast / forced colors =======================
   The whole palette is built from custom properties that map onto system
   colours in this mode, and the decorative layers (grid background, gradient
   blobs, shimmer, avatar transform) are removed: they are the parts that turn
   into visual noise when the user has asked the OS for a flat, high-contrast
   rendering. Focus is handled with a real outline, which survives here, and
   every border that carried meaning keeps a visible one. */
@media (forced-colors: active){
  :root{--header-border:CanvasText; --line:GrayText; --line-soft:GrayText;}
  /* Re-assert the ring: in forced-colors the box-shadow ring is dropped. */
  :focus-visible{outline:2px solid Highlight !important; outline-offset:2px !important;}
  html,body,#preloader{background:Canvas !important; background-image:none !important;}
  /* The atmosphere, grid and grain are decorative layers. Left in place they
     become noise over a flat Canvas, and the grain's `overlay` blend is not
     even honoured here, so it would render as a grey film. The atmosphere is
     body's own background-image, already zeroed on the line above — what is
     left is the grain, body::after. */
  body::after{display:none !important;}
  .card,.panel,.topbar,.field input,.field textarea,.field select,.stat,.faq-item,
  .post-card,.toc,.prose pre,.prose blockquote{
    border:1px solid CanvasText !important; background:Canvas !important; box-shadow:none !important;
  }
  a{color:LinkText !important;}
  .visited{color:VisitedText !important;}
  .btn,.submit-btn,.svc-cta,.mob-btn,.lang-btn,.nb,.hero-cta,.proj-link{
    border:1px solid ButtonText !important; background:ButtonFace !important; color:ButtonText !important;
  }
  /* The typewriter caret is animated colour; in forced-colors it would either
     vanish or flash, so it is replaced by a static block. */
  .typed::after{animation:none !important; background:CanvasText !important;}
  .avatar-box::after{display:none !important;}
  .avatar-box img{opacity:1 !important; transform:none !important; filter:none !important;}
  ::selection{background:Highlight !important; color:HighlightText !important;}
}

/* The `prefers-contrast: more` block moved to css/tokens.css. It used to live
   here, which meant the homepage was the only page that honoured it, and its
   light-theme branch was dead anyway: `:root` (0,1,0) loses to
   `html[data-theme="light"]` (0,1,1), so the high-contrast light theme was
   silently the normal light theme. A preference that only half the site obeys
   is not an accessibility feature. */

/* ======================= reduced transparency =======================
   Moved to css/tokens.css together with the rest of the atmosphere
   suppression. It only existed here, so the blog, the 24 post pages, the 404
   and the privacy page kept their glow and grain for users who had asked for
   less layering. What is left below is homepage-only. */

/* ======================= design system layer =======================
   Everything above is the original two-layer build: a base block and a later
   "polish" block that overrides it. The polish block re-declares the same
   selectors, so which one wins depends on source order rather than intent —
   that is how `h2.h-title` ended up rendering at weight 900 even though the
   rule asking for 800 is the more specific one.

   This layer is deliberately additive and last, so it settles the measurements
   that were actually wrong when checked in a live browser (2026-09-28):

     1. Headings inherited `line-height:1.75` from body, giving a 32px
        `h-title` a 56px line box — far too loose for a heading, and it pushed
        the gradient rule away from the text.
     2. `.hero-intro` was 16px, exactly body size, so the hero had no
        hierarchy between the lead paragraph and running text.
     3. Headings and the hero name are dense, so Persian/Arabic need a
        marginally taller box than Latin, and the numerals in the hero stats
        should be tabular so they do not jitter.

   Every value here is a real measured fix, not a restyle. `h2.h-title`
   (element + class) is used so it beats the bare `h1,h2,h3,h4` rule on
   specificity rather than relying on being last in the file. */

/* ---- design tokens ----
   The spacing scale used to be re-declared here, and this copy disagreed with
   the one in tokens.css about what two of the steps were worth:

     tokens.css   --sp-6:24px  --sp-8:32px  --sp-12:48px
     main.css     --sp-5:24px  --sp-6:32px  --sp-7:48px  --sp-8:64px

   Two scales, same token names, different values, and main.css loads after
   tokens.css, so the second one silently won. Nothing in the palette suite
   noticed, and nothing in the palette could: the checks ask whether every token
   is declared and every var() resolves, and both were true. A token declared
   twice with two values passes both questions.

   To be accurate about the damage, though: it was latent, not rendered. Of the
   four steps this sheet declared, only --sp-5 and --sp-6 were still referenced
   (.hop, the card grids, and a .section-head rule that a more specific selector
   150 lines above already outranked, so it never applied). Both resolved to
   24px here, which is what tokens.css said they were — so the homepage and the
   article pages agreed, and no screenshot would ever have shown this. The one
   that would have bitten is --sp-8: any future edit to a rule using it would
   have measured 32px on the homepage and 64px on the same rule everywhere else,
   with the two agreeing on the token name.

   So the scale is declared once, in tokens.css, and the two live references
   were remapped to the step holding the value they were written against:
   --sp-5 (24px) became --sp-6. Nothing renders differently. --sp-6 (32px) and
   --sp-7 (48px) and --sp-8 (64px) had no live references here and went with the
   block. tests/palette.test.mjs now fails on a second declaration of any step.

   tokens.css is the single source of truth. The radius scale has now moved
   there too, for the reason recorded next to it: three of the six stylesheets
   never load main.css, so a radius token declared only here was invisible to
   them. The easing and duration tokens stay here — unlike the radii, nothing
   outside main.css reads them. */
:root{
  --ring-w:2px;
  --ease:cubic-bezier(.2,.7,.3,1);
  --dur-fast:.18s; --dur:.25s; --dur-slow:.4s;
}

/* ---- type scale: fluid, with a tighter line-height than the body's ---- */
h1,h2,h3,h4{
  line-height:1.25;
  text-wrap:balance;
}
/* Persian and Arabic sit lower and have taller ascenders/descenders, so they
   need a little more leading than Latin at the same size. */
html[data-lang="fa"] h1,html[data-lang="fa"] h2,html[data-lang="fa"] h3,html[data-lang="fa"] h4,
html[data-lang="ar"] h1,html[data-lang="ar"] h2,html[data-lang="ar"] h3,html[data-lang="ar"] h4{
  line-height:1.45;
}
/* `h2.h-title` — beats the bare `h2` rule on specificity, not by source order.
   The `html[data-lang]` variants above score (0,2,1) against this element's
   (0,1,1), so the language-scoped weight has to be overridden here too or the
   Persian/Arabic headings render at 900 while the Latin ones render at 800. */
h2.h-title{
  /* 2.125rem (34px) sat too close to the hero name, which flattened the
     difference between a section heading and the page's title. */
  font-size:clamp(1.4375rem, 1.1rem + 1.2vw, 1.75rem);
  font-weight:800;
  line-height:1.3;
  letter-spacing:-.01em;
}
html[data-lang="fa"] h2.h-title,
html[data-lang="ar"] h2.h-title{
  font-weight:800;
  line-height:1.45;
}

/* ---- hero: give the lead paragraph a real step above body text ---- */
.hero-name{
  font-weight:800;
  line-height:1.12;
  text-wrap:balance;
}
/* Same specificity trap as the headings: the language-scoped rule scores
   (0,2,1) and would otherwise pin the hero name at 900 in fa/ar. */
html[data-lang="fa"] .hero-name,
html[data-lang="ar"] .hero-name{
  font-weight:800;
  line-height:1.3;
}
.hero-role{font-size:13.5px; letter-spacing:.005em;}
.hero-intro{
  /* 19px read as a second headline rather than a lead paragraph. 16.5px keeps
     a clear step above body text (15.5px) without competing with the name. */
  font-size:clamp(1rem, .96rem + .22vw, 1.0625rem);
  line-height:1.7;
  color:var(--muted);
  max-width:58ch;
}
html[data-lang="fa"] .hero-intro,html[data-lang="ar"] .hero-intro{line-height:1.95;}
/* .section-head's margin-bottom used to be set here from the spacing scale. It
   never applied: `.template01-content .section-head` at line 585 is (0,2,0)
   against this (0,1,0), and it sets `margin`, so the shorthand resets the
   longhand this rule was trying to change. Measured in the browser, the
   homepage's section heads were 26px from that rule and the two 26px
   overrides, and this one had no effect at all.

   It is removed rather than re-pointed at a stronger selector, because
   choosing a specificity to make it start working would change the spacing of
   every section on the site. The 26px that is actually rendering is the
   designed value and the audits measure it; this rule was a fourth opinion on a
   property that three others already settled. `h2.h-title + p` beside it does
   apply and stays. */
h2.h-title + p{color:var(--muted);}

/* ---- numerals: stop them reflowing as digits change ---- */
.hero-stats b, .hop-num, .rec-grid .meta, .rack-item .p{
  font-variant-numeric:tabular-nums;
  font-feature-settings:"tnum" 1;
}

/* ---- surfaces: a single hover lift, no duplicate shadows ---- */
.rack,.svc-card,.proj-card,.rec-card,.resume-card,.class-card{
  transition:transform var(--dur) var(--ease),
             border-color var(--dur) var(--ease),
             box-shadow var(--dur) var(--ease);
}
.rack:hover,.svc-card:hover,.proj-card:hover,.rec-card:hover,
.resume-card:hover,.class-card:hover{
  transform:translateY(-3px);
  box-shadow:var(--shadow-2);
}
/* Keyboard focus should get the same affordance as hover, otherwise the two
   input modes look and behave differently. */
.rack:focus-within,.svc-card:focus-within,.proj-card:focus-within,
.rec-card:focus-within,.resume-card:focus-within,.class-card:focus-within{
  border-color:var(--signal);
}

/* ---- controls: one radius, one focus treatment ----
   `.mob-btn` and `.port` used to be listed here, which put the 10px card radius
   on the header's own icon buttons *after* the rule that rounds them — the last
   declaration wins at the same specificity, so they rendered as squares. The
   circular controls keep their 999px from the rules above. */
.btn,.submit-btn,.svc-cta,.class-watch{
  border-radius:var(--radius-sm);
  transition:transform var(--dur-fast) var(--ease),
             background-color var(--dur) var(--ease),
             border-color var(--dur) var(--ease),
             color var(--dur) var(--ease),
             box-shadow var(--dur) var(--ease);
}
.btn-primary:hover,.submit-btn:hover{
  box-shadow:0 14px 30px -10px color-mix(in srgb, var(--signal) 62%, transparent);
  transform:translateY(-1px);
}
.field input,.field textarea,.field select{
  border-radius:var(--radius-sm);
  transition:border-color var(--dur) var(--ease),
             background-color var(--dur) var(--ease),
             box-shadow var(--dur) var(--ease);
}
/* Make the focus ring on inputs match the buttons' language. */
.field input:focus-visible,.field textarea:focus-visible,.field select:focus-visible{
  border-color:var(--signal);
}

/* ---- tags/chips: consistent pill geometry ---- */
.tag,.avail-chip,.rack-item,.class-watch{
  border-radius:999px;
  font-size:.8125rem;
  line-height:1.6;
}

/* ---- experience timeline: clearer rhythm, RTL-safe ---- */
.hop{
  padding-block:var(--sp-6);
  padding-inline-start:var(--sp-6);
  border-bottom:1px solid var(--line-soft);
  transition:background-color var(--dur) var(--ease);
}
.hop:hover{background:color-mix(in srgb, var(--panel) 55%, transparent);}
.hop-role{line-height:1.35;}

/* ---- grids: consistent gap rhythm across every card grid ---- */
.rec-grid,.proj-grid,.svc-grid,.classes-grid,.resume-grid{
  gap:var(--sp-4);
}
@media(min-width:768px){
  .rec-grid,.proj-grid,.svc-grid,.classes-grid,.resume-grid{gap:var(--sp-6);}
}

/* ---- reduced motion: kill transforms, not just animations ----
   `transition:none` alone still lets a `:hover` transform apply instantly,
   which is jarring. Reset the lift as well. */
@media (prefers-reduced-motion: reduce){
  .rack:hover,.svc-card:hover,.proj-card:hover,.rec-card:hover,
  .resume-card:hover,.class-card:hover,
  .btn-primary:hover,.submit-btn:hover{
    transform:none !important;
  }
  .hero,.hero-card{animation:none !important;}
}