/* Shared visual layer, loaded after this site's own stylesheet.
   It re-tokenises the palette, hosts the 3D scene canvases and restyles the
   shared surfaces. Every existing component rule keeps working underneath;
   nothing here replaces them.

   A deploy hazard worth knowing about, because it bit this file once and the
   symptom is baffling: a Pages deployment propagates asynchronously, so a
   request for a freshly hashed asset URL can reach an edge still serving the
   previous deployment. That edge then caches the OLD body under the NEW ?v=
   URL, and the content hash — the entire cache-busting mechanism — is defeated
   until the TTL expires. The page loads new HTML against old CSS and looks
   like the deploy silently failed.

   Do not fetch a new asset URL until the deployment has propagated; verify
   against the deployment-specific *.pages.dev preview URL first, which cannot
   be stale. If a hashed URL has already been poisoned, purging it needs an API
   token with the Cache Purge permission — the deploy token does not have it. */

/* Palette lifted from styles.css's own @media (prefers-color-scheme: dark)
   block and applied unconditionally.

   This file used to declare its own shorter palette here. It set --bg,
   --body and --ink to dark values and left --surface, --hero-a, --hero-b,
   --heading and --header-bg at the light ones underneath, so on a
   light-mode machine the hero and every card stayed white while the text
   on them was coloured for a dark background. Taking the site's own dark
   set wholesale means there is one palette, and it cannot drift out of
   sync with the components that read from it. */
:root {
    color-scheme: dark;
    --navy: #0d1f36;
    --navy-2: #122a4a;
    --heading: #e9eff7;
    --ink: #e9eff7;
    --body: #b5c3d6;
    /* lifted from #8a9cb2: at the old value the trust row on case-studies
       measured 4.84:1 over the brightest part of a scene, which is inside AA
       but leaves nothing in hand. This is the one text colour on the site with
       less headroom than the scenes need. */
    --muted: #9aabc0;
    --accent: #6aa7e8;
    --accent-dark: #92c0f2;
    --accent-strong: #1a67b8;
    --accent-strong-hover: #2278d2;
    --accent-soft: #132c4a;
    --accent-soft-line: #1f4166;
    --line: #22374f;
    --bg: #0a1322;
    --bg-alt: #0d1a2d;
    --surface: #101f35;
    --header-bg: rgba(10, 19, 34, 0.85);
    --hero-a: #0d1a2e;
    --hero-b: #0a1322;
    --prose: #c6d3e2;
    --card-hover-line: #2d4a6b;
    --focus-ring: rgba(106, 167, 232, 0.28);
    --glow: rgba(106, 167, 232, 0.06);
    --band-glow: rgba(126, 176, 232, 0.08);
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
    --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 28px rgba(0, 0, 0, 0.45);
    --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.35), 0 22px 44px rgba(0, 0, 0, 0.55);
    --edge: inset 0 1px 0 rgba(255, 255, 255, 0.05);
    --aero-bg:var(--bg);
    --aero-text:var(--body);
    --aero-signal:var(--accent);
    --aero-grid:rgba(120,190,220,.045);
    --scene-ease: cubic-bezier(.16, 1, .3, 1);
    --scene-r: 8px;
}

html { scroll-behavior: smooth; }

