planmymeal/styles/globals.css

1026 lines
32 KiB
CSS
Raw Permalink Normal View History

@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 *));
/* Resolve the nearest direction boundary, including nested LTR regions.
* Tailwind's ancestor-based defaults also match inside an opposing region. */
@custom-variant rtl (&:dir(rtl));
@custom-variant ltr (&:dir(ltr));
/* 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;
}
}
::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 BoardUI 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;
}
/* 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.
*
* Each override is part of its utility (`@utility`), so Tailwind emits it in
* the utilities layer right after the base utilities and before every variant:
* a later variant such as `disabled:shadow-none` wins in dark as it does in
* light. (Written as plain rules outside the layers, they outranked every
* variant, and a disabled button kept its shadow in dark.) The `:where()`
* keeps the utility's own specificity and matches only an element that has
* the base class inside `.dark`, so a variant on its own (`hover:shadow-xs`)
* keeps its value as before. One change in dark: a shadow variant on an
* element that also has a base shadow class (`shadow-sm hover:shadow-md`) now
* takes effect, with its light value, where the base class's dark value used
* to win in every state. */
@utility shadow-2xs {
&:where(.dark .shadow-2xs) {
--tw-shadow: var(--shadow-2xs);
}
}
@utility shadow-xs {
&:where(.dark .shadow-xs) {
--tw-shadow: var(--shadow-xs);
}
}
@utility shadow-sm {
&:where(.dark .shadow-sm) {
--tw-shadow: var(--shadow-sm);
}
}
@utility shadow-md {
&:where(.dark .shadow-md) {
--tw-shadow: var(--shadow-md);
}
}
@utility shadow-lg {
&:where(.dark .shadow-lg) {
--tw-shadow: var(--shadow-lg);
}
}
@utility shadow-xl {
&:where(.dark .shadow-xl) {
--tw-shadow: var(--shadow-xl);
}
}
@utility shadow-card {
&:where(.dark .shadow-card) {
--tw-shadow: var(--shadow-card);
}
}
@utility shadow-dropdown {
&:where(.dark .shadow-dropdown) {
--tw-shadow: var(--shadow-dropdown);
}
}
@utility shadow-sidebar {
&:where(.dark .shadow-sidebar) {
--tw-shadow: var(--shadow-sidebar);
}
}
@utility shadow-waitlist {
&:where(.dark .shadow-waitlist) {
--tw-shadow: var(--shadow-waitlist);
}
}
/* 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;
}
/* 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 {
/* SVG axes use physical coordinates. Keep their data and numeric labels in
* order while card headers, legends and surrounding controls follow RTL. */
direction: ltr;
}
.recharts-wrapper:focus,
.recharts-wrapper:focus-visible,
.recharts-wrapper :focus,
.recharts-wrapper :focus-visible {
outline: none;
}
/* 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. */
.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;
}
}
/* 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-start;
}
/* 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-start 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;
}
/* Separators sit *between* rows only: the trailing row's boundary is drawn by
* whatever follows the table (the container's bottom edge, or the pagination
* footer's own border-top), so a filtered-down table never doubles up lines. */
.bui-table tbody tr:not(:last-child) {
border-bottom: 1px solid var(--color-separator-border);
}
.bui-table tbody tr {
@apply transition-colors duration-150;
}
.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;
}
/* Agent Thinking (components/application/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/application/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/application/ai-chat): 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);
}
}
/* Shimmer sweep on a primary call to action (the Pro prompt card). */
@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;
}
}
/* Progress (components/base/progress): the indeterminate bar's band of
* light, 40% of the track wide, sweeping from fully off the start edge to
* fully off the end. RTL runs it in reverse. */
@keyframes bui-progress-sweep {
from {
translate: -100% 0;
}
to {
translate: 250% 0;
}
}
/* Skeleton (components/base/skeleton): a sheen sweeping each placeholder,
* drawn in layers like the loaders. A crisp 2px line leads, a tight glow
* trails it and a wide bloom trails that, all anchored to the sheen's
* leading edge so nothing soft runs ahead of the line. The sweep takes
* 60% of the cycle and rests off the block for the rest. RTL mirrors it.
* Position comes from the component's classes (relative or absolute): an
* unlayered position here would beat Tailwind's layered utilities. */
.bui-skeleton {
--bui-skeleton-line: rgb(255 255 255 / 0.9);
--bui-skeleton-glow: rgb(255 255 255 / 0.55);
--bui-skeleton-bloom: rgb(255 255 255 / 0.35);
overflow: hidden;
isolation: isolate;
}
.dark .bui-skeleton {
--bui-skeleton-line: rgb(255 255 255 / 0.2);
--bui-skeleton-glow: rgb(255 255 255 / 0.08);
--bui-skeleton-bloom: rgb(255 255 255 / 0.04);
}
.bui-skeleton::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent, var(--bui-skeleton-line)) right / 2px 100% no-repeat,
linear-gradient(90deg, transparent, var(--bui-skeleton-glow)) right / 72px 100% no-repeat,
linear-gradient(90deg, transparent, var(--bui-skeleton-bloom)) right / 240px 100% no-repeat;
translate: -100% 0;
animation: bui-skeleton-sheen 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.bui-skeleton:dir(rtl)::after {
scale: -1 1;
animation-direction: reverse;
}
@keyframes bui-skeleton-sheen {
0% {
translate: -100% 0;
}
60%,
100% {
translate: 100% 0;
}
}
@keyframes bui-skeleton-pulse {
50% {
opacity: 0.55;
}
}
/* No sweep under reduced motion; the block breathes instead. */
@media (prefers-reduced-motion: reduce) {
.bui-skeleton::after {
display: none;
}
.bui-skeleton {
animation: bui-skeleton-pulse 2s ease-in-out infinite;
}
}
/* Showcase card (docs index, landing, the starter's catalogue). */
.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);
}
}