ev_lib_classes 0.7.1

Pure styling data (Tailwind class tables) shared by the Dioxus uikit and the TS codegen — no Dioxus.
Documentation
/* AUTO-GENERATED by `nix run .#gen` from the repo-root tokens.css — do not edit. */
/*
 * Shared design tokens — the theme contract for the `ev_lib::uikit` (Rust) and
 * `@evinvest/uikit` (TypeScript) components. Import this file from any Tailwind v4
 * entrypoint. Single source for both ports — `@evinvest/uikit` copies it into
 * `styles/tokens.css` from its `prepare` script.
 *
 * The values below are EV's. A non-EV consumer writes its own sheet against the
 * same names; the class tables reference nothing else.
 *
 *   SCOPE      :root · .dark · .light      — a consumer defines one or both
 *   SURFACES   background · card · popover · muted · hover
 *   INK        ink · ink-mid · ink-soft
 *   LINES      border · input · ring
 *   ROLES      {role} and on-{role} for the ones that get filled:
 *              brand · primary · secondary · positive ·
 *              accent-trace · accent-debug · accent-info · accent-warn · accent-error
 *   SCALARS    radius · control-* · display-scale · page-* · shadows · fonts
 *   CHARTS     chart-1 … chart-5
 *   MARK       brand-mark · brand-aspect       — supplied, never defined here
 *
 * Custom properties inherit, so a scope class on a `<section>` re-themes its
 * subtree: `bg-card text-ink` is correct on both polarities with no prop. EV is
 * single-polarity, so `:root` and `.dark` carry the same block and `.light` is
 * undefined — a `Section(Light)` is then a no-op here, which is what a
 * single-polarity palette should do.
 *
 * Accents are ordered by significance, not by meaning; see docs/spec/accents.md.
 * Their values are read outside CSS (gitops mirrors them into Discord embed
 * bars), so they stay plain hex — no color-mix.
 *
 * Surface ladder — dark UI reads as unfinished when every plane is the same
 * value, so the steps below are deliberate and measured; keep them apart if you
 * retune. background #070d18 → secondary #0d1526 (1.07:1) → card #16233c (1.24:1
 * vs background) → popover #1d2c49 (1.13:1 vs card) → muted #26375c. Elevation
 * rides the same ladder via --shadow-card / -elevated / -overlay.
 */

/*
 * Toast (sonner) enter/exit motion, shared by both ports. Imported by the token
 * sheets so the lifecycle ships with the theme contract.
 */

/* ── Toast stacking (Sonner-style), shared by both ports via data-stack ──────
   Both `ev_lib::uikit` (Dioxus) and `@evinvest/uikit` (React) set data-stack on the
   toaster and drive the same machine: toasts are absolutely stacked at the
   pinned edge, pile up collapsed (front three peeking, each lifted + scaled by
   --index and clamped to --front-height), and spread to --offset on hover /
   keyboard focus (pure CSS — no JS state). Vars: the toaster sets --front-height
   + --gap; each toast sets --index, --z-index, --offset, --initial-height. React
   measures real heights; Dioxus can't (host-only), so it feeds a constant height
   — collapse is exact, expansion is uniform. The enter is a keyframe (plays on
   DOM insertion, so it works in both ports without a JS mount flip),
   `data-state=closed` slides out, and the node is dropped on the transitionend. */

[data-slot="toaster"][data-stack] [data-slot="toast"] {
  position: absolute;
  --y: translateY(0);
  opacity: 1;
  z-index: var(--z-index, 1);
  transform: var(--y) translateX(var(--swipe-x, 0px));
  /* a plain easeOut (no overshoot) so a restack settles instead of bouncing
     back past its slot */
  transition:
    transform 350ms cubic-bezier(0.21, 0.8, 0.4, 1),
    opacity 350ms,
    height 350ms;
  /* the enter is a keyframe (plays on DOM insertion in both ports — no JS mount
     flip, whose pre-paint timing the Dioxus effect couldn't drive). It animates
     the independent `translate` property, NOT `transform`, so it *composes* with
     the stack transform instead of overriding it: a toast pushed back mid-enter
     keeps repositioning smoothly instead of fighting the keyframe (rapid-fire
     lag). */
  animation: ev-toast-enter 350ms cubic-bezier(0.21, 0.8, 0.4, 1) backwards;
}
@keyframes ev-toast-enter {
  from {
    opacity: 0;
    translate: 0 var(--enter-y, 100%);
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

/* pin to the toaster's edge; set the lift sign + the off-screen enter offset */
[data-slot="toaster"][data-stack][data-y-position="bottom"] [data-slot="toast"] {
  bottom: 0;
  --lift: -1;
  --enter-y: 100%;
}
[data-slot="toaster"][data-stack][data-y-position="top"] [data-slot="toast"] {
  top: 0;
  --lift: 1;
  --enter-y: -100%;
}

/* closing: slide back off + fade out; the node is dropped on its transitionend */
[data-slot="toaster"][data-stack] [data-slot="toast"][data-state="closed"] {
  --y: translateY(var(--enter-y));
  opacity: 0;
}

/* collapsed: back toasts lift + shrink by depth, clamp to the front height */
[data-slot="toaster"][data-stack]
  [data-slot="toast"][data-state="open"][data-front="false"] {
  --y: translateY(calc(var(--lift) * var(--gap) * var(--index)))
    scale(calc(1 - var(--index) * 0.05));
  height: var(--front-height);
}
[data-slot="toaster"][data-stack] [data-slot="toast"][data-front="false"] > * {
  opacity: 0;
  transition: opacity 300ms;
}
/* only the front three show while collapsed */
[data-slot="toaster"][data-stack] [data-slot="toast"][data-visible="false"] {
  opacity: 0;
  pointer-events: none;
}

/* expanded (hover / keyboard focus): spread to the offset, real height, content
   + overflow back */
[data-slot="toaster"][data-stack]:hover [data-slot="toast"][data-state="open"],
[data-slot="toaster"][data-stack]:focus-within
  [data-slot="toast"][data-state="open"] {
  --y: translateY(calc(var(--lift) * var(--offset)));
  height: var(--initial-height);
}
[data-slot="toaster"][data-stack]:hover [data-slot="toast"] > *,
[data-slot="toaster"][data-stack]:focus-within [data-slot="toast"] > *,
[data-slot="toaster"][data-stack]:hover [data-slot="toast"][data-visible="false"],
[data-slot="toaster"][data-stack]:focus-within
  [data-slot="toast"][data-visible="false"] {
  opacity: 1;
  pointer-events: auto;
}

/* a transparent strip over the gap so the hover area stays contiguous between
   spread toasts (otherwise the pointer falls through the gap and it collapses) */
[data-slot="toaster"][data-stack] [data-slot="toast"]::after {
  content: "";
  position: absolute;
  left: 0;
  width: 100%;
  height: calc(var(--gap) + 1px);
}
[data-slot="toaster"][data-stack][data-y-position="bottom"]
  [data-slot="toast"]::after {
  bottom: 100%;
}
[data-slot="toaster"][data-stack][data-y-position="top"]
  [data-slot="toast"]::after {
  top: 100%;
}

/* 1:1 finger tracking during a swipe — kill the transition */
[data-slot="toaster"][data-stack] [data-slot="toast"][data-swiping="true"] {
  transition: none;
}

/* auto-dismiss without a host timer: a no-op "life" animation runs for --life,
   then its `animationend` fires (the toast closes itself). Hovering/focusing the
   stack pauses just that animation (animation-play-state's 2nd slot), so the
   countdown holds — and resumes with the elapsed time preserved — while the
   enter (1st slot) keeps running. Persistent toasts set data-autodismiss=false,
   so they get no life animation. (The React port uses setTimeout instead.) */
@keyframes ev-toast-life {
  from {
    --ev-life: 0;
  }
  to {
    --ev-life: 1;
  }
}
[data-slot="toaster"][data-stack]
  [data-slot="toast"][data-autodismiss="true"][data-state="open"] {
  animation:
    ev-toast-enter 350ms cubic-bezier(0.21, 0.8, 0.4, 1) backwards,
    ev-toast-life var(--life) linear;
}
[data-slot="toaster"][data-stack]:hover
  [data-slot="toast"][data-autodismiss="true"],
[data-slot="toaster"][data-stack]:focus-within
  [data-slot="toast"][data-autodismiss="true"] {
  animation-play-state: running, paused;
}

/* reduced motion: opacity-only enter + near-instant transform (still fires
   transitionend, so exit removal works), no height animation */
@keyframes ev-toast-fade-in {
  from {
    opacity: 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  [data-slot="toaster"][data-stack] [data-slot="toast"] {
    transition:
      transform 1ms linear,
      opacity 200ms;
    animation: ev-toast-fade-in 200ms;
  }
  /* keep the auto-dismiss life animation under reduced motion */
  [data-slot="toaster"][data-stack]
    [data-slot="toast"][data-autodismiss="true"][data-state="open"] {
    animation:
      ev-toast-fade-in 200ms,
      ev-toast-life var(--life) linear;
  }
}

@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --radius-2xl: calc(var(--radius) + 8px);

  /* Depth has to be a ladder, not a single drop shadow: on a dark theme the
   * value steps alone are too close to separate a resting card from a menu
   * from a modal, so each tier gets its own spread + opacity. */
  --shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.30);
  --shadow-elevated:
    0 4px 12px -2px rgb(0 0 0 / 0.40),
    0 2px 4px -2px rgb(0 0 0 / 0.30);
  --shadow-overlay: 0 16px 48px -12px rgb(0 0 0 / 0.60);

  /* The leading var() carries its own fallback: next/font (landing) sets
   * `--font-inter` / `--font-playfair`; a Rust consumer `@font-face`s the same
   * families itself. Without the in-var fallback an undefined leading var would
   * invalidate the whole `font-family` and drop the browser to its default — so
   * it must never resolve to guaranteed-invalid. */
  --font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, sans-serif;
  --font-serif: var(--font-playfair, "Playfair Display"), ui-serif, Georgia, serif;
  --font-mono: var(--font-inter, "Inter"), ui-monospace, monospace;
  --font-display: var(--font-playfair, "Playfair Display"), ui-serif, Georgia, serif;

  /* surfaces */
  --color-background: var(--background);
  --color-card: var(--card);
  --color-popover: var(--popover);
  --color-muted: var(--muted);
  --color-hover: var(--hover);

  /* ink — hierarchy, loudest first */
  --color-ink: var(--ink);
  --color-ink-mid: var(--ink-mid);
  --color-ink-soft: var(--ink-soft);

  /* lines */
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);

  /* roles */
  --color-brand: var(--brand);
  --color-primary: var(--primary);
  --color-on-primary: var(--on-primary);
  --color-secondary: var(--secondary);
  --color-on-secondary: var(--on-secondary);
  --color-positive: var(--positive);
  --color-on-positive: var(--on-positive);
  --color-accent-trace: var(--accent-trace);
  --color-on-accent-trace: var(--on-accent-trace);
  --color-accent-debug: var(--accent-debug);
  --color-on-accent-debug: var(--on-accent-debug);
  --color-accent-info: var(--accent-info);
  --color-on-accent-info: var(--on-accent-info);
  --color-accent-warn: var(--accent-warn);
  --color-on-accent-warn: var(--on-accent-warn);
  --color-accent-error: var(--accent-error);
  --color-on-accent-error: var(--on-accent-error);

  /* charts — distinguishable hues carrying no significance */
  --color-chart-1: var(--chart-1);
  --color-chart-2: var(--chart-2);
  --color-chart-3: var(--chart-3);
  --color-chart-4: var(--chart-4);
  --color-chart-5: var(--chart-5);
}

:root,
.dark {
  /* ── surfaces ────────────────────────────────────────────────── */
  --background: #070d18;
  --card: #16233c;
  --popover: #1d2c49;
  --muted: #26375c;
  /* the menu-item tint: a surface state, so it is named for the state */
  --hover: color-mix(in srgb, var(--ink) 8%, transparent);

  /* ── ink ─────────────────────────────────────────────────────── */
  --ink: #e6e1d3;
  --ink-mid: color-mix(in srgb, var(--ink) 82%, transparent);
  /* the most-used secondary text token, so it has to clear AA on its own */
  --ink-soft: color-mix(in srgb, var(--ink) 65%, transparent);

  /* ── lines ───────────────────────────────────────────────────── */
  --border: color-mix(in srgb, var(--ink) 14%, transparent);
  --input: color-mix(in srgb, var(--ink) 20%, transparent);
  --ring: #2a9d8f;

  /* ── roles ───────────────────────────────────────────────────── */
  --brand: #001e4e;
  /* teal, not the brand navy: navy filled a Button/Switch at ~1.1:1 against
   * these surfaces, i.e. an invisible control, and consumers hardcoded teal to
   * get around it. The brand navy still lives in --brand. */
  --primary: #2a9d8f;
  --on-primary: #070d18;
  --secondary: #0d1526;
  --on-secondary: var(--ink);
  /* valence, not significance: a success reads as good while staying quiet */
  --positive: #2e9e5b;
  --on-positive: #070d18;
  --accent-trace: #6b7fa0;
  --on-accent-trace: #070d18;
  --accent-debug: #2a9d8f;
  --on-accent-debug: #070d18;
  --accent-info: #4a90d9;
  --on-accent-info: #070d18;
  --accent-warn: #f2c94c;
  --on-accent-warn: #070d18;
  /* lifted off the brand red #da251d, which failed AA on a card. This one value
   * carries both the fill and the text; the vivid brand red is a gitops
   * constant, not a UI token. */
  --accent-error: #ef5b52;
  --on-accent-error: #070d18;

  --chart-1: #2a9d8f;
  --chart-2: #2e9e5b;
  --chart-3: #f2c94c;
  --chart-4: #e58aae;
  --chart-5: #4a90d9;

  /* ── scalars ─────────────────────────────────────────────────── */
  --radius: 0.375rem; /* 6px on mobile; bumped to 8px ≥ sm (see @media below) */
  /* control geometry, so a consumer reshapes every button/input at once */
  --control-radius: calc(var(--radius) - 2px);
  --control-py: 0.5rem;
  /* the Xl (call-to-action) button reads all three */
  --control-px: 2rem;
  --control-text: 1rem;
  /* multiplies the display type scale (uikit <Display>) */
  --display-scale: 1;
  /* the vertical rhythm of a full-bleed <Section>; `tight` is the sub-page one */
  --band-py: 2.25rem;
  --band-py-tight: 2rem;

  --sidebar-width: 14rem;

  /* page container — responsive gutter + max width (uikit <Container>) */
  --page-max: 90rem; /* 1440px */
  --page-px: 1rem; /* 16px — mobile gutter, scales up below */

  /* ── the mark ────────────────────────────────────────────────── *
   * `--brand-mark` (a url() at your own SVG) and `--brand-aspect` (its ratio)
   * are what `<Logo>` masks. Deliberately undefined: the kit ships no artwork,
   * and a default here would put EV's crown on somebody else's 404. Undeclared,
   * `mask-image` is invalid at computed-value time and the mark paints as a
   * solid block — loud, which is the point. Declare both beside the palette:
   *
   *   --brand-mark: url("/assets/logo.svg");
   *   --brand-aspect: 387 / 335;
   */

  /* shell offset — viewport space reserved by whatever shell hosts the app.
     Standalone apps get 0; a hosting shell overrides on :root. Size
     viewport-bound surfaces with calc(100dvh - var(--ev-shell-offset, 0px)). */
  --ev-shell-offset: 0px;
}

/* Mobile-first scale-ups: roomier gutters + slightly larger radii on bigger
   viewports. Lives here (not per-component) so every consumer scales alike. */
@media (min-width: 640px) {
  :root {
    --radius: 0.5rem;
    --page-px: 1.5rem;
  }
}

@media (min-width: 768px) {
  :root {
    --band-py: 4rem;
    --band-py-tight: 3.5rem;
  }
}

@media (min-width: 1024px) {
  :root {
    --page-px: 3rem;
  }
}