/* ==========================================================================
   BASE — reset, typography, grain, focus
   ========================================================================== */

/* --- Reset ---------------------------------------------------------------
   Minimal, not a full normalize. Only what this site actually needs. */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;

  /* THE PAGE IS LIGHT, AND THE BROWSER HAS TO BE TOLD.
     The root background and overscroll-behavior below govern what THIS PAGE
     paints. They do not govern the UA's own backdrop — the surface a browser
     shows in the rubber-band area and behind the toolbars — and with
     color-scheme undeclared a phone in dark mode gives that surface its dark
     default. That is the grey seen when pulling past the footer: not the
     site's cream failing to reach, the browser's own dark ground showing
     where the site never painted. One keyword moves it to the light default. */
  color-scheme: light;

  /* EVERY IN-PAGE JUMP CLEARS THE FIXED MASTHEAD.
     The bar is position: fixed, so it does not participate in layout and an
     anchor jump happily parks its target underneath it — back-to-top landed
     with the project title hidden behind the bar, and #main, #work and
     #about all had the same fault.

     scroll-padding-top on the scroller fixes all of them at once and is
     inherited by the smooth scroll above, rather than needing a
     scroll-margin-top on each target that a new anchor would forget. The
     value is the masthead's own token, so the two cannot drift. */
  scroll-padding-top: calc(var(--masthead-h) + var(--sp-s));

  /* NO SIDEWAYS SCROLL. Three full-bleed blocks — .about, the inverted
     footer and .brand — are sized with width: 100vw and pulled out of their
     container with margin-inline: calc(50% - 50vw). 100vw includes the
     scrollbar, so on any viewport that has one they overhang the content
     width by exactly the scrollbar's width: a measured 8px at 1280, on every
     page, enough to make the whole document scroll sideways.

     CLIP, NOT HIDDEN, and the distinction is the reason this is safe here.
     `hidden` makes the element a scroll container, which would move the
     document's scrollport and put every scroll-driven animation on the site —
     the spine's line, its stops, each card's coil — onto a different
     timeline. `clip` explicitly does not create one; it only stops the paint
     escaping. Verified before and after against a nine-point sample of the
     spine's coil offsets across the whole work section at 1280 and 375:
     identical to the tenth of a pixel.

     Fixing it at the source would mean removing the 100vw bleed, and that is
     only a no-op for the blocks whose parent is already full-width; .brand
     sits inside a max-width container and genuinely needs it. One rule that
     covers all three beats two different treatments that can drift. */
  overflow-x: clip;

  /* THE BOUNCE PAST THE BOTTOM LANDS ON THE FOOTER'S OWN GROUND.
     There is no dead space under the footer — the document ends at its
     bottom edge to within a rounding error. What a phone shows below it is
     rubber-band overscroll: the browser drags past the end of the document
     and paints the canvas, which until now was --paper propagated up from
     body. On the homepage that put a cream strip under a red footer, which
     is exactly what a gap looks like.

     Fixing it here rather than with overscroll-behavior keeps the native
     bounce and only corrects its colour. The construction:

       - background-color is --paper, so it fills the canvas ABOVE the
         document, where every page is cream.
       - the image is a hard 50% stop sized to 200% of the root box, so its
         upper half covers the document exactly and its lower half carries
         the tail colour for one full document height below it. Body is at
         least 100svh, so that is always more than a bounce can travel.
       - no-repeat is required, not stylistic: the root's background is
         painted across the whole canvas, and a repeating gradient would
         tile the tail back up above the page.

     --overscroll-tail defaults to --paper, so a page whose footer is cream
     is unchanged. footer.css raises it where the footer is inverted.

     THIS ALONE IS NOT ENOUGH, and shipping it proved why. Deployed and
     verified live — the served base.css matches this file byte for byte —
     a real phone still showed grey below the footer. WebKit paints only the
     root's background-COLOR into the rubber-band area; the background-image
     does not extend past the document, so the red never reached it and the
     cream background-color did. It read as grey rather than cream because
     body::before is fixed, gradient-free grain in multiply, and multiply has
     no backdrop to darken out there — the canvas is not part of the root's
     blending group — so it lays its own neutral noise straight over the top.
     Confirmed the desaturation locally against an isolated cream canvas.

     The gradient stays: it is correct wherever a browser honours it, and it
     costs nothing where one does not. What it is NOT is the fix. */
  --overscroll-tail: var(--paper);
  background-color: var(--paper);
  background-image: linear-gradient(var(--paper) 50%, var(--overscroll-tail) 50%);
  background-repeat: no-repeat;
  background-size: 100% 200%;

  /* NO BOUNCE PAST THE ENDS OF THE DOCUMENT.
     Removes the gesture rather than colouring what it reveals, so there is
     nothing to drag into at either end. Declared on both html and body: the
     viewport takes its value from html, but engines have historically
     propagated it from body, and the pair costs one line.

     Scoped to the y axis on purpose. overscroll-behavior: none would also
     stop scroll CHAINING sideways, and .pj-embed, .row--native and every
     zoom stage rely on their own horizontal scrollers behaving normally.
     .zoom__stage sets overscroll-behavior: contain for itself and is
     unaffected by either value here.

     Does not touch ordinary scrolling, anchor jumps, or scroll-behavior:
     smooth above — it governs only what happens at a boundary. */
  overscroll-behavior-y: none;
}

