@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap');

/* ---------------------------------------------------------------
   Every colour lives here. Switching data-theme on <html> swaps
   the whole palette. Per-client brand colours slot in as an
   override on --accent.
--------------------------------------------------------------- */
:root {
  /* ---- Duality brand ----
     The three colours off the logo. These own the chrome: the
     rail, the tints, the buttons. They stay out of the data
     palette below, so a client accent override can never change
     what a chart colour means. */
  --onyx:       #343839;
  --green:      #4BE891;
  --purple:     #6F8EFF;

  /* Cooled a touch from neutral grey. White cards need a page
     that is slightly blue to read as floating, rather than as a
     panel cut out of the background. */
  --paper:      #E9EDF2;
  --surface:    #FFFFFF;
  --card:       #FFFFFF;
  --rail:       #2C3031;
  --rail-ink:   #FFFFFF;
  --rail-muted: rgba(255, 255, 255, 0.52);
  /* Solid, not translucent. A see-through stroke doubles up
     wherever two lines of an icon cross, so the joins show. This
     is the same grey the old 52% white made on the rail, baked in.
     If the rail colour changes, recompute this with it. */
  --rail-icon:  #9A9C9D;

  --ink:        #16191C;
  --muted:      #6B7278;
  --faint:      #949BA1;
  --rule:       #E4E8EC;
  --rule-soft:  #EFF2F5;

  /* Tinted tiles, straight off the brand. Named by what they are
     rather than by what they hold, so the same three can be
     reassigned as the report grows.

     Dusty pastels, picked by hand for this theme rather than reused
     from dark mode — earlier passes (a light wash, dark mode's own
     full-saturation colours, and one round of these dusty pastels)
     were each tried and refined before landing here. Checked with a
     WCAG contrast script against --tint-ink, not eyeballed: the
     lowest of the three (purple) still clears 8.5:1. */
  --tint-mint:  #A8D9C0;
  --tint-lilac: #B9ACE8;
  --tint-sand:  #E6C477;
  --tint-ink:   #16191C;

  /* The table's active-column highlight. Its own token rather than
     an alias for --tint-lilac, same reasoning dark mode already
     uses below: a whole highlighted column in the same colour as
     the tint tiles fights with the numbers in it. A translucent
     neutral instead — --ink at low alpha over the light card —
     visibly a highlighted column without competing for attention. */
  --table-active: rgba(22, 25, 28, 0.07);
  --row-stroke: #2C3031;                /* outline round the row under the pointer */

  /* Secondary text sitting on a tint background — a tile's label,
     or the average-position banner's caption. Cannot reuse the
     page's own --muted (#6B7278) any more: contrast against these
     tints is too weak for text that size. This is a dedicated dark
     grey, close to --tint-ink but a shade lighter so it still reads
     as secondary text, checked against all three tints (worst case,
     purple again, clears 7.0:1). */
  --tint-muted: #252A2C;

  /* Frosted surfaces. --surface stays opaque because it doubles
     as a text colour on dark tiles, where translucency would
     just dim the type. Anything that is a *panel* uses these.

     Three weights so a hovered row inside a highlighted column
     still reads as hovered: the two stack, and identical values
     would cancel each other out. */
  --glass:        rgba(255, 255, 255, 0.55);
  --glass-strong: rgba(255, 255, 255, 0.85);
  --glass-edge:   rgba(255, 255, 255, 0.70);

  /* One shadow for anything that lifts off the page, a heavier
     one for menus that genuinely float above the content rather
     than sitting in it. Two values, not one per component. */
  --lift:       0 1px 2px rgba(20, 26, 34, 0.04),
                0 8px 24px rgba(20, 26, 34, 0.06);
  --lift-high:  0 12px 36px rgba(20, 26, 34, 0.14);

  /* The dropdown arrow, drawn to match the site switcher's. An image
     cannot read a CSS colour variable, so the colour is written into
     it: --muted in each theme. Change both if --muted changes. */
  --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B7278' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");

  --r-card:     18px;
  --r-tile:     14px;
  --r-control:  10px;

  --page1:      #1F7A4C;
  --page2:      #D99A2B;
  --page3:      #C4503C;
  --page4:      #C9CFD3;
  --page5:      #B0B8BD;
  --none:       #8E979D;

  --up:         #1F7A4C;
  --down:       #C4503C;
  --up-bg:      #E7F2EC;
  --down-bg:    #F9EAE7;

  /* The notification bell's unread badge. One value for both themes,
     not redefined below like --down is: the rail it sits on is dark
     near-black in both (--rail: #2C3031 light, #1D2225 dark), so the
     same red keeps 4.9:1 against the white count inside it either
     way. Checked with a WCAG contrast script, not eyeballed. */
  --notify-badge: #C4453D;

  /* Used only by the background wash. */
  --brand-a:    #4BE891;
  --brand-b:    #6F8EFF;
  --blob-alpha: 0.10;
  --blob-blur:  90px;
  --blob-size:  clamp(260px, 30vw, 520px);
}

[data-theme="dark"] {
  --paper:      #101316;
  --surface:    #191D21;
  --card:       #191D21;
  --rail:       #1D2225;
  --rail-ink:   #F3F6F8;
  --rail-muted: rgba(255, 255, 255, 0.45);
  --rail-icon:  #838587;

  --ink:        #E7EBED;
  --muted:      #99A2A8;
  --faint:      #6E777D;
  /* Raised from the first pass: at the old values (#272D33 and
     #1E2429) a border and a card were 1.2:1 apart — under WCAG's
     own 3:1 floor for a UI boundary, so a table edge or a hover
     wash read as almost the same grey as the surface around it.
     rule now clears 1.8:1, rule-soft a softer but still real
     1.35:1, and rule stays the bolder of the two either way. */
  --rule:       #404954;
  --rule-soft:  #2E353D;

  /* Third pass. The muted slate-blue fixed the contrast and the
     shouting, but read as flat and grey once it was sitting next
     to real content again. Reference: a dark dashboard whose
     tiles use fully saturated flat colour — amber, violet, lime —
     with dark text on top rather than white, which is what makes
     saturated colour survive on a near-black page without turning
     into a neon glow. Same idea here, in this app's own green /
     purple / amber tint families rather than copied hues, and
     --tint-ink flips to a dark colour to go with them. */
  --tint-mint:  #34D399;
  --tint-lilac: #B49CFC;
  --tint-sand:  #F0B94A;
  --tint-ink:   var(--onyx);
  /* --muted itself stays light in this theme, for the ink-on-card
     text it is normally used for — so a tile's label needs its
     own dark value rather than inheriting that one. */
  --tint-muted: #4A5051;

  /* The active-column highlight is deliberately NOT one of the
     tints above — full-saturation colour works on a small tile,
     but wash the same colour down an entire table column and it
     fights with the numbers instead of framing them. A neutral
     light grey instead: still clearly a highlighted column
     (2.7:1 against the card), without competing for attention. */
  --table-active: #5A626C30;
  --row-stroke: #AEB6BC;                /* light on dark, so the outline still reads */

  /* Far lower on dark. White at 25% over near-black is a grey
     panel, not a frosted one — the eye reads lightness, not
     opacity. */
  --glass:        rgba(255, 255, 255, 0.06);
  --glass-strong: rgba(255, 255, 255, 0.12);
  --glass-edge:   rgba(255, 255, 255, 0.13);

  /* Shadows do almost nothing on near-black, so cards separate
     with a hairline instead. That is why --rule carries more
     weight in this theme than in the light one. */
  --lift:       0 1px 2px rgba(0, 0, 0, 0.40);
  --lift-high:  0 12px 36px rgba(0, 0, 0, 0.55);

  --page1:      #3FA972;
  --page2:      #E5B052;
  --page3:      #D96D57;
  /* A ramp of greys is only readable if the steps between them
     are as deliberate as the steps between the colours above it.
     The first pass put none/page5/page4 at 1.36/1.68/2.16:1
     against the card — page4 was fine, but none and page5 sat in
     the same near-invisible band as the borders and tints above,
     so an empty bucket's bar or legend dot vanished into the row
     it was sitting on. Now: 2.0 / 2.6 / 3.6:1, each step clearly
     apart from the last and from --rule-soft, the track most of
     these sit on. */
  --page4:      #647682;
  --page5:      #546069;
  --none:       #464E53;

  --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2399A2A8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");

  --up:         #3FA972;
  --down:       #D96D57;
  --up-bg:      #16291F;
  --down-bg:    #2A1A17;

  /* A bright colour on near-black paper reads as a glow long
     before it reads as a tint, so it needs less than the light
     theme, not more. */
  --blob-alpha: 0.08;
  --blob-blur:  100px;
}

* { box-sizing: border-box; }

/* ---------------------------------------------------------------
   BACKGROUND

   The page colour sits on <html>, not <body>, so the blobs below
   can be painted between the two. Putting both on <body> would
   hide them behind its own background, which is the usual reason
   this effect appears to do nothing.
--------------------------------------------------------------- */
html {
  background: var(--paper);
  transition: background 180ms ease;
}

body {
  margin: 0;
  background: transparent;
  color: var(--ink);
  font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  font-variant-numeric: tabular-nums;
  transition: color 180ms ease;
}

/* ---------------------------------------------------------------
   Two soft corner accents, one green behind the top left and one
   blue-purple behind the bottom right.

   Sized with clamp and a hard ceiling rather than a plain vw
   value: at 58vw these filled a wide monitor corner to corner and
   the whole page read as a gradient wash instead of paper with
   two tinted corners. The cap is the difference.

   Tucked mostly off-screen so only the soft edge reaches the
   content. The middle of the page, where the numbers live, stays
   clean.
--------------------------------------------------------------- */
body::before,
body::after {
  content: '';
  position: fixed;
  z-index: 0;
  width: var(--blob-size);
  height: var(--blob-size);
  filter: blur(var(--blob-blur));
  opacity: var(--blob-alpha);
  pointer-events: none;
  will-change: border-radius, translate, scale, rotate;
}

/* ---------------------------------------------------------------
   Four animations per blob rather than one.

   translate, scale and rotate are animated as their own CSS
   properties instead of as a single transform. A transform can
   only be driven by one animation at a time, so combining them
   would force drift, pulse and spin onto a shared clock and the
   whole thing would visibly loop.

   Kept separate on mismatched durations — 17, 31, 11 and 120
   seconds — the combined state does not repeat for hours. That is
   what reads as alive rather than as a GIF.
--------------------------------------------------------------- */
body::before {
  top: calc(var(--blob-size) * -0.45);
  left: calc(var(--blob-size) * -0.3);
  background: var(--brand-a);
  animation:
    morph-a  17s ease-in-out infinite,
    float-a  31s ease-in-out infinite,
    pulse-a  11s ease-in-out infinite,
    spin-a  120s linear      infinite;
}

body::after {
  bottom: calc(var(--blob-size) * -0.45);
  right: calc(var(--blob-size) * -0.3);
  background: var(--brand-b);
  animation:
    morph-b  23s ease-in-out infinite,
    float-b  43s ease-in-out infinite,
    pulse-b  14s ease-in-out infinite,
    spin-b  150s linear      infinite reverse;
}

/* ---- shape ----
   Wide swings on purpose. Under 80px of blur a gentle change is
   invisible; the radius has to go somewhere near oval to register
   as movement at all. */
@keyframes morph-a {
  0%, 100% { border-radius: 62% 38% 46% 54% / 54% 42% 58% 46%; }
  20%      { border-radius: 38% 62% 63% 37% / 41% 68% 32% 59%; }
  40%      { border-radius: 71% 29% 34% 66% / 66% 31% 69% 34%; }
  60%      { border-radius: 44% 56% 70% 30% / 35% 60% 40% 65%; }
  80%      { border-radius: 55% 45% 38% 62% / 62% 47% 53% 38%; }
}

@keyframes morph-b {
  0%, 100% { border-radius: 44% 56% 61% 39% / 47% 58% 42% 53%; }
  25%      { border-radius: 67% 33% 39% 61% / 62% 36% 64% 38%; }
  50%      { border-radius: 35% 65% 56% 44% / 39% 63% 37% 61%; }
  75%      { border-radius: 59% 41% 33% 67% / 68% 44% 56% 32%; }
}

/* ---- drift ----
   Percentages of the blob's own size, never vw. A vw translate
   moved these halfway across a wide screen, which is what turned
   a corner accent into a sweep. */
@keyframes float-a {
  0%, 100% { translate: 0 0; }
  25%      { translate: 7% 5%; }
  50%      { translate: 2% 11%; }
  75%      { translate: -5% 4%; }
}

@keyframes float-b {
  0%, 100% { translate: 0 0; }
  30%      { translate: -8% -4%; }
  55%      { translate: -2% -11%; }
  80%      { translate: 5% -6%; }
}

/* ---- breath ----
   The slow swell that makes the pair feel like they are
   breathing. Fastest of the four, and the one doing most of the
   work at this blur. */
@keyframes pulse-a {
  0%, 100% { scale: 1; }
  50%      { scale: 1.14; }
}

@keyframes pulse-b {
  0%, 100% { scale: 1.06; }
  50%      { scale: 0.9; }
}

/* ---- turn ----
   Slow enough to be subliminal on its own, but it keeps the
   morphing edge from wobbling back and forth over the same
   ground. */
@keyframes spin-a { to { rotate: 360deg; } }
@keyframes spin-b { to { rotate: 360deg; } }

/* Below tablet width there is no corner left to tint — the
   content runs edge to edge, so the accents would sit under the
   numbers rather than beside them. */
@media (max-width: 720px) {
  body::before,
  body::after { display: none; }
}

/* The page containers have to be lifted above z-index 0 or the
   blobs would sit on top of the content rather than behind it. */
.wrap,
.shell,
.rail,
.canvas {
  position: relative;
  z-index: 1;
}

/* Ink on paper. A blurred wash prints as a grey smear and eats
   toner on a report a client may well print for a meeting. */
@media print {
  body::before,
  body::after { display: none; }
}

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

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; }
  /* The colour stays, the movement goes. */
  body::before,
  body::after { animation: none !important; }
}
