- {/* Assertive live region for step-change announcements */}
+ {/* Assertive announcement */}
- {/* Polite pending indicator */}
{state.isPending && (
{i18n.updating}
@@ -326,17 +296,10 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker
{/* ── Header ──────────────────────────────────────────────────────── */}
-
+
{i18n.title}
-
+
{i18n.percentCompleteLabel(progressPercent)}
@@ -345,7 +308,7 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker
{i18n.subtitle}
- {/* Progress bar */}
+ {/* CSS-animated progress bar — no framer-motion */}
-
- {/* Step count summary line */}
-
+
{i18n.stepsCompletedLabel(completedCount, sortedSteps.length)}
{isComplete && (
{i18n.allCompleted}
@@ -387,12 +341,8 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker
{/* ── Steps list ───────────────────────────────────────────────────── */}
-
+ {/* @ts-expect-error — AnimatePresence is lazy-loaded, types resolve at runtime */}
{sortedSteps.map((step, index) => {
- const isCurrent = effectiveCurrentStep === step.id;
- const isPending = state.isPending && isCurrent;
+ const isCurrent = effectiveCurrentStep === step.id;
+ const isPending = state.isPending && isCurrent;
const stepDescId = `${progressSummaryId}-desc-${index}`;
const indicatorColorClass = step.completed
@@ -413,27 +364,18 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker
: "border-pluto-200 bg-white text-pluto-600 dark:border-pluto-700 dark:bg-pluto-900/40 dark:text-pluto-400 group-hover:border-pluto-400 group-hover:bg-pluto-50 dark:group-hover:border-pluto-500 dark:group-hover:bg-pluto-800/50";
return (
-
{/* Step indicator button */}
- {/* Step text content */}
+ {/* Step text */}
{step.title}
{step.required && (
-
+
*
)}
-
+
{step.description}
@@ -516,33 +444,29 @@ export const OnboardingProgressTracker = memo(function OnboardingProgressTracker
completedLabel={i18n.completed}
inProgressLabel={i18n.inProgress}
pendingLabel={i18n.pending}
- prefersReducedMotion={prefersReducedMotion}
/>
- {/* Vertical connector line */}
+ {/* Vertical connector */}
{orientation === "vertical" && index < sortedSteps.length - 1 && (
)}
-
+
);
})}
-
+
{/* ── Completion banner ─────────────────────────────────────────────── */}
+ {/* @ts-expect-error — AnimatePresence is lazy-loaded */}
{isComplete && sortedSteps.length > 0 && (
-
-
-
-
+
+
-
- {i18n.successTitle}
-
-
- {i18n.successMessage}
-
+
{i18n.successTitle}
+
{i18n.successMessage}
-
+
)}
diff --git a/frontend/src/components/OnboardingTrackerSkeleton.tsx b/frontend/src/components/OnboardingTrackerSkeleton.tsx
new file mode 100644
index 00000000..a06d4614
--- /dev/null
+++ b/frontend/src/components/OnboardingTrackerSkeleton.tsx
@@ -0,0 +1,106 @@
+/**
+ * OnboardingTrackerSkeleton
+ *
+ * Server-safe, zero-dependency shimmer skeleton used as:
+ * 1. The dynamic() loading fallback while the tracker bundle streams in.
+ * 2. A standalone placeholder for SSR / Suspense boundaries.
+ *
+ * No framer-motion, no hooks, no "use client" — renders identically on
+ * server and client.
+ */
+
+import React from "react";
+
+function Bone({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+function StepRowSkeleton({ compact, isLast }: { compact: boolean; isLast: boolean }) {
+ return (
+
+ {/* Circle */}
+
+ {/* Text lines */}
+
+
+
+
+
+ {!isLast && (
+
+ )}
+
+ );
+}
+
+export interface OnboardingTrackerSkeletonProps {
+ stepCount?: number;
+ compact?: boolean;
+ loadingLabel?: string;
+ className?: string;
+}
+
+export default function OnboardingTrackerSkeleton({
+ stepCount = 3,
+ compact = false,
+ loadingLabel = "Loading onboarding progress…",
+ className = "",
+}: OnboardingTrackerSkeletonProps) {
+ const rows = Array.from({ length: stepCount }, (_, i) => i);
+
+ return (
+
+
{loadingLabel}
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {/* Progress bar track */}
+
+
+
+
+ {/* Step rows */}
+
+ {rows.map((i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/onboarding-reducer.ts b/frontend/src/components/onboarding-reducer.ts
index 2fc95782..71859954 100644
--- a/frontend/src/components/onboarding-reducer.ts
+++ b/frontend/src/components/onboarding-reducer.ts
@@ -1,16 +1,13 @@
/**
* State logic for the Onboarding Progress Tracker.
*
- * Extracted from the component so the optimistic-update lifecycle
- * (optimistic → confirm/rollback) is a pure, independently testable unit.
+ * Extracted so the optimistic-update lifecycle is a pure, independently
+ * testable unit.
*
- * Loading state enhancements:
- * - LOAD_START / LOAD_SUCCESS / LOAD_ERROR actions for initial data fetch
- * - SET_ERROR / CLEAR_ERROR for step-level and global errors
- * - RETRY action to re-enter loading state from an error state
- * - loadingState discriminated union: "idle" | "loading" | "success" | "error"
- * - errorMessage field for surfacing translated error text
- * - retryCount field so the UI can throttle retry attempts
+ * Bundle-optimisation notes:
+ * - Pure TypeScript, zero runtime imports — tree-shakeable by any bundler.
+ * - All selectors are standalone functions so consumers can import only what
+ * they need.
*/
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -19,18 +16,16 @@ export type LoadingState = "idle" | "loading" | "success" | "error";
export interface OnboardingState {
readonly currentStep: string | undefined;
- /** Optimistic step id set immediately on click, cleared on confirm/rollback. */
readonly optimisticStep: string | undefined;
- /** Text queued for the aria-live announcement region. */
readonly announcementText: string;
- /** True while an optimistic update is awaiting server confirmation. */
readonly isPending: boolean;
- /** Discriminated loading state for the initial data fetch. */
readonly loadingState: LoadingState;
- /** Error message to surface in the error banner (null when healthy). */
readonly errorMessage: string | null;
- /** Number of times the user has retried after an error. */
readonly retryCount: number;
+ /** Total step count — synced from props. */
+ readonly totalSteps: number;
+ /** Completed step count — synced from props. */
+ readonly completedSteps: number;
}
export type OnboardingAction =
@@ -39,33 +34,32 @@ export type OnboardingAction =
| { type: "CONFIRM_STEP"; payload: string }
| { type: "ROLLBACK_STEP" }
| { type: "SET_ANNOUNCEMENT"; payload: string }
- /** Begin initial data loading — shows skeleton UI. */
| { type: "LOAD_START" }
- /** Data loaded successfully — clears skeleton. */
| { type: "LOAD_SUCCESS" }
- /** Data loading failed — shows error banner. */
| { type: "LOAD_ERROR"; payload: string }
- /** Set a step-level or general error without going through the load cycle. */
| { type: "SET_ERROR"; payload: string }
- /** Dismiss the error banner. */
| { type: "CLEAR_ERROR" }
- /** Increment retryCount and re-enter loading state. */
- | { type: "RETRY" };
+ | { type: "RETRY" }
+ /** Sync derived counts from the steps prop without remounting. */
+ | { type: "SYNC_STEPS"; payload: { total: number; completed: number } };
// ── Factory ───────────────────────────────────────────────────────────────────
export function createInitialOnboardingState(
currentStep?: string,
- initialLoadingState: LoadingState = "idle",
+ totalSteps = 0,
+ completedSteps = 0,
): OnboardingState {
return {
currentStep,
optimisticStep: undefined,
announcementText: "",
isPending: false,
- loadingState: initialLoadingState,
+ loadingState: "idle",
errorMessage: null,
retryCount: 0,
+ totalSteps,
+ completedSteps,
};
}
@@ -77,23 +71,13 @@ export function onboardingReducer(
): OnboardingState {
switch (action.type) {
case "SET_CURRENT_STEP":
- return {
- ...state,
- currentStep: action.payload,
- optimisticStep: undefined,
- isPending: false,
- };
+ return { ...state, currentStep: action.payload, optimisticStep: undefined, isPending: false };
case "OPTIMISTIC_STEP":
return { ...state, optimisticStep: action.payload, isPending: true };
case "CONFIRM_STEP":
- return {
- ...state,
- currentStep: action.payload,
- optimisticStep: undefined,
- isPending: false,
- };
+ return { ...state, currentStep: action.payload, optimisticStep: undefined, isPending: false };
case "ROLLBACK_STEP":
return { ...state, optimisticStep: undefined, isPending: false };
@@ -102,49 +86,25 @@ export function onboardingReducer(
return { ...state, announcementText: action.payload };
case "LOAD_START":
- return {
- ...state,
- loadingState: "loading",
- errorMessage: null,
- };
+ return { ...state, loadingState: "loading", errorMessage: null };
case "LOAD_SUCCESS":
- return {
- ...state,
- loadingState: "success",
- errorMessage: null,
- };
+ return { ...state, loadingState: "success", errorMessage: null };
case "LOAD_ERROR":
- return {
- ...state,
- loadingState: "error",
- errorMessage: action.payload,
- isPending: false,
- };
+ return { ...state, loadingState: "error", errorMessage: action.payload, isPending: false };
case "SET_ERROR":
- return {
- ...state,
- errorMessage: action.payload,
- loadingState: "error",
- isPending: false,
- };
+ return { ...state, errorMessage: action.payload, loadingState: "error", isPending: false };
case "CLEAR_ERROR":
- return {
- ...state,
- errorMessage: null,
- loadingState: "idle",
- };
+ return { ...state, errorMessage: null, loadingState: "idle" };
case "RETRY":
- return {
- ...state,
- loadingState: "loading",
- errorMessage: null,
- retryCount: state.retryCount + 1,
- };
+ return { ...state, loadingState: "loading", errorMessage: null, retryCount: state.retryCount + 1 };
+
+ case "SYNC_STEPS":
+ return { ...state, totalSteps: action.payload.total, completedSteps: action.payload.completed };
default:
return state;
@@ -158,21 +118,19 @@ export function selectEffectiveStep(state: OnboardingState): string | undefined
return state.optimisticStep ?? state.currentStep;
}
-/** Progress percentage clamped to [0, 100]. */
-export function selectProgressPercent(
- completedSteps: number,
- totalSteps: number,
-): number {
- if (totalSteps === 0) return 0;
- return Math.min(100, Math.round((completedSteps / totalSteps) * 100));
+/**
+ * Progress percentage clamped to [0, 100].
+ * Reads totalSteps / completedSteps directly from state — no extra args needed.
+ */
+export function selectProgressPercent(state: OnboardingState): number {
+ if (state.totalSteps === 0) return 0;
+ return Math.min(100, Math.round((state.completedSteps / state.totalSteps) * 100));
}
-/** True while the component is in any loading-like state. */
export function selectIsLoading(state: OnboardingState): boolean {
return state.loadingState === "loading";
}
-/** True when the component is in a recoverable error state. */
export function selectHasError(state: OnboardingState): boolean {
return state.loadingState === "error" && state.errorMessage !== null;
}
diff --git a/frontend/src/hooks/useOnboardingI18n.ts b/frontend/src/hooks/useOnboardingI18n.ts
index 65b6e658..8f626e17 100644
--- a/frontend/src/hooks/useOnboardingI18n.ts
+++ b/frontend/src/hooks/useOnboardingI18n.ts
@@ -1,18 +1,22 @@
/**
* useOnboardingI18n
*
- * Centralises every translated string used by the Onboarding Progress Tracker.
- * Consuming components call this hook once and receive a stable object of
- * typed helpers — no duplicated useTranslations("onboarding") calls scattered
- * across files.
+ * Centralises every translated string for the Onboarding Progress Tracker.
+ *
+ * Bundle-optimisation notes:
+ * - The returned object is memoised with useMemo keyed on `t` (which only
+ * changes on locale switch) so the hook never causes downstream re-renders
+ * from reference inequality.
+ * - Helper functions are stable useCallback references, not plain arrow
+ * functions, so memo'd children won't re-render on each parent cycle.
*/
"use client";
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
-// ── Step data shape (minimal — component owns the full type) ──────────────────
+// ── Step data shape ───────────────────────────────────────────────────────────
interface StepMeta {
order: number;
@@ -25,7 +29,6 @@ interface StepMeta {
// ── Return type ───────────────────────────────────────────────────────────────
export interface OnboardingI18n {
- // Static strings
title: string;
subtitle: string;
progressBar: string;
@@ -41,27 +44,11 @@ export interface OnboardingI18n {
optional: string;
updating: string;
stepChangeFailed: string;
-
- // Parameterised helpers
- /** "X of Y steps completed" */
stepsCompletedLabel: (completed: number, total: number) => string;
- /** "N% complete" */
percentCompleteLabel: (percent: number) => string;
- /** Progress announcement for aria-live: "Progress: N% complete" */
progressAnnouncement: (percent: number) => string;
- /**
- * Full step button aria-label.
- * Appends ". Completed" and/or ". Required" as appropriate.
- */
- stepAriaLabel: (
- number: number,
- title: string,
- completed: boolean,
- required: boolean,
- ) => string;
- /** Step click announcement for the aria-live region. */
+ stepAriaLabel: (number: number, title: string, completed: boolean, required: boolean) => string;
stepAnnouncement: (step: StepMeta, total: number, statusLabel: string) => string;
- /** Status label for a given step state. */
statusLabel: (completed: boolean, isCurrent: boolean) => string;
}
@@ -70,9 +57,10 @@ export interface OnboardingI18n {
export function useOnboardingI18n(): OnboardingI18n {
const t = useTranslations("onboarding");
+ // ── Stable helper callbacks ───────────────────────────────────────────────
+
const stepsCompletedLabel = useCallback(
- (completed: number, total: number) =>
- t("stepsCompleted", { completed, total }),
+ (completed: number, total: number) => t("stepsCompleted", { completed, total }),
[t],
);
@@ -87,14 +75,8 @@ export function useOnboardingI18n(): OnboardingI18n {
);
const stepAriaLabel = useCallback(
- (
- number: number,
- title: string,
- completed: boolean,
- required: boolean,
- ): string => {
- if (completed && required)
- return t("stepLabelCompletedRequired", { number, title });
+ (number: number, title: string, completed: boolean, required: boolean): string => {
+ if (completed && required) return t("stepLabelCompletedRequired", { number, title });
if (completed) return t("stepLabelCompleted", { number, title });
if (required) return t("stepLabelRequired", { number, title });
return t("stepLabel", { number, title });
@@ -123,27 +105,36 @@ export function useOnboardingI18n(): OnboardingI18n {
[t],
);
- return {
- title: t("title"),
- subtitle: t("subtitle"),
- progressBar: t("progressBar"),
- stepsList: t("stepsList"),
- progressTracker: t("progressTracker"),
- allCompleted: t("allCompleted"),
- successTitle: t("successTitle"),
- successMessage: t("successMessage"),
- completed: t("completed"),
- inProgress: t("inProgress"),
- pending: t("pending"),
- required: t("required"),
- optional: t("optional"),
- updating: t("updating"),
- stepChangeFailed: t("stepChangeFailed"),
- stepsCompletedLabel,
- percentCompleteLabel,
- progressAnnouncement,
- stepAriaLabel,
- stepAnnouncement,
- statusLabel,
- };
+ // ── Memoised return object ────────────────────────────────────────────────
+ // Re-computes only when `t` changes (i.e. on locale switch).
+
+ return useMemo
(
+ () => ({
+ title: t("title"),
+ subtitle: t("subtitle"),
+ progressBar: t("progressBar"),
+ stepsList: t("stepsList"),
+ progressTracker: t("progressTracker"),
+ allCompleted: t("allCompleted"),
+ successTitle: t("successTitle"),
+ successMessage: t("successMessage"),
+ completed: t("completed"),
+ inProgress: t("inProgress"),
+ pending: t("pending"),
+ required: t("required"),
+ optional: t("optional"),
+ updating: t("updating"),
+ stepChangeFailed: t("stepChangeFailed"),
+ stepsCompletedLabel,
+ percentCompleteLabel,
+ progressAnnouncement,
+ stepAriaLabel,
+ stepAnnouncement,
+ statusLabel,
+ }),
+ // t is stable between renders unless the locale changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [t, stepsCompletedLabel, percentCompleteLabel, progressAnnouncement,
+ stepAriaLabel, stepAnnouncement, statusLabel],
+ );
}
diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts
index 7a9b1f46..2849443c 100644
--- a/frontend/src/hooks/useOnboardingProgress.ts
+++ b/frontend/src/hooks/useOnboardingProgress.ts
@@ -1,16 +1,12 @@
/**
* useOnboardingProgress
*
- * Encapsulates all stateful logic for the Onboarding Progress Tracker so the
- * component stays a pure presentation layer.
+ * Encapsulates all stateful logic for the Onboarding Progress Tracker.
*
- * Responsibilities:
- * - Owns the useReducer instance and exposes read-only derived values.
- * - Syncs external prop changes (steps array) into the reducer via SYNC_STEPS.
- * - Syncs external currentStep prop changes via SET_CURRENT_STEP.
- * - Handles optimistic step navigation with async callback + rollback.
- * - Produces translated screen-reader announcement strings via useOnboardingI18n.
- * - Fires onComplete when all required steps are done.
+ * Bundle-optimisation notes:
+ * - No direct framer-motion import — pure React hooks only.
+ * - selectProgressPercent now reads from state directly (no extra args).
+ * - SYNC_STEPS wired correctly with the updated action union.
*/
"use client";
@@ -32,7 +28,7 @@ import {
} from "@/components/onboarding-reducer";
import { useOnboardingI18n } from "@/hooks/useOnboardingI18n";
-// ── Shared step type (re-exported for consumers) ──────────────────────────────
+// ── Shared step type ──────────────────────────────────────────────────────────
export interface OnboardingStep {
id: string;
@@ -95,6 +91,7 @@ export function useOnboardingProgress({
const [state, dispatch] = useReducer(
onboardingReducer,
+ // Factory now takes (currentStep, totalSteps, completedSteps)
createInitialOnboardingState(
currentStepProp ?? sortedSteps[0]?.id,
sortedSteps.length,
@@ -125,6 +122,7 @@ export function useOnboardingProgress({
// ── Derived values ────────────────────────────────────────────────────────
const effectiveCurrentStep = selectEffectiveStep(state);
+ // selectProgressPercent now reads totalSteps/completedSteps from state
const progressPercent = selectProgressPercent(state);
// ── Completion side-effect ────────────────────────────────────────────────
@@ -173,10 +171,7 @@ export function useOnboardingProgress({
dispatch({ type: "CONFIRM_STEP", payload: stepId });
} catch {
dispatch({ type: "ROLLBACK_STEP" });
- dispatch({
- type: "SET_ANNOUNCEMENT",
- payload: i18n.stepChangeFailed,
- });
+ dispatch({ type: "SET_ANNOUNCEMENT", payload: i18n.stepChangeFailed });
}
},
[sortedSteps, effectiveCurrentStep, onStepChange, state.isPending, i18n],
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 083f58c3..93fe250c 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -1,56 +1,82 @@
-/** @type {import('tailwindcss').Config} */
-module.exports = {
- darkMode: "class",
- content: ["./src/**/*.{js,ts,jsx,tsx}"],
- theme: {
- extend: {
- screens: {
- xs: "475px",
- },
- colors: {
- night: "var(--color-night)",
- tide: "var(--color-tide)",
- mint: "var(--color-mint)",
- glow: "var(--color-glow)",
- primary: "var(--color-primary)",
- secondary: "var(--color-secondary)",
- accent: "var(--color-accent)",
- pluto: {
- 50: "var(--pluto-50)",
- 100: "var(--pluto-100)",
- 200: "var(--pluto-200)",
- 300: "var(--pluto-300)",
- 400: "var(--pluto-400)",
- 500: "var(--pluto-500)",
- 600: "var(--pluto-600)",
- 700: "var(--pluto-700)",
- 800: "var(--pluto-800)",
- 900: "var(--pluto-900)",
- },
- gray: {
- 950: "#000000",
- },
- },
- fontFamily: {
- heading: ["var(--font-heading)", "system-ui", "sans-serif"],
- body: ["var(--font-sans)", "system-ui", "sans-serif"],
- sans: ["var(--font-sans)", "system-ui", "sans-serif"],
- mono: ["var(--font-mono)", "ui-monospace", "monospace"],
- },
- keyframes: {
- "payment-confirmed": {
- "0%": { backgroundColor: "rgba(34, 197, 94, 0.3)" },
- "50%": { backgroundColor: "rgba(34, 197, 94, 0.15)" },
- "100%": { backgroundColor: "transparent" },
- },
- },
- animation: {
- "payment-confirmed": "payment-confirmed 1.2s ease-out forwards",
- },
- backgroundColor: {
- dark: "#000000",
- },
- },
- },
- plugins: [],
-};
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ darkMode: "class",
+ content: ["./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ screens: {
+ xs: "475px",
+ },
+ colors: {
+ night: "var(--color-night)",
+ tide: "var(--color-tide)",
+ mint: "var(--color-mint)",
+ glow: "var(--color-glow)",
+ primary: "var(--color-primary)",
+ secondary: "var(--color-secondary)",
+ accent: "var(--color-accent)",
+ pluto: {
+ 50: "var(--pluto-50)",
+ 100: "var(--pluto-100)",
+ 200: "var(--pluto-200)",
+ 300: "var(--pluto-300)",
+ 400: "var(--pluto-400)",
+ 500: "var(--pluto-500)",
+ 600: "var(--pluto-600)",
+ 700: "var(--pluto-700)",
+ 800: "var(--pluto-800)",
+ 900: "var(--pluto-900)",
+ },
+ gray: {
+ 950: "#000000",
+ },
+ },
+ fontFamily: {
+ heading: ["var(--font-heading)", "system-ui", "sans-serif"],
+ body: ["var(--font-sans)", "system-ui", "sans-serif"],
+ sans: ["var(--font-sans)", "system-ui", "sans-serif"],
+ mono: ["var(--font-mono)", "ui-monospace", "monospace"],
+ },
+ keyframes: {
+ "payment-confirmed": {
+ "0%": { backgroundColor: "rgba(34, 197, 94, 0.3)" },
+ "50%": { backgroundColor: "rgba(34, 197, 94, 0.15)" },
+ "100%": { backgroundColor: "transparent" },
+ },
+ shimmer: {
+ "0%": { backgroundPosition: "200% center" },
+ "100%": { backgroundPosition: "-200% center" },
+ },
+ "onboarding-fill": {
+ from: { transform: "scaleX(0)" },
+ to: { transform: "scaleX(1)" },
+ },
+ "onboarding-fade-up": {
+ from: { opacity: "0", transform: "translateY(8px)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "onboarding-check-pop": {
+ "0%": { transform: "scale(0)", opacity: "0" },
+ "70%": { transform: "scale(1.15)", opacity: "1" },
+ "100%": { transform: "scale(1)", opacity: "1" },
+ },
+ "onboarding-spin": {
+ from: { transform: "rotate(0deg)" },
+ to: { transform: "rotate(360deg)" },
+ },
+ },
+ animation: {
+ "payment-confirmed": "payment-confirmed 1.2s ease-out forwards",
+ shimmer: "shimmer 1.6s ease-in-out infinite",
+ "onboarding-fill": "onboarding-fill 0.55s cubic-bezier(0.16,1,0.3,1) forwards",
+ "onboarding-fade-up": "onboarding-fade-up 0.3s ease-out forwards",
+ "onboarding-check-pop": "onboarding-check-pop 0.35s cubic-bezier(0.16,1,0.3,1) forwards",
+ "onboarding-spin": "onboarding-spin 0.9s linear infinite",
+ },
+ backgroundColor: {
+ dark: "#000000",
+ },
+ },
+ },
+ plugins: [],
+};