ev_lib 0.3.5

EV-invest shared Rust libraries, one per feature
Documentation
/*
 * 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. Kept byte-for-byte in parity with `@evinvest/uikit`'s
 * `styles/tokens.css`; change one, change the other.
 *
 * Structure:
 *   @theme inline {}  — wires values into Tailwind utilities (processed by
 *                       Tailwind v4; ignored as unknown at-rule by browsers)
 *   :root {}          — raw CSS custom properties; work everywhere
 *   @keyframes / rules — toast (sonner) enter/exit motion at the tail; shipped
 *                       here so the lifecycle is self-contained (see below)
 */

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

  /* The leading var() carries its own fallback: next/font (landing) sets
   * `--font-inter` / `--font-playfair`; a Rust consumer renders `uikit::Fonts`,
   * which `@font-face`s the same families. 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;

  /* shadcn semantic utilities */
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);

  /* sidebar utilities (PC) */
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);

  /*
   * Brand palette — navy-led institutional base + tiered Vietnam-inspired accents.
   * Named by ROLE, not hue. Tier hierarchy:
   *   t1 (Ha Long Teal)  — recurring workhorse: labels, nav, secondary numbers
   *   t2 (Jungle Green)  — positive financial signals: yields, growth %, FDI
   *   t3 (Rice Gold)     — headline return figures: IRR, ROI, appreciation
   *   t4 (Lotus Pink)    — ≤1 per section; the punctuation jewel
   */
  --color-main-brand: #001e4e;
  --color-main-black: #070d18;
  --color-main-surface: #081020;
  --color-main-card: #0c1626;
  --color-main-mist: #e6e1d3;
  --color-main-accent-t1: #2a9d8f;
  --color-main-accent-t2: #2e9e5b;
  --color-main-accent-t3: #f2c94c;
  --color-main-accent-t4: #e58aae;
}

:root {
  /* ── shadcn semantic tokens ──────────────────────────────────── */
  --background: #070d18;
  --foreground: white;
  --card: #0c1626;
  --card-foreground: white;
  --popover: #0c1626;
  --popover-foreground: white;
  --primary: #001e4e;
  --primary-foreground: #e6e1d3;
  --secondary: #081020;
  --secondary-foreground: white;
  --muted: #16213a;
  --muted-foreground: color-mix(in srgb, #e6e1d3 40%, transparent);
  --accent: color-mix(in srgb, #e6e1d3 8%, transparent);
  --accent-foreground: white;
  --destructive: #da251d;
  --destructive-foreground: #e6e1d3;
  --border: color-mix(in srgb, #e6e1d3 10%, transparent);
  --input: color-mix(in srgb, #e6e1d3 20%, transparent);
  --ring: #2a9d8f;
  --radius: 0.375rem; /* 6px on mobile; bumped to 8px ≥ sm (see @media below) */

  /* ── sidebar (PC nav) ───────────────────────────────────────── */
  --sidebar: #0c1626;
  --sidebar-foreground: white;
  --sidebar-primary: #001e4e;
  --sidebar-primary-foreground: #e6e1d3;
  --sidebar-accent: color-mix(in srgb, #e6e1d3 8%, transparent);
  --sidebar-accent-foreground: white;
  --sidebar-border: color-mix(in srgb, #e6e1d3 10%, transparent);
  --sidebar-ring: #2a9d8f;
  --sidebar-width: 14rem;

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

/* 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: 1024px) {
  :root {
    --page-px: 3rem;
  }
}

/* ── Toast stacking (Sonner-style), shared by both ports via data-stack ──────
   Both `ev::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;
  }
}