Global styles
Tailwind entry css: dark-mode variant, base resets, component animations, and table styling. Imports theme.css and typography.css.
Installation
npx shadcn@latest add https://boardcn.dev/r/globals.jsonnpx shadcn@latest add https://boardcn.dev/r/globals.jsonBoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
@import "tailwindcss";
@import "./theme.css";
@import "./typography.css";
/* ---------------------------------------------------------------------------
* Dark mode strategy
*
* We use a class-based dark variant. Toggle dark mode by adding the `.dark`
* class to <html> (a theme provider like `next-themes` can do this later).
*
* <html class="dark"> → every `dark:*` utility activates
*
* Components should NOT use `dark:` prefixes directly. Instead they reference
* semantic color tokens (e.g. `bg-bg-primary`, `text-text-primary`) that flip
* under `.dark` in styles/theme.css.
* --------------------------------------------------------------------------- */
@custom-variant dark (&:where(.dark, .dark *));
/* The next theme is revealed by ThemeToggle from the exact interaction point.
* Native view-transition crossfades are disabled so only the soft circular
* clip-path animation runs; unsupported browsers simply ignore these rules. */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
@keyframes ai-chat-text-shimmer {
from {
background-position: 200% center;
}
to {
background-position: -100% center;
}
}
.agent-progress-loading-text {
color: transparent;
background-image: linear-gradient(
100deg,
var(--color-text-secondary) 16%,
var(--color-text-secondary) 38%,
var(--color-text-primary) 50%,
var(--color-text-secondary) 62%,
var(--color-text-secondary) 84%
);
background-position: 200% center;
background-size: 300% 100%;
background-clip: text;
-webkit-background-clip: text;
animation: ai-chat-text-shimmer 3.4s linear infinite;
will-change: background-position;
}
@media (prefers-reduced-motion: reduce) {
.agent-progress-loading-text {
color: var(--color-text-secondary);
background-image: none;
animation: none;
}
}
@keyframes image-generation-pulse {
0%,
100% {
opacity: 0.16;
scale: 0.62;
}
50% {
opacity: 0.95;
scale: 1.18;
}
}
.image-generation-pulse-dot {
animation: image-generation-pulse 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.image-generation-pulse-dot {
animation: none;
opacity: 0.58;
scale: 1;
}
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
/* Components often have their own hover/focus color transitions. Freeze them
* while the browser captures the next theme so the expanding circle contains
* the fully resolved new palette instead of a frame that is still fading from
* the old one. This is what makes the click-origin reveal read clearly. */
.theme-transitioning,
.theme-transitioning * {
transition: none !important;
}
/* Reset + base */
html,
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-variant-ligatures: contextual;
font-kerning: normal;
}
html {
color-scheme: light;
background-color: var(--color-background-full);
}
html.dark {
color-scheme: dark;
}
body {
background-color: var(--color-background-full);
color: var(--color-text-primary);
}
/* Theme-aware monochrome BoardCN mark. Both images keep their intrinsic
* dimensions while the root theme class decides which one participates in
* layout, avoiding a light-logo flash during hydration. */
.theme-logo-dark,
.theme-asset-dark {
display: none;
}
.dark .theme-logo-light,
.dark .theme-asset-light {
display: none;
}
.dark .theme-logo-dark,
.dark .theme-asset-dark {
display: block;
}
/* TanStack ships here as a transparent black raster mark. Normalizing the
* visible pixels to black before inversion keeps its antialiased alpha edges
* intact while producing a clean white logo on dark surfaces. */
.tanstack-logo {
filter: none;
}
.dark .tanstack-logo {
filter: brightness(0) invert(1);
}
/* Tailwind expands shadow utilities into `--tw-shadow` at build time. Point
* those generated utilities back to the live dark elevation tokens so the
* class-based theme can adjust opacity without component-level overrides. */
.dark .shadow-2xs {
--tw-shadow: var(--shadow-2xs);
}
.dark .shadow-xs {
--tw-shadow: var(--shadow-xs);
}
.dark .shadow-sm {
--tw-shadow: var(--shadow-sm);
}
.dark .shadow-md {
--tw-shadow: var(--shadow-md);
}
.dark .shadow-lg {
--tw-shadow: var(--shadow-lg);
}
.dark .shadow-xl {
--tw-shadow: var(--shadow-xl);
}
.dark .shadow-card {
--tw-shadow: var(--shadow-card);
}
.dark .shadow-dropdown {
--tw-shadow: var(--shadow-dropdown);
}
.dark .shadow-sidebar {
--tw-shadow: var(--shadow-sidebar);
}
.dark .shadow-waitlist {
--tw-shadow: var(--shadow-waitlist);
}
/* Component documentation uses a quieter structural border than interactive
* controls. Scope it to container elements so buttons, inputs, and menu
* triggers retain their own border-state tokens. */
.component-detail :where(div, section, article, ul, ol, table, fieldset).border-border-button-default {
border-color: var(--color-border-component-detail-container);
}
/* Hide default expand arrow on Safari */
details summary::-webkit-details-marker {
display: none;
}
/* Hide default number-input spinners */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Hide default search clear button */
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
}
/* iOS Safari auto-zooms the page when a focused form field's font-size is
* under 16px — our text-body-regular/medium tokens are 14px by design, so
* bump just the font-size (not the whole token) on small screens instead of
* changing the design system's type scale. */
@media (max-width: 639px) {
input,
select,
textarea {
font-size: 16px;
}
}
/* Soft fade applied to an animated number each time its target changes */
@keyframes number-fade {
from {
opacity: 0.35;
}
to {
opacity: 1;
}
}
.animate-number-fade {
animation: number-fade 220ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.animate-number-fade {
animation: none !important;
}
}
/* Recharts' accessibility layer makes the chart surface and its shapes
* (bars, sectors, dots) keyboard-focusable, and Chrome paints the UA focus
* outline around whichever one you click - a blue box around a ring segment
* or a bar. Every chart card drives its own focus feedback (hover darkening,
* headline swap), so the outline is noise: hide it across every Recharts
* chart. Keyboard users still get the cards' own active states. */
.recharts-wrapper:focus,
.recharts-wrapper:focus-visible,
.recharts-wrapper :focus,
.recharts-wrapper :focus-visible {
outline: none;
}
/* Landing hero widget marquee. The track holds TWO copies of the card set,
* so translating exactly -50% and looping restarts on identical pixels —
* the seam is invisible as long as every card carries the same 20px
* right-margin. `translate`, not `transform`, per the codebase convention
* (Tailwind v4 utilities write the `translate` property). */
@keyframes landing-marquee {
to {
translate: -50% 0;
}
}
/* 72s, not 60s: the track grew from 5 cards to 6, and the duration covers
* the whole -50% sweep — holding it constant would have sped the row up. */
.animate-landing-marquee {
animation: landing-marquee 72s linear infinite;
}
@keyframes landing-file-drop {
0% {
opacity: 0;
transform: translate3d(-92px, 64px, 0) rotate(-10deg) scale(0.76);
}
12% {
opacity: 1;
transform: translate3d(-76px, 52px, 0) rotate(-7deg) scale(0.9);
}
88% {
opacity: 1;
transform: translate3d(52px, -38px, 0) rotate(6deg) scale(0.82);
}
100% {
opacity: 0;
transform: translate3d(68px, -50px, 0) rotate(8deg) scale(0.1);
}
}
.animate-landing-file-drop {
backface-visibility: hidden;
transform-origin: center;
will-change: transform, opacity;
animation: landing-file-drop 1300ms linear both;
}
/* Block cursor on the landing install line. `steps(1)` — a terminal caret
* snaps between on and off, it never fades. */
@keyframes terminal-caret {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.animate-terminal-caret {
animation: terminal-caret 1060ms steps(1, end) infinite;
}
/* Phosphor bloom around the caret: a tight halo all round plus a wider pool
* cast below it, so the glow pools under the line the way a lit cursor does
* on a CRT. Sized in em so it scales with the install line's 12px → 18px
* responsive step. */
.landing-terminal-caret {
box-shadow:
0 0 0.3em color-mix(in srgb, var(--color-purple-500) 60%, transparent),
0 0.22em 0.75em color-mix(in srgb, var(--color-purple-500) 55%, transparent),
0 0.4em 1.1em color-mix(in srgb, var(--color-purple-500) 30%, transparent);
}
/* Liquid glass (landing nav + hero) — the rim light.
*
* A fixed conic gradient standing in for a bevel lit from a single direction:
* bright along the top edge where the light source is, dark down both sides
* where the glass turns away, and a weaker bounce along the bottom. It does
* not rotate — a travelling highlight reads as an animation playing on the
* control, where a real edge just sits there catching the room.
*
* Achromatic on purpose: highlights are white, shading is black, nothing in
* between carries a hue.
*
* The two-layer mask (padding-box XOR full box) keeps only the 1.5px ring,
* letting the gradient paint the stroke without covering the glass. */
/* Pro badge sheen. Rotating the conic layer reads as light travelling around
* brushed metal; the badge clips it, so only the sweep is visible. */
@keyframes bui-metal-sweep {
to {
transform: rotate(360deg);
}
}
.bui-metal-sweep {
animation: bui-metal-sweep 9s linear infinite;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.bui-metal-sweep {
animation: none;
}
}
/* Discount-badge glitter: tiny four-point stars around the pill, each fading
* in and out on its own clock. Peak opacity stays low so it reads as a
* shimmer, not confetti; per-star size/timing come in via custom properties. */
@keyframes bui-glitter {
0%,
100% {
opacity: 0;
transform: scale(0.2) rotate(-20deg);
}
50% {
opacity: var(--glitter-peak, 0.6);
transform: scale(1) rotate(15deg);
}
}
.bui-glitter {
animation: bui-glitter var(--glitter-duration, 3s) ease-in-out var(--glitter-delay, 0s)
infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-glitter {
animation: none;
opacity: 0;
}
}
/* Discount-badge shimmer: the band starts a full badge-width left of the pill
* and crosses it in the first 40% of the cycle, then idles — one pass every
* 2.5s. Eased per-segment, so the sweep itself accelerates and settles. */
@keyframes bui-badge-shimmer {
0% {
transform: translateX(0) skewX(-12deg);
}
40%,
100% {
transform: translateX(350%) skewX(-12deg);
}
}
.bui-badge-shimmer {
animation: bui-badge-shimmer 2.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-badge-shimmer {
animation: none;
opacity: 0;
}
}
.landing-glass-stroke {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
/* Every value here is set inline by `LiquidGlassSurface` from the shared
* glass config; the fallbacks are only for a bare use of the class. */
padding: var(--lg-rim-width, 1.5px);
background: conic-gradient(
from var(--lg-rim-angle, 0deg),
var(--lg-rim-hi, rgb(255 255 255 / 0.9)) 0deg,
var(--lg-rim-sh, rgb(0 0 0 / 0.14)) 70deg,
var(--lg-rim-dim, rgb(255 255 255 / 0.1)) 140deg,
var(--lg-rim-bounce, rgb(255 255 255 / 0.55)) 180deg,
var(--lg-rim-dim, rgb(255 255 255 / 0.1)) 220deg,
var(--lg-rim-sh, rgb(0 0 0 / 0.14)) 290deg,
var(--lg-rim-hi, rgb(255 255 255 / 0.9)) 360deg
);
/* Shorthands first, composites last: `-webkit-mask` aliases `mask` in
* Chromium, so a shorthand placed after a composite silently resets it to
* `add` — and an un-composited mask paints the full conic wash over the
* button instead of a ring. */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: var(--lg-rim-o, 1);
transition: opacity var(--lg-ms, 200ms) ease;
}
/* Liquid glass hover.
*
* Resting and hover values arrive together as inline custom properties, and
* the swap happens here in CSS rather than in React state — hover then costs
* a style recalculation instead of a re-render of everything inside the glass.
* `.lg-hover` is the opt-in: a surface without it keeps its resting value. */
.lg-tint {
background-color: var(--lg-tint, transparent);
transition: background-color var(--lg-ms, 200ms) ease;
}
.lg-sheen {
opacity: var(--lg-sheen, 0.5);
transition: opacity var(--lg-ms, 200ms) ease;
}
.lg-host {
box-shadow: var(--lg-shadow, none);
transition:
box-shadow var(--lg-ms, 200ms) ease,
transform var(--lg-ms, 200ms) ease;
}
.lg-host:hover {
box-shadow: var(--lg-shadow-hover, var(--lg-shadow, none));
transform: translateY(var(--lg-lift, 0px)) scale(var(--lg-scale, 1));
}
.group:hover .lg-tint.lg-hover {
background-color: var(--lg-tint-hover, var(--lg-tint, transparent));
}
.group:hover .lg-sheen.lg-hover {
opacity: var(--lg-sheen-hover, var(--lg-sheen, 0.5));
}
.group:hover .landing-glass-stroke.lg-hover {
opacity: var(--lg-rim-o-hover, var(--lg-rim-o, 1));
}
@media (prefers-reduced-motion: reduce) {
.lg-tint,
.lg-sheen,
.lg-host,
.landing-glass-stroke {
transition: none;
}
.lg-host:hover {
transform: none;
}
}
.landing-showcase-card {
transition: box-shadow 280ms ease;
}
.landing-showcase-card::after {
content: "";
position: absolute;
inset: 0;
z-index: 60;
pointer-events: none;
border: 1px solid var(--color-border-button-default);
border-radius: inherit;
transition: border-color 280ms ease, box-shadow 280ms ease;
}
.dark .landing-showcase-card::after {
border-color: rgb(255 255 255 / 0.08);
}
@media (min-width: 640px) and (hover: hover) {
.landing-showcase-card:hover::after,
.landing-showcase-card:focus-within::after {
border-color: var(--color-border-button-hover);
box-shadow: inset 0 0 0 2px var(--color-border-button-hover);
}
}
.landing-hero-dither-character {
backface-visibility: hidden;
will-change: mask-image, mask-position, opacity, transform, color;
}
@media (prefers-reduced-motion: reduce) {
.animate-landing-marquee {
animation: none;
}
/* Keep the caret, drop the blink. */
.animate-terminal-caret {
animation: none;
}
}
/* Split-flap ("Solari" board) digit, used by the image-generation countdown.
* Two leaves hinged on the card's centre seam:
* flap-fall the outgoing digit's TOP half swings down over the incoming
* bottom half. Eased IN, because a real flap is falling under
* gravity — it accelerates — and it darkens as it tips away
* from the light.
* flap-rise the incoming digit's BOTTOM half swings up from edge-on,
* starting exactly when the fall lands (delay = fall duration,
* `both` so it waits at 90deg instead of flashing flat). The
* small overshoot past 0deg is the slap against the stop —
* that bounce is what makes it read mechanical rather than
* like a plain rotation. */
@keyframes flap-fall {
from {
transform: rotateX(0deg);
filter: brightness(1);
}
to {
transform: rotateX(-90deg);
filter: brightness(0.86);
}
}
@keyframes flap-rise {
0% {
transform: rotateX(90deg);
filter: brightness(0.86);
}
70% {
transform: rotateX(-9deg);
}
86% {
transform: rotateX(4deg);
}
100% {
transform: rotateX(0deg);
filter: brightness(1);
}
}
.animate-flap-fall {
transform-origin: bottom center;
backface-visibility: hidden;
animation: flap-fall 150ms cubic-bezier(0.55, 0, 0.9, 0.35) forwards;
}
.animate-flap-rise {
transform-origin: top center;
backface-visibility: hidden;
animation: flap-rise 230ms cubic-bezier(0.25, 0.7, 0.35, 1) 150ms both;
}
@media (prefers-reduced-motion: reduce) {
.animate-flap-fall,
.animate-flap-rise {
animation-duration: 1ms;
animation-delay: 0ms;
}
}
/* New-row entrance for the Storage settings table. Three coordinated
* pieces (wrapper pairs with `grid grid-rows-[1fr]` + an overflow-hidden
* middle layer, content carries the slide):
* row-grow-in wrapper's grid track 0fr → 1fr — pushes the rows below
* down smoothly.
* row-slide-in content translates -100% → 0 with the SAME duration and
* easing, so it stays glued to the growing wrapper's bottom
* edge — visually the row slides out from underneath the
* header row above it.
* row-fade-in opacity/blur on a SHORTER clock than the movement — the
* row is fully visible before it finishes settling (fades
* must complete before movement ends). */
@keyframes row-grow-in {
from {
grid-template-rows: 0fr;
}
to {
grid-template-rows: 1fr;
}
}
@keyframes row-slide-in {
from {
translate: 0 -100%;
}
}
@keyframes row-fade-in {
from {
opacity: 0;
filter: blur(2px);
}
}
.animate-row-grow-in {
animation: row-grow-in 400ms ease-out;
}
.animate-row-slide-in {
animation:
row-slide-in 400ms ease-out,
row-fade-in 240ms ease-out;
}
/* Reverse of the Storage row entrance. The content rises into the clipped
* top edge while its grid track collapses, so the following rows move upward
* continuously and the deleted row disappears beneath the header surface. */
@keyframes row-collapse-out {
from {
grid-template-rows: 1fr;
}
to {
grid-template-rows: 0fr;
}
}
@keyframes row-slide-out {
to {
translate: 0 -100%;
}
}
@keyframes row-fade-out {
to {
opacity: 0;
filter: blur(2px);
}
}
.animate-row-collapse-out {
animation: row-collapse-out 225ms ease-in forwards;
}
.animate-row-slide-out {
animation:
row-slide-out 225ms ease-in forwards,
row-fade-out 160ms ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-row-grow-in,
.animate-row-slide-in,
.animate-row-collapse-out,
.animate-row-slide-out {
animation-duration: 1ms;
}
}
/* Transitions.dev — "Texts reveal", used by the Storage dropzone's content
* swaps (idle copy ↔ file name + status line).
*
* Entrance (`.is-shown` on the parent): lines rise 12px from below while
* un-blurring, 500ms on a soft-landing bezier, staggered 40ms per line so
* the eye lands on the first line first.
* Exit (`.is-hiding`): a single quiet 200ms opacity fade in place — no Y
* return, no blur, no stagger — so the disappearance never reads as a
* reverse reveal.
*
* Deviations from the original snippet: custom props are scoped to
* `.t-stagger` instead of :root, and `.t-stagger-line` doesn't force
* `display: block` so flex-based lines (the icon disc) keep their layout. */
.t-stagger {
--stagger-dur: 500ms;
--stagger-distance: 12px;
--stagger-stagger: 40ms;
--stagger-blur: 3px;
--stagger-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.t-stagger-line {
opacity: 0;
transform: translateY(var(--stagger-distance));
filter: blur(var(--stagger-blur));
transition:
opacity var(--stagger-dur) var(--stagger-ease),
transform var(--stagger-dur) var(--stagger-ease),
filter var(--stagger-dur) var(--stagger-ease);
will-change: transform, opacity, filter;
}
.t-stagger-line--2 {
transition-delay: var(--stagger-stagger);
}
.t-stagger-line--3 {
transition-delay: calc(var(--stagger-stagger) * 2);
}
.t-stagger.is-shown .t-stagger-line {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.t-stagger.is-hiding .t-stagger-line {
opacity: 0;
transform: translateY(0);
filter: blur(0);
transition:
opacity 200ms ease,
transform 0s linear,
filter 0s linear;
transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) {
.t-stagger-line {
transition: none !important;
}
}
/* Rolling-counter label swap: the old value lifts 5px and fades out while the
* new one rises in from 5px below and fades in. */
@keyframes label-out {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-10px);
opacity: 0;
}
}
@keyframes label-in {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-label-out {
animation: label-out 220ms ease-out forwards;
}
.animate-label-in {
animation: label-in 220ms ease-out;
}
/* Checkbox tick: draws the stroke on like it's being ticked by hand.
* The path sets pathLength=1 so the dash math is resolution-independent. */
@keyframes check-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
.animate-check-draw {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: check-draw 200ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-check-draw {
animation: none;
stroke-dashoffset: 0;
}
}
/* ---------------------------------------------------------------------------
* Chart entrance animations (AI profile template & contributions heatmap)
* ------------------------------------------------------------------------- */
/* Heatmap cells pop in softly — each cell gets a random-looking per-cell
* animation-delay so the grid fills in a scattered order. */
.contributions-grid {
--contribution-tier-0: var(--color-chart-neutral);
}
.contributions-grid[data-accent="emerald"] {
--contribution-tier-1: var(--color-emerald-200); --contribution-tier-2: var(--color-emerald-400); --contribution-tier-3: var(--color-emerald-500); --contribution-tier-4: var(--color-emerald-600); --contribution-tier-5: var(--color-emerald-700);
}
.contributions-grid[data-accent="green"] {
--contribution-tier-1: var(--color-green-200); --contribution-tier-2: var(--color-green-400); --contribution-tier-3: var(--color-green-500); --contribution-tier-4: var(--color-green-600); --contribution-tier-5: var(--color-green-700);
}
.contributions-grid[data-accent="teal"] {
--contribution-tier-1: var(--color-teal-200); --contribution-tier-2: var(--color-teal-400); --contribution-tier-3: var(--color-teal-500); --contribution-tier-4: var(--color-teal-600); --contribution-tier-5: var(--color-teal-700);
}
.contributions-grid[data-accent="cyan"] {
--contribution-tier-1: var(--color-cyan-200); --contribution-tier-2: var(--color-cyan-400); --contribution-tier-3: var(--color-cyan-500); --contribution-tier-4: var(--color-cyan-600); --contribution-tier-5: var(--color-cyan-700);
}
.contributions-grid[data-accent="blue"] {
--contribution-tier-1: var(--color-blue-200); --contribution-tier-2: var(--color-blue-400); --contribution-tier-3: var(--color-blue-500); --contribution-tier-4: var(--color-blue-600); --contribution-tier-5: var(--color-blue-700);
}
.contributions-grid[data-accent="indigo"] {
--contribution-tier-1: var(--color-indigo-200); --contribution-tier-2: var(--color-indigo-400); --contribution-tier-3: var(--color-indigo-500); --contribution-tier-4: var(--color-indigo-600); --contribution-tier-5: var(--color-indigo-700);
}
.contributions-grid[data-accent="violet"] {
--contribution-tier-1: var(--color-violet-200); --contribution-tier-2: var(--color-violet-400); --contribution-tier-3: var(--color-violet-500); --contribution-tier-4: var(--color-violet-600); --contribution-tier-5: var(--color-violet-700);
}
.contributions-grid[data-accent="rose"] {
--contribution-tier-1: var(--color-rose-200); --contribution-tier-2: var(--color-rose-400); --contribution-tier-3: var(--color-rose-500); --contribution-tier-4: var(--color-rose-600); --contribution-tier-5: var(--color-rose-700);
}
.contributions-grid[data-accent="amber"] {
--contribution-tier-1: var(--color-amber-200); --contribution-tier-2: var(--color-amber-400); --contribution-tier-3: var(--color-amber-500); --contribution-tier-4: var(--color-amber-600); --contribution-tier-5: var(--color-amber-700);
}
.dark .contributions-grid[data-accent="emerald"] {
--contribution-tier-1: var(--color-emerald-950); --contribution-tier-2: var(--color-emerald-800); --contribution-tier-3: var(--color-emerald-700); --contribution-tier-4: var(--color-emerald-600); --contribution-tier-5: var(--color-emerald-500);
}
.dark .contributions-grid[data-accent="green"] {
--contribution-tier-1: var(--color-green-950); --contribution-tier-2: var(--color-green-800); --contribution-tier-3: var(--color-green-700); --contribution-tier-4: var(--color-green-600); --contribution-tier-5: var(--color-green-500);
}
.dark .contributions-grid[data-accent="teal"] {
--contribution-tier-1: var(--color-teal-950); --contribution-tier-2: var(--color-teal-800); --contribution-tier-3: var(--color-teal-700); --contribution-tier-4: var(--color-teal-600); --contribution-tier-5: var(--color-teal-500);
}
.dark .contributions-grid[data-accent="cyan"] {
--contribution-tier-1: var(--color-cyan-950); --contribution-tier-2: var(--color-cyan-800); --contribution-tier-3: var(--color-cyan-700); --contribution-tier-4: var(--color-cyan-600); --contribution-tier-5: var(--color-cyan-500);
}
.dark .contributions-grid[data-accent="blue"] {
--contribution-tier-1: var(--color-blue-950); --contribution-tier-2: var(--color-blue-800); --contribution-tier-3: var(--color-blue-700); --contribution-tier-4: var(--color-blue-600); --contribution-tier-5: var(--color-blue-500);
}
.dark .contributions-grid[data-accent="indigo"] {
--contribution-tier-1: var(--color-indigo-950); --contribution-tier-2: var(--color-indigo-800); --contribution-tier-3: var(--color-indigo-700); --contribution-tier-4: var(--color-indigo-600); --contribution-tier-5: var(--color-indigo-500);
}
.dark .contributions-grid[data-accent="violet"] {
--contribution-tier-1: var(--color-violet-950); --contribution-tier-2: var(--color-violet-800); --contribution-tier-3: var(--color-violet-700); --contribution-tier-4: var(--color-violet-600); --contribution-tier-5: var(--color-violet-500);
}
.dark .contributions-grid[data-accent="rose"] {
--contribution-tier-1: var(--color-rose-950); --contribution-tier-2: var(--color-rose-800); --contribution-tier-3: var(--color-rose-700); --contribution-tier-4: var(--color-rose-600); --contribution-tier-5: var(--color-rose-500);
}
.dark .contributions-grid[data-accent="amber"] {
--contribution-tier-1: var(--color-amber-950); --contribution-tier-2: var(--color-amber-800); --contribution-tier-3: var(--color-amber-700); --contribution-tier-4: var(--color-amber-600); --contribution-tier-5: var(--color-amber-500);
}
.contribution-cell[data-tier="0"] { background-color: var(--contribution-tier-0); }
.contribution-cell[data-tier="1"] { background-color: var(--contribution-tier-1); }
.contribution-cell[data-tier="2"] { background-color: var(--contribution-tier-2); }
.contribution-cell[data-tier="3"] { background-color: var(--contribution-tier-3); }
.contribution-cell[data-tier="4"] { background-color: var(--contribution-tier-4); }
.contribution-cell[data-tier="5"] { background-color: var(--contribution-tier-5); }
@keyframes cell-pop {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-cell-pop {
animation: cell-pop 380ms ease-out both;
}
/* Bars grow up from the baseline; a per-bar delay staggers them left→right. */
@keyframes bar-rise {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.animate-bar-rise {
transform-origin: bottom;
animation: bar-rise 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Line chart draws in left→right via a clip-path sweep over the whole plot
* (line + gradient area together). */
@keyframes chart-reveal {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
.animate-chart-reveal {
animation: chart-reveal 1800ms cubic-bezier(0.33, 0, 0.15, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.animate-cell-pop,
.animate-bar-rise,
.animate-chart-reveal {
animation: none;
}
}
/* ---------------------------------------------------------------------------
* Table primitive (components/base/table)
*
* React Aria's collection components (Column/Row/Cell/…) can't be wrapped in
* custom components without losing collection behaviour, so the primitive
* re-exports them untouched and styling lives here, scoped to `.bui-table`.
* React Aria renders real <table>/<thead>/<th>/<tr>/<td> elements.
* --------------------------------------------------------------------------- */
.bui-table {
@apply w-full border-collapse text-left;
}
/* Header background + border-y live on the cells: with border-collapse the
* browser doesn't paint backgrounds/borders set on the <thead> row group.
* Backgrounds/borders are set with explicit CSS (theme vars) rather than
* @apply, which drops border-width/bg on these row-group cells. */
.bui-table th {
@apply px-3 py-2.5 text-left align-middle text-body-medium text-text-tertiary whitespace-nowrap outline-none;
background-color: var(--color-background-secondary-default);
border-top: 1px solid var(--color-separator-border);
border-bottom: 1px solid var(--color-separator-border);
transition: padding 200ms ease, font-size 200ms ease;
}
.bui-table td {
@apply px-3 py-2.5 align-middle text-body-medium text-text-primary outline-none;
transition: padding 200ms ease, font-size 200ms ease;
}
.bui-table tbody tr {
@apply transition-colors duration-150;
border-bottom: 1px solid var(--color-separator-border);
}
.bui-table tbody tr[data-focus-visible] {
@apply ring-2 ring-inset ring-border-focus-ring;
}
.bui-table.bui-table-sm th,
.bui-table.bui-table-sm td {
@apply px-2.5 py-1.5 text-body-2-medium;
}
/* Beam travelling the FAQ card's border.
*
* A conic gradient rotated by an `@property`-typed angle, clipped to a thin
* ring by the mask-xor trick the glass stroke uses (see `.landing-glass-stroke`
* for why the shorthand has to precede the composite). The glow is the same
* ring, wider and blurred, underneath.
*
* Both layers are masked. An unmasked glow paints the whole conic wash across
* the card's interior, which reads as a coloured blob in the middle rather
* than light on the edge.
*
* The angle is a registered property so it can be animated at all: a plain
* custom property is an unanimatable string, and the gradient would jump
* between keyframes instead of sweeping. */
@property --bui-beam-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes bui-beam-travel {
to {
--bui-beam-angle: 360deg;
}
}
.bui-beam,
.bui-beam-glow,
.bui-beam-aura {
pointer-events: none;
position: absolute;
/* -1px, not 0: `inset: 0` lands on the padding box, which puts the ring
* just inside the card's own border instead of on top of it. */
inset: -1px;
/* Not `inherit`. Each layer sits further out than the card, and a box that
* is bigger with the same radius has a *tighter* corner — the ring then cuts
* across the card's own corner and leaves a notch in the border. The radius
* has to grow by exactly the outset. The host sets --bui-beam-radius to its
* own corner. */
border-radius: calc(var(--bui-beam-radius, 26px) + 1px);
padding: 1.5px;
/* The sweep runs to 360deg, so the stop at 360 is the beam's leading tip and
* the ramp behind it is the tail. Brightest at the tip: it is the front of
* the travelling light, and the blurred copy underneath turns that peak into
* the glow. Anything dimmer at 360 puts the highlight mid-beam instead. */
background: conic-gradient(
from var(--bui-beam-angle),
transparent 0deg,
transparent 272deg,
rgb(104 136 251 / 0.22) 305deg,
rgb(104 136 251 / 0.8) 340deg,
rgb(150 176 255 / 0.95) 353deg,
rgb(219 230 255 / 1) 360deg
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
/* Two laps, then it stays gone: `forwards` holds the final keyframe so the
* gradient does not snap back to its start as the fade-out runs. The host
* mounts these only once the card is on screen. */
animation: bui-beam-travel 4.5s linear 2 forwards;
transition: opacity 700ms ease;
}
/* The same ring, blurred. Identical inset and padding to the crisp one on
* purpose: a wider band here drew a second, softer line beside the first and
* the border read as double. Same geometry, blurred, is one line with a glow —
* strongest at the tip, where the gradient peaks. */
.bui-beam-glow {
filter: blur(4px);
opacity: 0.85;
}
/* The aura the head carries with it.
*
* Its own gradient starts far later than the beam's, so the light gathers
* around the leading tip instead of tracing the whole tail — that is what
* makes it read as a glow travelling with the beam rather than a second,
* fatter border. Wide band, heavy blur, low opacity: enough to spill a little
* past the stroke, not enough to become an object of its own. The card must
* leave its overflow visible or this is the layer that gets cut off. */
.bui-beam-aura {
inset: -4px;
border-radius: calc(var(--bui-beam-radius, 26px) + 4px);
padding: 4px;
background: conic-gradient(
from var(--bui-beam-angle),
transparent 0deg,
transparent 322deg,
rgb(104 136 251 / 0.4) 348deg,
rgb(150 176 255 / 0.75) 360deg
);
filter: blur(8px);
opacity: 0.5;
}
/* Set by the host once both laps are done. */
.bui-beam-spent {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.bui-beam,
.bui-beam-glow,
.bui-beam-aura {
animation: none;
opacity: 0;
}
}
/* Agent Thinking (components/blocks/agent-thinking): the agent thinking state
* above a chat composer. The label reuses the ai-chat-text-shimmer travel;
* its base/highlight both derive from the tone the component sets via
* --bui-agent-thinking-tone, so every tone shimmers consistently. */
.bui-agent-thinking-label {
color: transparent;
background-image: linear-gradient(
100deg,
color-mix(in oklab, var(--bui-agent-thinking-tone) 55%, transparent) 30%,
var(--bui-agent-thinking-tone) 50%,
color-mix(in oklab, var(--bui-agent-thinking-tone) 55%, transparent) 70%
);
background-position: 200% center;
background-size: 300% 100%;
background-clip: text;
-webkit-background-clip: text;
animation: ai-chat-text-shimmer 2.6s linear infinite;
will-change: background-position;
}
/* Star sparkle: scale + fade in, settle, and vanish; per-star duration and
* stagger come in via inline animation-duration/-delay. */
@keyframes bui-agent-thinking-star {
0%,
100% {
transform: scale(0);
opacity: 0;
}
40% {
transform: scale(1);
opacity: 1;
}
60% {
transform: scale(0.8);
opacity: 0.9;
}
}
.bui-agent-thinking-star {
transform: scale(0);
animation-name: bui-agent-thinking-star;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
/* Infinity comet: the dash segment laps the normalized (pathLength=100)
* figure-eight once per animation-duration. */
@keyframes bui-agent-thinking-dash {
to {
stroke-dashoffset: -100;
}
}
.bui-agent-thinking-comet {
animation-name: bui-agent-thinking-dash;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-agent-thinking-label {
color: var(--bui-agent-thinking-tone);
background-image: none;
animation: none;
}
/* Stars rest visible instead of frozen at scale(0). */
.bui-agent-thinking-star {
animation: none;
transform: none;
opacity: 0.7;
}
.bui-agent-thinking-comet {
animation: none;
}
}
/* Star glyphs read a touch heavier than the dot grids on light backgrounds;
* soften only them (not the label) there. Dark mode keeps the full tone. */
.bui-agent-thinking-stars {
color: color-mix(in oklab, currentColor 75%, transparent);
}
.dark .bui-agent-thinking-stars {
color: inherit;
}
/* Composer Loader (components/blocks/composer-loader): the light band
* orbiting the composer while the agent works. Dash segments on a
* pathLength-normalized SVG stroke — the offset laps once per duration. */
@keyframes bui-composer-loader-dash {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -100;
}
}
@media (prefers-reduced-motion: reduce) {
.bui-composer-loader-rect {
animation: none !important;
}
}
/* Composer mic listening state (components/blocks/composer): equalizer
* bars dancing while voice input is active. Per-bar duration/delay come in
* inline so the bars drift out of phase like real audio. */
@keyframes bui-composer-mic-bars {
0%,
100% {
transform: scaleY(0.4);
}
50% {
transform: scaleY(1);
}
}
.bui-composer-mic-bar {
animation: bui-composer-mic-bars 0.9s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-composer-mic-bar {
animation: none;
transform: scaleY(0.7);
}
}
/* Composer effort slider (Ultra): the rail is under load. A one-shot kick
* then a tiny ongoing tremor, while a purple fire jet trails left off the
* thumb (see EffortFlame in composer.tsx). Reduced-motion keeps a static
* taper instead of the shader. */
@keyframes bui-effort-ultra-kick {
0%,
100% {
transform: translate3d(0, 0, 0);
}
18% {
transform: translate3d(-1.6px, 0.8px, 0);
}
38% {
transform: translate3d(1.4px, -0.7px, 0);
}
58% {
transform: translate3d(-1.1px, -0.9px, 0);
}
76% {
transform: translate3d(0.9px, 0.5px, 0);
}
}
@keyframes bui-effort-ultra-tremor {
0%,
100% {
transform: translate3d(0, 0, 0);
}
25% {
transform: translate3d(-0.9px, 0.55px, 0);
}
50% {
transform: translate3d(0.8px, -0.45px, 0);
}
75% {
transform: translate3d(-0.65px, -0.6px, 0);
}
}
.bui-effort-ultra {
animation:
bui-effort-ultra-kick 420ms ease-out 1,
bui-effort-ultra-tremor 180ms linear 420ms infinite;
will-change: transform;
}
.bui-effort-ultra-rail {
background: color-mix(in oklab, var(--color-background-secondary-default) 70%, #160814);
}
.bui-effort-ultra-flame-static {
pointer-events: none;
position: absolute;
inset: 0;
background: linear-gradient(
270deg,
rgb(255 242 255 / 0.88) 0%,
rgb(196 92 255 / 0.72) 16%,
rgb(124 42 186 / 0.4) 46%,
transparent 78%
);
mask-image: linear-gradient(
to bottom,
transparent 6%,
black 32%,
black 68%,
transparent 94%
);
}
@media (prefers-reduced-motion: reduce) {
.bui-effort-ultra {
animation: none;
will-change: auto;
}
}@import "tailwindcss";
@import "./theme.css";
@import "./typography.css";
/* ---------------------------------------------------------------------------
* Dark mode strategy
*
* We use a class-based dark variant. Toggle dark mode by adding the `.dark`
* class to <html> (a theme provider like `next-themes` can do this later).
*
* <html class="dark"> → every `dark:*` utility activates
*
* Components should NOT use `dark:` prefixes directly. Instead they reference
* semantic color tokens (e.g. `bg-bg-primary`, `text-text-primary`) that flip
* under `.dark` in styles/theme.css.
* --------------------------------------------------------------------------- */
@custom-variant dark (&:where(.dark, .dark *));
/* The next theme is revealed by ThemeToggle from the exact interaction point.
* Native view-transition crossfades are disabled so only the soft circular
* clip-path animation runs; unsupported browsers simply ignore these rules. */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
@keyframes ai-chat-text-shimmer {
from {
background-position: 200% center;
}
to {
background-position: -100% center;
}
}
.agent-progress-loading-text {
color: transparent;
background-image: linear-gradient(
100deg,
var(--color-text-secondary) 16%,
var(--color-text-secondary) 38%,
var(--color-text-primary) 50%,
var(--color-text-secondary) 62%,
var(--color-text-secondary) 84%
);
background-position: 200% center;
background-size: 300% 100%;
background-clip: text;
-webkit-background-clip: text;
animation: ai-chat-text-shimmer 3.4s linear infinite;
will-change: background-position;
}
@media (prefers-reduced-motion: reduce) {
.agent-progress-loading-text {
color: var(--color-text-secondary);
background-image: none;
animation: none;
}
}
@keyframes image-generation-pulse {
0%,
100% {
opacity: 0.16;
scale: 0.62;
}
50% {
opacity: 0.95;
scale: 1.18;
}
}
.image-generation-pulse-dot {
animation: image-generation-pulse 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.image-generation-pulse-dot {
animation: none;
opacity: 0.58;
scale: 1;
}
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
/* Components often have their own hover/focus color transitions. Freeze them
* while the browser captures the next theme so the expanding circle contains
* the fully resolved new palette instead of a frame that is still fading from
* the old one. This is what makes the click-origin reveal read clearly. */
.theme-transitioning,
.theme-transitioning * {
transition: none !important;
}
/* Reset + base */
html,
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-variant-ligatures: contextual;
font-kerning: normal;
}
html {
color-scheme: light;
background-color: var(--color-background-full);
}
html.dark {
color-scheme: dark;
}
body {
background-color: var(--color-background-full);
color: var(--color-text-primary);
}
/* Theme-aware monochrome BoardCN mark. Both images keep their intrinsic
* dimensions while the root theme class decides which one participates in
* layout, avoiding a light-logo flash during hydration. */
.theme-logo-dark,
.theme-asset-dark {
display: none;
}
.dark .theme-logo-light,
.dark .theme-asset-light {
display: none;
}
.dark .theme-logo-dark,
.dark .theme-asset-dark {
display: block;
}
/* TanStack ships here as a transparent black raster mark. Normalizing the
* visible pixels to black before inversion keeps its antialiased alpha edges
* intact while producing a clean white logo on dark surfaces. */
.tanstack-logo {
filter: none;
}
.dark .tanstack-logo {
filter: brightness(0) invert(1);
}
/* Tailwind expands shadow utilities into `--tw-shadow` at build time. Point
* those generated utilities back to the live dark elevation tokens so the
* class-based theme can adjust opacity without component-level overrides. */
.dark .shadow-2xs {
--tw-shadow: var(--shadow-2xs);
}
.dark .shadow-xs {
--tw-shadow: var(--shadow-xs);
}
.dark .shadow-sm {
--tw-shadow: var(--shadow-sm);
}
.dark .shadow-md {
--tw-shadow: var(--shadow-md);
}
.dark .shadow-lg {
--tw-shadow: var(--shadow-lg);
}
.dark .shadow-xl {
--tw-shadow: var(--shadow-xl);
}
.dark .shadow-card {
--tw-shadow: var(--shadow-card);
}
.dark .shadow-dropdown {
--tw-shadow: var(--shadow-dropdown);
}
.dark .shadow-sidebar {
--tw-shadow: var(--shadow-sidebar);
}
.dark .shadow-waitlist {
--tw-shadow: var(--shadow-waitlist);
}
/* Component documentation uses a quieter structural border than interactive
* controls. Scope it to container elements so buttons, inputs, and menu
* triggers retain their own border-state tokens. */
.component-detail :where(div, section, article, ul, ol, table, fieldset).border-border-button-default {
border-color: var(--color-border-component-detail-container);
}
/* Hide default expand arrow on Safari */
details summary::-webkit-details-marker {
display: none;
}
/* Hide default number-input spinners */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Hide default search clear button */
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
}
/* iOS Safari auto-zooms the page when a focused form field's font-size is
* under 16px — our text-body-regular/medium tokens are 14px by design, so
* bump just the font-size (not the whole token) on small screens instead of
* changing the design system's type scale. */
@media (max-width: 639px) {
input,
select,
textarea {
font-size: 16px;
}
}
/* Soft fade applied to an animated number each time its target changes */
@keyframes number-fade {
from {
opacity: 0.35;
}
to {
opacity: 1;
}
}
.animate-number-fade {
animation: number-fade 220ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.animate-number-fade {
animation: none !important;
}
}
/* Recharts' accessibility layer makes the chart surface and its shapes
* (bars, sectors, dots) keyboard-focusable, and Chrome paints the UA focus
* outline around whichever one you click - a blue box around a ring segment
* or a bar. Every chart card drives its own focus feedback (hover darkening,
* headline swap), so the outline is noise: hide it across every Recharts
* chart. Keyboard users still get the cards' own active states. */
.recharts-wrapper:focus,
.recharts-wrapper:focus-visible,
.recharts-wrapper :focus,
.recharts-wrapper :focus-visible {
outline: none;
}
/* Landing hero widget marquee. The track holds TWO copies of the card set,
* so translating exactly -50% and looping restarts on identical pixels —
* the seam is invisible as long as every card carries the same 20px
* right-margin. `translate`, not `transform`, per the codebase convention
* (Tailwind v4 utilities write the `translate` property). */
@keyframes landing-marquee {
to {
translate: -50% 0;
}
}
/* 72s, not 60s: the track grew from 5 cards to 6, and the duration covers
* the whole -50% sweep — holding it constant would have sped the row up. */
.animate-landing-marquee {
animation: landing-marquee 72s linear infinite;
}
@keyframes landing-file-drop {
0% {
opacity: 0;
transform: translate3d(-92px, 64px, 0) rotate(-10deg) scale(0.76);
}
12% {
opacity: 1;
transform: translate3d(-76px, 52px, 0) rotate(-7deg) scale(0.9);
}
88% {
opacity: 1;
transform: translate3d(52px, -38px, 0) rotate(6deg) scale(0.82);
}
100% {
opacity: 0;
transform: translate3d(68px, -50px, 0) rotate(8deg) scale(0.1);
}
}
.animate-landing-file-drop {
backface-visibility: hidden;
transform-origin: center;
will-change: transform, opacity;
animation: landing-file-drop 1300ms linear both;
}
/* Block cursor on the landing install line. `steps(1)` — a terminal caret
* snaps between on and off, it never fades. */
@keyframes terminal-caret {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.animate-terminal-caret {
animation: terminal-caret 1060ms steps(1, end) infinite;
}
/* Phosphor bloom around the caret: a tight halo all round plus a wider pool
* cast below it, so the glow pools under the line the way a lit cursor does
* on a CRT. Sized in em so it scales with the install line's 12px → 18px
* responsive step. */
.landing-terminal-caret {
box-shadow:
0 0 0.3em color-mix(in srgb, var(--color-purple-500) 60%, transparent),
0 0.22em 0.75em color-mix(in srgb, var(--color-purple-500) 55%, transparent),
0 0.4em 1.1em color-mix(in srgb, var(--color-purple-500) 30%, transparent);
}
/* Liquid glass (landing nav + hero) — the rim light.
*
* A fixed conic gradient standing in for a bevel lit from a single direction:
* bright along the top edge where the light source is, dark down both sides
* where the glass turns away, and a weaker bounce along the bottom. It does
* not rotate — a travelling highlight reads as an animation playing on the
* control, where a real edge just sits there catching the room.
*
* Achromatic on purpose: highlights are white, shading is black, nothing in
* between carries a hue.
*
* The two-layer mask (padding-box XOR full box) keeps only the 1.5px ring,
* letting the gradient paint the stroke without covering the glass. */
/* Pro badge sheen. Rotating the conic layer reads as light travelling around
* brushed metal; the badge clips it, so only the sweep is visible. */
@keyframes bui-metal-sweep {
to {
transform: rotate(360deg);
}
}
.bui-metal-sweep {
animation: bui-metal-sweep 9s linear infinite;
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.bui-metal-sweep {
animation: none;
}
}
/* Discount-badge glitter: tiny four-point stars around the pill, each fading
* in and out on its own clock. Peak opacity stays low so it reads as a
* shimmer, not confetti; per-star size/timing come in via custom properties. */
@keyframes bui-glitter {
0%,
100% {
opacity: 0;
transform: scale(0.2) rotate(-20deg);
}
50% {
opacity: var(--glitter-peak, 0.6);
transform: scale(1) rotate(15deg);
}
}
.bui-glitter {
animation: bui-glitter var(--glitter-duration, 3s) ease-in-out var(--glitter-delay, 0s)
infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-glitter {
animation: none;
opacity: 0;
}
}
/* Discount-badge shimmer: the band starts a full badge-width left of the pill
* and crosses it in the first 40% of the cycle, then idles — one pass every
* 2.5s. Eased per-segment, so the sweep itself accelerates and settles. */
@keyframes bui-badge-shimmer {
0% {
transform: translateX(0) skewX(-12deg);
}
40%,
100% {
transform: translateX(350%) skewX(-12deg);
}
}
.bui-badge-shimmer {
animation: bui-badge-shimmer 2.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-badge-shimmer {
animation: none;
opacity: 0;
}
}
.landing-glass-stroke {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: inherit;
/* Every value here is set inline by `LiquidGlassSurface` from the shared
* glass config; the fallbacks are only for a bare use of the class. */
padding: var(--lg-rim-width, 1.5px);
background: conic-gradient(
from var(--lg-rim-angle, 0deg),
var(--lg-rim-hi, rgb(255 255 255 / 0.9)) 0deg,
var(--lg-rim-sh, rgb(0 0 0 / 0.14)) 70deg,
var(--lg-rim-dim, rgb(255 255 255 / 0.1)) 140deg,
var(--lg-rim-bounce, rgb(255 255 255 / 0.55)) 180deg,
var(--lg-rim-dim, rgb(255 255 255 / 0.1)) 220deg,
var(--lg-rim-sh, rgb(0 0 0 / 0.14)) 290deg,
var(--lg-rim-hi, rgb(255 255 255 / 0.9)) 360deg
);
/* Shorthands first, composites last: `-webkit-mask` aliases `mask` in
* Chromium, so a shorthand placed after a composite silently resets it to
* `add` — and an un-composited mask paints the full conic wash over the
* button instead of a ring. */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: var(--lg-rim-o, 1);
transition: opacity var(--lg-ms, 200ms) ease;
}
/* Liquid glass hover.
*
* Resting and hover values arrive together as inline custom properties, and
* the swap happens here in CSS rather than in React state — hover then costs
* a style recalculation instead of a re-render of everything inside the glass.
* `.lg-hover` is the opt-in: a surface without it keeps its resting value. */
.lg-tint {
background-color: var(--lg-tint, transparent);
transition: background-color var(--lg-ms, 200ms) ease;
}
.lg-sheen {
opacity: var(--lg-sheen, 0.5);
transition: opacity var(--lg-ms, 200ms) ease;
}
.lg-host {
box-shadow: var(--lg-shadow, none);
transition:
box-shadow var(--lg-ms, 200ms) ease,
transform var(--lg-ms, 200ms) ease;
}
.lg-host:hover {
box-shadow: var(--lg-shadow-hover, var(--lg-shadow, none));
transform: translateY(var(--lg-lift, 0px)) scale(var(--lg-scale, 1));
}
.group:hover .lg-tint.lg-hover {
background-color: var(--lg-tint-hover, var(--lg-tint, transparent));
}
.group:hover .lg-sheen.lg-hover {
opacity: var(--lg-sheen-hover, var(--lg-sheen, 0.5));
}
.group:hover .landing-glass-stroke.lg-hover {
opacity: var(--lg-rim-o-hover, var(--lg-rim-o, 1));
}
@media (prefers-reduced-motion: reduce) {
.lg-tint,
.lg-sheen,
.lg-host,
.landing-glass-stroke {
transition: none;
}
.lg-host:hover {
transform: none;
}
}
.landing-showcase-card {
transition: box-shadow 280ms ease;
}
.landing-showcase-card::after {
content: "";
position: absolute;
inset: 0;
z-index: 60;
pointer-events: none;
border: 1px solid var(--color-border-button-default);
border-radius: inherit;
transition: border-color 280ms ease, box-shadow 280ms ease;
}
.dark .landing-showcase-card::after {
border-color: rgb(255 255 255 / 0.08);
}
@media (min-width: 640px) and (hover: hover) {
.landing-showcase-card:hover::after,
.landing-showcase-card:focus-within::after {
border-color: var(--color-border-button-hover);
box-shadow: inset 0 0 0 2px var(--color-border-button-hover);
}
}
.landing-hero-dither-character {
backface-visibility: hidden;
will-change: mask-image, mask-position, opacity, transform, color;
}
@media (prefers-reduced-motion: reduce) {
.animate-landing-marquee {
animation: none;
}
/* Keep the caret, drop the blink. */
.animate-terminal-caret {
animation: none;
}
}
/* Split-flap ("Solari" board) digit, used by the image-generation countdown.
* Two leaves hinged on the card's centre seam:
* flap-fall the outgoing digit's TOP half swings down over the incoming
* bottom half. Eased IN, because a real flap is falling under
* gravity — it accelerates — and it darkens as it tips away
* from the light.
* flap-rise the incoming digit's BOTTOM half swings up from edge-on,
* starting exactly when the fall lands (delay = fall duration,
* `both` so it waits at 90deg instead of flashing flat). The
* small overshoot past 0deg is the slap against the stop —
* that bounce is what makes it read mechanical rather than
* like a plain rotation. */
@keyframes flap-fall {
from {
transform: rotateX(0deg);
filter: brightness(1);
}
to {
transform: rotateX(-90deg);
filter: brightness(0.86);
}
}
@keyframes flap-rise {
0% {
transform: rotateX(90deg);
filter: brightness(0.86);
}
70% {
transform: rotateX(-9deg);
}
86% {
transform: rotateX(4deg);
}
100% {
transform: rotateX(0deg);
filter: brightness(1);
}
}
.animate-flap-fall {
transform-origin: bottom center;
backface-visibility: hidden;
animation: flap-fall 150ms cubic-bezier(0.55, 0, 0.9, 0.35) forwards;
}
.animate-flap-rise {
transform-origin: top center;
backface-visibility: hidden;
animation: flap-rise 230ms cubic-bezier(0.25, 0.7, 0.35, 1) 150ms both;
}
@media (prefers-reduced-motion: reduce) {
.animate-flap-fall,
.animate-flap-rise {
animation-duration: 1ms;
animation-delay: 0ms;
}
}
/* New-row entrance for the Storage settings table. Three coordinated
* pieces (wrapper pairs with `grid grid-rows-[1fr]` + an overflow-hidden
* middle layer, content carries the slide):
* row-grow-in wrapper's grid track 0fr → 1fr — pushes the rows below
* down smoothly.
* row-slide-in content translates -100% → 0 with the SAME duration and
* easing, so it stays glued to the growing wrapper's bottom
* edge — visually the row slides out from underneath the
* header row above it.
* row-fade-in opacity/blur on a SHORTER clock than the movement — the
* row is fully visible before it finishes settling (fades
* must complete before movement ends). */
@keyframes row-grow-in {
from {
grid-template-rows: 0fr;
}
to {
grid-template-rows: 1fr;
}
}
@keyframes row-slide-in {
from {
translate: 0 -100%;
}
}
@keyframes row-fade-in {
from {
opacity: 0;
filter: blur(2px);
}
}
.animate-row-grow-in {
animation: row-grow-in 400ms ease-out;
}
.animate-row-slide-in {
animation:
row-slide-in 400ms ease-out,
row-fade-in 240ms ease-out;
}
/* Reverse of the Storage row entrance. The content rises into the clipped
* top edge while its grid track collapses, so the following rows move upward
* continuously and the deleted row disappears beneath the header surface. */
@keyframes row-collapse-out {
from {
grid-template-rows: 1fr;
}
to {
grid-template-rows: 0fr;
}
}
@keyframes row-slide-out {
to {
translate: 0 -100%;
}
}
@keyframes row-fade-out {
to {
opacity: 0;
filter: blur(2px);
}
}
.animate-row-collapse-out {
animation: row-collapse-out 225ms ease-in forwards;
}
.animate-row-slide-out {
animation:
row-slide-out 225ms ease-in forwards,
row-fade-out 160ms ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-row-grow-in,
.animate-row-slide-in,
.animate-row-collapse-out,
.animate-row-slide-out {
animation-duration: 1ms;
}
}
/* Transitions.dev — "Texts reveal", used by the Storage dropzone's content
* swaps (idle copy ↔ file name + status line).
*
* Entrance (`.is-shown` on the parent): lines rise 12px from below while
* un-blurring, 500ms on a soft-landing bezier, staggered 40ms per line so
* the eye lands on the first line first.
* Exit (`.is-hiding`): a single quiet 200ms opacity fade in place — no Y
* return, no blur, no stagger — so the disappearance never reads as a
* reverse reveal.
*
* Deviations from the original snippet: custom props are scoped to
* `.t-stagger` instead of :root, and `.t-stagger-line` doesn't force
* `display: block` so flex-based lines (the icon disc) keep their layout. */
.t-stagger {
--stagger-dur: 500ms;
--stagger-distance: 12px;
--stagger-stagger: 40ms;
--stagger-blur: 3px;
--stagger-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.t-stagger-line {
opacity: 0;
transform: translateY(var(--stagger-distance));
filter: blur(var(--stagger-blur));
transition:
opacity var(--stagger-dur) var(--stagger-ease),
transform var(--stagger-dur) var(--stagger-ease),
filter var(--stagger-dur) var(--stagger-ease);
will-change: transform, opacity, filter;
}
.t-stagger-line--2 {
transition-delay: var(--stagger-stagger);
}
.t-stagger-line--3 {
transition-delay: calc(var(--stagger-stagger) * 2);
}
.t-stagger.is-shown .t-stagger-line {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.t-stagger.is-hiding .t-stagger-line {
opacity: 0;
transform: translateY(0);
filter: blur(0);
transition:
opacity 200ms ease,
transform 0s linear,
filter 0s linear;
transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) {
.t-stagger-line {
transition: none !important;
}
}
/* Rolling-counter label swap: the old value lifts 5px and fades out while the
* new one rises in from 5px below and fades in. */
@keyframes label-out {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-10px);
opacity: 0;
}
}
@keyframes label-in {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-label-out {
animation: label-out 220ms ease-out forwards;
}
.animate-label-in {
animation: label-in 220ms ease-out;
}
/* Checkbox tick: draws the stroke on like it's being ticked by hand.
* The path sets pathLength=1 so the dash math is resolution-independent. */
@keyframes check-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
.animate-check-draw {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: check-draw 200ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-check-draw {
animation: none;
stroke-dashoffset: 0;
}
}
/* ---------------------------------------------------------------------------
* Chart entrance animations (AI profile template & contributions heatmap)
* ------------------------------------------------------------------------- */
/* Heatmap cells pop in softly — each cell gets a random-looking per-cell
* animation-delay so the grid fills in a scattered order. */
.contributions-grid {
--contribution-tier-0: var(--color-chart-neutral);
}
.contributions-grid[data-accent="emerald"] {
--contribution-tier-1: var(--color-emerald-200); --contribution-tier-2: var(--color-emerald-400); --contribution-tier-3: var(--color-emerald-500); --contribution-tier-4: var(--color-emerald-600); --contribution-tier-5: var(--color-emerald-700);
}
.contributions-grid[data-accent="green"] {
--contribution-tier-1: var(--color-green-200); --contribution-tier-2: var(--color-green-400); --contribution-tier-3: var(--color-green-500); --contribution-tier-4: var(--color-green-600); --contribution-tier-5: var(--color-green-700);
}
.contributions-grid[data-accent="teal"] {
--contribution-tier-1: var(--color-teal-200); --contribution-tier-2: var(--color-teal-400); --contribution-tier-3: var(--color-teal-500); --contribution-tier-4: var(--color-teal-600); --contribution-tier-5: var(--color-teal-700);
}
.contributions-grid[data-accent="cyan"] {
--contribution-tier-1: var(--color-cyan-200); --contribution-tier-2: var(--color-cyan-400); --contribution-tier-3: var(--color-cyan-500); --contribution-tier-4: var(--color-cyan-600); --contribution-tier-5: var(--color-cyan-700);
}
.contributions-grid[data-accent="blue"] {
--contribution-tier-1: var(--color-blue-200); --contribution-tier-2: var(--color-blue-400); --contribution-tier-3: var(--color-blue-500); --contribution-tier-4: var(--color-blue-600); --contribution-tier-5: var(--color-blue-700);
}
.contributions-grid[data-accent="indigo"] {
--contribution-tier-1: var(--color-indigo-200); --contribution-tier-2: var(--color-indigo-400); --contribution-tier-3: var(--color-indigo-500); --contribution-tier-4: var(--color-indigo-600); --contribution-tier-5: var(--color-indigo-700);
}
.contributions-grid[data-accent="violet"] {
--contribution-tier-1: var(--color-violet-200); --contribution-tier-2: var(--color-violet-400); --contribution-tier-3: var(--color-violet-500); --contribution-tier-4: var(--color-violet-600); --contribution-tier-5: var(--color-violet-700);
}
.contributions-grid[data-accent="rose"] {
--contribution-tier-1: var(--color-rose-200); --contribution-tier-2: var(--color-rose-400); --contribution-tier-3: var(--color-rose-500); --contribution-tier-4: var(--color-rose-600); --contribution-tier-5: var(--color-rose-700);
}
.contributions-grid[data-accent="amber"] {
--contribution-tier-1: var(--color-amber-200); --contribution-tier-2: var(--color-amber-400); --contribution-tier-3: var(--color-amber-500); --contribution-tier-4: var(--color-amber-600); --contribution-tier-5: var(--color-amber-700);
}
.dark .contributions-grid[data-accent="emerald"] {
--contribution-tier-1: var(--color-emerald-950); --contribution-tier-2: var(--color-emerald-800); --contribution-tier-3: var(--color-emerald-700); --contribution-tier-4: var(--color-emerald-600); --contribution-tier-5: var(--color-emerald-500);
}
.dark .contributions-grid[data-accent="green"] {
--contribution-tier-1: var(--color-green-950); --contribution-tier-2: var(--color-green-800); --contribution-tier-3: var(--color-green-700); --contribution-tier-4: var(--color-green-600); --contribution-tier-5: var(--color-green-500);
}
.dark .contributions-grid[data-accent="teal"] {
--contribution-tier-1: var(--color-teal-950); --contribution-tier-2: var(--color-teal-800); --contribution-tier-3: var(--color-teal-700); --contribution-tier-4: var(--color-teal-600); --contribution-tier-5: var(--color-teal-500);
}
.dark .contributions-grid[data-accent="cyan"] {
--contribution-tier-1: var(--color-cyan-950); --contribution-tier-2: var(--color-cyan-800); --contribution-tier-3: var(--color-cyan-700); --contribution-tier-4: var(--color-cyan-600); --contribution-tier-5: var(--color-cyan-500);
}
.dark .contributions-grid[data-accent="blue"] {
--contribution-tier-1: var(--color-blue-950); --contribution-tier-2: var(--color-blue-800); --contribution-tier-3: var(--color-blue-700); --contribution-tier-4: var(--color-blue-600); --contribution-tier-5: var(--color-blue-500);
}
.dark .contributions-grid[data-accent="indigo"] {
--contribution-tier-1: var(--color-indigo-950); --contribution-tier-2: var(--color-indigo-800); --contribution-tier-3: var(--color-indigo-700); --contribution-tier-4: var(--color-indigo-600); --contribution-tier-5: var(--color-indigo-500);
}
.dark .contributions-grid[data-accent="violet"] {
--contribution-tier-1: var(--color-violet-950); --contribution-tier-2: var(--color-violet-800); --contribution-tier-3: var(--color-violet-700); --contribution-tier-4: var(--color-violet-600); --contribution-tier-5: var(--color-violet-500);
}
.dark .contributions-grid[data-accent="rose"] {
--contribution-tier-1: var(--color-rose-950); --contribution-tier-2: var(--color-rose-800); --contribution-tier-3: var(--color-rose-700); --contribution-tier-4: var(--color-rose-600); --contribution-tier-5: var(--color-rose-500);
}
.dark .contributions-grid[data-accent="amber"] {
--contribution-tier-1: var(--color-amber-950); --contribution-tier-2: var(--color-amber-800); --contribution-tier-3: var(--color-amber-700); --contribution-tier-4: var(--color-amber-600); --contribution-tier-5: var(--color-amber-500);
}
.contribution-cell[data-tier="0"] { background-color: var(--contribution-tier-0); }
.contribution-cell[data-tier="1"] { background-color: var(--contribution-tier-1); }
.contribution-cell[data-tier="2"] { background-color: var(--contribution-tier-2); }
.contribution-cell[data-tier="3"] { background-color: var(--contribution-tier-3); }
.contribution-cell[data-tier="4"] { background-color: var(--contribution-tier-4); }
.contribution-cell[data-tier="5"] { background-color: var(--contribution-tier-5); }
@keyframes cell-pop {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-cell-pop {
animation: cell-pop 380ms ease-out both;
}
/* Bars grow up from the baseline; a per-bar delay staggers them left→right. */
@keyframes bar-rise {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.animate-bar-rise {
transform-origin: bottom;
animation: bar-rise 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Line chart draws in left→right via a clip-path sweep over the whole plot
* (line + gradient area together). */
@keyframes chart-reveal {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0 0 0 0);
}
}
.animate-chart-reveal {
animation: chart-reveal 1800ms cubic-bezier(0.33, 0, 0.15, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.animate-cell-pop,
.animate-bar-rise,
.animate-chart-reveal {
animation: none;
}
}
/* ---------------------------------------------------------------------------
* Table primitive (components/base/table)
*
* React Aria's collection components (Column/Row/Cell/…) can't be wrapped in
* custom components without losing collection behaviour, so the primitive
* re-exports them untouched and styling lives here, scoped to `.bui-table`.
* React Aria renders real <table>/<thead>/<th>/<tr>/<td> elements.
* --------------------------------------------------------------------------- */
.bui-table {
@apply w-full border-collapse text-left;
}
/* Header background + border-y live on the cells: with border-collapse the
* browser doesn't paint backgrounds/borders set on the <thead> row group.
* Backgrounds/borders are set with explicit CSS (theme vars) rather than
* @apply, which drops border-width/bg on these row-group cells. */
.bui-table th {
@apply px-3 py-2.5 text-left align-middle text-body-medium text-text-tertiary whitespace-nowrap outline-none;
background-color: var(--color-background-secondary-default);
border-top: 1px solid var(--color-separator-border);
border-bottom: 1px solid var(--color-separator-border);
transition: padding 200ms ease, font-size 200ms ease;
}
.bui-table td {
@apply px-3 py-2.5 align-middle text-body-medium text-text-primary outline-none;
transition: padding 200ms ease, font-size 200ms ease;
}
.bui-table tbody tr {
@apply transition-colors duration-150;
border-bottom: 1px solid var(--color-separator-border);
}
.bui-table tbody tr[data-focus-visible] {
@apply ring-2 ring-inset ring-border-focus-ring;
}
.bui-table.bui-table-sm th,
.bui-table.bui-table-sm td {
@apply px-2.5 py-1.5 text-body-2-medium;
}
/* Beam travelling the FAQ card's border.
*
* A conic gradient rotated by an `@property`-typed angle, clipped to a thin
* ring by the mask-xor trick the glass stroke uses (see `.landing-glass-stroke`
* for why the shorthand has to precede the composite). The glow is the same
* ring, wider and blurred, underneath.
*
* Both layers are masked. An unmasked glow paints the whole conic wash across
* the card's interior, which reads as a coloured blob in the middle rather
* than light on the edge.
*
* The angle is a registered property so it can be animated at all: a plain
* custom property is an unanimatable string, and the gradient would jump
* between keyframes instead of sweeping. */
@property --bui-beam-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes bui-beam-travel {
to {
--bui-beam-angle: 360deg;
}
}
.bui-beam,
.bui-beam-glow,
.bui-beam-aura {
pointer-events: none;
position: absolute;
/* -1px, not 0: `inset: 0` lands on the padding box, which puts the ring
* just inside the card's own border instead of on top of it. */
inset: -1px;
/* Not `inherit`. Each layer sits further out than the card, and a box that
* is bigger with the same radius has a *tighter* corner — the ring then cuts
* across the card's own corner and leaves a notch in the border. The radius
* has to grow by exactly the outset. The host sets --bui-beam-radius to its
* own corner. */
border-radius: calc(var(--bui-beam-radius, 26px) + 1px);
padding: 1.5px;
/* The sweep runs to 360deg, so the stop at 360 is the beam's leading tip and
* the ramp behind it is the tail. Brightest at the tip: it is the front of
* the travelling light, and the blurred copy underneath turns that peak into
* the glow. Anything dimmer at 360 puts the highlight mid-beam instead. */
background: conic-gradient(
from var(--bui-beam-angle),
transparent 0deg,
transparent 272deg,
rgb(104 136 251 / 0.22) 305deg,
rgb(104 136 251 / 0.8) 340deg,
rgb(150 176 255 / 0.95) 353deg,
rgb(219 230 255 / 1) 360deg
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
/* Two laps, then it stays gone: `forwards` holds the final keyframe so the
* gradient does not snap back to its start as the fade-out runs. The host
* mounts these only once the card is on screen. */
animation: bui-beam-travel 4.5s linear 2 forwards;
transition: opacity 700ms ease;
}
/* The same ring, blurred. Identical inset and padding to the crisp one on
* purpose: a wider band here drew a second, softer line beside the first and
* the border read as double. Same geometry, blurred, is one line with a glow —
* strongest at the tip, where the gradient peaks. */
.bui-beam-glow {
filter: blur(4px);
opacity: 0.85;
}
/* The aura the head carries with it.
*
* Its own gradient starts far later than the beam's, so the light gathers
* around the leading tip instead of tracing the whole tail — that is what
* makes it read as a glow travelling with the beam rather than a second,
* fatter border. Wide band, heavy blur, low opacity: enough to spill a little
* past the stroke, not enough to become an object of its own. The card must
* leave its overflow visible or this is the layer that gets cut off. */
.bui-beam-aura {
inset: -4px;
border-radius: calc(var(--bui-beam-radius, 26px) + 4px);
padding: 4px;
background: conic-gradient(
from var(--bui-beam-angle),
transparent 0deg,
transparent 322deg,
rgb(104 136 251 / 0.4) 348deg,
rgb(150 176 255 / 0.75) 360deg
);
filter: blur(8px);
opacity: 0.5;
}
/* Set by the host once both laps are done. */
.bui-beam-spent {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.bui-beam,
.bui-beam-glow,
.bui-beam-aura {
animation: none;
opacity: 0;
}
}
/* Agent Thinking (components/blocks/agent-thinking): the agent thinking state
* above a chat composer. The label reuses the ai-chat-text-shimmer travel;
* its base/highlight both derive from the tone the component sets via
* --bui-agent-thinking-tone, so every tone shimmers consistently. */
.bui-agent-thinking-label {
color: transparent;
background-image: linear-gradient(
100deg,
color-mix(in oklab, var(--bui-agent-thinking-tone) 55%, transparent) 30%,
var(--bui-agent-thinking-tone) 50%,
color-mix(in oklab, var(--bui-agent-thinking-tone) 55%, transparent) 70%
);
background-position: 200% center;
background-size: 300% 100%;
background-clip: text;
-webkit-background-clip: text;
animation: ai-chat-text-shimmer 2.6s linear infinite;
will-change: background-position;
}
/* Star sparkle: scale + fade in, settle, and vanish; per-star duration and
* stagger come in via inline animation-duration/-delay. */
@keyframes bui-agent-thinking-star {
0%,
100% {
transform: scale(0);
opacity: 0;
}
40% {
transform: scale(1);
opacity: 1;
}
60% {
transform: scale(0.8);
opacity: 0.9;
}
}
.bui-agent-thinking-star {
transform: scale(0);
animation-name: bui-agent-thinking-star;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
/* Infinity comet: the dash segment laps the normalized (pathLength=100)
* figure-eight once per animation-duration. */
@keyframes bui-agent-thinking-dash {
to {
stroke-dashoffset: -100;
}
}
.bui-agent-thinking-comet {
animation-name: bui-agent-thinking-dash;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-agent-thinking-label {
color: var(--bui-agent-thinking-tone);
background-image: none;
animation: none;
}
/* Stars rest visible instead of frozen at scale(0). */
.bui-agent-thinking-star {
animation: none;
transform: none;
opacity: 0.7;
}
.bui-agent-thinking-comet {
animation: none;
}
}
/* Star glyphs read a touch heavier than the dot grids on light backgrounds;
* soften only them (not the label) there. Dark mode keeps the full tone. */
.bui-agent-thinking-stars {
color: color-mix(in oklab, currentColor 75%, transparent);
}
.dark .bui-agent-thinking-stars {
color: inherit;
}
/* Composer Loader (components/blocks/composer-loader): the light band
* orbiting the composer while the agent works. Dash segments on a
* pathLength-normalized SVG stroke — the offset laps once per duration. */
@keyframes bui-composer-loader-dash {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -100;
}
}
@media (prefers-reduced-motion: reduce) {
.bui-composer-loader-rect {
animation: none !important;
}
}
/* Composer mic listening state (components/blocks/composer): equalizer
* bars dancing while voice input is active. Per-bar duration/delay come in
* inline so the bars drift out of phase like real audio. */
@keyframes bui-composer-mic-bars {
0%,
100% {
transform: scaleY(0.4);
}
50% {
transform: scaleY(1);
}
}
.bui-composer-mic-bar {
animation: bui-composer-mic-bars 0.9s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bui-composer-mic-bar {
animation: none;
transform: scaleY(0.7);
}
}
/* Composer effort slider (Ultra): the rail is under load. A one-shot kick
* then a tiny ongoing tremor, while a purple fire jet trails left off the
* thumb (see EffortFlame in composer.tsx). Reduced-motion keeps a static
* taper instead of the shader. */
@keyframes bui-effort-ultra-kick {
0%,
100% {
transform: translate3d(0, 0, 0);
}
18% {
transform: translate3d(-1.6px, 0.8px, 0);
}
38% {
transform: translate3d(1.4px, -0.7px, 0);
}
58% {
transform: translate3d(-1.1px, -0.9px, 0);
}
76% {
transform: translate3d(0.9px, 0.5px, 0);
}
}
@keyframes bui-effort-ultra-tremor {
0%,
100% {
transform: translate3d(0, 0, 0);
}
25% {
transform: translate3d(-0.9px, 0.55px, 0);
}
50% {
transform: translate3d(0.8px, -0.45px, 0);
}
75% {
transform: translate3d(-0.65px, -0.6px, 0);
}
}
.bui-effort-ultra {
animation:
bui-effort-ultra-kick 420ms ease-out 1,
bui-effort-ultra-tremor 180ms linear 420ms infinite;
will-change: transform;
}
.bui-effort-ultra-rail {
background: color-mix(in oklab, var(--color-background-secondary-default) 70%, #160814);
}
.bui-effort-ultra-flame-static {
pointer-events: none;
position: absolute;
inset: 0;
background: linear-gradient(
270deg,
rgb(255 242 255 / 0.88) 0%,
rgb(196 92 255 / 0.72) 16%,
rgb(124 42 186 / 0.4) 46%,
transparent 78%
);
mask-image: linear-gradient(
to bottom,
transparent 6%,
black 32%,
black 68%,
transparent 94%
);
}
@media (prefers-reduced-motion: reduce) {
.bui-effort-ultra {
animation: none;
will-change: auto;
}
}