body {
    background: var(--aero-bg, #080d16);
    color: var(--aero-text, #cbd5ec);
    -webkit-font-smoothing: antialiased;
}

/* the faint engineering grid that sits under everything */
body::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background-image:
        linear-gradient(var(--aero-grid, rgba(130,160,230,.045)) 1px, transparent 1px),
        linear-gradient(90deg, var(--aero-grid, rgba(130,160,230,.045)) 1px, transparent 1px);
    background-size: 72px 72px;
    -webkit-mask-image: radial-gradient(ellipse 120% 80% at 50% 0%, #000 25%, transparent 75%);
    mask-image: radial-gradient(ellipse 120% 80% at 50% 0%, #000 25%, transparent 75%);
}

h1, h2, h3, h4 { text-wrap: balance; letter-spacing: -0.03em; }

/* Nav labels must not break inside an item. The row is flex-wrap: nowrap, so
   when the bar gets tight the items do not wrap onto a second line — instead
   the text inside them does, and "AI Agents" becomes "AI" above "Agents". The
   bar then stands at two rows while every item still reports as fitting, which
   is why a width check alone does not catch it.

   The items themselves have room: they measure 588px inside a 767px bar with a
   tenth entry added. It is only the line breaking that needs stopping. */
/* Scoped to the width where the bar is actually a bar. Below 1000px the nav is
   a stacked panel behind the menu button, where nowrap and a left margin would
   be wrong.

   The gap override that used to sit here is gone with the ten item nav that
   needed it. Five items fit at the stylesheet's own spacing. */
@media (min-width: 1001px) {
    .site-header .nav-links a { white-space: nowrap; }

    /* The call to action breaks the same way: "Contact Us" over two lines makes
       the header taller than the bar it sits in. */
    .site-header .nav-cta { white-space: nowrap; }
}

/* With a tenth item the list reached the wordmark, so "TECHWORKSLAB" and
   "Norva" ran together as one word.

   The margin alone did not fix it, and the reason is worth recording: the brand
   was being squeezed narrower than its own text, so the wordmark overflowed its
   box by 34px and crossed into the nav regardless of any gap. The container
   reported the correct width the whole time; only the span was out of bounds.
   Refusing to shrink it is the actual fix, and the margin then does what it
   looks like it does. */
.site-header .brand { flex-shrink: 0; }
.site-header .brand span { white-space: nowrap; }

@media (min-width: 1001px) {
    .site-header .nav-links { margin-left: 1.75rem; }
}

/* ------------------------------------------------------------- scenes --- */

/* Sections that host a canvas. The container must outrank the scrim: a
   container with its own z-index creates a stacking context that a child
   cannot escape, which is what put an earlier scrim on top of its own copy. */
.scene-host { position: relative; overflow: hidden; }
.scene-host > .container,
.scene-host > .container-narrow { position: relative; z-index: 3; }

.scene-host > canvas[data-scene] {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    z-index: 0;
    opacity: 0;
    pointer-events: none;
    transition: opacity 1.5s var(--scene-ease);
    /* A token feather, in pixels rather than percent, and this is load-bearing.
       It used to be `transparent 0, #000 15%, #000 85%, transparent 100%`, which
       is what made the page read as a stack of separate panels: the graphic is
       now one continuous thing laid out in page coordinates, and continuity is
       judged at the seams, so fading 30% of every section to zero deleted all
       the evidence precisely where the eye looks for it. A percentage is also
       the wrong unit — on a 5000px article it erased 750px at each end.

       It is now 4px, down from 16, because there is no longer anything for it
       to hide. Every layer is either keyed to page position (lattice, rails,
       marked passages) or constant down the page (the washes, which became
       horizontal gradients for exactly this reason), so two stacked canvases
       agree at the join instead of being blended into agreement. What is left
       is insurance against subpixel rounding where a section boundary lands on
       a fractional device pixel. */
    --scene-mask: linear-gradient(180deg, transparent 0, #000 4px,
                                          #000 calc(100% - 4px), transparent 100%);
    -webkit-mask-image: var(--scene-mask);
    mask-image: var(--scene-mask);
}

/* One flat opacity for the whole site, and it has to be one number rather than
   a ladder now. The scene no longer clears itself where the copy is, because
   every version of that read as a smudge, so this is the entire contrast
   mechanism: it is set so the brightest pixel the scene can paint is still
   inside the budget if a glyph lands on it.

   It used to run .34 base / .42 hero / .38 page header / .44 article. That was
   safe on contrast but it cannot survive a graphic that crosses section
   boundaries: a rail leaving a .34 section and entering a .42 one steps in
   weight at the join, which is the seam problem in another form. Sections earn
   their presence through density instead, which is free — see engine.js. */
.scene-host > canvas[data-scene].live { opacity: .34; }

/* No vignette. A radial fade to the page colour was doing two things badly:
   on a section with its own tint it drew an inner shadow frame, and where two
   of those frames met it read as a seam across the page. Legibility is not its
   job any more, so it is gone rather than softened. */

/* One surface, no bands.

   These were flat tints, then eased gradients that stopped short of the page
   colour so the boundary would not dip. Both still drew a line, because any
   two adjacent sections that do not meet at the same value meet at an edge,
   and easing only decides how sharp that edge is. Easing all the way to the
   page colour trades the step for a trough instead: two tinted sections in a
   row both fade down at the join and the boundary reads as a dark band.

   There is no arrangement of per-section tints that avoids both. The page
   carries one background and the canvas supplies the variation, which is what
   it is for — the washes already vary by section and the rails already run
   through. Nothing here paints a boundary any more, so there is none to hide.

   styles.css gives these a flat --navy fill plus a radial sheen, so this has
   to name the page colour rather than simply drop the rule. */
.facts, .section-navy, .section-alt {
    background: var(--bg);
    background-image: none;
}

/* The one deliberate band. It is the closing call to action and the last
   section before the footer, so it earns emphasis — but it reaches the page
   colour exactly at both ends, so neither edge is a line. */
.cta-band {
    background: linear-gradient(180deg,
        var(--bg) 0, #0d1f36 34%, #0d1f36 66%, var(--bg) 100%);
    background-image: linear-gradient(180deg,
        var(--bg) 0, #0d1f36 34%, #0d1f36 66%, var(--bg) 100%);
}

/* Two literal 1px rules drawn across the page. The hero's was the hard line
   between the opening screen and the facts band that prompted this; the
   results band carries the same pair on the case-study pages. A border is the
   most direct way to say "these are separate panels", which is the opposite of
   what the rest of this file is doing. */
.hero, .hero-new { border-bottom: 0; }
.results-band { border-top: 0; border-bottom: 0; }

/* The hero also carried two CSS glows, one of them centred at 8% 95% — the
   bottom-left corner — inside a box with overflow: hidden. A pool of light
   ending on the section's own edge is a bright band that stops dead at the
   join, which is the same seam drawn a different way and the part still
   visible once the border was gone.

   Removing it costs nothing: this is what the canvas washes already do, and
   they run at a fixed position across the width so they cannot terminate at a
   horizontal boundary. */
.hero::before,
.hero-new::before,
.page-hero::before,
.page-header::before { background: none; }

/* The per-host opacity overrides and the article's centre-channel mask are both
   gone. The rails sit at 13.5% and 86.5% of the width, well outside any measure
   of prose, so nothing has to be masked out of the middle any more — the
   graphic simply is not there. */

/* hero copy is the one place type sits over live geometry, so it carries its
   own shadow — cheap insurance against whatever the scene draws behind it */
.hero h1, .hero-new h1, .page-hero h1, .page-header h1,
.hero .hero-lede, .hero-new .hero-lede,
.hero .eyebrow, .hero-new .eyebrow,
.hero-text h1, .hero-text p {
    text-shadow: 0 2px 26px rgba(4, 6, 14, .92), 0 1px 4px rgba(4, 6, 14, .7);
}

/* --------------------------------------------------------- long-form --- */

/* Body copy in the insights pieces is justified to both margins.

   Justification without hyphenation is worse than ragged-right, not better:
   the browser can only stretch the word spaces, so a long word forces a line
   of wide gaps and those gaps line up down the column as rivers. `hyphens:
   auto` lets it break words instead, which is what makes the spacing even, and
   it needs the lang attribute on <html> to pick a dictionary — every page here
   has lang="en".

   Only paragraphs. Headings, list items, pull quotes, captions and anything in
   a table stay ragged: they are short enough that justifying them produces the
   stretched-out lines this is meant to avoid. */
.article-prose p,
.article p {
    text-align: justify;
    -webkit-hyphens: auto;
    hyphens: auto;
    hyphenate-limit-chars: 7 3 3;
}

/* Insurance rather than a fix for anything currently published: none of these
   pieces carries a code sample today, but justifying preformatted text would
   destroy the alignment that is the reason for showing it, and the same block
   is now in all four sites' copies of this file. */
.article-prose pre,
.article-prose pre p,
.article-prose code,
.article pre,
.article code {
    text-align: left;
    -webkit-hyphens: none;
    hyphens: none;
}

/* A narrow column cannot absorb justification — under about 45 characters
   there are too few word spaces per line to distribute the slack, and the
   result is gappy however good the hyphenation is. Phones get ragged-right. */
@media (max-width: 640px) {
    .article-prose p,
    .article p {
        text-align: left;
        -webkit-hyphens: none;
        hyphens: none;
    }
}

/* The install line under a package grid. Constrained because it is a run of
   prose sitting beneath a wide grid, and would otherwise stretch to the full
   container while everything above it is in columns. */
.pkg-note {
    max-width: 68ch;
    margin-top: 1.75rem;
    font-size: 0.95rem;
    color: var(--prose);
}

.pkg-note code {
    font-size: 0.9em;
}

/* ------------------------------------------------------- micro-motion --- */

/* Gated behind .js, set on <html> before anything else can throw. */
.js [data-reveal] {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity .85s var(--scene-ease), transform .85s var(--scene-ease);
}

.js [data-reveal].in { opacity: 1; transform: none; }

:focus-visible {
    outline: 2px solid var(--aero-signal, #3fd6a4);
    outline-offset: 3px;
    border-radius: 2px;
}

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: .001ms !important;
        transition-duration: .001ms !important;
        scroll-behavior: auto !important;
    }
    .js [data-reveal] { opacity: 1 !important; transform: none !important; }
    canvas[data-scene] { display: none !important; }
}


/* Rules that were still gated behind prefers-color-scheme in styles.css.
   The palette above is applied unconditionally, so these have to be too,
   or a light-mode visitor gets pale badges on a dark page. */
.tag.adam { background: #16345e; color: #a9c8f2; }
    .tag.sdtm { background: #12402a; color: #93dcae; }
    .tag.tlg  { background: #4a3512; color: #ecc580; }
    .tag.app  { background: #33255c; color: #c8b5f2; }
    .tag.sub  { background: #4b1d1d; color: #f0a8a8; }
    .tag.meta { background: #0e3d45; color: #8adce8; }
