/* ============================================================
   Portfolio_DS  .  system.css
   The base layer: everything all six pages share.

   Load order on every page is fixed and matters:
       tokens.css  ->  system.css  ->  index.css | case.css  ->  page <style>

   Colour comes only from tokens.css. Do not add a literal colour
   here. Run `python3 tools/check.py` after any change.
   ============================================================ */

html{scroll-behavior:smooth}
@media (prefers-reduced-motion: reduce){
  html{scroll-behavior:auto}
}
a{color:inherit;text-decoration:none}
:focus-visible{outline:2px solid var(--focus);outline-offset:3px;border-radius:3px}
p{margin:0}
ul{margin:0;padding:0;list-style:none}
.shell{max-width:var(--maxw);margin:0 auto;padding-inline:var(--pad)}
/* The frosted header. The blur is redrawn every frame the page moves under it, so it is
   one filter, not two: a saturate() pass on top doubled the work for a change nobody can
   see through 88% paper. -webkit- is for Safari before 18, which otherwise draws no
   blur at all. */
header{position:sticky;top:0;z-index:50;background:color-mix(in srgb,var(--paper) 88%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border-bottom:1px solid var(--line-2)}
.back{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;font-weight:500;color:var(--ink-3);transition:color .18s}
.back:hover{color:var(--violet-ink)}
.toggle{
  margin-left:auto;display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:var(--raise);color:var(--ink-2);
  padding:7px 12px 7px 10px;border-radius:100px;cursor:pointer;font-family:var(--mono);font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;font-weight:500;transition:border-color .18s,color .18s
}
.toggle:hover{border-color:var(--violet);color:var(--ink)}
.toggle svg{width:13px;height:13px;display:block}
.toggle .sun{display:none}
:root[data-theme="dark"] .toggle .sun{display:block}
:root[data-theme="dark"] .toggle .moon{display:none}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]) .toggle .sun{display:block}
  :root:not([data-theme="light"]) .toggle .moon{display:none}
}
/* Ambient field behind a hero. A WebGL noise wash whose colours are read from the
   tokens at runtime, so it re-themes with everything else rather than carrying its
   own palette. The script behind it is assets/js/field.js, one copy for all six
   pages, and this rule is the same: a page opts in by putting
   <div class="hero-field"><canvas id="hero-field"></canvas></div> inside its hero,
   and gets exactly the field the homepage has. That is the point of it living here
   rather than in a family sheet. It was stated twice, in index.css and case.css,
   for as long as the two families stopped it at different heights.

   The mask reaches its transparent stop exactly at the box's bottom edge, so the
   height is the only thing that decides where the field ends and there is never a
   hard cut. 72% is the shipped stop on every page.

   On the two index heroes 72% clears the strip of --ink-4 labels below it: the
   homepage facts start at 84% of their hero and the work index stats at 76%. On the
   four case heroes it does not. Their facts strip starts at 61% at its highest and
   the field runs into it, which costs those labels AA. That is a deliberate, recorded
   exception, not an oversight: see docs/ACCESSIBILITY.md before changing this number
   in either direction.

   Violet only. A hero already spends its one lime on the pulse and the headline
   mark, and the accent budget is one per section. */
.hero-field{
  position:absolute;left:0;right:0;top:0;height:72%;z-index:0;pointer-events:none;
  -webkit-mask-image:radial-gradient(112% 96% at 84% 30%,#000 0%,#000 26%,transparent 74%);
  mask-image:radial-gradient(112% 96% at 84% 30%,#000 0%,#000 26%,transparent 74%)
}
.hero-field canvas{display:block;width:100%;height:100%}
.attr{font-family:var(--mono);font-size:11px;letter-spacing:.07em;text-transform:uppercase;line-height:1.7;color:var(--quote-mute)}
.attr b{display:block;color:var(--quote-fg);font-weight:500;letter-spacing:.05em}

/* The closing band every page ends on, the contact on the two index pages and the call
   to action on the four case studies: --paper-2, the grain, the --raise highlight on
   top. Only a top edge: the footer's own border-top is the bottom one, and a second
   line there would sit against it as a doubled rule. Every colour on it clears AA with room to spare,
   the violet em 5.75:1 light and 5.09:1 dark, the grain's worst pixel 5.66 and 4.95. */
.close-band{position:relative;isolation:isolate;background:var(--paper-2);box-shadow:inset 0 1px 0 var(--raise)}
.close-band::before{
  content:"";position:absolute;inset:0;pointer-events:none;z-index:-1;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='s' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.15' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='2' intercept='-.5'/%3E%3CfeFuncG type='linear' slope='2' intercept='-.5'/%3E%3CfeFuncB type='linear' slope='2' intercept='-.5'/%3E%3CfeFuncA type='table' tableValues='1 1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23s)'/%3E%3C/svg%3E");
  background-size:120px 120px;opacity:var(--surface-grain);mix-blend-mode:overlay
}
/* A tile grid laid into the closing band, very light: --line hairlines on a 48px
   square (36px on a phone), faded out toward the left so the heading and the buttons
   sit on clean ground and the pattern fills the empty right. It sits over the
   grain and under the text. A pointer over the band lights the tile under it with a
   trace of violet that fades out behind it, which is all the motion there is; 
   tiles.js only moves one square, so there is nothing for reduced motion to stop
   beyond the fade, and that preference drops the fade too. */
.close-band .tiles{
  position:absolute;inset:0;z-index:-1;pointer-events:none;overflow:hidden;
  background-image:linear-gradient(to right,var(--line) 1px,transparent 1px),linear-gradient(to bottom,var(--line) 1px,transparent 1px);
  background-size:48px 48px;
  mask-image:linear-gradient(to right,transparent 28%,rgba(0,0,0,.55) 78%);
  -webkit-mask-image:linear-gradient(to right,transparent 28%,rgba(0,0,0,.55) 78%)
}
.close-band .tiles i{
  position:absolute;left:0;top:0;width:48px;height:48px;opacity:0;
  background:color-mix(in srgb,var(--violet) 22%,transparent);transition:opacity 1.2s ease
}
.close-band .tiles i.on{opacity:1;transition:none}
@media(max-width:760px){
  .close-band .tiles{background-size:36px 36px}
  .close-band .tiles i{width:36px;height:36px}
}
@media (prefers-reduced-motion: reduce){.close-band .tiles i{transition:none}}

/* Scroll reveal, driven by assets/js/reveal.js, which adds .rv to the content of each
   section and .rv-in as it comes into view, then takes both off once it has landed so
   the element is back on its own styles. One distance, one duration, one curve on every
   page. Only the script adds .rv, so without it, or under reduced motion, nothing is
   hidden.
   The three properties are !important because the element being revealed often has
   its own transition (a card's border-color, a button's hover), set in a sheet that
   loads after this one, and an equal selector there would replace the reveal's curve
   outright: the Next project card popped in unanimated that way. It only holds while
   .rv is on, which the script takes off the moment the reveal lands, so the element's
   own transitions are back for its hover. */
.rv{
  opacity:0!important;transform:translate3d(0,20px,0)!important;
  transition:opacity .9s cubic-bezier(.22,1,.36,1),transform .9s cubic-bezier(.22,1,.36,1)!important
}
.rv.rv-in{opacity:1!important;transform:none!important}
