refactor: refresh chat indicators, settings pages, and shared UI primitives
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BusyDotsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DOT_DELAYS_MS = [0, 200, 400] as const;
|
||||
|
||||
export const BusyDots: React.FC<BusyDotsProps> = ({ className }) => (
|
||||
<>
|
||||
{'\u00A0'}
|
||||
<span className={cn('inline-flex', className)} aria-hidden="true">
|
||||
{DOT_DELAYS_MS.map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className="animate-busy-pulse"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
>
|
||||
.
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -83,5 +83,5 @@ Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the
|
||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `GenericStatusSpinner.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Starfield Twinkle — a 4×4 grid of tiny dots that flicker
|
||||
* like stars in a night sky. Each dot has its own random phase
|
||||
* and duration so the pattern never looks mechanical.
|
||||
*
|
||||
* Corners are hidden (same as original) to soften the grid shape.
|
||||
*/
|
||||
|
||||
const COLS = 4;
|
||||
const ROWS = 4;
|
||||
const SPACING = 3.2; // viewBox units between centers
|
||||
const OFFSET = 2.7; // center the grid in 15×15
|
||||
const DOT_R = 0.7; // small dot radius — star-like
|
||||
|
||||
const cornerIndices = new Set([0, 3, 12, 15]);
|
||||
|
||||
const stars = Array.from({ length: COLS * ROWS }, (_, i) => ({
|
||||
id: i,
|
||||
cx: (i % COLS) * SPACING + OFFSET,
|
||||
cy: Math.floor(i / COLS) * SPACING + OFFSET,
|
||||
isCorner: cornerIndices.has(i),
|
||||
// Each star gets its own rhythm — varying duration + delay
|
||||
duration: 2.4 + Math.random() * 2.4,
|
||||
delay: Math.random() * 3.5,
|
||||
}));
|
||||
|
||||
export function GenericStatusSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
data-component="opencode-spinner"
|
||||
className={className}
|
||||
fill="var(--foreground)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{stars.map((star) => (
|
||||
<circle
|
||||
key={star.id}
|
||||
cx={star.cx}
|
||||
cy={star.cy}
|
||||
r={DOT_R}
|
||||
style={
|
||||
star.isCorner
|
||||
? { opacity: 0 }
|
||||
: {
|
||||
animation: `star-twinkle ${star.duration}s ease-in-out infinite`,
|
||||
animationDelay: `${star.delay}s`,
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { BusyDots } from './BusyDots';
|
||||
|
||||
const MAX_SHINE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
|
||||
const MAX_BUSY_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
|
||||
|
||||
interface MinDurationShineTextProps {
|
||||
active: boolean;
|
||||
@@ -20,68 +20,54 @@ export const MinDurationShineText: React.FC<MinDurationShineTextProps> = ({
|
||||
style,
|
||||
title,
|
||||
}) => {
|
||||
// Once active, we latch shine on and only turn it off after active becomes
|
||||
// false AND minDurationMs has elapsed since we first started shining.
|
||||
// All bookkeeping lives in refs so intermediate re-renders (children
|
||||
// changing, props updating) can never cause a flicker.
|
||||
const shineStartRef = React.useRef<number | null>(active ? Date.now() : null);
|
||||
const [isShining, setIsShining] = React.useState(active);
|
||||
const busyStartRef = React.useRef<number | null>(active ? Date.now() : null);
|
||||
const [isBusy, setIsBusy] = React.useState(active);
|
||||
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Latch on: if active becomes true, start shining immediately.
|
||||
if (active && shineStartRef.current === null) {
|
||||
shineStartRef.current = Date.now();
|
||||
}
|
||||
if (active && !isShining) {
|
||||
// Synchronous state set during render is fine for a latch-on — React
|
||||
// will coalesce it with the current render pass.
|
||||
// But we can't call setState during render, so we use an effect below.
|
||||
if (active && busyStartRef.current === null) {
|
||||
busyStartRef.current = Date.now();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (active) {
|
||||
// Cancel any pending off-timer.
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (shineStartRef.current === null) {
|
||||
shineStartRef.current = Date.now();
|
||||
if (busyStartRef.current === null) {
|
||||
busyStartRef.current = Date.now();
|
||||
}
|
||||
|
||||
// Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools
|
||||
const elapsed = Date.now() - shineStartRef.current;
|
||||
if (elapsed >= MAX_SHINE_DURATION_MS) {
|
||||
setIsShining(false);
|
||||
shineStartRef.current = null;
|
||||
|
||||
const elapsed = Date.now() - busyStartRef.current;
|
||||
if (elapsed >= MAX_BUSY_DURATION_MS) {
|
||||
setIsBusy(false);
|
||||
busyStartRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsShining(true);
|
||||
|
||||
setIsBusy(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isShining) {
|
||||
shineStartRef.current = null;
|
||||
if (!isBusy) {
|
||||
busyStartRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// active went false — schedule turn-off respecting minDurationMs.
|
||||
const startedAt = shineStartRef.current ?? Date.now();
|
||||
const startedAt = busyStartRef.current ?? Date.now();
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
// Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools
|
||||
if (elapsed >= MAX_SHINE_DURATION_MS) {
|
||||
setIsShining(false);
|
||||
shineStartRef.current = null;
|
||||
|
||||
if (elapsed >= MAX_BUSY_DURATION_MS) {
|
||||
setIsBusy(false);
|
||||
busyStartRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const remaining = Math.max(0, minDurationMs - elapsed);
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsShining(false);
|
||||
shineStartRef.current = null;
|
||||
setIsBusy(false);
|
||||
busyStartRef.current = null;
|
||||
timerRef.current = null;
|
||||
}, remaining);
|
||||
|
||||
@@ -91,19 +77,12 @@ export const MinDurationShineText: React.FC<MinDurationShineTextProps> = ({
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [active, minDurationMs, isShining]);
|
||||
|
||||
if (isShining) {
|
||||
return (
|
||||
<Text variant="shine" className={className} title={title}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}, [active, minDurationMs, isBusy]);
|
||||
|
||||
return (
|
||||
<span className={className} style={style} title={title}>
|
||||
{children}
|
||||
{isBusy ? <BusyDots /> : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@/components/ui/text';
|
||||
// import { SessionActiveSpinner } from './SessionActiveSpinner';
|
||||
import { GenericStatusSpinner } from './GenericStatusSpinner';
|
||||
import { BusyDots } from './BusyDots';
|
||||
|
||||
interface WorkingPlaceholderProps {
|
||||
isWorking: boolean;
|
||||
@@ -190,20 +188,18 @@ export function WorkingPlaceholder({
|
||||
const countdownLabel = retryCountdown !== null && retryCountdown > 0
|
||||
? ` in ${formatRetryCountdown(retryCountdown)}`
|
||||
: '';
|
||||
const retryText = `Retrying${countdownLabel}${attemptLabel}...`;
|
||||
const retryText = `Retrying${countdownLabel}${attemptLabel}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center text-muted-foreground pl-0.5"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={retryText}
|
||||
aria-label={`${retryText}...`}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<GenericStatusSpinner className="size-[15px] shrink-0" />
|
||||
<Text variant="shine" className="typography-ui-header">
|
||||
{retryText}
|
||||
</Text>
|
||||
<span className="typography-ui-header">
|
||||
{retryText}
|
||||
<BusyDots />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -214,7 +210,6 @@ export function WorkingPlaceholder({
|
||||
}
|
||||
|
||||
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
|
||||
const displayText = `${label}...`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -226,11 +221,9 @@ export function WorkingPlaceholder({
|
||||
aria-label={label}
|
||||
data-waiting={displayedPermission ? 'true' : undefined}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<GenericStatusSpinner className="size-[15px] shrink-0" />
|
||||
<Text variant="shine" className="typography-ui-header">
|
||||
{displayText}
|
||||
</Text>
|
||||
<span className="typography-ui-header">
|
||||
{label}
|
||||
<BusyDots />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiInformationLine } from '@remixicon/react';
|
||||
@@ -236,7 +235,7 @@ export const MagicPromptsPage: React.FC = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-6 px-6 flex items-center gap-2 text-muted-foreground">
|
||||
<GridLoader size="sm" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
<span className="typography-ui">Loading Magic Prompts...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -908,7 +907,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
</div>
|
||||
{pushBusy && (
|
||||
<div className="pt-0.5 text-muted-foreground">
|
||||
<GridLoader size="sm" />
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -1058,7 +1057,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<GridLoader size="sm" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current animate-busy-pulse" aria-label="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
@@ -46,12 +45,6 @@ import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, r
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
|
||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||
|
||||
const getAttentionDiamondDelay = (index: number): string => {
|
||||
return index === 4 ? '0ms' : '130ms';
|
||||
};
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
type SecondaryMeta = {
|
||||
@@ -387,17 +380,19 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
? <GridLoader size="xs" className="text-primary" />
|
||||
? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
|
||||
aria-label="Session active"
|
||||
title="Session active"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
||||
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
||||
) : (
|
||||
<span key={i} className="h-[3px] w-[3px]" />
|
||||
)
|
||||
))}
|
||||
</span>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
||||
aria-label="Unread updates"
|
||||
title="Unread updates"
|
||||
/>
|
||||
);
|
||||
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface GridLoaderProps {
|
||||
className?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
xs: { container: 'gap-[1px]', dot: 'h-[3px] w-[3px]' },
|
||||
sm: { container: 'gap-0.5', dot: 'h-1 w-1' },
|
||||
md: { container: 'gap-1', dot: 'h-1.5 w-1.5' },
|
||||
lg: { container: 'gap-1.5', dot: 'h-2 w-2' },
|
||||
};
|
||||
|
||||
const getPulseDelayMs = (index: number): number => {
|
||||
return ((index % 3) + Math.floor(index / 3)) * 150;
|
||||
};
|
||||
|
||||
const GridLoader: React.FC<GridLoaderProps> = ({ className, size = 'md' }) => {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('grid grid-cols-3 place-items-center', config.container, className)}
|
||||
style={{ width: '11px', height: '11px' }}
|
||||
aria-label="Loading"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn('shrink-0 rounded-full bg-current animate-grid-pulse', config.dot)}
|
||||
style={{ animationDelay: `${getPulseDelayMs(i)}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export { GridLoader };
|
||||
@@ -16,24 +16,6 @@ const variants = [
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
variant: "shine",
|
||||
component: ({ children, className, ...props }) => (
|
||||
<span
|
||||
{...props}
|
||||
data-component="oc-text-shimmer"
|
||||
data-active="true"
|
||||
className={className}
|
||||
>
|
||||
<span data-slot="text-shimmer-char">
|
||||
<span data-slot="text-shimmer-char-base">{children}</span>
|
||||
<span data-slot="text-shimmer-char-shimmer" data-run="true" aria-hidden="true">
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
variant: "generate-effect",
|
||||
component: ({ children, className, ...props }) => {
|
||||
@@ -210,12 +192,10 @@ export type TextProps = {
|
||||
} & React.ComponentProps<"span"> &
|
||||
Partial<MotionProps>;
|
||||
|
||||
export function Text({ variant = "shine", className, ...props }: TextProps) {
|
||||
const FALLBACK_INDEX = 1;
|
||||
|
||||
export function Text({ variant = "static", className, ...props }: TextProps) {
|
||||
const variantComponent = variants.find((v) => v.variant === variant)?.component;
|
||||
|
||||
const Component = variantComponent || variants[FALLBACK_INDEX].component;
|
||||
const Component = variantComponent || variants[0].component;
|
||||
|
||||
return <Component {...props} className={className} />;
|
||||
}
|
||||
|
||||
+4
-119
@@ -308,101 +308,6 @@ textarea[data-chat-input="true"]:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="oc-text-shimmer"] {
|
||||
--oc-text-shimmer-step: 45ms;
|
||||
--oc-text-shimmer-duration: 1200ms;
|
||||
--oc-text-shimmer-angle: 90deg;
|
||||
--oc-text-shimmer-spread: 5.2ch;
|
||||
--oc-text-shimmer-size: 360%;
|
||||
--oc-text-shimmer-base-color: color-mix(in srgb, var(--surface-muted-foreground) 75%, transparent);
|
||||
--oc-text-shimmer-peak-color: var(--surface-foreground);
|
||||
--oc-text-shimmer-sweep: linear-gradient(
|
||||
var(--oc-text-shimmer-angle),
|
||||
transparent calc(50% - var(--oc-text-shimmer-spread)),
|
||||
var(--oc-text-shimmer-peak-color) 50%,
|
||||
transparent calc(50% + var(--oc-text-shimmer-spread))
|
||||
);
|
||||
--oc-text-shimmer-base: linear-gradient(var(--oc-text-shimmer-base-color), var(--oc-text-shimmer-base-color));
|
||||
|
||||
}
|
||||
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char"] {
|
||||
display: inline-grid;
|
||||
white-space: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-base"],
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
grid-area: 1 / 1;
|
||||
white-space: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
color: var(--surface-muted-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] {
|
||||
animation-name: oc-text-shimmer-sweep;
|
||||
animation-duration: var(--oc-text-shimmer-duration);
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-delay: calc(var(--oc-text-shimmer-step) * -1);
|
||||
will-change: background-position;
|
||||
}
|
||||
|
||||
@keyframes oc-text-shimmer-sweep {
|
||||
0% {
|
||||
background-position:
|
||||
100% 0,
|
||||
0 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position:
|
||||
0% 0,
|
||||
0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-image: var(--oc-text-shimmer-sweep), var(--oc-text-shimmer-base);
|
||||
background-size:
|
||||
var(--oc-text-shimmer-size) 100%,
|
||||
100% 100%;
|
||||
background-position:
|
||||
100% 0,
|
||||
0 0;
|
||||
background-repeat: no-repeat;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="oc-text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
animation: none !important;
|
||||
color: inherit;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-shimmer {
|
||||
0% {
|
||||
stop-color: currentColor;
|
||||
@@ -513,12 +418,6 @@ svg.animate-spin {
|
||||
}
|
||||
}
|
||||
|
||||
/* Starfield twinkle — dots fade through varying brightness like stars */
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% { opacity: 0.15; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%,
|
||||
100% {
|
||||
@@ -1415,7 +1314,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||
font-family: "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@keyframes grid-pulse {
|
||||
@keyframes oc-busy-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
@@ -1424,21 +1323,8 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||
}
|
||||
}
|
||||
|
||||
.animate-grid-pulse {
|
||||
animation: grid-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes attention-diamond-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.52;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-attention-diamond-pulse {
|
||||
animation: attention-diamond-pulse 2.3s ease-in-out infinite;
|
||||
.animate-busy-pulse {
|
||||
animation: oc-busy-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes navrail-dot-wave {
|
||||
@@ -1525,8 +1411,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-grid-pulse,
|
||||
.animate-attention-diamond-pulse {
|
||||
.animate-busy-pulse {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user