/* ==========================================================================
   COLOR SYSTEM
   --------------------------------------------------------------------------
   Three layers, in cascade order:

     L1  PRIMITIVES  raw ramps. Never referenced by a component rule.
     L2  SEMANTIC    what every rule below actually consumes.
     L3  BRIDGE      re-points the Framer export's own token UUIDs at L2, so
                     the generated HTML inherits the system without editing
                     560 KB of machine-written markup.

   Contrast notes throughout are measured against the page surface
   --color-bg-primary (#faf9f7) unless stated otherwise, and are WCAG 2.2
   ratios. "AA" = passes 4.5:1 for normal text. "UI" = passes 3:1, valid for
   large text and for non-text controls (SC 1.4.11) but NOT for body copy.

   Ramps were generated in OKLCH so lightness and chroma step evenly; every
   adjacent pair is >= 3.5 OKLab dE apart, so no two levels are
   indistinguishable. Hues were chosen to sit with the existing warm greys
   (all of which live at OKLCH hue 82-91), which is why the green reads olive
   rather than emerald and the orange reads terracotta rather than safety.
   ========================================================================== */

:root {
  /* --- L1: NEUTRAL ------------------------------------------------------
     Not a new ramp. Every value except --neutral-200 already shipped
     somewhere in the export; promoting them to tokens carries no visual
     change. --neutral-200 is the one added rung, filling the 100 -> 300 gap
     (dE 6.6 / 6.5 to its neighbours).                                     */
  --neutral-0:   #ffffff;
  --neutral-50:  #fffffb;
  --neutral-100: #faf9f7;  /*  ----   page surface (bone)                  */
  --neutral-200: #e6e3de;  /*  1.22   subtle fills                         */
  --neutral-300: #d1cec7;  /*  1.49   hairlines, dividers                  */
  --neutral-400: #ada89f;  /*  2.25   DECORATIVE ONLY — never text         */
  --neutral-500: #817b6f;  /*  3.99   UI boundaries, large text       UI   */
  --neutral-600: #69645a;  /*  5.59   secondary body text             AA   */
  --neutral-700: #5e5a4f;  /*  6.54   labels, captions                AA   */
  --neutral-800: #201e18;  /* 15.84                                        */
  --neutral-900: #1f1f1f;  /* 15.67   primary text / ink              AA   */
  --neutral-950: #0f0d0b;  /* 18.43                                        */

  /* Translucent ink. The glass surfaces need real alpha, not flat greys.
     Eight canonical steps replace the 26 ad-hoc alpha values that were
     scattered through this file (.02 .03 .035 .04 .045 .05 .06 .08 .09 .10
     .12 .15 .16 .18 .20 .22 .25 .30 .45 .46 .50 .58 .60 .70 .75 .85).      */
  --ink-a03: rgba(31, 31, 31, 0.03);
  --ink-a05: rgba(31, 31, 31, 0.05);
  --ink-a08: rgba(31, 31, 31, 0.08);
  --ink-a12: rgba(31, 31, 31, 0.12);
  --ink-a16: rgba(31, 31, 31, 0.16);
  --ink-a22: rgba(31, 31, 31, 0.22);
  --ink-a30: rgba(31, 31, 31, 0.30);
  --ink-a50: rgba(31, 31, 31, 0.50);
  --ink-a70: rgba(31, 31, 31, 0.70);
  --ink-a85: rgba(31, 31, 31, 0.85);

  /* --- L1: GREEN — the STRUCTURAL accent --------------------------------
     ANCHORED ON #49734d, which is --green-500 exactly. OKLCH hue 147.

     Rungs 50-300 are tinted SURFACES and hold their original lightness —
     dropping them would restyle every tinted panel. 400-900 are re-cut
     around the anchor: same lightness spacing as before, same chroma
     PROFILE (tints stay quiet, mid-tones carry the colour), new hue. Every
     adjacent pair stays >= 3.5 OKLab dE apart, so no two rungs are
     indistinguishable.

     Green is not the action colour — orange is (below). Green carries the
     QUIET structural layer: links, focus, success, selection, list markers,
     and the chatbot's system icons. One loud accent, one quiet one.

     500 IS the anchor and is the green anyone actually reads: link text,
     structural accent, success. 400 is the focus ring — it must clear 3:1
     on bone (3.56) AND on a green-50 tinted surface (3.47), which is the
     tighter of the two.                                                    */
  --green-50:  #f1f8f1;  /*  1.03   success / selected wash               */
  --green-100: #dfede0;  /*  1.15   tinted surface                        */
  --green-200: #c5dcc6;  /*  1.38   border on a green tint                */
  --green-300: #9dbf9f;  /*  1.92   decorative, disabled accent           */
  --green-400: #648e67;  /*  3.56   border, icon, FOCUS RING          UI  */
  --green-500: #49734d;  /*  5.19   THE GREEN — links, accent, success AA */
  --green-600: #395e3d;  /*  7.02   link hover                        AA  */
  --green-650: #315034;  /*  8.57   link visited, pressed             AA  */
  --green-700: #29432c;  /* 10.32   text on a green tint              AA  */
  --green-800: #203322;  /* 12.81   heading on a green tint           AA  */
  --green-900: #162317;  /* 15.51   deepest                           AA  */

  /* --- L1: ORANGE — the ACTION accent, and the only warm ramp -----------
     ANCHORED ON #a64823, which is --orange-500 exactly. OKLCH hue 41.

     This is ONE ramp doing what two used to do. The system previously
     carried both a --clay-* family (action fill + display headings) and a
     separate --orange-* family (warning), which is how the landing page
     ended up showing two different oranges at once: the hero heading on
     clay-700 (#7d2f24) and the "My work" button on clay-500 (#b95747).
     Both now resolve to this single anchor, so the page shows exactly one
     orange. --clay-* is gone; nothing references it.

     Same construction as the green: 50-300 hold their original lightness so
     tinted panels are unchanged, 400-900 are re-cut around the anchor,
     every adjacent pair >= 3.5 OKLab dE.

     500 is the anchor: AA as text on bone (5.59) AND carries a white label
     (5.88), which is what lets one value serve both the display headings
     and the primary button. 600/650 are the hover and pressed states —
     states, not second brand colours.

     Danger folds into this family too (aliases below). Error states stay
     identifiable without hue because the state utilities pair every colour
     with a glyph (check / triangle / cross), which is what satisfies
     WCAG 1.4.1 now that brand, warning and error share a family.           */
  --orange-50:  #fdf2ef;  /*  1.04   accent / warning / error wash        */
  --orange-100: #fae2da;  /*  1.18   tinted surface                       */
  --orange-200: #f3cabb;  /*  1.43   border on an orange tint             */
  --orange-300: #e7a58e;  /*  1.96   decorative                           */
  --orange-400: #b96647;  /*  3.95   border, icon                     UI  */
  --orange-500: #a64823;  /*  5.59   THE ORANGE — headings, action fill   */
                          /*         (white label 5.88)                AA */
  --orange-600: #8c3208;  /*  7.75   action hover (white label 8.16)   AA  */
  --orange-650: #7b2800;  /*  9.30   action pressed, text on tint      AA  */
  --orange-700: #6b2000;  /* 10.90   heading on an orange tint         AA  */
  --orange-800: #481602;  /* 14.35   deep                              AA  */
  --orange-900: #2b0c02;  /* 17.26   deepest                           AA  */

  /* Back-compat aliases. Every rule that consumed --danger-* keeps working. */
  --danger-50:  var(--orange-50);
  --danger-200: var(--orange-200);
  --danger-500: var(--orange-500);  /*  5.59   error fill              AA  */
  --danger-600: var(--orange-600);  /*  7.75   error text              AA  */
  --danger-700: var(--orange-700);  /* 10.90                           AA  */

  /* ======================================================================
     L2: SEMANTIC — the only layer components are allowed to consume.
     ====================================================================== */

  /* Backgrounds and surfaces */
  --color-bg-primary:        var(--neutral-100);
  --color-bg-secondary:      var(--neutral-50);
  --color-bg-accent:         var(--green-50);
  --color-bg-warning:        var(--orange-50);
  --color-bg-danger:         var(--danger-50);
  --color-surface:           rgba(255, 255, 255, 0.55);  /* glass panel    */
  --color-surface-raised:    rgba(251, 251, 247, 0.60);  /* glass pill     */
  --color-surface-hover:     var(--ink-a05);
  --color-surface-active:    var(--ink-a08);
  --color-surface-selected:  var(--green-50);
  --color-surface-sunken:    var(--ink-a03);

  /* Text */
  --color-text-primary:      var(--neutral-900);  /* 15.67            AA  */
  --color-text-secondary:    var(--neutral-600);  /*  5.59            AA  */
  --color-text-label:        var(--neutral-700);  /*  6.54            AA  */
  --color-text-muted:        var(--neutral-500);  /*  3.99  LARGE TEXT ONLY */
  --color-text-disabled:     var(--neutral-400);  /*  2.25  non-informational */
  --color-text-on-accent:    #ffffff;
  --color-text-on-dark:      var(--neutral-100);
  --color-text-link:         var(--green-500);    /*  5.19            AA  */
  --color-text-link-hover:   var(--green-600);    /*  7.02            AA  */
  --color-text-link-visited: var(--green-650);    /*  8.57            AA  */
  /* Body-copy emphasis. Was --orange-650, which put a visibly darker orange
     on the emphasised phrases than the one on the headings and buttons --
     32 nodes of it against 7 heading nodes on the DealApp case study alone.
     The ramp was already one family; this is the last role that read as a
     second brand orange, so it joins the anchor. Still AA on bone (5.59 vs
     the 4.5 body text needs). The 600/650 rungs stay where they belong:
     hover and pressed, which are states, not colours.                     */
  --color-text-highlight:    var(--orange-500);   /*  5.59            AA  */

  /* Display headings. The big H1s are the one place the orange is used as
     TEXT rather than as a fill. It keeps its own token — a heading is not a
     control, and naming the role separately is what lets the two diverge
     later — but it currently resolves to the SAME --orange-500 as the
     primary button, deliberately: one orange on the page.           5.59:1 */
  --color-text-heading:      var(--orange-500);   /*  5.59            AA  */

  /* Word-level emphasis inside a display heading. Used to pick out single
     words in the hero ("people" green, "businesses" orange) — the green is
     the structural accent and the orange is the action accent, so the two
     halves of the sentence carry the two halves of the palette.           */
  --color-text-emphasis-green:  var(--green-500);  /*  5.19           AA  */
  --color-text-emphasis-orange: var(--orange-500); /*  5.59           AA  */

  /* Borders. Decorative hairlines stay light on purpose — WCAG 1.4.11
     applies to boundaries that are needed to identify a control, not to
     ornamental rules. Anything that IS a control boundary uses -strong or
     an accent, all of which clear 3:1.                                     */
  --color-border-subtle:   var(--ink-a08);        /*  1.17  ornamental     */
  --color-border-default:  var(--ink-a16);        /*  1.38  ornamental     */
  --color-border-strong:   var(--neutral-500);    /*  3.99  controls   UI  */
  --color-border-accent:   var(--green-400);      /*  3.56             UI  */
  --color-border-warning:  var(--orange-400);     /*  3.95             UI  */
  --color-border-danger:   var(--danger-500);     /*  5.59             AA  */
  --color-border-on-tint:  var(--green-200);

  /* Actions — ORANGE. Ink stays the dominant button colour across the site;
     the orange is reserved for the primary call to action so the two do not
     compete. Green used to sit here and no longer does: with orange on the
     buttons and green on the structural layer, an accent's hue now tells you
     which kind of thing it is.                                             */
  --color-action-primary:         var(--orange-500); /*  5.59          AA  */
  --color-action-primary-hover:   var(--orange-600); /*  7.75          AA  */
  --color-action-primary-active:  var(--orange-650); /*  9.30          AA  */
  --color-action-primary-subtle:  var(--orange-50);
  --color-action-neutral:         var(--neutral-900);
  --color-action-neutral-hover:   var(--neutral-800);
  --color-action-disabled-bg:     var(--neutral-200);
  --color-action-disabled-text:   var(--neutral-500);

  /* The quiet structural accent — green. Not an action: these mark system
     and reading affordances (selection, list markers, system icons). Kept as
     its own token so re-colouring the buttons can never drag them along.   */
  --color-accent-structural:        var(--green-500);  /*  5.19        AA  */
  --color-accent-structural-strong: var(--green-700);  /* 10.32        AA  */
  --color-accent-structural-subtle: var(--green-100);

  /* Focus. One ring, everywhere, and it stays GREEN — the ring is a system
     affordance, not an action, so it belongs to the structural layer. Keeping
     it green also stops it from disappearing into an orange button it is
     drawn around. Clears 3:1 against bone (3.56) and against a green-50
     tinted surface (3.47), so it stays visible on both.                     */
  --color-focus-ring:        var(--green-400);
  --color-focus-ring-offset: var(--color-bg-primary);
  --focus-ring-width:        2px;
  --focus-ring-offset:       2px;

  /* Status. Warning and danger now share the orange family with the action
     colour, so every status utility below carries a glyph as well as a hue.
     Warning and danger are separated by LEVEL, not hue (500 vs 650).       */
  --color-success:         var(--green-500);   /*  5.19              AA  */
  --color-success-surface: var(--green-50);
  --color-success-border:  var(--green-400);   /*  3.56              UI  */
  --color-warning:         var(--orange-600);  /*  7.75              AA  */
  --color-warning-surface: var(--orange-50);
  --color-warning-border:  var(--orange-400);  /*  3.95              UI  */
  --color-danger:          var(--danger-600);  /*  7.75              AA  */
  --color-danger-surface:  var(--danger-50);
  --color-danger-border:   var(--danger-500);  /*  5.59              AA  */
}

/* ==========================================================================
   L3: BRIDGE — re-point the Framer export's own tokens at the system.

   Only the two link tokens are safe to re-point. --token-68e8bb38 looks like
   a third ("link text colour") but it is used 37x as a background-color and
   only 4x as a link colour, so re-pointing it would turn three dozen
   surfaces green. Prose link colour is handled below by targeting the
   consumer rule instead.

   The export also carries eight further colour tokens (#0d3861, #16528a,
   #2778c4, #d3bb8a, #011d3852 and three translucent blues) that are
   referenced zero times anywhere on the site — Framer default leftovers.
   They are deliberately left alone: unused values cost nothing and editing
   them would be churn.
   ========================================================================== */
:root,
body {
  /* `body` as well as `:root`: the export re-declares its whole token set on
     <body>, so a :root-only override is shadowed for every element below it.
     The !important is what beats Framer's own declaration, since its <style>
     block sits later in the document than this file's <link>. */
  --token-680f5157-c169-43ae-8a43-6a59750aca14: var(--color-text-link-hover) !important;
  --token-a2b4f957-afbf-4088-9480-d071ba7cf731: var(--color-text-link) !important;

  /* The export's 18 "muted body copy" tokens are all #1f1f1f80 — 50% ink,
     which lands at 3.19:1 on bone and fails AA at the 16-20px it is used at.
     Every one of the six that are actually referenced is consumed as
     --framer-text-color and nothing else (verified across all six pages), so
     re-pointing them at the brand's own secondary grey is safe: it fixes 33
     paragraphs at once and moves them onto a warm neutral that matches the
     rest of the scale. The 12 unreferenced ones are mapped too, so the fix
     survives if Framer starts emitting them.                        5.59:1 */
  --token-1486a41f-bc8d-4d4c-af45-33586d202de4: var(--color-text-secondary) !important;
  --token-273708ee-7cde-4471-8be3-220e65dc1971: var(--color-text-secondary) !important;
  --token-2decd730-1a1a-4cb9-9ed4-7dd3162e7acd: var(--color-text-secondary) !important;
  --token-39c2db53-4966-44f7-9a9e-c3587327b1f4: var(--color-text-secondary) !important;
  --token-4d8aa9e5-118d-4e59-aee4-e87bfb85d6b6: var(--color-text-secondary) !important;
  --token-5852f280-952c-4790-9773-1725d5bf7be4: var(--color-text-secondary) !important;
  --token-6c62d59f-1b34-4382-b11c-02cae996dd85: var(--color-text-secondary) !important;
  --token-7ac3c8fe-d4cc-4cec-8aec-ca36f8ff8a82: var(--color-text-secondary) !important;
  --token-7ba49b12-e0ed-4405-bb85-13d5e54634df: var(--color-text-secondary) !important;
  --token-7fbe2ae1-9d37-4dc8-8d1e-285c2b4a8ce4: var(--color-text-secondary) !important;
  --token-83141c58-2dcf-4a35-8f01-0a4113c8b2ee: var(--color-text-secondary) !important;
  --token-87301fc1-437c-4970-b057-dce8337ad9dd: var(--color-text-secondary) !important;
  --token-8cf0b082-7ac1-4034-aeb5-0eb147af40f9: var(--color-text-secondary) !important;
  --token-a8578263-b21b-491e-8e42-14c68991cb85: var(--color-text-secondary) !important;
  --token-b45b4f1e-79a0-41d8-b090-3de899165aed: var(--color-text-secondary) !important;
  --token-d3b9992b-537e-4eef-99ff-5d585f96f0ec: var(--color-text-secondary) !important;
  --token-efe9d4f6-eb81-4351-85c0-bbba2aeaf58d: var(--color-text-secondary) !important;
  --token-fee2e6da-179a-4907-b633-7ba28daf46f9: var(--color-text-secondary) !important;
}

/* ==========================================================================
   FOCUS — the site had no focus-visible styling at all. This is the single
   biggest accessibility gain in the change: every interactive element now
   shows the same ring, and it is a ring rather than a colour swap, so it
   does not rely on colour alone.
   ========================================================================== */
a:focus-visible,
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible,
[role="button"]:focus-visible,
[data-framer-name="Tap Target"]:focus-visible {
  outline: var(--focus-ring-width) solid var(--color-focus-ring);
  outline-offset: var(--focus-ring-offset);
  /* NO `border-radius: inherit` here. It was presumably meant to make the ring
     follow the element's shape, but `outline` already follows border-radius on
     its own in every browser that supports :focus-visible. What it actually
     did was REPLACE each element's own radius with its parent's for as long as
     it was focused — 36 elements on the home page alone have a radius that
     differs from their parent's, including the CTA button and the card pill at
     999px against a parent at 0. Focusing any of them squared it off, ring and
     all: the chatbot's 99px input became a sharp rectangle the moment you
     clicked into it. */
}

/* Windows High Contrast Mode keeps a visible ring even when colours are
   replaced by the system palette. */
@media (forced-colors: active) {
  a:focus-visible,
  button:focus-visible,
  input:focus-visible,
  [tabindex]:focus-visible {
    outline: 2px solid CanvasText;
  }
}

/* ==========================================================================
   PROSE LINKS — green, and still underlined. The underline is what carries
   the meaning; the colour is reinforcement, so the link is identifiable
   without perceiving hue (WCAG 1.4.1).

   Scoped to rich-text prose. Navigation items, button labels and card titles
   are also <a class="framer-text"> and deliberately stay ink, which is what
   keeps the accent from spreading across every surface.
   ========================================================================== */
.rich-text-wrapper a.framer-text,
p.framer-text a.framer-text:not([data-styles-preset="ro7OPezbn"]),
li.framer-text a.framer-text {
  color: var(--color-text-link) !important;
  text-decoration-color: color-mix(in srgb, var(--color-text-link) 45%, transparent);
  text-underline-offset: 0.18em;
  transition: color 180ms ease, text-decoration-color 180ms ease;
}

.rich-text-wrapper a.framer-text:hover,
p.framer-text a.framer-text:not([data-styles-preset="ro7OPezbn"]):hover,
li.framer-text a.framer-text:hover {
  color: var(--color-text-link-hover) !important;
  text-decoration-color: currentColor;
}

.rich-text-wrapper a.framer-text:active,
p.framer-text a.framer-text:not([data-styles-preset="ro7OPezbn"]):active,
li.framer-text a.framer-text:active {
  color: var(--green-700) !important;
}

/* ==========================================================================
   NAVIGATION — active section coloured, every other item muted grey.

   The nav / footer menu links all share one Framer style preset
   (data-styles-preset="ro7OPezbn"), and NOTHING else on the site uses it — it
   is the six page links plus LinkedIn and the footer email, in both the header
   <nav> and the footer, at every breakpoint. That preset is the handle:
   scoping to it colours every nav instance at once and cannot touch prose.

   Note the :not() added to the prose-link rule above — those links live inside
   <p class="framer-text"> too, so the prose rule was painting every nav item
   green (active and inactive alike). Excluding the preset there lets this rule
   own the nav's resting state with a simple, low specificity: inactive items
   sit in the secondary grey (5.59:1 AA — nav labels are small text and need the
   AA grey, not the large-text muted one), with no prose underline.

   The ACTIVE item is installActiveNavStyle() in static-site.js, whose
   [data-ias-current-route] / [data-framer-page-link-current] selectors are more
   specific and colour the current section green + bold. Green is the
   structural/navigation accent here; orange stays the action colour, so the
   two never blur. */
a.framer-text[data-styles-preset="ro7OPezbn"] {
  color: var(--color-text-secondary) !important;
  text-decoration-color: transparent !important;
}

a.framer-text[data-styles-preset="ro7OPezbn"]:hover {
  color: var(--color-text-primary) !important;
}

/* ==========================================================================
   DISPLAY HEADINGS — ink, with the ONE bold word in the orange.

   The model here changed deliberately. Headings used to be wholly orange (a
   tag-scoped h1 rule plus a phone-breakpoint h2 rule). They are now INK, and
   only the emphasised word — the <strong> the copy already bolds — takes the
   heading accent. A mostly-ink headline with a single warm word reads as
   editorial rather than as a theme, and it lets the accent MEAN something:
   the word the sentence is actually about.

   Why this selector, and why it is safe:

   - `> strong` (DIRECT child) is the whole trick. The simple accent headings
     — "Selected <strong>Projects</strong>", "Trusted <strong>Collaborators
     </strong>", "Licenses & <strong>certifications</strong>" — carry the
     bold word as a direct child of the heading. The two hero headings that
     must NOT follow this rule ("Design that people…", "Hi, It's Soultan")
     wrap every word in <span><strong>…</strong></span>, so a direct-child
     selector never reaches them. Their word-level colour is done in JS
     instead (see colourHeadingEmphasisWords in static-site.js), which is also
     the only layer that survives Framer re-rendering the hero from component
     data. Verified: on the home page the only span-wrapped heading strongs
     are those two heroes.

   - Tag-agnostic across h1–h4 on purpose. Framer mounts a different component
     tree per breakpoint and demotes display headings (an h1 at ≥810px becomes
     an h2 below it), so a tag-specific rule would drop the accent on phones.
     Matching the strong under ANY heading level is stable across every tree.

   - Override --framer-text-color, not `color` alone. Framer gives most text a
     <span class="framer-text"> whose rule is `color: var(--framer-text-color)`;
     a plain `color` on the strong is re-overridden by any inheriting span.
     Setting the custom property fixes the strong and its inner spans at once.
     `color` is kept alongside for strongs whose text is a bare node.
     !important is required because Framer sets --framer-text-color inline.

   Headings with no bold word stay fully ink — including the project
   case-study titles ("CIB Bank", "Optimizing Property Discovery"), which have
   no <strong>. That is the intended consequence of "colour just the bold
   word": no accent word, no accent.                                 5.59:1  */
:is(h1, h2, h3, h4) > strong.framer-text {
  --framer-text-color: var(--color-text-heading) !important;
  color: var(--color-text-heading) !important;
}

/* The About page's three stat numerals -- 30+ / 70+ / 5+. They are <h3>s with
   no <strong>, so the accent-word rule above cannot reach them and they were
   shipping ink. data-framer-name="Number" is the whole selector: it appears on
   this page only, seven times, and every one wraps a stat numeral (three
   per-breakpoint copies of 30+, two of 70+, two of 5+), so it covers every
   breakpoint tree without naming a single Framer hash. The label under each
   numeral stays muted -- the number is the figure, the label is the caption.

   --framer-text-color rather than `color` alone for the usual reason, and
   !important because Framer sets the ink token inline on the element.  5.59:1 */
[data-framer-name="Number"] h3.framer-text {
  --framer-text-color: var(--color-text-heading) !important;
  color: var(--color-text-heading) !important;
}

/* …except where the bolded half is NOT the meaningful half. The mentorship
   hero bolds "Design Better," but the word the sentence turns on is
   "Together", so colourHeadingEmphasisWords() stamps this attribute on the
   strong and takes the accent to the right word instead. Same specificity
   plus one attribute, and !important to match, so it wins over the rule
   above rather than fighting Framer's inline --framer-text-color.

   This keeps the "one warm word per heading" promise the rule above is
   built on: without it the heading would carry two orange phrases. */
:is(h1, h2, h3, h4) > strong.framer-text[data-ias-emphasis-off] {
  --framer-text-color: var(--color-text-primary) !important;
  color: var(--color-text-primary) !important;
}

/* Display headings that are not heading TAGS. The project pages set their
   section titles ("Project summary", "Tools & Practices") as <p> at 48px, so
   the tag-scoped rule above misses them and they alone stayed ink. They are
   headings in every sense that matters to a reader, so they take the same
   accent and the same token — accentDisplayParagraphTitles() in
   static-site.js stamps the attribute after measuring the rendered size,
   which is the part CSS cannot express. */
p.framer-text > strong.framer-text[data-ias-title-accent] {
  --framer-text-color: var(--color-text-heading) !important;
  color: var(--color-text-heading) !important;
}

/* ==========================================================================
   STEP CARDS — the numbered "why work with me" feature cards.

   Each card is [data-framer-name="Card"] holding a [data-framer-name="Step
   number"] numeral and a [data-framer-name="Heading & Description"] block.
   Those authored names are stable across a re-export (unlike the hashed
   .framer-xxxxx classes), and "Step number" / "Heading & Description" are
   unique to these five cards, so the rules cannot leak onto other sections.
   Level-agnostic (:is(h1,h2,h3)) for the same per-breakpoint-demotion reason
   as the headings above.

   Title stays INK and goes bold (the copy ships it at weight 500); the big
   numeral drops to the muted grey so it reads as a quiet index rather than
   competing with the title. The number is 72px, comfortably in --color-text-
   muted's large-text band (3.99:1, SC 1.4.3 large-text threshold is 3:1).

   The title deliberately does NOT take the red. Five stacked accent titles
   read as a list of links rather than a list of principles, and the red is
   rationed to headings that open a section. Weight, not hue, is what ranks
   these against their body copy.                                    15.67:1 */
[data-framer-name="Step number"] :is(h1, h2, h3).framer-text {
  --framer-text-color: var(--color-text-muted) !important;
  color: var(--color-text-muted) !important;
}
[data-framer-name="Heading & Description"] [data-framer-name="Heading"] :is(h1, h2, h3).framer-text {
  --framer-text-color: var(--color-text-primary) !important;
  color: var(--color-text-primary) !important;
  --framer-font-weight: 600 !important;
  font-weight: 600 !important;
}

/* ==========================================================================
   GREEN'S HOMES — the quiet structural accent.

   With the orange on the buttons, green needs somewhere durable to live, and
   these
   are chosen because they survive a Framer re-export: they key off real
   elements and pseudo-elements, not the hashed .framer-xxxxx classes (those
   hashes change every time the site is re-published).
   ========================================================================== */

/* Text selection. Site had none, so this was browser-default blue on a warm
   bone page. A green-100 wash with green-900 ink reads 13.9:1 and finally
   matches the palette.                                                     */
::selection {
  background: var(--color-accent-structural-subtle);
  color: var(--green-900);
}

/* List markers in prose. The bullet is structure, not content, so it takes
   the structural accent while the text stays ink — the same logic that keeps
   H2s ink under an orange H1.                                              */
.rich-text-wrapper li.framer-text::marker,
.rich-text-wrapper ul li::marker,
.rich-text-wrapper ol li::marker {
  color: var(--color-accent-structural);
}

/* ==========================================================================
   STATE UTILITIES — none of these existed. Each pairs its colour with a
   non-colour cue (icon, weight, border) so state is never colour-alone.
   ========================================================================== */
.ias-state-success,
.ias-state-warning,
.ias-state-danger {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 10px 14px;
  border-radius: 12px;
  border: 1px solid;
  font-size: 13.5px;
  line-height: 1.5;
  font-weight: 500;
}

.ias-state-success::before,
.ias-state-warning::before,
.ias-state-danger::before {
  flex: 0 0 auto;
  font-weight: 700;
  line-height: 1.5;
}

.ias-state-success {
  background: var(--color-success-surface);
  border-color: var(--color-success-border);
  color: var(--green-700);
}
.ias-state-success::before { content: "\2713"; }   /* check  */

.ias-state-warning {
  background: var(--color-warning-surface);
  border-color: var(--color-warning-border);
  color: var(--orange-650);
}
.ias-state-warning::before { content: "\26A0"; }   /* triangle */

.ias-state-danger {
  background: var(--color-danger-surface);
  border-color: var(--color-danger-border);
  color: var(--danger-700);
}
.ias-state-danger::before { content: "\2715"; }    /* cross  */

[aria-invalid="true"] {
  border-color: var(--color-border-danger) !important;
}

[aria-disabled="true"],
:disabled {
  cursor: not-allowed;
  opacity: 1;                                  /* opacity would fail contrast */
  background: var(--color-action-disabled-bg) !important;
  color: var(--color-action-disabled-text) !important;
  border-color: var(--neutral-300) !important;
}

/* Animations */
@keyframes ias-fade-in {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}

html {
  scroll-behavior: smooth;
}

body.codex-static-ready [style*="opacity:0"],
body.codex-static-ready [style*="opacity: 0"] {
  opacity: 1 !important;
}

body.codex-static-ready [style*="opacity:0"][style*="translateY"],
body.codex-static-ready [style*="opacity: 0"][style*="translateY"] {
  transform: none !important;
  animation: codex-reveal 700ms cubic-bezier(.22, 1, .36, 1) both;
}
/* Nav dropdown panel: transition lives on a neutral base so the panel
   animates on BOTH open and close. (Previously the transition was only on
   the closed-state selector, so opening had no transition and the panel +
   items snapped in together. The bar now expands first, then items cascade.) */
body.codex-static-ready nav [data-framer-name="Links"] {
  overflow: hidden;
  transition: max-height 340ms cubic-bezier(.22, 1, .36, 1), opacity 240ms ease, transform 320ms cubic-bezier(.22, 1, .36, 1);
  position: relative;
}

/* Items are clipped within the Links container */
body.codex-static-ready nav [data-framer-name="Links"] > div:first-child {
  overflow: hidden;
}

/* Nav menu items group-reveal AFTER the gray surface. Framer owns the open
   toggle: tapping flips the nav's data-framer-name to "Desktop/Mobile: Open"
   and circular-reveals the gray surface via clip-path + opacity (~450ms).
   Framer ALSO runs its own per-item opacity animation that drives each item to
   opacity:1 in ~60ms — long before the surface is visible (verified on the real
   tap path: at 60ms the surface is only ~10% opaque while items already sit at
   opacity 1). Fighting that per-item animation is unreliable: a CSS animation
   on the items competes with Framer's at the SAME cascade level (CSS
   Animations), so the winner is non-deterministic and Framer usually wins.
   The robust lever is the items' WRAPPER (Links > first child): Framer never
   animates it (its computed opacity stays 1 throughout the open and it has no
   inline opacity), so a CSS animation on the wrapper has no competitor. Holding
   the wrapper at opacity:0 for ~360ms (via `both` backwards-fill) hides ALL
   items through opacity compounding while the surface reveals, then fades the
   group in. */
@keyframes codex-nav-items-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* Closed: panel collapsed */
body.codex-static-ready nav:not(.codex-nav-open):not([data-framer-name*="Open"]) [data-framer-name="Links"] {
  max-height: 0;
  opacity: 0 !important;
  pointer-events: none;
  transform: translateY(-8px);
}

/* Closed: items hidden + offset; no stagger so they fade out together on close */
body.codex-static-ready nav:not(.codex-nav-open):not([data-framer-name*="Open"]) [data-framer-name="Links"] [style*="opacity:0"],
body.codex-static-ready nav:not(.codex-nav-open):not([data-framer-name*="Open"]) [data-framer-name="Links"] [style*="opacity: 0"] {
  opacity: 0 !important;
  transform: translateY(8px) !important;
  pointer-events: none;
  transition-delay: 0s !important;
}

/* Open: panel expanded */
body.codex-static-ready nav.codex-nav-open [data-framer-name="Links"],
body.codex-static-ready nav[data-framer-name*="Open"] [data-framer-name="Links"] {
  max-height: 80vh;
  opacity: 1 !important;
  pointer-events: auto;
  transform: none;
}

/* Open: the items WRAPPER (Links > first child) fades in as a group AFTER the
   surface. `both` backwards-fill parks the wrapper at opacity:0 for the 360ms
   delay (by then the surface is fully opaque and ~95% clip-revealed), then
   fades the whole group in over 320ms. Animating the wrapper — not the items —
   sidesteps Framer's per-item opacity animation entirely (Framer leaves the
   wrapper alone, verified), so this reliably holds every item hidden until the
   surface is up, regardless of the per-item cascade fight Framer wins. */
body.codex-static-ready nav.codex-nav-open [data-framer-name="Links"] > div:first-child,
body.codex-static-ready nav[data-framer-name*="Open"] [data-framer-name="Links"] > div:first-child {
  animation: codex-nav-items-in 320ms cubic-bezier(.22, 1, .36, 1) 360ms both;
  pointer-events: auto;
}

[data-framer-name="Tap Target"],
[data-highlight="true"],
a {
  cursor: pointer;
}

a[data-highlight="true"],
a[class*="framer-"] {
  transition: transform 180ms ease, filter 180ms ease, opacity 180ms ease;
}

a[data-highlight="true"]:hover,
a[class*="framer-"]:hover {
  filter: brightness(.96);
}

a[data-highlight="true"]:active,
a[class*="framer-"]:active {
  transform: scale(.985);
}

@keyframes codex-reveal {
  from {
    opacity: 0;
    transform: translateY(24px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

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

  body.codex-static-ready [style*="opacity:0"][style*="translateY"],
  body.codex-static-ready [style*="opacity: 0"][style*="translateY"] {
    animation: none;
  }

  /* Nav items appear instantly with the surface — no group fade/hold */
  body.codex-static-ready nav.codex-nav-open [data-framer-name="Links"] > div:first-child,
  body.codex-static-ready nav[data-framer-name*="Open"] [data-framer-name="Links"] > div:first-child {
    animation: none;
  }

  /* Chatbot field border: keep the gradient ring, drop the rotation */
  .ias-chatbot-pill::before {
    animation: none;
  }

  .ias-thinking-icon,
  .ias-thinking-dots span,
  .ias-chatbot-answer-reveal {
    animation: none;
  }

  .ias-thinking-copy {
    transition: opacity 120ms ease;
    filter: none;
    transform: none;
  }
}

/* ==========================================================================
   PROJECT CARD — hover choreography.

   Four things move when a card is hovered: the photo fades out
   (.framer-18c8xrv), the dim layer fades in over the black media well
   (.framer-sl5st9, 0 -> 0.3), the well takes a glow (.framer-286zp6
   box-shadow) and rounds off, and the pill rises. They have to share one
   duration and one curve or the card reads as several separate reactions.

   The well was the odd one out: Framer ships it with `transition: all`, and
   the shorthand with no time component resolves to `transition-duration: 0s`,
   so its glow SNAPPED while everything else eased. Naming the property and
   the duration is the whole fix — `all` is also wasteful, since it makes the
   engine watch every animatable property on a large element.

   One duration for the group, slightly longer than the old 300ms because the
   dim is a large area and short fades on large areas read as instant.

   TWO CURVES, on purpose. Framer's cubic-bezier(.22, 1, .36, 1) has its first
   control point at y=1, so it reaches 87% of the change in the first third of
   the duration and then creeps through the remaining two thirds — measured on
   the dim: 0 -> 0.260 -> 0.297 -> 0.300 at 0/140/280/420ms. On a small shape
   change that snap is the point. On a large-area OPACITY fade the eye reads
   the first third as the whole transition and the rest as nothing, which is
   why the dim looked instant no matter how long the duration got.

   So shape keeps the snappy curve, and opacity gets an even one that spends
   its time where the eye is actually watching. Same 420ms either way, so the
   card still lands as one gesture.
   ========================================================================== */
/* NOTHING here transitions the photo or the dim, and that is deliberate.
   Framer Motion already animates both — the card component ships
   {"...-hover":{opacity:0}} for the photo and
   {"...-hover":{borderRadius:24, opacity:.3}} for the dim, tweened on its own
   rAF loop. Motion writes those values INLINE every frame, so a CSS
   transition layered on top is restarted continuously and never progresses,
   and a CSS `opacity: ... !important` simply replaces Motion's tween with its
   end value at class-flip time. Either way the dim lands instantly. Both were
   in here, which is why tuning duration and easing never helped.

   The glow is different: box-shadow is ours (Motion does not set it), so it
   is the one thing on the media well that genuinely needs a transition. */
/* The card's own focus ring is inset for the same reason as the form fields:
   the card is flush with .framer-zyehs3's edge, so an outset ring loses the
   side that touches it. Same 2px of --color-focus-ring, drawn just inside the
   card's box where nothing can clip it. */
a.framer-1TapC:focus-visible {
  outline-offset: calc(-1 * var(--focus-ring-width)) !important;
}

/* Radius is the one property still driven by a plain declaration (see below),
   so it needs a transition of its own or the corners would snap to 24px while
   the dim eases. Opacity is deliberately absent here — it is animation-driven,
   and listing it would put a transition back in Motion's way. */
.framer-1TapC .framer-18c8xrv,
.framer-1TapC .framer-sl5st9 {
  transition: border-radius 420ms cubic-bezier(.22, 1, .36, 1) !important;
}

/* NO OUTWARD GLOW. There used to be a `box-shadow: 0 0 40px` here and it was
   sliced flat on whichever side each card touched its container.

   Two nested clippers sit above the cards, and they are not equivalent:

     .framer-175s4ke  (grid)   overflow: hidden — 0 descendants overflow it,
                               so it clips nothing except a glow
     .framer-zyehs3   (outer)  overflow: clip   — 29 descendants overflow it,
                               up to 192px past the bottom. Load-bearing: it
                               masks real content and must stay.

   A 40px glow needs 40px of room on every side. Every card is flush with the
   outer clipper on at least one edge (the left column at x=133, the right at
   x=1133), so no combination of unclipping renders it fully — relaxing the
   grid alone would fix three sides and still cut the fourth. The glow was
   also redundant: the dim overlay and the pill's blob flood already carry the
   hover state. Deleting it removes the artifact instead of chasing it. */

/* ANIMATIONS, not transitions, and not `opacity: … !important`.

   Framer's hover variant does not reliably drive these cards — that is why
   the overrides existed in the first place ("align hover across landing,
   about and portfolio"). Dropping them stopped the card dimming at all and
   left the dim layer at its 12px resting radius against a 24px media well,
   which is what read as clipped corners.

   But `opacity: … !important` is what made the dim instant: it pins the
   computed value to the end state the moment .hover lands, and Motion is
   writing these same properties inline every frame, so a CSS transition on
   top has nothing stable to interpolate.

   Keyframes sidestep both problems. In the cascade, animations outrank normal
   author rules AND inline styles — they lose only to !important — so an
   animation beats Motion's inline writes without needing !important at all,
   and it carries its own tween instead of depending on a start value that
   Motion keeps overwriting. `both` holds the end state while hovered.

   The radius is the one thing that stays a plain declaration: it has to match
   the well's 24px exactly or the corners mismatch, and a half-applied radius
   is worse than an unanimated one. */
/* OUR OWN DIM. Injected by syncProjectCardDim() into the media well, which
   Framer never unmounts. Nothing but this stylesheet reads or writes it, so a
   plain transition does exactly what it says — no Motion tween to replace, no
   inline rewrite to interpolate against, no variant that might not fire.
   Every previous attempt failed on one of those three.

   Sits above the photo and below the pill. The photo keeps its own radius, so
   this only needs to match the well's 24px to keep the corners flush. */
.framer-1TapC .ias-card-dim {
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: 24px;
  background: var(--color-action-neutral);
  opacity: 0;
  transition: opacity 420ms cubic-bezier(.4, 0, .2, 1);
  pointer-events: none;
}

.framer-1TapC:hover .ias-card-dim,
.framer-1TapC:focus-visible .ias-card-dim,
.framer-1TapC.hover .ias-card-dim {
  opacity: 0.45;
}

/* The pill has to clear the dim. Its container is transformed, so it already
   makes a stacking context — this only fixes its order relative to the
   overlay, which would otherwise depend on DOM order. */
.framer-1TapC .framer-19vud32-container {
  z-index: 2;
}

/* Pin Framer's own two layers at their resting values so the card dims ONCE.
   If the variant fires we would otherwise get its dim plus ours, and the photo
   would vanish rather than darken — the project image staying visible under
   the dim is the point. Radius still syncs to 24px or the corners clip. */
.framer-1TapC:hover .framer-18c8xrv,
.framer-1TapC:focus-visible .framer-18c8xrv,
.framer-1TapC.hover .framer-18c8xrv {
  opacity: 1 !important;
}

.framer-1TapC:hover .framer-sl5st9,
.framer-1TapC:focus-visible .framer-sl5st9,
.framer-1TapC.hover .framer-sl5st9 {
  opacity: 0 !important;
  border-radius: 24px !important;
}

/* ==========================================================================
   PROJECT CARD — the "Check out!" pill.

   Anatomy. Authored data-framer-names survive a re-export; the hashed
   .framer-xxxxx classes do not, so prefer a name wherever one exists. The
   card hook above is hashed only because Framer gives the link no name.

     a.framer-1TapC                    the card — one big link
       └ [data-framer-name="Images"]   media well (glow on hover)
           └ span.framer-GK0bB         the pill — bone, 999px
               └ svg                   the arrow

   The pill is the one place where the authored name is NOT the stable hook:
   Framer drops data-framer-name on hover (see below), so the component class
   is what survives. Everything else here keys on the name.

   1. ARROW DIRECTION. The card bundle hard-codes the icon as `ArrowUpLeft`
      (two props, ak4OnnMcF and FRRsEDdSO), so the arrow points away from the
      project it opens. The name sits in generated vendor JS that the next
      Framer export overwrites, so it is corrected here with a quarter turn
      (↖ -> ↗) rather than by editing the bundle: same glyph, the
      conventional "opens this project" direction, and it survives re-export.
      Rotating a transform costs no layout.

   2. HOVER FEEDBACK. The pill appeared on card hover but had no state of its
      own — moving the pointer onto it did nothing. It now fills with the
      action red and flips label and arrow to bone. The pill is only ever
      mounted inside a card (verified: zero instances elsewhere), so scoping
      to .framer-1TapC cannot leak onto other buttons.

   3. KEYBOARD PARITY. Every rule above keyed on :hover alone, so a keyboard
      user tabbing the grid saw nothing. :focus-visible is added to each.
      The pill itself cannot follow: the bundle mounts it conditionally on
      pointer hover, so on focus it is absent from the DOM entirely and no
      CSS can bring it back. The glow and the fades are what carry the state
      for keyboard users; the card also takes the global focus ring.
   ========================================================================== */
/* Hook on .framer-GK0bB, NOT on [data-framer-name="Secondary | lg"]. The
   moment the pill is hovered Framer re-renders it WITHOUT the
   data-framer-name attribute (it returns null) and adds its own `hover`
   class, so any name-based selector silently stops matching exactly when the
   hover state needs it — which left the arrow un-rotated and the fill unpainted
   on hover. .framer-GK0bB is present in both states. Verified card-scoped:
   `.framer-GK0bB:not(.framer-1TapC *)` matches zero elements.

   Both :hover and .hover are listed for the same reason the card rules above
   do it — Framer drives its own class and the pointer drives the pseudo. */
.framer-1TapC .framer-GK0bB svg {
  transform: rotate(90deg);
}

/* The fill RISES on hover, echoing the site CTA's inverse-fill: there, four
   blobs rise and flood the pill. That effect is four injected nodes, which
   cannot be reused here — the card bundle mounts this pill conditionally on
   pointer hover, so anything injected into it is destroyed on every unhover
   (verified: a probe node survives the hover re-render but not an
   unhover -> re-hover cycle). So the flood is a pseudo-element instead, and
   ::before is the only one free — Framer already uses ::after (content is ""
   there, `none` on ::before).

   One element means one edge rather than four staggered blobs, so the 999px
   radius is what shapes the leading edge as it climbs. The pill already ships
   position:relative and overflow:hidden, so the pseudo is clipped to the pill
   exactly the way .ias-cta-blob-inner clips the real blobs — nothing extra
   needed. Timing is the card group's 420ms and curve, not the CTA's 450ms
   ease — matching the card it lives in matters more than matching the button
   it borrows the idea from.

   background-color is NOT used to paint this: Framer holds it inline on the
   span and it resisted being overridden from a stylesheet — putting outline,
   border-radius and background-color in ONE !important rule applied the first
   two and not the third. The pseudo sidesteps the question entirely, so this
   was not chased further.

   Ink, not the action red, so the pill reads as the neutral "open this"
   affordance and leaves the red to actual calls to action. */
/* ANIMATIONS, NOT TRANSITIONS — and keyed on the CARD, not the pill.

   Two things were wrong with the obvious approach, and they share a cause:
   the pill is mounted ONLY while the card is hovered.

   1. Gating the flood on the pill's own :hover/.hover meant you had to land
      the pointer precisely on a pill that had just appeared under it. Hovering
      the card — the actual gesture — left it bone. The trigger belongs on
      .framer-1TapC, which is also the thing that mounts the pill.

   2. A transition cannot run on a freshly mounted node: there is no previous
      computed value to interpolate from, so it lands at its end state
      instantly. That is the "default to hover is instant" symptom, and no
      amount of tuning duration or easing fixes it. Keyframe animations DO run
      on insertion, so every part of the pill is animated, not transitioned.
      `both` holds the end state for as long as the pill is mounted.

   Everything runs on the same 420ms and curve as the card group, so the pill
   arrives, floods to ink, and turns its label over as one motion. */
@keyframes ias-card-pill-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* THE BLOB FLOOD — the site CTA's effect, not an approximation of it.

   Geometry mirrors the CTA's own rules exactly (four circles at 25% width,
   left 0/30/60/90, scale 1.7, rising from 150%), because those rules are
   scoped to a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) and cannot be
   reused by selector. The markup is injected by syncProjectCardBlobs() in
   static-site.js, which re-runs off the existing MutationObserver — the pill
   unmounts on every unhover, so the blobs have to be re-created each time.

   ANIMATION, not transition, and the stagger is animation-delay rather than
   transition-delay. The CTA can use transitions because it is always in the
   DOM; this pill is mounted mid-hover, and a transition on a freshly mounted
   node has no previous value to interpolate from, so it would land on its end
   state instantly. Keyframes run on insertion, which is exactly what a
   just-mounted node needs.

   Only the colour differs from the CTA: the blobs are ink rather than bone,
   so the pill floods dark instead of inverting to the page surface. */
@keyframes ias-card-blob-rise {
  from { transform: translate3d(0, 150%, 0) scale(1.7); }
  to   { transform: translateZ(0) scale(1.7); }
}

.framer-1TapC .framer-GK0bB .ias-card-blob-inner {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  border-radius: 999px;
  pointer-events: none;
}

.framer-1TapC .framer-GK0bB .ias-card-blob-group {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
}

.framer-1TapC .framer-GK0bB .ias-card-blob {
  position: absolute;
  top: 2px;
  width: 25%;
  height: 100%;
  border-radius: 100%;
  background: var(--color-action-neutral);
  animation: ias-card-blob-rise 450ms ease both;
}

.framer-1TapC .framer-GK0bB .ias-card-blob:nth-child(1) { left: 0;   animation-delay: 0ms; }
.framer-1TapC .framer-GK0bB .ias-card-blob:nth-child(2) { left: 30%; animation-delay: 80ms; }
.framer-1TapC .framer-GK0bB .ias-card-blob:nth-child(3) { left: 60%; animation-delay: 160ms; }
.framer-1TapC .framer-GK0bB .ias-card-blob:nth-child(4) { left: 90%; animation-delay: 240ms; }

/* Animating `color` beats `.framer-text { color: var(--framer-text-color) }`
   outright — animations outrank normal declarations, so unlike the static
   case this needs no --framer-text-color override and no !important. */
@keyframes ias-card-pill-label {
  from { color: var(--color-text-primary); }
  to   { color: var(--color-text-on-accent); }
}

@keyframes ias-card-pill-icon {
  from { color: var(--color-text-primary); fill: var(--color-text-primary); }
  to   { color: var(--color-text-on-accent); fill: var(--color-text-on-accent); }
}

/* Even curve, same reasoning as the dim above — this is an opacity fade, not a
   shape change. */
.framer-1TapC .framer-GK0bB {
  animation: ias-card-pill-in 420ms cubic-bezier(.4, 0, .2, 1) both;
}

/* Everything except the blob layer sits above it — same shape as the CTA's own
   `> :not(.ias-cta-blob-inner)` rule. */
.framer-1TapC .framer-GK0bB > :not(.ias-card-blob-inner) {
  position: relative;
  z-index: 2;
}

/* The label turns over slightly behind the leading blob rather than with it:
   at 0ms the pill is still bone and white-on-bone would be invisible. 120ms
   is roughly when the first two blobs have covered the label's box. */
.framer-1TapC .framer-GK0bB .framer-text {
  animation: ias-card-pill-label 450ms ease 120ms both;
}

.framer-1TapC .framer-GK0bB svg,
.framer-1TapC .framer-GK0bB svg * {
  animation: ias-card-pill-icon 450ms ease 120ms both;
}

/* Movement is the vestibular trigger, not colour — so the climb, the pill's
   entrance and the card's transform/radius easing all drop to instant, while
   the opacity and colour cross-fades stay. The card still reads as a state
   change, it just stops travelling. */
@media (prefers-reduced-motion: reduce) {
  /* NOT `animation: none` — the end state is what these animations carry.
     Cancelling them would strand the flood covering the pill with an ink
     label sitting on it (1.3:1, illegible). Collapsing the duration lands the
     same end state with no travel, which is what reduced motion asks for. */
  .framer-1TapC .framer-GK0bB,
  .framer-1TapC .framer-GK0bB .ias-card-blob,
  .framer-1TapC .framer-GK0bB .framer-text,
  .framer-1TapC .framer-GK0bB svg,
  .framer-1TapC .framer-GK0bB svg * {
    animation-duration: 1ms !important;
    animation-delay: 0ms !important;
  }

  /* The dim is an opacity cross-fade, which is not travel — it stays. Only the
     radius easing on Framer's layers is dropped. */
  .framer-1TapC .framer-18c8xrv,
  .framer-1TapC .framer-sl5st9 {
    transition: none !important;
  }
}

/* Dynamic background transparency overrides */
html, body, #main, [data-framer-root] {
  background: transparent !important;
  background-color: transparent !important;
}

/* Remove navbar background so the animated bg shows through */
nav.framer-5vKhO,
nav.framer-5vKhO[style] {
  background-color: transparent !important;
  background: transparent !important;
  -webkit-backdrop-filter: none !important;
  backdrop-filter: none !important;
}

/* Chatbot Widget Styles */
.ias-chatbot-widget {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 2147483647; /* Ensure it floats above all other elements */
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  display: flex;
  flex-direction: column;
  align-items: center;
  transition: all 400ms cubic-bezier(0.16, 1, 0.3, 1);
  width: 340px;
}

.ias-chatbot-widget.expanded {
  width: 450px;
}

/* Scrolled to the footer: lift clear of the nav row so those links stay
   clickable (the widget sits at z-index 2147483647 and would eat the taps).
   Toggled by the scroll handler in static-site.js; the 400ms ease-out on
   .ias-chatbot-widget animates it.
   :not(.expanded) because the open panel's max-height is tuned for
   bottom:24px and would touch the viewport top if raised too.
   Higher specificity than the 480px override, so it wins at every width. */
.ias-chatbot-widget.nav-clear:not(.expanded) {
  bottom: 88px;
}

@media (prefers-reduced-motion: reduce) {
  /* Scroll-triggered movement is a vestibular trigger — snap instead. */
  .ias-chatbot-widget {
    transition: none;
  }
}

/* Dimming Website Overlay */
.ias-chatbot-overlay {
  position: fixed;
  inset: 0;
  background: var(--ink-a16);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  opacity: 0;
  pointer-events: none;
  z-index: 2147483645; /* Just below chatbot widget */
  transition: opacity 400ms ease;
}

.ias-chatbot-overlay.active {
  opacity: 1;
  pointer-events: auto;
}

/* Collapsed Pill State */
.ias-chatbot-pill {
  display: flex;
  align-items: center;
  gap: 10px;
  position: relative; /* containing block for the animated gradient border ::before */
  background: var(--color-surface-raised) !important;
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid var(--ink-a16);
  border-radius: 99px;
  padding: 12px 20px;
  box-shadow: 0 4px 20px var(--ink-a05), 0 0 0 1px var(--ink-a03);
  cursor: pointer;
  width: 100%;
  box-sizing: border-box;
  transition: all 300ms ease;
  min-height: 48px;
}

/* Animated gradient border for the chatbot field. A darker charcoal
   conic-gradient ring rotates continuously around the pill, layered on top of
   the static 1px border. It is drawn on a ::before using the standard
   mask-composite "border" recipe (two mask layers, exclude/xor) so only the
   thin edge shows and never the fill. The @property-registered angle lets the
   conic gradient interpolate smoothly — without it the custom property would
   jump at the keyframe boundary instead of animating. Colors stay in the
   site's monochrome ink palette (var(--color-text-primary)); pointer-events:none keeps the
   pill clickable. */
@property --ias-chatbot-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

.ias-chatbot-pill::before {
  content: "";
  position: absolute;
  inset: -1px; /* reach the border-box edge so the ring sits over the 1px border */
  border-radius: inherit;
  padding: 1.5px; /* ring thickness */
  background: conic-gradient(
    from var(--ias-chatbot-angle),
    var(--ink-a05),
    var(--ink-a50),
    var(--ink-a05),
    var(--ink-a50),
    var(--ink-a05)
  );
  -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;
  pointer-events: none;
  animation: ias-chatbot-border-spin 4s linear infinite;
}

@keyframes ias-chatbot-border-spin {
  to { --ias-chatbot-angle: 360deg; }
}

.ias-chatbot-pill:hover {
  transform: translateY(-2px); /* Only translate vertically, do not shift horizontally! */
  border-color: var(--ink-a22);
  box-shadow: 0 6px 24px var(--ink-a08);
}

/* The sparkle is a system marker, not a control, so it reads from the
   structural token rather than the action one — otherwise moving the buttons
   to orange would have silently turned it warm too. */
.ias-chatbot-sparkle {
  color: var(--color-accent-structural);
  font-size: 16px;
  animation: ias-sparkle-pulse 2s infinite ease-in-out;
}

.ias-chatbot-pill span.placeholder {
  color: var(--color-text-secondary);   /* was rgba(ink,.6) = 4.30:1, below AA */
  font-size: 15px;
  font-weight: 500;
  user-select: none;
  flex-grow: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Expanded Window State */
.ias-chatbot-window {
  display: flex;
  flex-direction: column;
  background: var(--color-surface) !important;
  backdrop-filter: blur(32px);
  -webkit-backdrop-filter: blur(32px);
  border: 1px solid var(--ink-a08);
  border-radius: 24px;
  width: 100%;
  height: 0;
  opacity: 0;
  pointer-events: none;
  box-shadow: 0 20px 48px var(--ink-a08);
  overflow: hidden;
  transition: all 400ms cubic-bezier(0.16, 1, 0.3, 1);
  box-sizing: border-box;
}

.ias-chatbot-widget.expanded .ias-chatbot-window {
  height: 680px; /* Increased height to contain re-ordered elements beautifully */
  max-height: calc(100vh - 100px); /* Restrict height to stay within viewport bounds */
  opacity: 1;
  pointer-events: auto;
  margin-bottom: 12px;
  border-color: var(--ink-a22); /* Darker border when active/expanded */
  box-shadow: 0 24px 60px var(--ink-a16);
}

.ias-chatbot-widget.expanded .ias-chatbot-pill {
  opacity: 0;
  pointer-events: none;
  height: 0;
  min-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  border-color: transparent;
  margin: 0;
  overflow: hidden;
}

/* Header */
.ias-chatbot-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 24px;
  border-bottom: 1px solid var(--ink-a08);
}

.ias-chatbot-header-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 16px;
  font-weight: 700;
  color: var(--color-text-primary);
  letter-spacing: -0.01em;
}

.ias-chatbot-close-btn {
  background: transparent;
  border: none;
  cursor: pointer;
  color: var(--color-text-secondary);   /* was rgba(ink,.45) = 2.78:1, failed AA */
  font-size: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  transition: all 200ms ease;
}

.ias-chatbot-close-btn:hover {
  background: var(--color-surface-hover);
  color: var(--color-text-primary);
}

/* Content / History */
.ias-chatbot-content {
  flex-grow: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch; /* smooth touch scroll support */
  touch-action: pan-y; /* prevent default event blocking */
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  scroll-behavior: smooth;
}

/* Empty View */
.ias-chatbot-empty-view {
  display: flex;
  flex-direction: column;
  gap: 24px;
  height: 100%;
}

.ias-chatbot-empty-header {
  text-align: center;
  margin-top: 8px;
}

.ias-chatbot-empty-title {
  font-size: 26px;
  font-weight: 800;
  color: var(--color-text-primary);
  margin: 0 0 10px 0;
  letter-spacing: -0.02em;
}

.ias-chatbot-empty-desc {
  font-size: 14px;
  line-height: 1.55;
  color: var(--color-text-secondary);   /* was 4.30:1, below AA */
  margin: 0;
}

.ias-chatbot-empty-portrait-wrap {
  margin-top: 12px; /* Set margin top since it is placed at the top of empty view now */
  margin-bottom: 8px;
  display: flex;
  justify-content: center;
}

.ias-chatbot-empty-portrait {
  max-height: 185px; /* Sized to sit cleanly on top */
  width: auto;
  object-fit: contain;
  filter: drop-shadow(0 4px 12px var(--ink-a05));
  user-select: none;
  pointer-events: none;
}

/* Suggestions / Prompts */
.ias-chatbot-suggestions {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.ias-chatbot-suggestion-title {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-text-secondary);   /* was 2.78:1, failed AA */
  font-weight: 700;
  margin-bottom: 2px;
}

.ias-chatbot-suggestion-chips {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: center; /* center them horizontally */
  gap: 8px;
  width: 100%;
  padding: 0;
  margin: 0;
  list-style: none;
}

.ias-chatbot-chip {
  background: var(--ink-a03);
  border: 1px solid var(--ink-a08);
  border-radius: 12px;
  padding: 8px 12px; /* 12px left/right padding */
  font-size: 13px;
  font-weight: 500;
  color: var(--color-text-secondary);
  cursor: pointer;
  transition: all 200ms ease;
  user-select: none;
  text-align: center;
  width: fit-content; /* Contain the content! */
  max-width: 95%;
  box-sizing: border-box;
}

.ias-chatbot-chip:hover {
  background: var(--color-surface-selected);
  color: var(--green-700);
  border-color: var(--color-border-on-tint);
  transform: translateY(-1px);
}

/* Messages */
.ias-chatbot-message {
  max-width: 85%;
  padding: 12px 16px;
  border-radius: 18px;
  font-size: 14px;
  line-height: 1.5;
  animation: ias-message-fade 250ms ease-out forwards;
}

.ias-chatbot-message.user {
  background: var(--color-text-primary);
  color: var(--color-text-on-accent);
  align-self: flex-end;
  border-bottom-right-radius: 4px;
  box-shadow: 0 4px 12px var(--ink-a08);
}

.ias-chatbot-message.bot {
  background: var(--ink-a05);
  color: var(--color-text-primary);
  align-self: flex-start;
  border-bottom-left-radius: 4px;
}

.ias-chatbot-message.bot a {
  color: var(--color-text-link);
  text-decoration: underline;
  font-weight: 600;
}

.ias-chatbot-message.bot a:hover {
  color: var(--color-text-link-hover);
}

/* Loading state */
.ias-chatbot-thinking {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 43px;
  color: var(--color-text-secondary);   /* was 4.05:1, below AA */
  background: var(--ink-a03);
  overflow: hidden;
  user-select: none;
}

.ias-thinking-icon {
  display: inline-flex;
  color: var(--color-text-secondary);   /* was 2.86:1, failed AA */
  font-size: 13px;
  line-height: 1;
  animation: ias-thinking-pulse 1.6s ease-in-out infinite;
}

.ias-thinking-copy {
  display: inline-block;
  white-space: nowrap;
  transition: opacity 180ms ease, transform 220ms cubic-bezier(.22, 1, .36, 1), filter 220ms ease;
}

.ias-thinking-copy.is-visible {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}

.ias-thinking-copy.is-switching {
  opacity: 0;
  transform: translateY(5px);
  filter: blur(3px);
}

.ias-thinking-dots {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  margin-left: -1px;
}

.ias-thinking-dots span {
  width: 3px;
  height: 3px;
  border-radius: 999px;
  background: var(--ink-a50);   /* was 2.78:1 */
  animation: ias-thinking-dot 1.2s ease-in-out infinite;
}

.ias-thinking-dots span:nth-child(2) {
  animation-delay: 120ms;
}

.ias-thinking-dots span:nth-child(3) {
  animation-delay: 240ms;
}

.ias-chatbot-answer-reveal {
  animation: ias-answer-reveal 260ms cubic-bezier(.22, 1, .36, 1) both;
}

/* Input Footer */
.ias-chatbot-footer {
  padding: 14px 20px;
  border-top: 1px solid var(--ink-a08);
  display: flex;
  gap: 10px;
  align-items: center;
}

.ias-chatbot-input {
  flex-grow: 1;
  border: 1px solid var(--color-border-strong);   /* was 1.44:1, below the 3:1 floor for a control boundary */
  background: var(--ink-a03);
  border-radius: 99px;
  padding: 10px 18px;
  font-size: 14px;
  color: var(--color-text-primary);
  outline: none;
  transition: all 200ms ease;
  min-height: 40px;
}

.ias-chatbot-input:focus-visible {
  border-color: var(--color-border-accent);
  background: var(--ink-a03);
  outline: var(--focus-ring-width) solid var(--color-focus-ring);
  outline-offset: var(--focus-ring-offset);
}

.ias-chatbot-send-btn {
  background: var(--color-action-primary);
  color: var(--color-text-on-accent);
  border: none;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 200ms ease;
  flex-shrink: 0;
}

.ias-chatbot-send-btn:hover {
  transform: scale(1.05);
  background: var(--color-action-primary-hover);
}

.ias-chatbot-send-btn:active {
  transform: scale(.98);
  background: var(--color-action-primary-active);
}

.ias-chatbot-send-btn:disabled {
  transform: none;
  cursor: not-allowed;
}

/* Animations */
@keyframes ias-sparkle-pulse {
  0%, 100% { opacity: 0.6; transform: scale(1); }
  50% { opacity: 1; transform: scale(1.2); }
}

@keyframes ias-message-fade {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes ias-answer-reveal {
  from { opacity: 0; transform: translateY(6px); filter: blur(4px); }
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
}

@keyframes ias-thinking-pulse {
  0%, 100% { opacity: 0.55; transform: scale(1); }
  50% { opacity: 1; transform: scale(1.12); }
}

@keyframes ias-thinking-dot {
  0%, 80%, 100% { opacity: 0.32; transform: translateY(0) scale(0.8); }
  40% { opacity: 1; transform: translateY(-2px) scale(1); }
}

@keyframes ias-dot-pulse {
  0%, 80%, 100% { transform: scale(0); }
  40% { transform: scale(1.0); }
}

/* Link Chips */
.ias-chatbot-link-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  overflow: hidden;
  background: var(--ink-a05);
  border: 1px solid var(--ink-a08);
  border-radius: 20px;
  padding: 5px 12px 5px 10px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--color-text-primary) !important;
  text-decoration: none !important;
  margin: 3px 2px;
  transition: all 200ms ease;
  vertical-align: middle;
  max-width: 100%;
  min-width: 0;
  white-space: nowrap;
  cursor: pointer;
  box-sizing: border-box;
}

.ias-chatbot-link-chip span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  line-height: 1.2;
  min-width: 0;
}

.ias-chatbot-link-chip:hover {
  background: var(--color-surface-selected);
  border-color: var(--color-border-on-tint);
  transform: translateY(-1px);
  box-shadow: 0 2px 8px var(--ink-a05);
}

.ias-link-icon {
  flex-shrink: 0;
  stroke: var(--ink-a50);
  transition: stroke 200ms ease;
}

/* Same reasoning as the sparkle: an affordance icon, not an action fill. */
.ias-chatbot-link-chip:hover .ias-link-icon {
  stroke: var(--color-accent-structural);
}

/* Follow-up Prompts */
.ias-chatbot-followup-container {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: 6px;
  align-self: flex-start;
  margin-top: -8px; /* sits closely below the bot message bubble */
  margin-bottom: 8px;
  padding-left: 12px; /* aligns nicely with the start of the message */
  max-width: 90%;
  animation: ias-fade-in 250ms ease-out;
}

.ias-chatbot-followup-chip {
  background: var(--ink-a05);
  border: 1px solid var(--ink-a12);
  border-radius: 12px;
  padding: 6px 10px;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--ink-a85);
  cursor: pointer;
  transition: all 200ms ease;
  user-select: none;
  width: fit-content;
  box-sizing: border-box;
}

.ias-chatbot-followup-chip:hover {
  background: var(--color-surface-selected);
  color: var(--green-700);
  border-color: var(--color-border-on-tint);
  transform: translateY(-0.5px);
}

/* Removed legacy signature text block */
.framer-1ojr88j {
  display: none !important;
}

/* Responsive adjustment */
@media (max-width: 480px) {
  .ias-chatbot-widget {
    width: calc(100% - 32px);
    left: 16px;
    transform: none;
    bottom: 16px;
  }
  .ias-chatbot-widget.expanded {
    width: calc(100% - 32px);
  }
  .ias-chatbot-widget.expanded .ias-chatbot-window {
    height: 80vh !important; /* 80% of screen height on mobile */
    max-height: 80vh;
  }
}


/* ==========================================================================
   PRIMARY CALL TO ACTION — the site's one orange button.

   These three classes are the site-wide CTA pill ("Let's talk" / "Go deeper"
   style). They are styled by an inline <style> block inside each page's
   <body>, which beats this file on document order, so every rule here is
   deliberately raised to (0,2,2) with `body ...` to win on specificity
   instead. Do not "simplify" the `body` prefix away — the override stops
   working without it.

   The inverse-fill hover is preserved exactly: four blobs rise and flood the
   pill with bone, and the label flips from white to the hover shade
   (--orange-600, 7.75:1 on that bone fill). Only the hues changed; the
   motion is untouched.

   Secondary and ghost buttons stay ink on purpose. One orange button per view
   is what keeps the accent reading as "the action" rather than decoration.
   ========================================================================== */
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) {
  background-color: var(--color-action-primary) !important;
  border-color: var(--color-action-primary) !important;
}

body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN)::before {
  border-color: var(--color-action-primary) !important;
}

body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) .ias-cta-blob-inner {
  background: var(--color-action-primary) !important;
}

body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) .ias-cta-blob {
  background: var(--color-bg-primary) !important;
}

body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) .framer-text,
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) svg,
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN) svg * {
  color: var(--color-text-on-accent) !important;
  fill: var(--color-text-on-accent) !important;
}

/* Hover / focus: the bone flood is up, so the label goes to the hover shade. */
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN):is(:hover, :focus-visible) .framer-text,
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN):is(:hover, :focus-visible) svg,
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN):is(:hover, :focus-visible) svg * {
  color: var(--color-action-primary-hover) !important;
  fill: var(--color-action-primary-hover) !important;
}

/* Pressed: darken the pill itself rather than only moving it, so the state is
   legible without relying on the 1px translate alone. */
body a:is(.framer-ySdnV, .framer-gwARn, .framer-dxTjN):active {
  background-color: var(--color-action-primary-active) !important;
  border-color: var(--color-action-primary-active) !important;
}

/* ==========================================================================
   FORMS — the contact / newsletter fields.

   Framer ships these with a rgba(ink, .3) hairline, which is 1.89:1 against
   the page. The border is the only thing that identifies the field, so
   SC 1.4.11 applies and it has to clear 3:1: --color-border-strong is 3.99:1.

   Focus adds the green ring on top of a green border, so the focused field
   is distinguishable from the merely hovered one by more than hue alone.
   ========================================================================== */
input.framer-formspark-input,
textarea.framer-formspark-input,
.framer-formspark-form input:not([type="submit"]),
.framer-formspark-form textarea {
  border-color: var(--color-border-strong) !important;
}

input.framer-formspark-input:hover,
textarea.framer-formspark-input:hover {
  border-color: var(--neutral-600) !important;
}

/* The ring is drawn INSIDE these fields — the one place on the site that does
   not use the global outset offset.

   The email input and the message textarea end flush with the right edge of
   .framer-10j07i7 (both at x=1133 at 1280px), and Framer ships that container
   with `overflow: clip`. An outset ring needs
   --focus-ring-offset + --focus-ring-width = 4px of room outside the field,
   and on that edge there is none, so its right-hand side was being cut off
   while the other three sides drew normally.

   A negative offset keeps the whole ring inside the field's own border box,
   where no ancestor can clip it. Loosening the container's overflow was the
   alternative and is worse: it is Framer's own, applied section-wide, and
   would let anything else in that section spill.

   Still 2px of --color-focus-ring against the field's own surface, so it
   clears 3:1 for SC 1.4.11 exactly as the outset ring did; only which side of
   the border it sits on has changed. */
input.framer-formspark-input:focus-visible,
textarea.framer-formspark-input:focus-visible,
.framer-formspark-form input:not([type="submit"]):focus-visible,
.framer-formspark-form textarea:focus-visible {
  border-color: var(--color-border-accent) !important;
  outline: var(--focus-ring-width) solid var(--color-focus-ring) !important;
  outline-offset: calc(-1 * var(--focus-ring-width)) !important;
}

input.framer-formspark-input[aria-invalid="true"],
textarea.framer-formspark-input[aria-invalid="true"] {
  border-color: var(--color-border-danger) !important;
}

/* ==========================================================================
   SUBMIT BUTTON — the form's primary action, so it takes the action colour.

   Framer ships it with `background: rgb(31,31,31)` inline (ink), which made it
   read as a neutral/secondary control; the primary submit should be the one
   orange action on the form. The inline background is why every rule here is
   !important — an inline declaration otherwise wins.

   It is GATED: static-site.js (gateContactFormSubmits) sets `disabled` until
   every field is filled, and the :disabled rule below shows the disabled
   tokens (SC 1.4.3 exempts disabled controls from contrast, but the pairing
   still reads as clearly inert), flipping to the live orange the moment the
   form is complete. cursor + the token swap both change, so the state does not
   rely on colour alone.

   Selector is the bare `input[type="submit"]`, not the .framer-formspark-*
   scope the field rules above use: the home "Let's Connect" form ships as a
   plain <form> with an unclassed submit, and the only input[type=submit] on
   the whole site is the contact submit (three pages), so there is nothing else
   for this to catch. */
input[type="submit"] {
  background: var(--color-action-primary) !important;
  color: var(--color-text-on-accent) !important;
  cursor: pointer !important;
}
/* No background transition on purpose: the gating observer re-touches this
   element every frame while the form is on screen, which restarts a colour
   transition before it can land and leaves the disabled fill stuck on the
   enabled "from" colour. An instant swap is correct and stable. */

input[type="submit"]:hover:not(:disabled) {
  background: var(--color-action-primary-hover) !important;
}

input[type="submit"]:active:not(:disabled) {
  background: var(--color-action-primary-active) !important;
}

input[type="submit"]:disabled {
  background: var(--color-action-disabled-bg) !important;
  color: var(--color-action-disabled-text) !important;
  cursor: not-allowed !important;
}

/* ==========================================================================
   ABOUT STORY — un-stick the illustration column when the layout stacks.

   The eye/circuit illustration lives in [data-framer-name="Images"], which
   Framer makes position:sticky so it stays beside the text while the story
   scrolls, in the two-column desktop layout (the section's container is
   flex-direction:row at >=1200px). Below 1200px Framer stacks that section
   into a single column (flex-direction:column) — and a sticky child in a
   stacked column hangs in the viewport and scrolls straight over the
   paragraphs beneath it, because the column sits at z-index:1, above the text.
   That is the tablet overlap.

   Dropping it to relative below the breakpoint lets it scroll away with its
   own content. A sticky box that is not currently stuck already occupies its
   normal-flow position, so this changes nothing about layout or spacing — it
   only removes the stick-to-viewport behaviour. Desktop (>=1200px) is
   untouched and keeps the intended side-by-side sticky. Scoped with :has() to
   the illustration column specifically, so no other "Images" block is
   affected. Matches Framer's own 1199.98px breakpoint boundary.

   Second part: the illustration itself (.ias-illustration-circuit-host, which
   is the [data-framer-name="Image 1"] node) also carries a scroll-linked
   parallax — Framer rewrites its inline transform to translateY(±~130px) as
   you scroll. In the two-column desktop layout that drift stays inside the
   illustration's own column; stacked, it slides the eye down into the
   paragraph directly beneath it, so un-sticking alone still leaves a partial
   overlap at the extremes of the swing. Pinning transform:none here holds the
   illustration at its layout position (which clears the text) and drops only
   the decorative parallax on the stacked breakpoints; the CSS !important beats
   Framer's per-frame inline write. Desktop keeps the effect. */
@media (max-width: 1199.98px) {
  [data-framer-name="Images"]:has(.ias-illustration-circuit-host) {
    position: relative !important;
    /* The column also carries top:160px — a relative nudge that aligns the
       illustration against the text in the side-by-side desktop layout. Stacked,
       that same 160px shoves the illustration down over the paragraph beneath
       it (it is the static half of the overlap, under the sticky + parallax).
       Zeroing it drops the illustration back onto its flex position, leaving the
       container's own 50px gap between it and the story. */
    top: 0 !important;
  }
  [data-framer-name="Images"] .ias-illustration-circuit-host {
    transform: none !important;
  }
}


/* ==========================================================================
   L4: LAYOUT SYSTEM — breakpoints, 8-point spacing, a 12-column grid, and a
   two-ratio fluid type scale.

   The colour layers above (L1 primitives / L2 semantic / L3 bridge) gave the
   site a system for colour and nothing else. Everything spatial was still
   whatever Framer emitted: type sizes hard-coded inline, one value for all
   three of its breakpoints; page margins stepping 32 -> 80 -> 100px; section
   rhythm stepping 40 -> 70 -> 100px; no column grid at all. This layer is
   the same three-tier idea applied to space and type.

   Two conventions are adopted wholesale rather than invented:

     BREAKPOINTS — Material Design 3 window size classes. They already agree
     with what this site does at 600px and 1200px, which is why they were
     picked over Bootstrap's or Tailwind's sets.

       compact    <  600px      phones
       medium     >= 600px      large phones, small tablets portrait
       expanded   >= 840px      tablets landscape, small laptops
       large      >= 1200px     desktops              <- container maxes out
       x-large    >= 1600px

     Most of those are ANCHOR points, not branch points. Because the tokens
     below are fluid, the only place this layer actually needs a media query
     is the compact/medium seam at 600px, where the grid changes column
     count; 840, 1200 and 1600 are simply where the ramps are pinned. That
     is the whole argument for `clamp()` over a stack of overrides — one
     seam instead of four, and no step change at any of them.

     So the layer adds exactly three queries, and they use one number:
       (min-width:  600px)    12 columns, second margin segment
       (max-width:  599.98px) heading line-breaking
       (max-height: 599.98px) compact-height top padding

     Two older queries elsewhere in this file predate the system and are
     left alone deliberately: `max-width: 1199.98px` un-sticks the About
     illustration at Framer's own variant boundary, and `max-width: 480px`
     sizes the chatbot panel, which was tuned by hand and is not a layout
     concern. Neither is on the scale; both are noted here so the count
     above stays honest.

     Framer's own DOM-variant switch stays at 810px. That boundary is in the
     markup — it is where the export swaps one hero component for another —
     so it is not ours to move. It sits inside the `expanded` band, and the
     tokens are continuous across it: measured at 809px and 811px the margin
     moves 54.29 -> 54.51px and the section gap 59.51 -> 59.69px, where the
     export jumped 32 -> 80px and 40 -> 70px.

     SPACING — 8-point grid. Every spacing value in this layer is a multiple
     of 8 (with 4 available for hairline work). This is the one place the
     export was edited rather than wrapped: its desktop page margin and
     section gap were 100px, the only spatial values on the site that were
     not multiples of 8. They are now 96px. Besides putting them on the
     grid, it makes the 12-column arithmetic come out whole: a 1200px
     container with 96px margins and 24px gutters gives exactly 62px
     columns, where 100px margins gave 61.33px.

   TYPE is deliberately NOT snapped to the 8-point grid — type scales are
   ratio-based, spacing grids are additive, and forcing one onto the other
   collapses adjacent steps. See the type block for its two ratios.
   ========================================================================== */

:root {
  /* --- L4: SPACING PRIMITIVES — 8-point grid ---------------------------- */
  --space-1:    4px;
  --space-2:    8px;
  --space-3:   12px;
  --space-4:   16px;
  --space-5:   24px;
  --space-6:   32px;
  --space-7:   40px;
  --space-8:   48px;
  --space-9:   64px;
  --space-10:  80px;
  --space-11:  96px;
  --space-12: 128px;

  /* The fixed navigation bar's height, from Framer's own
     `.framer-w2ulnb { height: 64px }`. A token because two rules below need
     "the room under the bar" and neither should re-type the number. It is
     --space-9, so it is already on the grid. */
  --nav-height: 64px;

  /* --- L4: THE COLUMN GRID ----------------------------------------------
     Twelve columns from the medium breakpoint up — the near-universal
     choice (Material, Bootstrap, Figma's default layout grid) because 12
     divides by 2, 3, 4 and 6, so halves, thirds, quarters and sixths are
     all expressible without a second grid. Four columns in the compact
     band, which is Material's own rule and not a shortcut: twelve columns
     across a 324px content width leaves 11px per column once the gutters
     are paid for, which is not a grid, it is arithmetic. Spans are out of
     4 on a phone and out of 12 above it, exactly as in Bootstrap's
     `col-6 col-md-4`.

     Margin and gutter are fluid but anchored on 8-point values at the
     breakpoints, so at every boundary the grid lands on the spacing scale:

       viewport  cols  margin  gutter     viewport  cols  margin  gutter
        320px      4    24px    16px       840px     12    58px    24px
        375px      4    26px    18px      1024px     12    77px    24px
        600px     12    32px    24px      1200px     12    96px    24px

     Margin needs two segments because it should not grow linearly from a
     phone to a desktop: it stays tight (24-32px) across the compact band,
     then opens out towards the 96px desktop margin. Gutter needs one — it
     reaches Material's 24px at the compact/medium boundary and holds. */
  --grid-columns: 4;
  --grid-max: 1200px;
  --grid-margin: clamp(24px, 2.8571vw + 14.8571px, 32px);
  --grid-gutter: clamp(16px, 2.8571vw + 6.8571px, 24px);

  /* Vertical rhythm between page sections. Flat at Framer's 40px across the
     compact band, opening to 96px at 1200px. */
  --section-gap: clamp(40px, 9.3333vw - 16px, 96px);

  /* --- L4: FLUID TYPE SCALE ---------------------------------------------
     A two-ratio scale in the Utopia sense: the display steps interpolate
     between a wide scale on desktop and a tight one on a phone, because the
     column shrinks faster than type can follow. A single ratio cannot do
     both jobs — hold it wide enough for desktop and a 65-character headline
     runs to eight lines on a 320px screen; tighten it enough for the phone
     and the desktop hierarchy flattens.

       at 1200px   ratio 1.145   32 -> 36 -> 40 -> 48   (+ 72 at 1.5)
       at  320px   ratio 1.063   25 -> 27 -> 28 -> 30   (+ 34 at 1.13)

     The desktop end is the brand's existing scale, reproduced exactly — the
     clamp maximum of every step is the value Framer already emits, so at
     600px and above these compute to the native size and the established
     desktop composition is untouched. The compact end was derived from
     measured line counts rather than picked: each min is the largest size
     that keeps 9-13 characters per line in Chonburi at a 320px viewport,
     which is what holds every heading to five lines or fewer.

     Steps are named by role, not by pixel size, so a component asks for
     `display-lg` rather than "48px".

     Bounds are in rem, not px. A font-size expressed purely in vw is frozen
     under browser page zoom — zooming halves the CSS-px viewport, the vw
     term shrinks by exactly as much as the zoom magnifies, and the text
     never gets bigger. That is a documented WCAG 1.4.4 failure, and it is
     what the export had. With rem bounds the floor rises with the root
     size: measured 34.5px -> 60px at 200% text zoom, with no overflow. */
  --type-display-xl: clamp(2.125rem,  13.5714vw - 9.4286px,  4.5rem);   /* 34 -> 72 */
  --type-display-lg: clamp(1.875rem,    6.4286vw + 9.4286px,   3rem);   /* 30 -> 48 */
  --type-display-md: clamp(1.75rem,     4.2857vw + 14.2857px, 2.5rem);  /* 28 -> 40 */
  --type-display-sm: clamp(1.6875rem,   3.9286vw + 14.4286px, 2.375rem); /* 27 -> 38 */
  --type-display-s2: clamp(1.6875rem,   3.2143vw + 16.7143px, 2.25rem);  /* 27 -> 36 */
  --type-display-xs: clamp(1.5625rem,   2.5vw    + 17px,       2rem);   /* 25 -> 32 */
}

/* Medium and up: the grid becomes twelve columns, and the margin picks up
   its second segment — 32px at the medium boundary opening to 96px at
   1200px, where the container maxes out. Two segments rather than one ramp
   from 320px, because the margin should stay tight across the whole
   compact band and only then open towards the desktop figure. */
@media (min-width: 600px) {
  :root {
    --grid-columns: 12;
    --grid-margin: clamp(32px, 10.6667vw - 32px, 96px);
  }
}


/* --------------------------------------------------------------------------
   TYPE — bind the scale to the export's inline sizes.

   Framer writes `--framer-font-size` as an inline style on every text node
   and writes the SAME value into all three of its breakpoint variants:
   index.html carries three copies of the hero heading — desktop <h1>,
   tablet <h1>, phone <h2> — and all three say 48px. Nothing about the type
   responded to width; only the column did. On a 375px screen that made the
   65-character headline eight lines and 422px tall, 52% of the viewport,
   with the description cut in half and the CTA 75px below the fold. At
   320px it was ten lines, 528px, 73%.

   Each inline size is re-pointed at its scale step. Body copy — 24px and
   below — is never touched: 16px is the readability floor, and shrinking
   body text to win layout is the thing this change exists to avoid.

   No media query. Each clamp already returns the native value from 600px
   up, so one unconditional rule covers every width and there is no
   breakpoint at which sizes jump.

   WHY !important, AND WHY TWO SELECTORS PER STEP. The size is an inline
   style, so no author selector outranks it without `!important`. And the
   attribute has two spellings during one page load: the server HTML ships
   `--framer-font-size:48px` (no space) and React's hydration re-serialises
   it to `--framer-font-size: 48px` (space). Matching only one form would
   restyle the page halfway through boot and reflow it under the reader.
   Both forms are matched deliberately — do not "tidy" one away.
   -------------------------------------------------------------------------- */

[style*="--framer-font-size:72px"],
[style*="--framer-font-size: 72px"] { --framer-font-size: var(--type-display-xl) !important; }

[style*="--framer-font-size:48px"],
[style*="--framer-font-size: 48px"] { --framer-font-size: var(--type-display-lg) !important; }

[style*="--framer-font-size:40px"],
[style*="--framer-font-size: 40px"] { --framer-font-size: var(--type-display-md) !important; }

[style*="--framer-font-size:38px"],
[style*="--framer-font-size: 38px"] { --framer-font-size: var(--type-display-sm) !important; }

[style*="--framer-font-size:36px"],
[style*="--framer-font-size: 36px"] { --framer-font-size: var(--type-display-s2) !important; }

[style*="--framer-font-size:32px"],
[style*="--framer-font-size: 32px"] { --framer-font-size: var(--type-display-xs) !important; }

/* Line breaking, not just line sizes. `balance` is the browser's own
   line-breaker for headings: it equalises line lengths instead of filling
   greedily and leaving a one-word last line ("with." alone under seven full
   ones). Compact band only — applying it at every width would re-break the
   desktop headline, and desktop is meant to come out of this unchanged.
   Chrome stops balancing past ~6 lines, which is above where any heading
   lands after the ramp. `pretty` on body copy is the cheaper sibling: it
   only pulls back orphans and has no line cap, so it is safe on long
   paragraphs. Both degrade to normal wrapping where unsupported.

   The `body` prefix is not decoration. Framer's own `h2.framer-text` rule
   carries a text-wrap declaration at (0,1,1), which outranks a bare
   attribute selector, so without the extra element the sizes change and the
   line breaking silently does not. Same reason the CTA block earlier in
   this file is raised to (0,2,2). */
@media (max-width: 599.98px) {
  body .framer-text[style*="--framer-font-size:72px"],
  body .framer-text[style*="--framer-font-size: 72px"],
  body .framer-text[style*="--framer-font-size:48px"],
  body .framer-text[style*="--framer-font-size: 48px"],
  body .framer-text[style*="--framer-font-size:40px"],
  body .framer-text[style*="--framer-font-size: 40px"],
  body .framer-text[style*="--framer-font-size:38px"],
  body .framer-text[style*="--framer-font-size: 38px"],
  body .framer-text[style*="--framer-font-size:36px"],
  body .framer-text[style*="--framer-font-size: 36px"],
  body .framer-text[style*="--framer-font-size:32px"],
  body .framer-text[style*="--framer-font-size: 32px"] {
    text-wrap: balance;
  }
  body p.framer-text {
    text-wrap: pretty;
  }
}


/* --------------------------------------------------------------------------
   THE PAGE CONTAINER — the grid's one required consumer.

   Every page is a single container holding its sections in a column, and
   Framer gave it three fixed settings with nothing in between: margins
   32 / 80 / 100px and section gaps 40 / 70 / 100px, switching at 810px and
   1200px. Crossing 810px moved the text column 96px sideways and the
   section rhythm 30px vertically in one pixel of resize. Below 810px there
   was one setting for everything, so a 320px screen paid the same 64px of
   side padding as an 800px one — a fifth of the display.

   It also publishes the resolved column width, so components can be sized
   in grid columns rather than in guesses — a third of the page is four
   columns plus the three gutters between them:

     width: calc(4 * var(--grid-column) + 3 * var(--grid-gutter));

   `--grid-column` is built from `100%`, which resolves against the element
   the variable is USED on, not the one it is declared on. That is correct
   for anything whose width comes from this container — which is every
   section on this site, all of them full-width in the column stack — and
   wrong inside a narrower box. Size from it only in full-width contexts.

   SELECTOR. The container's own class is a per-page hash (.framer-jxpcsj on
   the home page, .framer-e0hynt on About) that changes on every re-export,
   so it is addressed structurally: it is the one plain div child of the
   Framer root. Framer suffixes its wrapper divs with `-container` (the nav
   is .framer-14btbcc-container), which is what the :not() excludes; the
   remaining sibling is a <canvas>, which `div` skips.
   -------------------------------------------------------------------------- */

#main > [data-framer-root] > div:not([class*="-container"]) {
  --grid-column: calc(
    (100% - (var(--grid-columns) - 1) * var(--grid-gutter))
    / var(--grid-columns)
  );
  max-width: var(--grid-max);
  padding-inline: var(--grid-margin);
  gap: var(--section-gap);
}


/* --------------------------------------------------------------------------
   HORIZONTAL OVERFLOW — kill the sideways scroll on 1200-1320px screens.

   Two sections on the home page ("WHY WORK WITH ME?" and the About story
   image) are scroll-reveal elements: Framer parks them at translateX(160px)
   / translateX(-120px) and animates the offset to zero when they scroll
   into view. Parked, they stick out past their column. Above ~1320px the
   window absorbs it and below 1200px those sections stack, so the damage
   lands in a band that includes 1280x800 — the most common laptop
   resolution there is, where the page picks up 12px of horizontal scroll
   that a visitor meets on arrival, before anything has animated.

   This is not caused by the grid above: measured with this stylesheet
   disabled, the same 12px is there. It is the export's own.

   `clip`, not `hidden`. Both stop the scroll, but `hidden` creates a scroll
   container, and a scroll container breaks every `position: sticky`
   descendant — which on this site includes those very sections, plus the
   About story column. `overflow-x: clip` clips without becoming a
   scrollport, so sticky keeps resolving against the viewport.

   On #main rather than html/body: setting it on the root elements does not
   actually stop the scroll here (the root's overflow propagates to the
   viewport in a way that leaves scrollWidth intact), while #main is a plain
   full-width block that clips cleanly. The fixed nav lives inside #main and
   is unaffected — a fixed element's containing block is the viewport, so it
   is not clipped by an ancestor with no transform of its own. The chatbot
   widget and the gradient canvas are body-level siblings of #main and never
   enter the clip at all.
   -------------------------------------------------------------------------- */

#main {
  overflow-x: clip;
}


/* --------------------------------------------------------------------------
   CARD AND FIELD COLLECTIONS — intrinsic columns, not breakpoint columns.

   A twelve-column grid is the right instrument for page-level spans, and
   the wrong one for a collection of cards: cards have a minimum width at
   which their content stops working, and that minimum is a property of the
   card, not of the viewport. Both collections below therefore use
   `auto-fit` + `minmax()`, which is the established modern answer (and what
   Material's own guidance moved to) — the column count falls out of the
   space available instead of being announced at a breakpoint. Floors are
   8-point values so they still land on the spacing scale.

   `min(100%, …)` caps the floor at the container width, which is what stops
   a very narrow screen from overflowing when one column is already too
   wide for it.

   The contact form's name/email pair is the same problem in miniature.
   Framer renders it as an unclassed div carrying only inline styles —
   `display:grid; grid-auto-flow:column; gap:15px` — and `grid-auto-flow:
   column` means "put every field on one row" with no width below which that
   stops being a good idea. On a 390px phone the fields were 164px each and
   on a 320px one 130px, about a dozen characters of an email address while
   you type it. A 192px floor stacks them below roughly 430px.

   Same two-spelling attribute match as the type block: the server HTML says
   `grid-auto-flow:column` and hydration rewrites it with a space.
   -------------------------------------------------------------------------- */

form [style*="grid-auto-flow:column"],
form [style*="grid-auto-flow: column"] {
  grid-auto-flow: row !important;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 192px), 1fr)) !important;
  gap: var(--grid-gutter) !important;
}


/* --------------------------------------------------------------------------
   OPEN NAV PANEL — make the whole menu reachable without unpinning the bar.

   ANATOMY, because it is not what it looks like. The open menu looks like a
   full-screen panel; it is not. The <nav> is a 64px bar (Framer's
   `.framer-w2ulnb { height: 64px }` at the phone breakpoints) with
   `overflow: visible`, and the link list is a sibling block that SPILLS out
   of it. Framer sizes that block

     .framer-ezo1r0 { min-height: calc(var(--framer-viewport-height, 100vh));
                      flex: none; place-content: center; }

   so it is a full viewport tall on its own, hanging below a 64px bar. The
   menu is therefore always 100vh + 64px — it overflows the screen by the
   height of the bar, on every device, and always has. At 375x667 that put
   "My resume", the only call to action in the menu, at y=664-700 with no
   way to reach it: a fixed bar cannot be scrolled.

   The first attempt at this capped the NAV at 100dvh and let the nav
   scroll. That was wrong in a way worth recording: it turned the 64px bar
   into a 64px scrollport wrapping 724px of content, so the bar's own
   contents — logo and close button — became scrollable content. Any drag
   scrolled them off the top of the screen, which is exactly what the bug
   report showed. Capping a container that overflows by design, without
   first asking which box is actually overflowing, moves the damage instead
   of removing it.

   The fix goes at the cause. The list is given the space that actually
   exists beneath the bar and allowed to scroll inside itself:

     - `min-height: 0` drops Framer's 100vh floor, which was the whole
       source of the overflow (min-height beats max-height in the cascade,
       so the existing `max-height: 80vh` reveal above was inert).
     - `max-height: calc(100dvh - var(--nav-height))` is the room under the
       bar. `dvh` because mobile browsers shrink the visible area as their
       toolbars expand, and `vh` measures the toolbar-hidden height — the
       exact case that hides the last item.
     - `height` matched to that same room fills the panel instead of letting
       it shrink to its content. With `min-height: 0` and Framer's
       `height: min-content`, the box was only as tall as the six links
       (452px of a 900px window), so `place-content: center` centred them
       inside a short box near the top of the screen rather than in the
       space the menu appears to occupy. It is set BELOW `min-height` and
       ABOVE `max-height` so the reveal still animates: height is clamped by
       max-height, and max-height is what transitions 0 -> full on open, so
       the panel still grows out of the bar rather than appearing at size.
     - `padding-bottom: calc(20px + var(--nav-height))` puts the list on the
       centre of the WINDOW rather than the centre of this box. The box
       starts 64px down (it is the bar's sibling, not its child) so centring
       inside it lands 32px low; reserving the bar's height at the bottom,
       on top of Framer's own 20px, lifts the centre line by (84-20)/2 =
       32px and cancels it exactly. Measured at 1440x900: list centre 482 ->
       450, against a window centre of 450. The list clears the bar with
       room to spare — 412px of links inside 836px — so nothing slides
       under the logo or the close button.
     - `overflow-y: auto` scrolls the LIST. The bar is not inside it, so the
       logo and close button are structurally incapable of scrolling away.
     - `justify-content: safe center` keeps the centring, but drops to
       flex-start the moment the content is taller than the box. A plain
       centred flex container that overflows pushes content past its START
       edge, where no scrolling can reach it — that is how a centred menu
       loses its first item on a landscape phone. `safe` is ignored by
       engines that do not support it, which leaves Framer's plain `center`:
       the behaviour we have today, never worse.

   Scoped to the open state, addressed both ways it can be signalled:
   Framer's runtime writes `data-framer-name="Mobile: Opend"` (their typo),
   and this file's older nav rules also expect a `.codex-nav-open` class.
   `*="Open"` covers every spelling of the first.

   Prefixed `#main` rather than `body.codex-static-ready`, which is what the
   nav rules earlier in this file use. Either works — that class is added in
   a requestAnimationFrame at the end of ready() and does land on a real
   page. `#main` is preferred here only because this rule fixes geometry
   that must hold even if the ready() chain is interrupted, and it beats
   Framer's `.framer-ezo1r0` on its own.
   -------------------------------------------------------------------------- */

#main nav.codex-nav-open [data-framer-name="Links"],
#main nav[data-framer-name*="Open"] [data-framer-name="Links"] {
  min-height: 0;
  height: calc(100vh - var(--nav-height));
  height: calc(100dvh - var(--nav-height));
  max-height: calc(100vh - var(--nav-height));
  max-height: calc(100dvh - var(--nav-height));
  padding-bottom: calc(20px + var(--nav-height));
  overflow-y: auto;
  overscroll-behavior: contain;
  justify-content: safe center;
}


/* --------------------------------------------------------------------------
   COMPACT HEIGHT — the vertical half of the breakpoint system.

   Framer sets the opening section's top padding from the WIDTH breakpoint
   alone: 120px at >=810px, 32px below it. Turn a phone sideways and both
   halves of that decision go wrong at once — the window is now 844px wide,
   so it takes the 120px, and 390px tall, so 120px is nearly a third of the
   screen. Stacked under the 64px nav and the 64px of clearance beneath it,
   the hero did not begin until y=248 of 390: two thirds of the viewport
   spent before the first word.

   Height is the constraint, so the query is on height — and it reuses the
   compact/medium boundary rather than inventing a number, so the system has
   one 600px seam read on both axes. `4vh` keeps the padding proportional to
   whatever room there is; `max(--space-4, …)` stops it collapsing onto the
   nav on very short windows. Nothing taller than 600px is touched, which
   leaves every portrait phone (667px and up) and every desktop on Framer's
   original spacing.

   Measured at 844x390: the hero moves from y=248 to y=144, turning 142px of
   visible hero into 246px.
   -------------------------------------------------------------------------- */

@media (max-height: 599.98px) {
  #main > [data-framer-root] > div:not([class*="-container"]) > :first-child {
    padding-block-start: max(var(--space-4), 4vh);
  }
}


/* --------------------------------------------------------------------------
   PROJECT HEROES — let the dashboard shots out of the 1200px column.

   Two case studies share one hero template: a 465x675 screenshot positioned
   to run past the text column. The page container (max-width 1200px,
   overflow: clip) cuts the last 72px of it — 15% of the dashboard's width.
   Nothing forces that cut: at 1440 the image ends at x=1384 against a 1425px
   viewport, and at 1920 there are ~280px of empty space to the right of
   where it is trimmed. It reads as an accident rather than a bleed.

   AUDITED, all six project pages at 1440px. Only two clip:

     envoy-america   72px / 15% cut     cib   72px / 15% cut
     buildnow-website, edunatives, dealapp, trustbill — clean, 0px

   and on both, the hero and its wrappers are the ONLY things the container
   clips; everything revealed lands on screen. The four clean pages are left
   alone rather than swept into a blanket rule.

   KNOWN CEILINGS, so nobody re-opens these expecting more.

   Envoy's source asset (1305x2097) is ITSELF a partial screenshot: its right
   edge cuts through the interface mid-word ("Roger Hetrick's trip", the
   "Customer name" row). Revealing the hidden 15% shows more dashboard but
   the image still ends on a cut — it moves the edge, it does not remove it.

   CIB's asset is the opposite: a complete 2524x1796 capture with the whole
   UI intact. What crops it there is not the container but `object-fit:
   cover` fitting a 1.41 landscape screenshot into a 0.69 portrait slot,
   which discards 51% of its width — the entire Cards / Activity calendar /
   Live chat column. This rule recovers 15% of what is drawn; the other half
   needs the slot's aspect ratio changed, not a clip removed.

   Both bottom/side crops are deliberately left alone, because undoing them
   means unpinning fixed pixel heights three levels up the hero (image 1 ->
   Frame 87 -> wrapper, e.g. 519 / 526 / 553px on Envoy at tablet), each one
   a place the next export would silently disagree with this file. The real
   fix for both is in Framer: size the frame to the asset's aspect, or
   recapture at the aspect the frame wants.

   `overflow-x: visible` with `overflow-y` left at Framer's `clip`. That pair
   is legal and stays as specified — `visible` only degrades to `auto` when
   the other axis is a scrolling value, which `clip` is not — so the vertical
   clipping the export relies on is untouched. No horizontal scroll can
   result: #main clips the x-axis at the viewport (see HORIZONTAL OVERFLOW
   above), so the image bleeds to the screen edge at most.

   Scoped with :has() to the two containers that hold these images, so every
   other page keeps Framer's clipping exactly as exported. Each asset id is
   the stable part of the filename, shared by all of that image's srcset
   variants, and it is matched on the `src` attribute rather than the loaded
   variant. If a re-export changes an id the rule simply stops matching and
   that hero returns to today's clipped state — it fails back to the current
   design rather than breaking a new one.
   -------------------------------------------------------------------------- */

#main > [data-framer-root] > div:has(img[src*="zVN7aEf2Pw5"]),   /* envoy-america */
#main > [data-framer-root] > div:has(img[src*="9dfqgOFXa"]) {    /* cib */
  overflow-x: visible;
}


/* --------------------------------------------------------------------------
   IMAGE LIGHTBOX — the popup viewer for case-study screenshots.

   Markup is built at runtime by installProjectImageLightbox() in
   static-site.js; see the note there for which images qualify and why. This
   is only its appearance.

   Everything sizing-related is expressed against the small viewport units so
   the dialog fits the space a mobile browser is ACTUALLY showing, not the
   taller one it reports while the toolbars are collapsed — the same reason
   the nav panel uses dvh. `svh` here rather than `dvh` because the dialog
   must not resize under the reader's fingers while a toolbar animates.

   The image is capped to the viewport by default and switches to its natural
   size when the reader clicks it, which is the whole point of the feature:
   these files are several times the resolution they are drawn at. When
   zoomed the dialog scrolls, so nothing becomes unreachable — the same rule
   the nav panel follows.

   Pinch-zoom still works on top of all this, because the viewport meta sets
   initial-scale without ever setting maximum-scale or user-scalable=no.
   -------------------------------------------------------------------------- */

.ias-lightbox {
  width: 100vw;
  max-width: 100vw;
  height: 100svh;
  max-height: 100svh;
  margin: 0;
  /* No padding: LEVEL 1 is meant to fill the view, so the only thing that may
     bound the image is the viewport itself. A 24px inset here cost ~14% of the
     drawn area on a phone, which is the size that mattered least to lose. */
  padding: 0;
  border: 0;
  background: transparent;
  overflow: auto;
  overscroll-behavior: contain;
  /* The dialog is the click-to-dismiss surface, so it has to fill the screen
     and centre the image within itself rather than shrink-wrap it.

     `safe` guards level 2, where the image is larger than the dialog. Plain
     centring overflows it equally on both sides, and whether the START side
     stays scrollable is engine-dependent — Chrome anchors the scroll origin to
     the image and keeps all of it reachable (measured: identical scroll range
     either way), the alignment data-loss the `safe` keyword exists for is
     Safari's. It also keeps offsetLeft/offsetTop measured from the same origin
     the zoom-to-point maths in static-site.js scrolls against. Written as
     longhands: `place-items: safe center` is ambiguous to parse. */
  display: grid;
  align-items: safe center;
  justify-items: safe center;
}

/* Put back what `display: grid` above took away. The UA stylesheet hides a
   closed dialog with `dialog:not([open]) { display: none }`, but author styles
   beat user-agent styles whatever their specificity, so declaring display at
   all leaves a CLOSED dialog rendering. That is not just a stray close button
   over the page — the dialog is a fixed 100vw x 100svh box, so it also
   swallowed clicks meant for the content behind it, measured at two of four
   sample points after a single open-and-close. Any rule that sets `display` on
   a <dialog> has to restore this. */
.ias-lightbox:not([open]) {
  display: none;
}

.ias-lightbox::backdrop {
  background: var(--ink-a85, rgba(31, 31, 31, 0.85));
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);
}

/* LEVEL 1 — the whole image, as large as the viewport allows, never scrolled.
   Bounded by the viewport units rather than by 100% of the dialog so the cap
   is the screen itself and cannot be reduced by anything on the dialog. */
.ias-lightbox-img {
  display: block;
  max-width: 100vw;
  max-height: 100svh;
  width: auto;
  height: auto;
  background: var(--color-surface, #fffffb);
  box-shadow: 0 24px 64px var(--ink-a50, rgba(31, 31, 31, 0.5));
}

/* Offered only when the source genuinely holds more detail than the fitted
   view — the JS adds this class after measuring, so the cursor never promises
   a zoom that would do nothing. */
.ias-lightbox-zoomable .ias-lightbox-img {
  cursor: zoom-in;
}

/* LEVEL 2 — the source at its own resolution, for reading annotations that
   are illegible at any fitted size. Scrolling is expected here and only here;
   the dialog is the scrollport, so every part stays reachable. */
.ias-lightbox-zoomed .ias-lightbox-img {
  max-width: none;
  max-height: none;
  cursor: zoom-out;
}

/* The affordance for level 2. A cursor change says nothing on a touch screen,
   where this feature is worth the most — a wide screenshot fitted to a phone
   is a thumbnail, and without a prompt nobody would know a second tap exists.
   Shown only while there is a level 2 to reach (the JS adds .ias-lightbox-
   zoomable only when the source genuinely holds more), and it swaps to the
   way back out once zoomed. */
.ias-lightbox-hint {
  position: fixed;
  left: 50%;
  bottom: var(--space-5);
  transform: translateX(-50%);
  z-index: 1;
  display: none;
  padding: var(--space-2) var(--space-4);
  font-size: 14px;
  line-height: 1.4;
  color: var(--color-text-primary, #1f1f1f);
  background: var(--color-surface-raised, #ffffff);
  border: 1px solid var(--color-border-default, #d1cec7);
  border-radius: 999px;
  pointer-events: none;
}

/* Exactly one hint at a time, and only while a level 2 exists to reach. Both
   states are stated positively rather than as show-all-then-hide-one: that
   shape put `.ias-lightbox-zoomable .ias-lightbox-hint` (0,2,0) above
   `.ias-lightbox-hint-out` (0,1,0), so both labels rendered stacked at
   level 1. */
.ias-lightbox-zoomable:not(.ias-lightbox-zoomed) .ias-lightbox-hint-in {
  display: block;
}

.ias-lightbox-zoomable.ias-lightbox-zoomed .ias-lightbox-hint-out {
  display: block;
}

/* Pinned to the viewport, not scrolled away with a zoomed image. */
.ias-lightbox-close {
  position: fixed;
  top: var(--space-4);
  right: var(--space-4);
  z-index: 1;
  width: 44px;               /* 44px: comfortably over the 24px WCAG 2.5.8 floor */
  height: 44px;
  display: grid;
  place-items: center;
  font-size: 24px;
  line-height: 1;
  color: var(--color-text-primary, #1f1f1f);
  background: var(--color-surface-raised, #ffffff);
  border: 1px solid var(--color-border-default, #d1cec7);
  border-radius: 999px;
  cursor: pointer;
  transition: background-color 160ms ease, transform 160ms ease;
}

.ias-lightbox-close:hover {
  background: var(--color-surface-hover, #faf9f7);
}

.ias-lightbox-close:active {
  transform: scale(.96);
}

/* The trigger side: a screenshot that can be opened says so on hover, and is
   focusable, so it also picks up the site-wide ring from the FOCUS block. */
img[data-ias-lightbox="true"] {
  cursor: zoom-in;
}

@media (prefers-reduced-motion: reduce) {
  .ias-lightbox-close {
    transition: none;
  }
  .ias-lightbox-close:active {
    transform: none;
  }
}

/* ==========================================================================
   FOOTER CTA HEADLINE — "Let's Talk!" carries the invitation, so it is set
   in bold rather than the body regular it shipped as.

   .framer-1iqkigu is the whole selector. It is the footer CTA's Description
   container and it appears twice per page (three times on the home page, one
   per breakpoint tree); every one of those nodes on all twelve pages wraps
   the "Let's Talk!" paragraph and nothing else, so the class covers every
   breakpoint without naming a per-page Framer hash.

   700 rather than the 500/600 rungs used elsewhere: Rubik Bold is already
   vendored (fonts.gstatic.com/90a469f8becd8517-…woff2) and declared in the
   page's @font-face block, so this loads a real bold face instead of a
   browser-synthesised one. The <p> carries no inline font-weight, so no
   !important is needed — but the Framer var is set alongside `font-weight`
   because .framer-text resolves its weight through that token first.
   ========================================================================== */
.framer-1iqkigu p.framer-text {
  --framer-font-weight: 700;
  font-weight: 700;
}

/* ==========================================================================
   MENU OVERLAY — centre the links on the viewport, not 10px right of it.

   The panel centres its own content (place-content:center; align-items:
   center) and the base rule pads it symmetrically at 20px. Both open/closed
   nav variants then re-declare the padding as `20px 20px 20px 40px`, and a
   centred child inside a padding box that is 20px wider on the left lands
   exactly (40-20)/2 = 10px right of the true centre. Measured: every link's
   glyph centre sat at 197.5 against a 187.5 viewport centre at 375px, and
   723 against 712.5 at 1440px — the same 10px at every width, which is why
   this is one rule and not a per-breakpoint set.

   The class is repeated three times to out-specify `.framer-5vKhO.framer-v-…
   .framer-ezo1r0` (0,3,0) — class count is compared before element count, so
   two classes plus `nav` (0,2,1) loses to it and three (0,3,1) wins. No
   !important, and no variant hash named, so a re-export that renames the
   variants keeps the fix. Only the left is touched: 20px all round is what
   the base rule already intended, and the widest link (Mentorship, 170px)
   clears the 335px it leaves on a 375px screen, so nothing rewraps.
   ========================================================================== */
nav .framer-ezo1r0.framer-ezo1r0.framer-ezo1r0 {
  padding-left: 20px;
}

/* ==========================================================================
   TOOL GRID (About) — give the rows the same rhythm as the columns.

   Framer made the COLUMN gap responsive and left the ROW gap fixed:
   110px / 80px at >=1200, 40px / 80px at 810-1199, 30px / 80px below that.
   So the one relationship that carries a grid — rows tighter than columns —
   holds on desktop (0.73) and inverts on a phone, where each row sits 2.7x
   further from the next than its own tiles sit from each other. 19 tiles
   stop reading as one field and read as five stranded bands; the section
   runs 689px, taller than the 812px viewport once the heading is counted,
   so the toolset cannot be taken in at a glance.

   32px (--space-6) sets the rows just off the 30px columns, restoring the
   near-square rhythm the desktop layout has. It stays well clear of the
   10px icon-to-label gap — 3.2x — so each label still groups with its own
   icon rather than floating between rows (proximity does the grouping here;
   there are no rules or cards to do it). Section height 689px -> 497px,
   which fits one phone screen.

   Only `row-gap`, so the responsive column gap is untouched, and scoped to
   Framer's own <=809.98px band so the desktop and tablet values stay theirs.
   `#main` (1,1,0) out-specifies `.framer-nrfNg .framer-1uwkmek` (0,2,0)
   without !important, matching the OPEN NAV PANEL rule above.
   ========================================================================== */

@media (max-width: 809.98px) {
  #main .framer-1uwkmek {
    row-gap: var(--space-6);
  }
}

/* The tablet band has the same inversion, milder: 40px columns against the
   same fixed 80px rows, so a row sits twice as far from the next as its own
   tiles sit from each other, and 84% of a 95px tile's own height. --space-7
   (40px) squares it with the columns there. The three bands now read as one
   decision -- 32/30 on a phone, 40/40 here, 80/110 on desktop: the gaps grow
   with the viewport while the row:column ratio tightens the wider the layout
   gets (1.07 -> 1.00 -> 0.73), instead of one axis scaling and the other
   standing still. Measured at 885px: pitch 175px -> 135px, section 444px ->
   364px, still 7 tiles per row. */

@media (min-width: 810px) and (max-width: 1199.98px) {
  #main .framer-1uwkmek {
    row-gap: var(--space-7);
  }
}

/* --------------------------------------------------------------------------
   TOOL GRID LABELS — brand names break between words, never inside one.

   Framer sets `word-break: break-word` on the label container, and because
   word-break inherits, it reaches the <p>. Each tile is only as wide as its
   40px icon, so on a phone the property does exactly what it is asked to:
   "Figma" renders as "Figm / a", and so do Framer, Notion, Linear, Metabase
   and ChatGPT. A product name split mid-word is not a wrap, it is a
   misspelling on first read, and these are other companies' marks.

   It also fed the row-spacing fix above. The spurious second line made those
   tiles 84px against the others' 67px, so the row pitch alternated 115 / 100
   / 99 / 99 instead of holding one value -- the rows were uneven because the
   labels were, not because the gap was wrong.

   `normal` on both properties, declared on the <p>, which beats the inherited
   value outright. Not scoped to a breakpoint: above 810px the tiles are wide
   enough that nothing wrapped anyway, so this is inert there and there is no
   second copy of the rule to keep in step. Measured at 375px: 4 tiles per row
   as before, every label on one line except "Adobe Suite", which wraps
   between its two words as it should, and the grid drops 497px -> 480px.
   -------------------------------------------------------------------------- */

#main .framer-1uwkmek p.framer-text {
  word-break: normal;
  overflow-wrap: normal;
}

/* --------------------------------------------------------------------------
   MENTORSHIP VALUE CARDS — stop pushing them off the side of the phone.

   The five cards are a fixed `width: 282px` sitting in wrappers that carry
   `padding: 0 70px` and alternate `justify-content` between flex-start and
   flex-end -- a stagger drawn for a tablet, applied to Framer's whole
   <=809.98px band. On a phone the wrapper's content box is 324 - 140 = 184px,
   so a 282px card cannot fit it: each one started at x=96 and ran to 378,
   past the 375px edge, and the flex-end ones ran off the LEFT instead.
   Nothing scrolled -- an ancestor clips -- so the overflow was silent text
   loss. 3px at 375, ~17px at 360, and 56px at 320, where "Staying hungry to
   explor" and "eaking things down simply" is what the reader actually got.

   Below 480px the inset is dropped and the card is centred instead. 480 is
   where the arithmetic stops working (282 + 140 of padding + 52 of page
   margin = 474), and it is a seam this file already uses, so no new
   breakpoint is invented. `max-width: 100%` covers the narrow end: at 320 the
   wrapper is 272px, so the card shrinks to fit rather than clipping again.
   Above 480px nothing changes -- the tablet stagger is untouched.

   Selected through the one container hash rather than naming all ten wrapper
   and card classes: `.framer-1a9e7sq`'s element children are exactly these
   five wrappers plus the connector <svg>, which `> div` skips.

   The stagger goes with the inset, and with it the dashed connector that
   linked the offset cards: its path is absolutely positioned at x 112-211,
   so with centred 282px cards it now sits behind them. It was decorative and
   already barely legible at this width; readable cards are the better trade.
   Measured at 375: cards centred at 47-329, 46px of margin either side, no
   clipping at 320, 360 or 375.
   -------------------------------------------------------------------------- */

@media (max-width: 480px) {
  #main .framer-1a9e7sq > div {
    padding-inline: 0;
    justify-content: center;
  }

  #main .framer-1a9e7sq > div > div {
    max-width: 100%;
  }
}

/* --------------------------------------------------------------------------
   CASE-STUDY "MY ROLE" CARD — make it flex like the Timeline card beside it.

   The hero meta row holds two identical-looking cards, authored differently.
   Timeline's inner frame is `flex: 1 0 0px`, so it takes whatever the row has
   left -- 248px on a phone. My role's is `flex: 0 0 auto` with a hard
   `width: 320px`, so it takes 320px whatever the row has. The row's content
   box on a 375px screen is 324 - 32 of padding = 292px, of which the icon and
   gap eat 44, so the block ran from x=86 to 406: 31px past the screen edge,
   silently, because an ancestor clips rather than scrolls.

   Nothing is losing text today -- "Product designer & manager" is the longest
   string any of the six case studies puts in there and it ends at x=215, well
   inside the cut. This is the kind of defect that stays invisible until the
   copy changes, so it is fixed at the cause rather than left as a trap: the
   block is given its sibling's flex, which resolves it to exactly the 248px
   Timeline already gets, and the label is given `width: 100%` of that instead
   of the fixed 320. Measured: the glyphs do not move -- "My role" still starts
   at x=86 and stays on one line -- only the box around them stops overhanging.

   Selected on `data-framer-name`, which is the same on all six pages, rather
   than the six different class hashes Framer generated for the same
   component. `:has()` reaches the parent frame, which carries no name of its
   own; where it is unsupported the child rule alone leaves the block exactly
   as it is today, never worse.

   Scoped to 480px, the same seam as the mentorship cards above: the row stops
   fitting the 320px block below ~406px, so 480 clears it with room, and every
   width above -- tablet included -- keeps Framer's original geometry.
   -------------------------------------------------------------------------- */

@media (max-width: 480px) {
  #main div:has(> [data-framer-name="My role"]) {
    flex: 1 0 0px;
  }

  #main [data-framer-name="My role"] {
    width: 100%;
  }
}

/* --------------------------------------------------------------------------
   TEXT CHIPS — size them to the words they now hold, not the words they used
   to hold.

   The research-method, outcome and tool chips are `width: min-content` below
   810px. min-content on a chip whose label cannot wrap is the longest WORD in
   it, so "Stakeholder interviews" was given 95px -- "Stakeholder" plus the
   16px of padding -- while the line it has to render is 152px. The label
   overflowed a box with `justify-content: center`, so it was cut off at BOTH
   ends: the row read "eholder intervi", "behavior recor", "ability testi",
   and the first chip in the row spilled 3px past the left edge of the screen.
   Eight of the fifteen chips on the DealApp page were clipped this way.

   The Framer names say how it happened: the frame holding "Stakeholder
   interviews" is still named "User interviews", the one holding "User
   behavior recordings" is named "Card sorting". The labels were rewritten
   after the frames were sized, and nothing re-measured them.

   `width: auto` on a `flex: 0 0 auto` chip resolves to max-content -- the
   whole label -- which is what the same chips already do at 810px and up.
   Applied across Framer's whole <=809.98px band rather than a narrower seam
   precisely because it is a no-op wherever the layout already works: measured
   at 809px, all fifteen chips keep the exact width they have today.

   Selected on structure, not on hashes: a bordered box whose only child is a
   RichTextContainer is a chip and nothing else on the site is. That matches
   all six case studies without naming the ~30 different class hashes Framer
   generated for one repeated component.
   -------------------------------------------------------------------------- */

@media (max-width: 809.98px) {
  #main [data-border="true"]:has(> [data-framer-component-type="RichTextContainer"]:only-child) {
    width: auto;
  }
}