body { overscroll-behavior-y: none; }

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

body,
h1, h2, h3, h4, h5, h6,
p, figure, blockquote, dl, dd {
  margin: 0;
}

ul[role='list'],
ol[role='list'] {
  list-style: none;
  margin: 0;
  padding: 0;
}

img,
picture,
video,
svg {
  display: block;
  max-width: 100%;
}

input, button, textarea, select { font: inherit; color: inherit; }

/* Square corners, everywhere, no exceptions (CLAUDE.md §2). */
* { border-radius: var(--radius); }


/* --- Document ------------------------------------------------------------ */

body {
  min-height: 100svh;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--type-body);
  line-height: var(--lh-normal);
  font-synthesis-weight: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}


/* --- Palm-leaf grain -----------------------------------------------------
   Procedural, not a raster tile: feTurbulence is evaluated by the browser,
   so there is no image asset, no network request and nothing to keep in
   sync. Inlined as an SVG data URI.

   Anisotropic on purpose. baseFrequency is '0.035 0.9' — low along x, high
   along y — so the noise varies slowly left-to-right and rapidly
   top-to-bottom. That elongates every feature horizontally, giving fine
   parallel horizontal fibre rather than uniform static.

   (Swapping those two numbers rotates the grain 90deg. It was the wrong way
   round initially, which read as vertical streaks.)

   stitchTiles='stitch' makes the 260px tile seamless, so tiling the data
   URI does not produce a visible repeat.

   Fixed to the viewport so it reads as a property of the paper rather than
   something that scrolls with the content. */

body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: var(--z-grain);
  pointer-events: none;
  opacity: var(--grain-opacity);
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.035 0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: var(--grain-scale) var(--grain-scale);
}

/* The grain is texture, not information. Users who reduce motion often also
   reduce visual noise; halve it rather than removing it, so the paper does
   not go flat. */
@media (prefers-reduced-motion: reduce) {
  body::before { opacity: calc(var(--grain-opacity) * 0.5); }
}


/* --- Typography ---------------------------------------------------------- */

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 400;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-display);
  text-wrap: balance;
}

h1 { font-size: var(--type-display); }
h2 { font-size: var(--type-title); }
h3 {
  font-family: var(--font-body);
  font-size: var(--type-head);
  line-height: var(--lh-snug);
  letter-spacing: var(--ls-normal);
  color: var(--kumkumam);
}

p {
  max-width: var(--measure);
  text-wrap: pretty;
}

/* Eyebrow / meta label. Used for years, roles, section numbers. */
.eyebrow {
  font-size: var(--type-micro);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  color: var(--kumkumam);
}

.lead { font-size: var(--type-lead); }

.caption {
  font-size: var(--type-small);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
  max-width: var(--measure-narrow);
}

/* The Malayalam name mark. Used once, on the hero. */
.mal {
  font-family: var(--font-mal);
  font-weight: 400;
  letter-spacing: var(--ls-normal);
}


/* --- Links ----------------------------------------------------------------
   Underline offset well clear of descenders. Colour shift on hover only —
   --kumkumam-lit is a hover/active token and never a resting state. */

a {
  color: var(--kumkumam);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.22em;
  transition: color var(--t-quick) var(--ease);
}

a:hover { color: var(--kumkumam-lit); }


/* --- Focus ---------------------------------------------------------------
   Keyboard navigable with visible focus states (CLAUDE.md §12). Square,
   like everything else, and offset so it never crowds the glyphs. */

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

:focus:not(:focus-visible) { outline: none; }

.skip-link {
  border-radius: var(--radius-control);
  position: absolute;
  left: var(--gutter);
  top: 0;
  z-index: var(--z-nav);
  padding: var(--sp-2xs) var(--sp-s);
  background: var(--ink);
  color: var(--paper);
  transform: translateY(-120%);
  transition: transform var(--t-quick) var(--ease);
}

.skip-link:focus { transform: translateY(0); }

/* The colour is restated because `a:hover` above is (0,1,1) and beats
   .skip-link's (0,1,0) `color: var(--paper)`. Without this, hovering the
   focused skip link turned it kumkumam-lit on --ink — 3.04:1, below the AA
   floor §12 sets, on the one control that exists purely for accessibility. */
.skip-link:hover,
.skip-link:focus { color: var(--paper); }


/* --- Selection ------------------------------------------------------------ */

::selection {
  background: var(--kumkumam);
  color: var(--paper);
}


/* --- Dark-ground grain ---------------------------------------------------
   The body grain above uses multiply, which can only darken. On the dark
   containers in §7 (Studio Eikira's gold, quarantined) and §10 (the Flexx
   assets, black edge to edge) it therefore does nothing at all — a near-black
   multiplied by ~0.97 is the same near-black. That is inherent to the blend
   mode, not a value that wants tuning.

   Those grounds get their own layer in screen, which can only lighten. Same
   procedural turbulence, same tile, same horizontal fibre — so the two
   surfaces read as the same stock, one lit from the paper side and one from
   the ink side.

   Scoped strictly to the dark containers. It must never apply to paper, where
   screen would wash the ground out.

   .has-dark-grain is the grain WITHOUT the ink ground. A case study's own
   container is dark but is not --ink — Mailchimp's is Heritage Brown — so it
   needs the texture while setting its own background. Adding .contained-dark
   there would overwrite the brand colour with ink, which is the opposite of
   what §7 asks for. */

.frame--dark,
.contained-dark,
.has-dark-grain {
  position: relative;
  isolation: isolate;      /* keep the blend inside the container */
}

.frame--dark::after,
.contained-dark::after,
.has-dark-grain::after {
  content: '';
  position: absolute;      /* out of grid flow — .frame is a grid */
  inset: 0;
  pointer-events: none;
  z-index: 2;
  mix-blend-mode: screen;
  opacity: var(--grain-opacity-dark);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.035 0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: var(--grain-scale) var(--grain-scale);
}

@media (prefers-reduced-motion: reduce) {
  .frame--dark::after,
  .contained-dark::after,
  .has-dark-grain::after { opacity: calc(var(--grain-opacity-dark) * 0.5); }
}


/* --- The conch motif, site-wide ------------------------------------------
   VALAMPIRI SHANKHU IS RIGHT-COILING. Both source files in assets/conch/ are
   drawn left-coiling, which is the wrong shell — not a stylistic variant. The
   motif is mirrored at render time instead of by rewriting path data, so the
   two files stay byte-identical to what was exported and cannot drift apart.

   Apply .conch-flip to a group wrapping BOTH the visible artwork and, where
   one exists, the mask that reveals it. A mask with maskUnits="userSpaceOnUse"
   resolves in the user space of the element referencing it, so a single
   wrapper flips both together and registration is structurally guaranteed
   rather than maintained by hand.

   Both conch files use the same 1000x1000 viewBox, so the translate is always
   1000. transform-box and transform-origin are set explicitly because the
   default for SVG elements has changed across spec revisions, and an
   unqualified scaleX(-1) would mirror about the wrong axis in some engines.

   EVERY conch instance takes this class — hero, scroll progress spiral,
   section dividers, cursor. One motif, one orientation. The favicon is a
   standalone file and cannot take a CSS class; it needs the same
   translate(1000,0) scale(-1,1) applied as a group wrapper inside the file
   itself when it is built. */

.conch-flip {
  transform: translate(1000px, 0) scaleX(-1);
  transform-box: view-box;
  transform-origin: 0 0;
}


/* --- Utility -------------------------------------------------------------- */

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
