feat(bots): add bot-widget loading bar with cycle-complete hide and sync-state deference, plus matching cycle-complete polish on SyncIndicator
This commit is contained in:
parent
061608558a
commit
6fd6844611
5 changed files with 335 additions and 43 deletions
|
|
@ -33,6 +33,7 @@ export type BotWidgetEmbedOptions = {
|
|||
theme: Theme;
|
||||
language: string;
|
||||
onError: (error: Error) => void;
|
||||
onReady?: () => void;
|
||||
};
|
||||
|
||||
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
|
||||
|
|
@ -157,6 +158,18 @@ export class BotWidgetEmbed {
|
|||
// demote the widget into the chat fallback even though the widget is fine.
|
||||
private isReady = false;
|
||||
|
||||
// Two halves of «widget is fully booted». `iframeLoaded` flips on the
|
||||
// iframe's `load` event (document complete + same-origin assets ready);
|
||||
// `handshakeReady` flips on the ClientWidgetApi `ready` event (host
|
||||
// capability handshake complete). The external `onReady` callback only
|
||||
// fires once BOTH have happened — defensive coupling so a same-origin
|
||||
// synchronous handshake (cached bundle) can't beat the document-complete
|
||||
// signal and trigger a premature loading-bar fade. In practice load
|
||||
// tends to fire first, but the order is not guaranteed by the spec.
|
||||
private iframeLoaded = false;
|
||||
|
||||
private handshakeReady = false;
|
||||
|
||||
private readonly disposables: Array<() => void> = [];
|
||||
|
||||
// Expected origin of the widget iframe — captured once at construction
|
||||
|
|
@ -284,6 +297,26 @@ export class BotWidgetEmbed {
|
|||
const iframe = createBotIframe(preset);
|
||||
container.append(iframe);
|
||||
|
||||
// External onReady fires only when BOTH the iframe document is fully
|
||||
// loaded AND the ClientWidgetApi handshake is complete — see
|
||||
// `iframeLoaded` / `handshakeReady` field comments. The host gate this
|
||||
// serves is the loading-bar overlay in BotWidgetMount.
|
||||
const fireExternalReady = () => {
|
||||
if (this.iframeLoaded && this.handshakeReady) {
|
||||
this.options.onReady?.();
|
||||
}
|
||||
};
|
||||
|
||||
// Attach the iframe load listener BEFORE the src assignment for the
|
||||
// same reason ClientWidgetApi attaches its internal listener early —
|
||||
// a cached same-origin document can finish loading before src returns
|
||||
// control here, and a late listener would miss the load event.
|
||||
const onIframeLoad = () => {
|
||||
this.iframeLoaded = true;
|
||||
fireExternalReady();
|
||||
};
|
||||
iframe.addEventListener('load', onIframeLoad);
|
||||
|
||||
const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset);
|
||||
const api = new ClientWidgetApi(widget, iframe, driver);
|
||||
|
||||
|
|
@ -296,8 +329,10 @@ export class BotWidgetEmbed {
|
|||
|
||||
const onReady = () => {
|
||||
this.isReady = true;
|
||||
this.handshakeReady = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[BotWidget] handshake complete');
|
||||
fireExternalReady();
|
||||
};
|
||||
// Element-Web treats `error:preparing` as informational — it never tears
|
||||
// down the widget. Vojo follows the same rule: log the upstream cause and
|
||||
|
|
@ -325,6 +360,7 @@ export class BotWidgetEmbed {
|
|||
window.addEventListener('message', this.onWidgetMessage);
|
||||
|
||||
this.disposables.push(
|
||||
() => iframe.removeEventListener('load', onIframeLoad),
|
||||
() => api.off('ready', onReady),
|
||||
() => api.off('error:preparing', onPreparingError),
|
||||
() => iframe.removeEventListener('error', onIframeError),
|
||||
|
|
|
|||
|
|
@ -1,28 +1,65 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const Host = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: color.Background.Container,
|
||||
},
|
||||
]);
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
export const FrameMount = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const Toolbar = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: config.space.S300,
|
||||
right: config.space.S300,
|
||||
zIndex: 1,
|
||||
maxWidth: `calc(100% - ${toRem(24)})`,
|
||||
// Loading bar overlay shown while the widget iframe boots and capability
|
||||
// handshake completes. Mirrors `SyncIndicator` (sweep + radial fade + glow)
|
||||
// but pinned to the TOP of the iframe frame (under the BotShell hero) and
|
||||
// painted in the brand fleet-violet `Primary.Main` (#9580ff) instead of the
|
||||
// sync-state green. Resolves the «empty page on bot click» complaint:
|
||||
// before this, the iframe took ~200-800ms to first paint and the user saw
|
||||
// only the hero on a flat dark surface. Hex is hardcoded for the same
|
||||
// reason `BotShell.HeroAvatar` hardcodes it: keep the bot accent stable
|
||||
// across folds palette swaps.
|
||||
const slide = keyframes({
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
});
|
||||
|
||||
// Container clips horizontally so the slide-keyframe wraps invisibly off-
|
||||
// screen at the edges. Vertically the upper drop-shadow halo (-6px..0) IS
|
||||
// intentionally clipped — the bar sits flush against the BotShell hero's
|
||||
// bottom border and a halo glowing UP would visually bleed into the hero
|
||||
// chrome. Only the lower halo (0..8px) renders, glowing DOWN into the
|
||||
// iframe — mirror of SyncIndicator's bottom-anchored bar where the halo
|
||||
// renders UP into the page. Don't bump `top` to surface the upper halo;
|
||||
// that would float the bar away from the hero edge and break the seam.
|
||||
export const LoadingBarRoot = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '14px',
|
||||
pointerEvents: 'none',
|
||||
// Above the iframe, but no need for the global Z400 — local stacking
|
||||
// context inside Frame is enough.
|
||||
zIndex: 2,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const LoadingBar = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '2px',
|
||||
background:
|
||||
'radial-gradient(ellipse 50% 100% at center, #9580ff 0%, rgba(149, 128, 255, 0.45) 35%, rgba(149, 128, 255, 0) 70%)',
|
||||
filter: 'drop-shadow(0 0 6px rgba(149, 128, 255, 0.75))',
|
||||
animation: `${slide} 1.6s linear infinite`,
|
||||
willChange: 'transform',
|
||||
// 250ms opacity transition matches SyncIndicator's crossfade so the bar
|
||||
// doesn't pop in/out abruptly when ready flips. animationPlayState is
|
||||
// toggled inline at the call site so the compositor doesn't keep
|
||||
// sweeping while the bar is invisible (opacity:0 alone doesn't pause
|
||||
// CSS animations — verified across browsers).
|
||||
transition: 'opacity 250ms ease',
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Room, SyncState } from 'matrix-js-sdk';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||
import * as css from './BotWidgetMount.css';
|
||||
|
||||
// Anti-flicker debounce — same rationale as `SyncIndicator`'s
|
||||
// `PROGRESS_FADE_IN_DELAY_MS`. On a warm/cached widget the iframe can
|
||||
// boot in <100ms; without the delay the bar would briefly appear and
|
||||
// fade out, reading as a flash. 100ms is just above the typical instant-
|
||||
// load envelope yet below the perception threshold for «I clicked and
|
||||
// nothing happened».
|
||||
const LOADING_BAR_FADE_IN_DELAY_MS = 100;
|
||||
|
||||
// Fallback hide deadline if `animationiteration` never fires — covers
|
||||
// any compositor freeze that drops the event (e.g. tab backgrounded
|
||||
// mid-cycle and just foregrounded). 2000ms gives ~400ms slack over the
|
||||
// 1.6s slide-cycle: a fast 60Hz display fires iteration ~16-32ms after
|
||||
// cycle end, but a backgrounded compositor can be 100+ms late. The
|
||||
// reduced-motion path skips this branch entirely.
|
||||
const LOADING_BAR_HIDE_FALLBACK_MS = 2000;
|
||||
|
||||
// Renders the iframe-mount target and wires up `useBotWidgetEmbed` to
|
||||
// drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell
|
||||
// refactor — «Host» was overloaded with BotExperienceHost (page-level
|
||||
|
|
@ -16,7 +34,130 @@ type BotWidgetMountProps = {
|
|||
|
||||
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||
const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||
|
||||
return <div ref={containerRef} className={css.FrameMount} />;
|
||||
// Track Matrix sync state so the bot loading bar yields to the global
|
||||
// SyncIndicator when the connection is unhealthy. Without this, on a
|
||||
// dropped network the user would see TWO sweeping bars at once — the
|
||||
// bot bar at top stuck in «still loading» plus the SyncIndicator at
|
||||
// bottom in transient/error state. The bottom bar is the canonical
|
||||
// connection-state surface; the top one defers.
|
||||
const mx = useMatrixClient();
|
||||
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback((state: SyncState) => {
|
||||
setSyncState(state);
|
||||
}, [])
|
||||
);
|
||||
useEffect(() => {
|
||||
// Close the render-to-effect gap (same pattern as SyncIndicator) so
|
||||
// a Sync event fired between useState init and listener attach
|
||||
// doesn't get dropped.
|
||||
setSyncState(mx.getSyncState());
|
||||
}, [mx]);
|
||||
|
||||
// Healthy = SyncIndicator's «hidden» branch (`Syncing` or `Stopped`).
|
||||
// Anything else (`Reconnecting`, `Catchup`, `Error`, initial null)
|
||||
// means the bottom bar will be visible, so we should not also show
|
||||
// ours. Mirrors `stateToVisual` in SyncIndicator.tsx.
|
||||
const syncHealthy = syncState === SyncState.Syncing || syncState === SyncState.Stopped;
|
||||
|
||||
// Three logical phases for the loading bar:
|
||||
// - hidden (visible=false, pendingHide=false) → initial / post-hide
|
||||
// - visible (visible=true, pendingHide=false) → animating actively
|
||||
// - closing (visible=true, pendingHide=true) → ready fired mid-
|
||||
// cycle, waiting for
|
||||
// animationiteration
|
||||
// to land at the
|
||||
// cycle boundary
|
||||
// before snapping
|
||||
// invisible
|
||||
// The closing → hidden transition is driven by the iteration event so
|
||||
// the user always sees a complete left-to-right sweep and never a
|
||||
// mid-stroke cut. A fallback timer covers the case where the event
|
||||
// can't fire (compositor freeze on a backgrounded tab). Reduced-motion
|
||||
// and sync-unhealthy paths skip cycle-complete entirely — for the
|
||||
// first there's no animation cycle to honour, for the second the
|
||||
// bottom SyncIndicator should take over without UI overlap.
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [pendingHide, setPendingHide] = useState(false);
|
||||
|
||||
// Reasons the bar should NOT be shown:
|
||||
// - 'ready' — widget loaded normally, hide gracefully (cycle-complete)
|
||||
// - 'sync' — connection unhealthy, snap-hide so the SyncIndicator's
|
||||
// bar is the only visible chrome (no double-bar UX)
|
||||
// - null — bar should be visible
|
||||
// Order matters: `ready` takes precedence so a successfully-loaded
|
||||
// widget that then drops its connection still gets the polished cycle
|
||||
// hide on the original ready event rather than being re-classified as
|
||||
// a sync-issue snap.
|
||||
let hideReason: 'ready' | 'sync' | null = null;
|
||||
if (ready) hideReason = 'ready';
|
||||
else if (!syncHealthy) hideReason = 'sync';
|
||||
|
||||
useEffect(() => {
|
||||
if (hideReason !== null) {
|
||||
// Bar wasn't shown yet — cached fast load (or a handshake that
|
||||
// landed during the debounce window) won the race. Nothing to do;
|
||||
// bar stays hidden, no flash.
|
||||
if (!visible) return undefined;
|
||||
// Sync-issue snap: jump straight to invisible so the bottom
|
||||
// SyncIndicator can take over without two bars overlapping.
|
||||
// Reduced-motion: animation is off (no iterations ever land), so
|
||||
// parking a static stripe for ~2s isn't graceful, just stuck.
|
||||
if (
|
||||
hideReason === 'sync' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
setVisible(false);
|
||||
setPendingHide(false);
|
||||
return undefined;
|
||||
}
|
||||
// hideReason === 'ready' with motion enabled — bar is mid-sweep.
|
||||
// Defer the actual hide to the next cycle boundary. Fallback
|
||||
// force-hides if the event never lands.
|
||||
setPendingHide(true);
|
||||
const fallback = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setPendingHide(false);
|
||||
}, LOADING_BAR_HIDE_FALLBACK_MS);
|
||||
return () => clearTimeout(fallback);
|
||||
}
|
||||
// Bar should show — clear any leftover hide request from a previous
|
||||
// ready-then-not-ready blip and (if not yet visible) schedule the
|
||||
// debounced fade-in.
|
||||
setPendingHide(false);
|
||||
if (visible) return undefined;
|
||||
const fadeIn = setTimeout(() => setVisible(true), LOADING_BAR_FADE_IN_DELAY_MS);
|
||||
return () => clearTimeout(fadeIn);
|
||||
}, [hideReason, visible]);
|
||||
|
||||
const handleIteration = () => {
|
||||
if (pendingHide) {
|
||||
setVisible(false);
|
||||
setPendingHide(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading bar stays mounted; opacity carries the visible/hidden
|
||||
// crossfade. animationPlayState is paused while invisible so the
|
||||
// compositor doesn't keep sweeping a 0-alpha element forever.
|
||||
// pointer-events: none on the root so it never intercepts iframe
|
||||
// clicks even mid-fade.
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className={css.FrameMount} />
|
||||
<div className={css.LoadingBarRoot} aria-hidden>
|
||||
<div
|
||||
className={css.LoadingBar}
|
||||
style={{
|
||||
opacity: visible ? 1 : 0,
|
||||
animationPlayState: visible ? 'running' : 'paused',
|
||||
}}
|
||||
onAnimationIteration={handleIteration}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
|
@ -13,22 +13,29 @@ type UseBotWidgetEmbedOptions = {
|
|||
onError: () => void;
|
||||
};
|
||||
|
||||
type UseBotWidgetEmbedResult = {
|
||||
// True once the widget's `ready` event has fired (capability handshake
|
||||
// completed). Resets to false whenever the embed is recreated (room or
|
||||
// preset change). Drives the loading-bar overlay in BotWidgetMount.
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
// Encapsulates the imperative BotWidgetEmbed lifecycle so the host React
|
||||
// component stays small. Mirrors useCallEmbed's split: the hook owns the
|
||||
// effect, the component owns the DOM ref + visual chrome.
|
||||
//
|
||||
// Returns a ref to the live embed instance; the host can use it to push
|
||||
// runtime updates (e.g. theme) without forcing an iframe remount.
|
||||
// effect, the component owns the DOM ref + visual chrome. Live embed
|
||||
// instance is held in a private ref for runtime updates (theme
|
||||
// propagation below); not exposed because no caller needs it today.
|
||||
export const useBotWidgetEmbed = ({
|
||||
containerRef,
|
||||
preset,
|
||||
room,
|
||||
onError,
|
||||
}: UseBotWidgetEmbedOptions): RefObject<BotWidgetEmbed | undefined> => {
|
||||
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
||||
const { i18n } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const theme = useTheme();
|
||||
const embedRef = useRef<BotWidgetEmbed>();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Capture mount-time values in refs so the lifecycle effect doesn't re-run
|
||||
// when locale/theme change — those are propagated separately via setTheme.
|
||||
|
|
@ -50,6 +57,10 @@ export const useBotWidgetEmbed = ({
|
|||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
// Reset readiness whenever the embed is (re)created. Subsequent
|
||||
// `ready` event from the new ClientWidgetApi flips this back to true.
|
||||
setReady(false);
|
||||
|
||||
let embed: BotWidgetEmbed | undefined;
|
||||
try {
|
||||
embed = new BotWidgetEmbed({
|
||||
|
|
@ -60,6 +71,7 @@ export const useBotWidgetEmbed = ({
|
|||
theme: themeRef.current,
|
||||
language: languageRef.current,
|
||||
onError,
|
||||
onReady: () => setReady(true),
|
||||
});
|
||||
embedRef.current = embed;
|
||||
} catch (error) {
|
||||
|
|
@ -95,5 +107,5 @@ export const useBotWidgetEmbed = ({
|
|||
embedRef.current?.setTheme(theme).catch(() => undefined);
|
||||
}, [theme]);
|
||||
|
||||
return embedRef;
|
||||
return { ready };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,11 +4,23 @@ import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
|
|||
import { isAndroidPlatform } from '../../utils/capacitor';
|
||||
import * as css from './SyncIndicator.css';
|
||||
|
||||
// Suppress 0-150ms green flashes during fast transient state cycles
|
||||
// Suppress sub-100ms green flashes during fast transient state cycles
|
||||
// (e.g. one /sync request blips Reconnecting then immediately recovers to
|
||||
// Syncing — without the delay the bar would briefly appear at whatever
|
||||
// position the slide animation happened to be at).
|
||||
const PROGRESS_FADE_IN_DELAY_MS = 150;
|
||||
// position the slide animation happened to be at). 100ms keeps the bar
|
||||
// responsive enough that real transitions land before the user gives up.
|
||||
//
|
||||
// Trade-off: combined with the cycle-complete hide below, transients in
|
||||
// the 100-1800ms window now block the bar for a full slide cycle (~2s
|
||||
// of visible bar) instead of cutting at the actual recovery moment.
|
||||
// Deliberate choice — graceful animation > strict timing accuracy.
|
||||
const PROGRESS_FADE_IN_DELAY_MS = 100;
|
||||
|
||||
// Fallback hide deadline if `animationiteration` never fires — covers
|
||||
// any compositor freeze that drops the event (e.g. tab backgrounded
|
||||
// mid-cycle and just foregrounded). 2100ms gives ~300ms slack over the
|
||||
// 1.8s slide-cycle. The reduced-motion path skips this branch entirely.
|
||||
const PROGRESS_HIDE_FALLBACK_MS = 2100;
|
||||
|
||||
// On detected resume, hold the visual at "progress" (green) for this long if
|
||||
// the SDK is in `Error`. Worst-case keep-alive backoff in matrix-js-sdk is
|
||||
|
|
@ -142,17 +154,60 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
|
|||
const visual: Visual =
|
||||
resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
|
||||
|
||||
// Anti-flicker debounce: the green layer only becomes visible if the
|
||||
// progress visual is sustained for at least 150ms.
|
||||
// Three logical phases for the green progress bar (mirrors the bot
|
||||
// widget loading bar):
|
||||
// - hidden (showProgress=false, pendingProgressHide=false)
|
||||
// - visible (showProgress=true, pendingProgressHide=false)
|
||||
// - closing (showProgress=true, pendingProgressHide=true) →
|
||||
// progress→hidden landed mid-sweep, waiting for the
|
||||
// animationiteration boundary to land before snapping
|
||||
// invisible so the user always sees a complete left-to-
|
||||
// right sweep, never a mid-stroke cut.
|
||||
// Cycle-complete is only applied for progress→hidden. progress→error
|
||||
// skips it because we want the red layer to take over without a half-
|
||||
// faded green sweep visually mixing on top of it.
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [pendingProgressHide, setPendingProgressHide] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visual !== 'progress') {
|
||||
setShowProgress(false);
|
||||
return undefined;
|
||||
if (visual === 'progress') {
|
||||
setPendingProgressHide(false);
|
||||
if (showProgress) return undefined;
|
||||
const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [visual]);
|
||||
if (visual === 'hidden' && showProgress) {
|
||||
// Reduced-motion: animation is off, no iteration will land,
|
||||
// parking a static green bar for ~2s isn't graceful — it's just
|
||||
// a stuck bar. Snap invisible immediately.
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setShowProgress(false);
|
||||
setPendingProgressHide(false);
|
||||
return undefined;
|
||||
}
|
||||
// Sync recovered while bar was mid-sweep — defer the hide to the
|
||||
// next iteration boundary. Fallback force-hides if the event
|
||||
// never lands (compositor freeze on backgrounded tab).
|
||||
setPendingProgressHide(true);
|
||||
const fallback = setTimeout(() => {
|
||||
setShowProgress(false);
|
||||
setPendingProgressHide(false);
|
||||
}, PROGRESS_HIDE_FALLBACK_MS);
|
||||
return () => clearTimeout(fallback);
|
||||
}
|
||||
// Either visual === 'error' (red layer takes over, drop green now)
|
||||
// or visual === 'hidden' && !showProgress (already hidden, no-op).
|
||||
setShowProgress(false);
|
||||
setPendingProgressHide(false);
|
||||
return undefined;
|
||||
}, [visual, showProgress]);
|
||||
|
||||
const handleProgressIteration = () => {
|
||||
if (pendingProgressHide) {
|
||||
setShowProgress(false);
|
||||
setPendingProgressHide(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showError = visual === 'error';
|
||||
|
||||
|
|
@ -160,6 +215,15 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
|
|||
// 250ms transition (set on barBase). `animationPlayState: 'paused'` on
|
||||
// the green layer when invisible avoids continuous compositor work —
|
||||
// `opacity: 0` does NOT pause CSS animations in any major browser.
|
||||
//
|
||||
// On progress→error we snap the green to opacity:0 with no transition
|
||||
// (`transition: 'none'`). The default 250ms fade-out would otherwise
|
||||
// cross-fade with the red layer's 250ms fade-in — both stack at the
|
||||
// same bottom edge, both at intermediate alpha, producing a muddy
|
||||
// green-on-red blend at the exact moment we're trying to alarm the
|
||||
// user. The `paused` animation also freezes the green at whatever
|
||||
// mid-cycle position it was at, so the cross-fade looks like a static
|
||||
// green smear bleeding into a static red bar. Snap-hide kills it.
|
||||
return (
|
||||
<div className={css.root} aria-hidden>
|
||||
<div
|
||||
|
|
@ -167,7 +231,9 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
|
|||
style={{
|
||||
opacity: showProgress ? 1 : 0,
|
||||
animationPlayState: showProgress ? 'running' : 'paused',
|
||||
transition: showError ? 'none' : undefined,
|
||||
}}
|
||||
onAnimationIteration={handleProgressIteration}
|
||||
/>
|
||||
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue