@exxatdesignux/ui 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/init.mjs +29 -0
- package/package.json +7 -2
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +485 -0
- package/template/Logo/Exxat_Prism.svg +39 -0
- package/template/Logo/Exxat_one.svg +36 -0
- package/template/README.md +58 -0
- package/template/app/(app)/compliance/page.tsx +10 -0
- package/template/app/(app)/dashboard/loading.tsx +18 -0
- package/template/app/(app)/dashboard/page.tsx +36 -0
- package/template/app/(app)/data-list/[id]/page.tsx +28 -0
- package/template/app/(app)/data-list/new/page.tsx +31 -0
- package/template/app/(app)/data-list/page.tsx +10 -0
- package/template/app/(app)/error.tsx +43 -0
- package/template/app/(app)/help/page.tsx +34 -0
- package/template/app/(app)/layout.tsx +54 -0
- package/template/app/(app)/loading.tsx +18 -0
- package/template/app/(app)/question-bank/page.tsx +10 -0
- package/template/app/(app)/rotations/page.tsx +15 -0
- package/template/app/(app)/settings/page.tsx +17 -0
- package/template/app/(app)/sites/all/page.tsx +13 -0
- package/template/app/(app)/team/page.tsx +10 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +1811 -0
- package/template/app/layout.tsx +95 -0
- package/template/app/page.tsx +9 -0
- package/template/components/.gitkeep +0 -0
- package/template/components/app-sidebar-dynamic.tsx +15 -0
- package/template/components/app-sidebar.tsx +901 -0
- package/template/components/ask-leo-composer.tsx +216 -0
- package/template/components/ask-leo-sidebar.tsx +509 -0
- package/template/components/chart-area-interactive.tsx +293 -0
- package/template/components/charts-overview.tsx +2321 -0
- package/template/components/command-menu-01.tsx +133 -0
- package/template/components/command-menu-02.tsx +386 -0
- package/template/components/command-menu.tsx +182 -0
- package/template/components/compliance-board-view.tsx +134 -0
- package/template/components/compliance-client.tsx +92 -0
- package/template/components/compliance-list-view.tsx +59 -0
- package/template/components/compliance-page-header.tsx +89 -0
- package/template/components/compliance-table.tsx +525 -0
- package/template/components/dashboard-onboarding-gallery.tsx +13 -0
- package/template/components/dashboard-onboarding.tsx +21 -0
- package/template/components/dashboard-promo-banner.tsx +67 -0
- package/template/components/dashboard-quota-progress-card.tsx +369 -0
- package/template/components/dashboard-report-charts.tsx +69 -0
- package/template/components/dashboard-section-heading.tsx +68 -0
- package/template/components/dashboard-tabs.tsx +598 -0
- package/template/components/data-list-client.tsx +239 -0
- package/template/components/data-list-table-cells.test.tsx +22 -0
- package/template/components/data-list-table-cells.tsx +173 -0
- package/template/components/data-list-table.tsx +879 -0
- package/template/components/data-table/filter-date-calendar.tsx +38 -0
- package/template/components/data-table/filter-text-value-input.tsx +77 -0
- package/template/components/data-table/index.tsx +1612 -0
- package/template/components/data-table/pagination.tsx +256 -0
- package/template/components/data-table/types.ts +91 -0
- package/template/components/data-table/use-table-state.ts +566 -0
- package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
- package/template/components/data-view-dashboard-charts-team.tsx +968 -0
- package/template/components/data-view-dashboard-charts.tsx +1668 -0
- package/template/components/data-views/board-card-primitives.tsx +93 -0
- package/template/components/data-views/index.ts +41 -0
- package/template/components/data-views/list-page-board-card.tsx +192 -0
- package/template/components/data-views/list-page-board-template.tsx +122 -0
- package/template/components/data-views/placement-board-card.tsx +262 -0
- package/template/components/export-drawer.tsx +375 -0
- package/template/components/exxat-product-logo.tsx +453 -0
- package/template/components/form-layout-01.tsx +131 -0
- package/template/components/getting-started.tsx +625 -0
- package/template/components/key-metrics.tsx +920 -0
- package/template/components/leo-insight-indicator.tsx +364 -0
- package/template/components/leo-typing-dots.tsx +121 -0
- package/template/components/list-hub-status-badge.tsx +51 -0
- package/template/components/list-page-dashboard-charts.tsx +18 -0
- package/template/components/nav-documents.tsx +89 -0
- package/template/components/nav-main.tsx +58 -0
- package/template/components/nav-secondary.tsx +64 -0
- package/template/components/nav-user.tsx +190 -0
- package/template/components/new-placement-back-btn.tsx +28 -0
- package/template/components/new-placement-form.tsx +1066 -0
- package/template/components/onboarding/index.ts +4 -0
- package/template/components/onboarding/onboarding-01.tsx +7 -0
- package/template/components/onboarding/onboarding-02.tsx +7 -0
- package/template/components/onboarding/onboarding-03.tsx +7 -0
- package/template/components/onboarding/onboarding-04.tsx +7 -0
- package/template/components/page-header.tsx +57 -0
- package/template/components/placement-detail.tsx +438 -0
- package/template/components/placements-board-view.tsx +404 -0
- package/template/components/placements-list-view.tsx +285 -0
- package/template/components/placements-page-header.tsx +160 -0
- package/template/components/placements-table-columns.tsx +639 -0
- package/template/components/product-switcher.tsx +116 -0
- package/template/components/question-bank-board-view.tsx +205 -0
- package/template/components/question-bank-client.tsx +77 -0
- package/template/components/question-bank-list-view.tsx +59 -0
- package/template/components/question-bank-page-header.tsx +89 -0
- package/template/components/question-bank-table.tsx +586 -0
- package/template/components/rotations-empty-state.tsx +47 -0
- package/template/components/rotations-panel-activator.tsx +8 -0
- package/template/components/secondary-nav.tsx +394 -0
- package/template/components/secondary-panel.tsx +239 -0
- package/template/components/section-cards.tsx +106 -0
- package/template/components/settings-appearance-card.tsx +424 -0
- package/template/components/settings-client.tsx +537 -0
- package/template/components/settings-form-row.tsx +42 -0
- package/template/components/sidebar-auto-collapse.tsx +23 -0
- package/template/components/sidebar-auto-open.tsx +18 -0
- package/template/components/sidebar-shell.tsx +37 -0
- package/template/components/site-header.tsx +93 -0
- package/template/components/sites-all-client.tsx +154 -0
- package/template/components/sites-board-view.tsx +67 -0
- package/template/components/sites-list-view.tsx +47 -0
- package/template/components/sites-table.tsx +312 -0
- package/template/components/system-banner-slot.tsx +66 -0
- package/template/components/table-properties/column-row.tsx +90 -0
- package/template/components/table-properties/draggable-list.ts +49 -0
- package/template/components/table-properties/drawer-button.tsx +231 -0
- package/template/components/table-properties/drawer.tsx +1102 -0
- package/template/components/table-properties/filter-card.tsx +251 -0
- package/template/components/table-properties/index.ts +22 -0
- package/template/components/table-properties/sort-card.tsx +59 -0
- package/template/components/table-properties/types.ts +124 -0
- package/template/components/task-list-panel.tsx +98 -0
- package/template/components/task-priority-badge.tsx +28 -0
- package/template/components/team-board-view.tsx +114 -0
- package/template/components/team-client.tsx +93 -0
- package/template/components/team-list-view.tsx +62 -0
- package/template/components/team-page-header.tsx +92 -0
- package/template/components/team-table.tsx +525 -0
- package/template/components/templates/list-page.tsx +576 -0
- package/template/components/templates/primary-page-template.tsx +56 -0
- package/template/components/theme-color-sync.tsx +32 -0
- package/template/components/theme-provider.tsx +71 -0
- package/template/components/tinted-icon-disc.tsx +53 -0
- package/template/components/ui/ai-thinking-surface.tsx +121 -0
- package/template/components/ui/avatar.tsx +1 -0
- package/template/components/ui/badge.tsx +1 -0
- package/template/components/ui/banner.tsx +1 -0
- package/template/components/ui/breadcrumb.tsx +1 -0
- package/template/components/ui/button.tsx +1 -0
- package/template/components/ui/calendar.tsx +1 -0
- package/template/components/ui/card.tsx +1 -0
- package/template/components/ui/chart.tsx +1 -0
- package/template/components/ui/checkbox.tsx +1 -0
- package/template/components/ui/coach-mark.tsx +1 -0
- package/template/components/ui/collapsible.tsx +1 -0
- package/template/components/ui/command.tsx +1 -0
- package/template/components/ui/date-picker-field.tsx +1 -0
- package/template/components/ui/dialog.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +159 -0
- package/template/components/ui/drag-handle-grip.tsx +1 -0
- package/template/components/ui/drawer.tsx +1 -0
- package/template/components/ui/dropdown-menu.tsx +1 -0
- package/template/components/ui/field.tsx +1 -0
- package/template/components/ui/form.tsx +1 -0
- package/template/components/ui/input-group.tsx +1 -0
- package/template/components/ui/input-mask.tsx +1 -0
- package/template/components/ui/input.tsx +1 -0
- package/template/components/ui/kbd.tsx +1 -0
- package/template/components/ui/label.tsx +1 -0
- package/template/components/ui/leo-icon.tsx +726 -0
- package/template/components/ui/payment-card-fields.tsx +1 -0
- package/template/components/ui/popover.tsx +1 -0
- package/template/components/ui/radio-group.tsx +1 -0
- package/template/components/ui/select.tsx +1 -0
- package/template/components/ui/selection-tile-grid.tsx +1 -0
- package/template/components/ui/separator.tsx +1 -0
- package/template/components/ui/sheet.tsx +1 -0
- package/template/components/ui/sidebar.tsx +1 -0
- package/template/components/ui/skeleton.tsx +1 -0
- package/template/components/ui/sonner.tsx +1 -0
- package/template/components/ui/status-badge.tsx +1 -0
- package/template/components/ui/table.tsx +1 -0
- package/template/components/ui/tabs.tsx +1 -0
- package/template/components/ui/textarea.tsx +1 -0
- package/template/components/ui/tip.tsx +1 -0
- package/template/components/ui/toggle-group.tsx +1 -0
- package/template/components/ui/toggle-switch.tsx +1 -0
- package/template/components/ui/toggle.tsx +1 -0
- package/template/components/ui/tooltip.tsx +1 -0
- package/template/components/ui/view-segmented-control.tsx +1 -0
- package/template/components.json +27 -0
- package/template/contexts/chart-variant-context.tsx +35 -0
- package/template/contexts/command-menu-context.tsx +28 -0
- package/template/contexts/dashboard-view-context.tsx +35 -0
- package/template/contexts/product-context.tsx +38 -0
- package/template/contexts/system-banner-context.tsx +127 -0
- package/template/docs/command-menu-pattern.md +45 -0
- package/template/docs/data-views-pattern.md +160 -0
- package/template/ecosystem.config.cjs +20 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fontawesome-subset.manifest.json +190 -0
- package/template/hooks/.gitkeep +0 -0
- package/template/hooks/use-app-theme.ts +1 -0
- package/template/hooks/use-coach-mark.ts +1 -0
- package/template/hooks/use-mobile.ts +1 -0
- package/template/hooks/use-mod-key-label.ts +1 -0
- package/template/lib/.gitkeep +0 -0
- package/template/lib/ask-leo-route-context.ts +133 -0
- package/template/lib/chart-keyboard-selection.test.ts +20 -0
- package/template/lib/chart-keyboard-selection.ts +17 -0
- package/template/lib/chart-line-dash.ts +16 -0
- package/template/lib/coach-mark-registry.ts +68 -0
- package/template/lib/command-menu-config.ts +127 -0
- package/template/lib/command-menu-search-data.ts +44 -0
- package/template/lib/conditional-rule-match.ts +32 -0
- package/template/lib/dashboard-customize-coach-mark.ts +18 -0
- package/template/lib/dashboard-layout-merge.ts +63 -0
- package/template/lib/data-list-display-options.ts +35 -0
- package/template/lib/data-list-persistence.ts +280 -0
- package/template/lib/data-list-view-surface.ts +58 -0
- package/template/lib/data-list-view.ts +29 -0
- package/template/lib/data-view-dashboard-storage.ts +101 -0
- package/template/lib/date-filter.ts +8 -0
- package/template/lib/dev-log.test.ts +28 -0
- package/template/lib/dev-log.ts +8 -0
- package/template/lib/editable-target.ts +10 -0
- package/template/lib/floating-sheet-panel.ts +72 -0
- package/template/lib/initials-from-name.ts +7 -0
- package/template/lib/list-page-table-properties.ts +52 -0
- package/template/lib/list-status-badges.ts +168 -0
- package/template/lib/logo-dev.ts +12 -0
- package/template/lib/mock/compliance-kpi.ts +61 -0
- package/template/lib/mock/compliance.ts +146 -0
- package/template/lib/mock/dashboard.ts +105 -0
- package/template/lib/mock/navigation.tsx +231 -0
- package/template/lib/mock/placements-kpi.ts +134 -0
- package/template/lib/mock/placements.ts +183 -0
- package/template/lib/mock/question-bank-kpi.ts +61 -0
- package/template/lib/mock/question-bank.ts +142 -0
- package/template/lib/mock/sites-directory.ts +16 -0
- package/template/lib/mock/sites-kpi.ts +25 -0
- package/template/lib/mock/team-kpi.ts +60 -0
- package/template/lib/mock/team.ts +118 -0
- package/template/lib/motion-ui.ts +17 -0
- package/template/lib/placement-board-card-layout.ts +79 -0
- package/template/lib/placement-lifecycle.ts +5 -0
- package/template/lib/row-height.ts +10 -0
- package/template/lib/stock-portrait.ts +11 -0
- package/template/lib/utils.test.ts +13 -0
- package/template/lib/utils.ts +1 -0
- package/template/next.config.mjs +15 -0
- package/template/package.json +83 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/public/Illustration/Rotation.svg +74 -0
- package/template/public/avatars/user.svg +11 -0
- package/template/public/favicon/favicon.ico +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logos/exxat-one.svg +36 -0
- package/template/public/logos/exxat-prism.svg +39 -0
- package/template/public/mock-schools/emory.svg +4 -0
- package/template/public/mock-schools/rush.svg +4 -0
- package/template/scripts/fontawesome-subset-audit.mjs +190 -0
- package/template/scripts/pm2-startup-macos.sh +13 -0
- package/template/skills-lock.json +10 -0
- package/template/stores/app-store.ts +33 -0
- package/template/tests/setup.ts +1 -0
- package/template/tsconfig.json +35 -0
- package/template/types/react-payment-inputs.d.ts +19 -0
- package/template/vitest.config.ts +18 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LeoIcon — character-driven Ask Leo icon.
|
|
5
|
+
*
|
|
6
|
+
* Geometry: faithful translation of Figma node 171:1022 (fa-star-christmas).
|
|
7
|
+
* The star is a 4-armed plus/cross with rounded caps (Primary) plus 4
|
|
8
|
+
* diagonal rounded-capsule sparkles in the corners (Secondary, opacity 0.4).
|
|
9
|
+
*
|
|
10
|
+
* Motion philosophy — 2D only, character-driven:
|
|
11
|
+
* • No 3D perspective. The star lives on the screen plane, not in space.
|
|
12
|
+
* • Continuous spring reactions to cursor — never keyframe "pops".
|
|
13
|
+
* • Head-tilt (rotateZ), magnetic drift, proximity scale — 2D, readable.
|
|
14
|
+
* • Each corner sparkle tracks cursor direction independently: the sparkle
|
|
15
|
+
* the cursor points toward brightens, scales, and leans outward while
|
|
16
|
+
* the others stay quiet. This reads as "the star noticed you".
|
|
17
|
+
* • Idle breath + saccades keep running during hover (composed via nested
|
|
18
|
+
* transforms), so the star is always alive.
|
|
19
|
+
* • Click = brief squash (0.92) + expanding ring + sparkle burst.
|
|
20
|
+
*
|
|
21
|
+
* variant="ambient" Breathing presence — no cursor reactions.
|
|
22
|
+
* variant="interactive" Full cursor tracking for hero/welcome surfaces.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as React from "react"
|
|
26
|
+
import {
|
|
27
|
+
animate,
|
|
28
|
+
motion,
|
|
29
|
+
AnimatePresence,
|
|
30
|
+
useMotionValue,
|
|
31
|
+
useSpring,
|
|
32
|
+
useTransform,
|
|
33
|
+
useReducedMotion,
|
|
34
|
+
type Variants,
|
|
35
|
+
type MotionValue,
|
|
36
|
+
} from "motion/react"
|
|
37
|
+
import { cn } from "@/lib/utils"
|
|
38
|
+
|
|
39
|
+
// Glow color for atmospheric layers — same brand color as the star body,
|
|
40
|
+
// kept at very low opacities so it reads as a subtle halo, not a blob.
|
|
41
|
+
const GLOW = "var(--brand-color)"
|
|
42
|
+
|
|
43
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export type LeoIconVariant = "ambient" | "interactive"
|
|
46
|
+
export type LeoIconSize = "sm" | "md" | "lg" | "xl"
|
|
47
|
+
|
|
48
|
+
export interface LeoIconProps {
|
|
49
|
+
variant?: LeoIconVariant
|
|
50
|
+
size?: LeoIconSize
|
|
51
|
+
className?: string
|
|
52
|
+
style?: React.CSSProperties
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type SZ = { root: string; px: number }
|
|
56
|
+
|
|
57
|
+
const SIZES: Record<LeoIconSize, SZ> = {
|
|
58
|
+
sm: { root: "size-8", px: 32 },
|
|
59
|
+
md: { root: "size-10", px: 40 },
|
|
60
|
+
lg: { root: "size-14", px: 56 },
|
|
61
|
+
xl: { root: "size-20", px: 80 },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Easings ──────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const EASE_BREATH = [0.45, 0.05, 0.2, 1] as const
|
|
67
|
+
const EASE_SOFT = [0.22, 1, 0.36, 1] as const
|
|
68
|
+
|
|
69
|
+
// ─── Geometry (from Figma node 171:1022 — viewBox 0 0 168 168, center 84,84)
|
|
70
|
+
|
|
71
|
+
const STAR_BODY_PATH =
|
|
72
|
+
"M70 98L31.3906 88.3531C29.4 87.85 28 86.0562 28 84C28 81.9438 29.4 80.15 31.3906 79.6469L70 70L79.6469 31.3906C80.15 29.4 81.9438 28 84 28C86.0562 28 87.85 29.4 88.3531 31.3906L98 70L136.609 79.6469C138.6 80.15 140 81.9438 140 84C140 86.0562 138.6 87.85 136.609 88.3531L98 98L88.3531 136.609C87.85 138.6 86.0562 140 84 140C81.9438 140 80.15 138.6 79.6469 136.609L70 98Z"
|
|
73
|
+
|
|
74
|
+
interface SparkleCfg {
|
|
75
|
+
id: "ne" | "se" | "sw" | "nw"
|
|
76
|
+
path: string
|
|
77
|
+
/** outward unit vector from center (84,84) */
|
|
78
|
+
diag: readonly [number, number]
|
|
79
|
+
/** stagger phase (seconds) for idle pulsing */
|
|
80
|
+
phase: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SPARKLES: readonly SparkleCfg[] = [
|
|
84
|
+
{
|
|
85
|
+
id: "nw",
|
|
86
|
+
path: "M43.5313 43.5313C41.475 45.5875 41.475 48.9125 43.5313 50.9469L54.0313 61.4469C56.0875 63.5031 59.4125 63.5031 61.4469 61.4469C63.4813 59.3906 63.5031 56.0656 61.4469 54.0313L50.9688 43.5313C48.9125 41.475 45.5875 41.475 43.5531 43.5313H43.5313Z",
|
|
87
|
+
diag: [-1, -1],
|
|
88
|
+
phase: 2.4,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "sw",
|
|
92
|
+
path: "M43.5313 117.031C41.475 119.087 41.475 122.412 43.5313 124.447C45.5875 126.481 48.9125 126.503 50.9469 124.447L61.4469 113.947C63.5031 111.891 63.5031 108.566 61.4469 106.531C59.3906 104.497 56.0656 104.475 54.0313 106.531L43.5313 117.031Z",
|
|
93
|
+
diag: [-1, 1],
|
|
94
|
+
phase: 1.6,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "ne",
|
|
98
|
+
path: "M106.531 54.0313C104.475 56.0875 104.475 59.4125 106.531 61.4469C108.587 63.4813 111.912 63.5031 113.947 61.4469L124.447 50.9469C126.503 48.8906 126.503 45.5656 124.447 43.5313C122.391 41.4969 119.066 41.475 117.031 43.5313L106.531 54.0313Z",
|
|
99
|
+
diag: [1, -1],
|
|
100
|
+
phase: 0.0,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "se",
|
|
104
|
+
path: "M106.531 106.531C104.475 108.587 104.475 111.912 106.531 113.947L117.031 124.447C119.087 126.503 122.412 126.503 124.447 124.447C126.481 122.391 126.503 119.066 124.447 117.031L113.947 106.531C111.891 104.475 108.566 104.475 106.531 106.531Z",
|
|
105
|
+
diag: [1, 1],
|
|
106
|
+
phase: 0.8,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
// ─── Variants ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
// Star body: always breathes + saccades. Never hover-popped — cursor reactions
|
|
113
|
+
// live on the outer wrapper and compose via nested transforms.
|
|
114
|
+
const starBodyVariants: Variants = {
|
|
115
|
+
idle: {
|
|
116
|
+
scale: [1, 1.032, 1, 1.02, 1],
|
|
117
|
+
rotate: [0, 0, 2, 0, 0, -2.4, 0, 0, 1.2, 0, 0],
|
|
118
|
+
transition: {
|
|
119
|
+
scale: {
|
|
120
|
+
duration: 6, repeat: Infinity, ease: EASE_BREATH,
|
|
121
|
+
times: [0, 0.25, 0.5, 0.75, 1],
|
|
122
|
+
},
|
|
123
|
+
rotate: {
|
|
124
|
+
duration: 11, repeat: Infinity, ease: "easeOut",
|
|
125
|
+
times: [0, 0.18, 0.20, 0.26, 0.46, 0.48, 0.55, 0.74, 0.76, 0.83, 1],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Sparkle inner (idle twinkle + click scatter along its own diagonal)
|
|
132
|
+
const sparkleInnerVariantsFor = (
|
|
133
|
+
phase: number, diag: readonly [number, number],
|
|
134
|
+
): Variants => ({
|
|
135
|
+
idle: {
|
|
136
|
+
opacity: [0.75, 1, 0.75, 0.9, 0.75],
|
|
137
|
+
scale: [0.92, 1.08, 0.92, 1.02, 0.92],
|
|
138
|
+
x: 0, y: 0,
|
|
139
|
+
transition: {
|
|
140
|
+
duration: 3.2, delay: phase, repeat: Infinity, ease: "easeInOut",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
scatter: {
|
|
144
|
+
opacity: [1, 0],
|
|
145
|
+
scale: [1.4, 0.6],
|
|
146
|
+
x: diag[0] * 18,
|
|
147
|
+
y: diag[1] * 18,
|
|
148
|
+
transition: { duration: 0.65, ease: [0.2, 1, 0.4, 1] },
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const SPARKLE_VARIANTS_BY_ID: Record<SparkleCfg["id"], Variants> = {
|
|
153
|
+
ne: sparkleInnerVariantsFor(0.0, [ 1, -1]),
|
|
154
|
+
se: sparkleInnerVariantsFor(0.8, [ 1, 1]),
|
|
155
|
+
sw: sparkleInnerVariantsFor(1.6, [-1, 1]),
|
|
156
|
+
nw: sparkleInnerVariantsFor(2.4, [-1, -1]),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Per-sparkle directional response to cursor ──────────────────────────────
|
|
160
|
+
// Outer <g> wraps the sparkle. Its style reacts to how aligned the cursor is
|
|
161
|
+
// with this sparkle's outward direction. Sparkles in the cursor's direction
|
|
162
|
+
// brighten, grow, and lean outward; others stay at their base opacity.
|
|
163
|
+
// `bornAmount` (0→1) scales the base opacity during the birth animation so
|
|
164
|
+
// sparkles bloom in *after* the main body materializes.
|
|
165
|
+
|
|
166
|
+
function CornerSparkle({
|
|
167
|
+
c, reduced, cast, mx, my, bornAmount,
|
|
168
|
+
}: {
|
|
169
|
+
c: SparkleCfg
|
|
170
|
+
reduced: boolean
|
|
171
|
+
cast: boolean
|
|
172
|
+
mx: MotionValue<number>
|
|
173
|
+
my: MotionValue<number>
|
|
174
|
+
bornAmount: MotionValue<number>
|
|
175
|
+
}) {
|
|
176
|
+
// Unit vector in the sparkle's outward direction.
|
|
177
|
+
const sx = c.diag[0] / Math.SQRT2
|
|
178
|
+
const sy = c.diag[1] / Math.SQRT2
|
|
179
|
+
|
|
180
|
+
// Alignment: how much the cursor vector points at this sparkle. Range [0, 1].
|
|
181
|
+
// Combines direction (dot product with sparkle's outward vector) with
|
|
182
|
+
// proximity magnitude so distant cursors barely register.
|
|
183
|
+
const align = useTransform([mx, my] as MotionValue<number>[], ([x, y]) => {
|
|
184
|
+
const mag = Math.hypot(x as number, y as number)
|
|
185
|
+
if (mag < 0.01) return 0
|
|
186
|
+
const dot = ((x as number) * sx + (y as number) * sy) / mag
|
|
187
|
+
const magScale = Math.min(1, mag * 2) // mag range [0, 0.5] → [0, 1]
|
|
188
|
+
return Math.max(0, Math.min(1, dot * magScale))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Spring the alignment so the reaction feels organic, not snappy.
|
|
192
|
+
const sprAlign = useSpring(align, { stiffness: 180, damping: 26, mass: 0.5 })
|
|
193
|
+
|
|
194
|
+
// Derived outer-group reactions — multiplied by bornAmount so sparkles are
|
|
195
|
+
// invisible during body birth, then fade in.
|
|
196
|
+
const outerOpacity = useTransform(
|
|
197
|
+
[sprAlign, bornAmount] as MotionValue<number>[],
|
|
198
|
+
([a, b]) => (0.4 + (a as number) * 0.55) * (b as number),
|
|
199
|
+
)
|
|
200
|
+
const outerScale = useTransform(sprAlign, v => 1 + v * 0.35)
|
|
201
|
+
const outerX = useTransform(sprAlign, v => c.diag[0] * v * 6)
|
|
202
|
+
const outerY = useTransform(sprAlign, v => c.diag[1] * v * 6)
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<motion.g
|
|
206
|
+
style={{
|
|
207
|
+
opacity: reduced ? 0.4 : outerOpacity,
|
|
208
|
+
scale: reduced ? 1 : outerScale,
|
|
209
|
+
x: reduced ? 0 : outerX,
|
|
210
|
+
y: reduced ? 0 : outerY,
|
|
211
|
+
transformBox: "fill-box",
|
|
212
|
+
transformOrigin: "center",
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<motion.path
|
|
216
|
+
d={c.path}
|
|
217
|
+
fill="var(--brand-color)"
|
|
218
|
+
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
|
219
|
+
variants={SPARKLE_VARIANTS_BY_ID[c.id]}
|
|
220
|
+
animate={reduced ? undefined : cast ? "scatter" : "idle"}
|
|
221
|
+
/>
|
|
222
|
+
</motion.g>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Birth animation — "from a single point, a star" ────────────────────────
|
|
227
|
+
// Outer wrapper plays on mount: starts as a scale-0 bright-blurry pinpoint
|
|
228
|
+
// and blooms into a crisp star. Runs once, then sits at its resting state.
|
|
229
|
+
|
|
230
|
+
const birthVariants: Variants = {
|
|
231
|
+
hidden: {
|
|
232
|
+
scale: 0,
|
|
233
|
+
opacity: 0,
|
|
234
|
+
filter: "blur(4px)",
|
|
235
|
+
},
|
|
236
|
+
live: {
|
|
237
|
+
scale: [0, 0.12, 1.04, 1],
|
|
238
|
+
opacity: [0, 1, 1, 1],
|
|
239
|
+
filter: ["blur(4px)", "blur(2.2px)", "blur(0px)", "blur(0px)"],
|
|
240
|
+
transition: {
|
|
241
|
+
duration: 0.9,
|
|
242
|
+
times: [0, 0.18, 0.78, 1],
|
|
243
|
+
ease: [0.2, 0.8, 0.2, 1],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Core SVG — 2D only. Cursor reactions on the inner wrapper. ──────────────
|
|
249
|
+
|
|
250
|
+
function LeoStarSVG({
|
|
251
|
+
px, reduced, pressed, cast, mx, my, engage,
|
|
252
|
+
}: {
|
|
253
|
+
px: number
|
|
254
|
+
reduced: boolean
|
|
255
|
+
pressed: boolean
|
|
256
|
+
cast: boolean
|
|
257
|
+
mx: MotionValue<number>
|
|
258
|
+
my: MotionValue<number>
|
|
259
|
+
engage: MotionValue<number>
|
|
260
|
+
}) {
|
|
261
|
+
// 2D reactions — tight but subtle. No 3D space at all.
|
|
262
|
+
const tiltCfg = { stiffness: 200, damping: 22, mass: 0.55 }
|
|
263
|
+
const rotZ = useSpring(useTransform(mx, [-0.5, 0.5], [-10, 10]), tiltCfg)
|
|
264
|
+
const shiftX = useSpring(useTransform(mx, [-0.5, 0.5], [-6, 6]), tiltCfg)
|
|
265
|
+
const shiftY = useSpring(useTransform(my, [-0.5, 0.5], [-6, 6]), tiltCfg)
|
|
266
|
+
|
|
267
|
+
// Proximity scale driven by `engage` spring (0 → 1 on hover in, decays on out).
|
|
268
|
+
const proxScale = useTransform(engage, [0, 1], [1, 1.1])
|
|
269
|
+
|
|
270
|
+
// Quick click squash on the star body (composed with idle breath via nested g).
|
|
271
|
+
const pressScale = useSpring(pressed ? 0.92 : 1, {
|
|
272
|
+
stiffness: 380, damping: 26, mass: 0.4,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Birth → live handoff. Once born, sparkles are allowed to appear.
|
|
276
|
+
const bornAmount = useMotionValue(reduced ? 1 : 0)
|
|
277
|
+
React.useEffect(() => {
|
|
278
|
+
if (reduced) { bornAmount.set(1); return }
|
|
279
|
+
const controls = animate(bornAmount, 1, {
|
|
280
|
+
duration: 0.55, delay: 0.4, ease: [0.22, 1, 0.36, 1],
|
|
281
|
+
})
|
|
282
|
+
return () => controls.stop()
|
|
283
|
+
}, [bornAmount, reduced])
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
// Outer: birth animation (runs once on mount)
|
|
287
|
+
<motion.span
|
|
288
|
+
style={{ display: "inline-flex" }}
|
|
289
|
+
variants={birthVariants}
|
|
290
|
+
initial={reduced ? false : "hidden"}
|
|
291
|
+
animate="live"
|
|
292
|
+
>
|
|
293
|
+
{/* Inner: cursor reactions (always active) */}
|
|
294
|
+
<motion.span
|
|
295
|
+
style={{
|
|
296
|
+
display: "inline-flex",
|
|
297
|
+
rotate: reduced ? 0 : rotZ,
|
|
298
|
+
scale: reduced ? 1 : proxScale,
|
|
299
|
+
x: reduced ? 0 : shiftX,
|
|
300
|
+
y: reduced ? 0 : shiftY,
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<svg
|
|
304
|
+
width={px}
|
|
305
|
+
height={px}
|
|
306
|
+
viewBox="0 0 168 168"
|
|
307
|
+
aria-hidden
|
|
308
|
+
style={{ overflow: "visible", display: "block" }}
|
|
309
|
+
>
|
|
310
|
+
{/* 4 corner sparkles — each reacts to cursor direction independently */}
|
|
311
|
+
{SPARKLES.map(c => (
|
|
312
|
+
<CornerSparkle
|
|
313
|
+
key={c.id}
|
|
314
|
+
c={c}
|
|
315
|
+
reduced={reduced}
|
|
316
|
+
cast={cast}
|
|
317
|
+
mx={mx}
|
|
318
|
+
my={my}
|
|
319
|
+
bornAmount={bornAmount}
|
|
320
|
+
/>
|
|
321
|
+
))}
|
|
322
|
+
|
|
323
|
+
{/* Star body — breath + saccades always running.
|
|
324
|
+
Wrapped in <motion.g> so click squash composes with breath scale. */}
|
|
325
|
+
<motion.g
|
|
326
|
+
style={{
|
|
327
|
+
scale: reduced ? 1 : pressScale,
|
|
328
|
+
transformBox: "fill-box",
|
|
329
|
+
transformOrigin: "center",
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
<motion.path
|
|
333
|
+
d={STAR_BODY_PATH}
|
|
334
|
+
fill="var(--brand-color)"
|
|
335
|
+
style={{ transformBox: "fill-box", transformOrigin: "center" }}
|
|
336
|
+
variants={starBodyVariants}
|
|
337
|
+
animate={reduced ? undefined : "idle"}
|
|
338
|
+
/>
|
|
339
|
+
</motion.g>
|
|
340
|
+
</svg>
|
|
341
|
+
</motion.span>
|
|
342
|
+
</motion.span>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Twinkle system (external firefly sparkles around the star) ──────────────
|
|
347
|
+
|
|
348
|
+
interface Twinkle {
|
|
349
|
+
id: number
|
|
350
|
+
x: number; y: number
|
|
351
|
+
dx: number; dy: number
|
|
352
|
+
size: number
|
|
353
|
+
rot: number
|
|
354
|
+
dur: number
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function TwinkleShape({ size }: { size: number }) {
|
|
358
|
+
return (
|
|
359
|
+
<svg
|
|
360
|
+
width={size}
|
|
361
|
+
height={size}
|
|
362
|
+
viewBox="0 0 16 16"
|
|
363
|
+
aria-hidden
|
|
364
|
+
style={{ display: "block" }}
|
|
365
|
+
>
|
|
366
|
+
<path
|
|
367
|
+
d="M8 0 L9.5 6.5 L16 8 L9.5 9.5 L8 16 L6.5 9.5 L0 8 L6.5 6.5 Z"
|
|
368
|
+
fill="var(--brand-color)"
|
|
369
|
+
/>
|
|
370
|
+
</svg>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function TwinkleDot({ t, onDone }: { t: Twinkle; onDone: (id: number) => void }) {
|
|
375
|
+
return (
|
|
376
|
+
<motion.span
|
|
377
|
+
aria-hidden
|
|
378
|
+
className="pointer-events-none absolute"
|
|
379
|
+
style={{ top: "50%", left: "50%", rotate: t.rot }}
|
|
380
|
+
initial={{ x: t.x, y: t.y, scale: 0, opacity: 0 }}
|
|
381
|
+
animate={{
|
|
382
|
+
x: t.x + t.dx,
|
|
383
|
+
y: t.y + t.dy,
|
|
384
|
+
scale: [0, 1, 0.85, 0],
|
|
385
|
+
opacity: [0, 1, 0.9, 0],
|
|
386
|
+
}}
|
|
387
|
+
transition={{
|
|
388
|
+
duration: t.dur,
|
|
389
|
+
scale: { duration: t.dur, times: [0, 0.28, 0.65, 1], ease: EASE_SOFT },
|
|
390
|
+
opacity: { duration: t.dur, times: [0, 0.28, 0.65, 1], ease: EASE_SOFT },
|
|
391
|
+
x: { duration: t.dur, ease: "easeOut" },
|
|
392
|
+
y: { duration: t.dur, ease: "easeOut" },
|
|
393
|
+
}}
|
|
394
|
+
onAnimationComplete={() => onDone(t.id)}
|
|
395
|
+
>
|
|
396
|
+
<TwinkleShape size={t.size} />
|
|
397
|
+
</motion.span>
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function useTwinkles(
|
|
402
|
+
enabled: boolean,
|
|
403
|
+
size: number,
|
|
404
|
+
opts: {
|
|
405
|
+
hoverRef?: React.MutableRefObject<boolean>
|
|
406
|
+
cursorRef?: React.MutableRefObject<{ x: number; y: number } | null>
|
|
407
|
+
} = {},
|
|
408
|
+
) {
|
|
409
|
+
const [twinkles, setTwinkles] = React.useState<Twinkle[]>([])
|
|
410
|
+
const idRef = React.useRef(0)
|
|
411
|
+
const { hoverRef, cursorRef } = opts
|
|
412
|
+
|
|
413
|
+
const spawnOne = React.useCallback(() => {
|
|
414
|
+
const hovered = hoverRef?.current ?? false
|
|
415
|
+
const cursor = cursorRef?.current ?? null
|
|
416
|
+
const radius = size * (0.34 + Math.random() * 0.30)
|
|
417
|
+
|
|
418
|
+
let angle: number
|
|
419
|
+
if (cursor) {
|
|
420
|
+
const base = Math.atan2(cursor.y, cursor.x)
|
|
421
|
+
angle = base + (Math.random() - 0.5) * Math.PI * 0.55
|
|
422
|
+
} else {
|
|
423
|
+
angle = Math.random() * Math.PI * 2
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const x = Math.cos(angle) * radius
|
|
427
|
+
const y = Math.sin(angle) * radius
|
|
428
|
+
const drift = size * 0.09
|
|
429
|
+
const sparkSize = 3 + Math.random() * (hovered ? 4 : 2.5)
|
|
430
|
+
|
|
431
|
+
setTwinkles(prev => [...prev, {
|
|
432
|
+
id: idRef.current++,
|
|
433
|
+
x, y,
|
|
434
|
+
dx: Math.cos(angle) * drift,
|
|
435
|
+
dy: Math.sin(angle) * drift,
|
|
436
|
+
size: sparkSize,
|
|
437
|
+
rot: (Math.random() - 0.5) * 60,
|
|
438
|
+
dur: 1.2 + Math.random() * 0.9,
|
|
439
|
+
}])
|
|
440
|
+
}, [size, hoverRef, cursorRef])
|
|
441
|
+
|
|
442
|
+
React.useEffect(() => {
|
|
443
|
+
if (!enabled) return
|
|
444
|
+
let cancelled = false
|
|
445
|
+
let timeoutId: ReturnType<typeof setTimeout>
|
|
446
|
+
|
|
447
|
+
const schedule = () => {
|
|
448
|
+
const hovered = hoverRef?.current ?? false
|
|
449
|
+
const min = hovered ? 280 : 2800
|
|
450
|
+
const max = hovered ? 680 : 5800
|
|
451
|
+
const delay = min + Math.random() * (max - min)
|
|
452
|
+
timeoutId = setTimeout(() => {
|
|
453
|
+
if (cancelled) return
|
|
454
|
+
spawnOne()
|
|
455
|
+
schedule()
|
|
456
|
+
}, delay)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
timeoutId = setTimeout(() => {
|
|
460
|
+
if (cancelled) return
|
|
461
|
+
spawnOne()
|
|
462
|
+
schedule()
|
|
463
|
+
}, 500 + Math.random() * 900)
|
|
464
|
+
|
|
465
|
+
return () => { cancelled = true; clearTimeout(timeoutId) }
|
|
466
|
+
}, [enabled, spawnOne, hoverRef])
|
|
467
|
+
|
|
468
|
+
const removeTwinkle = React.useCallback((id: number) => {
|
|
469
|
+
setTwinkles(prev => prev.filter(t => t.id !== id))
|
|
470
|
+
}, [])
|
|
471
|
+
|
|
472
|
+
const spawnBurst = React.useCallback((count: number) => {
|
|
473
|
+
const driftDist = size * 0.65
|
|
474
|
+
const additions: Twinkle[] = []
|
|
475
|
+
for (let i = 0; i < count; i++) {
|
|
476
|
+
const angle = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * 0.5
|
|
477
|
+
additions.push({
|
|
478
|
+
id: idRef.current++,
|
|
479
|
+
x: 0, y: 0,
|
|
480
|
+
dx: Math.cos(angle) * driftDist,
|
|
481
|
+
dy: Math.sin(angle) * driftDist,
|
|
482
|
+
size: 4 + Math.random() * 4,
|
|
483
|
+
rot: Math.random() * 60 - 30,
|
|
484
|
+
dur: 0.7 + Math.random() * 0.3,
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
setTwinkles(prev => [...prev, ...additions])
|
|
488
|
+
}, [size])
|
|
489
|
+
|
|
490
|
+
return { twinkles, removeTwinkle, spawnBurst }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ─── Ambient variant ─────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
function AmbientIcon({ sz, reduced }: { sz: SZ; reduced: boolean }) {
|
|
496
|
+
// Dummy motion values so LeoStarSVG always runs its hooks.
|
|
497
|
+
const mx = useMotionValue(0)
|
|
498
|
+
const my = useMotionValue(0)
|
|
499
|
+
const engage = useMotionValue(0)
|
|
500
|
+
const { twinkles, removeTwinkle } = useTwinkles(!reduced, sz.px)
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
<span className={cn("relative inline-flex items-center justify-center shrink-0", sz.root)}>
|
|
504
|
+
{/* Breathing aura — complementary gold, very subtle */}
|
|
505
|
+
<motion.span
|
|
506
|
+
aria-hidden
|
|
507
|
+
className="pointer-events-none absolute inset-[-22%] rounded-full"
|
|
508
|
+
style={{
|
|
509
|
+
background: `radial-gradient(circle, ${GLOW} 0%, transparent 65%)`,
|
|
510
|
+
}}
|
|
511
|
+
animate={reduced ? { opacity: 0.04 } : {
|
|
512
|
+
opacity: [0.03, 0.07, 0.03],
|
|
513
|
+
scale: [0.9, 1.04, 0.9],
|
|
514
|
+
}}
|
|
515
|
+
transition={{ duration: 6.2, repeat: Infinity, ease: EASE_BREATH }}
|
|
516
|
+
/>
|
|
517
|
+
|
|
518
|
+
<AnimatePresence>
|
|
519
|
+
{twinkles.map(t => (
|
|
520
|
+
<TwinkleDot key={t.id} t={t} onDone={removeTwinkle} />
|
|
521
|
+
))}
|
|
522
|
+
</AnimatePresence>
|
|
523
|
+
|
|
524
|
+
<div className="relative z-10">
|
|
525
|
+
<LeoStarSVG
|
|
526
|
+
px={sz.px}
|
|
527
|
+
reduced={reduced}
|
|
528
|
+
pressed={false}
|
|
529
|
+
cast={false}
|
|
530
|
+
mx={mx}
|
|
531
|
+
my={my}
|
|
532
|
+
engage={engage}
|
|
533
|
+
/>
|
|
534
|
+
</div>
|
|
535
|
+
</span>
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ─── Interactive variant ─────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
function InteractiveIcon({ sz, reduced }: { sz: SZ; reduced: boolean }) {
|
|
542
|
+
const rootRef = React.useRef<HTMLSpanElement>(null)
|
|
543
|
+
const hoverRef = React.useRef(false)
|
|
544
|
+
const cursorRef = React.useRef<{ x: number; y: number } | null>(null)
|
|
545
|
+
const [pressed, setPressed] = React.useState(false)
|
|
546
|
+
const [cast, setCast] = React.useState(false)
|
|
547
|
+
const [rings, setRings] = React.useState<number[]>([])
|
|
548
|
+
|
|
549
|
+
const { twinkles, removeTwinkle, spawnBurst } = useTwinkles(
|
|
550
|
+
!reduced, sz.px, { hoverRef, cursorRef },
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
const mx = useMotionValue(0)
|
|
554
|
+
const my = useMotionValue(0)
|
|
555
|
+
const engage = useSpring(0, { stiffness: 170, damping: 25 })
|
|
556
|
+
|
|
557
|
+
const auraOpacity = useTransform(engage, [0, 1], [0.03, 0.07])
|
|
558
|
+
const auraScale = useTransform(engage, [0, 1], [0.92, 1.08])
|
|
559
|
+
|
|
560
|
+
// Viewport-wide cursor awareness.
|
|
561
|
+
// While mounted, Leo watches the entire window. Cursor position relative to
|
|
562
|
+
// the star's center drives mx/my (direction) and engage (proximity).
|
|
563
|
+
// The farther the cursor, the smaller the response — exponential falloff.
|
|
564
|
+
React.useEffect(() => {
|
|
565
|
+
if (reduced) return
|
|
566
|
+
let rafId = 0
|
|
567
|
+
|
|
568
|
+
const onMove = (e: MouseEvent) => {
|
|
569
|
+
if (rafId) return // coalesce to one update per frame
|
|
570
|
+
rafId = requestAnimationFrame(() => {
|
|
571
|
+
rafId = 0
|
|
572
|
+
const node = rootRef.current
|
|
573
|
+
if (!node) return
|
|
574
|
+
const rect = node.getBoundingClientRect()
|
|
575
|
+
const cx = rect.left + rect.width / 2
|
|
576
|
+
const cy = rect.top + rect.height / 2
|
|
577
|
+
const dx = e.clientX - cx
|
|
578
|
+
const dy = e.clientY - cy
|
|
579
|
+
const dist = Math.hypot(dx, dy)
|
|
580
|
+
const radius = rect.width / 2
|
|
581
|
+
|
|
582
|
+
// Unit direction vector from star center to cursor.
|
|
583
|
+
const dirX = dist > 1 ? dx / dist : 0
|
|
584
|
+
const dirY = dist > 1 ? dy / dist : 0
|
|
585
|
+
|
|
586
|
+
// Proximity: 1 when cursor is on the star, falls off exponentially
|
|
587
|
+
// past the star's edge. Half-life ≈ 195 px.
|
|
588
|
+
const edgeDist = Math.max(0, dist - radius)
|
|
589
|
+
const prox = Math.exp(-edgeDist / 280)
|
|
590
|
+
|
|
591
|
+
// Encode direction × proximity so mx/my naturally attenuate with distance.
|
|
592
|
+
mx.set(dirX * 0.5 * prox)
|
|
593
|
+
my.set(dirY * 0.5 * prox)
|
|
594
|
+
cursorRef.current = { x: dirX * prox, y: dirY * prox }
|
|
595
|
+
engage.set(prox)
|
|
596
|
+
hoverRef.current = prox > 0.45
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Reset when cursor exits the document entirely.
|
|
601
|
+
const onDocLeave = () => {
|
|
602
|
+
mx.set(0); my.set(0)
|
|
603
|
+
cursorRef.current = null
|
|
604
|
+
engage.set(0)
|
|
605
|
+
hoverRef.current = false
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
window.addEventListener("mousemove", onMove, { passive: true })
|
|
609
|
+
document.addEventListener("mouseleave", onDocLeave)
|
|
610
|
+
|
|
611
|
+
return () => {
|
|
612
|
+
if (rafId) cancelAnimationFrame(rafId)
|
|
613
|
+
window.removeEventListener("mousemove", onMove)
|
|
614
|
+
document.removeEventListener("mouseleave", onDocLeave)
|
|
615
|
+
}
|
|
616
|
+
}, [mx, my, engage, reduced])
|
|
617
|
+
|
|
618
|
+
const onDown = React.useCallback(() => setPressed(true), [])
|
|
619
|
+
const onUp = React.useCallback(() => setPressed(false), [])
|
|
620
|
+
|
|
621
|
+
const onClick = React.useCallback(() => {
|
|
622
|
+
if (reduced) return
|
|
623
|
+
setCast(true)
|
|
624
|
+
setTimeout(() => setCast(false), 720)
|
|
625
|
+
|
|
626
|
+
const id = Date.now() + Math.random()
|
|
627
|
+
setRings(prev => [...prev, id])
|
|
628
|
+
setTimeout(() => setRings(prev => prev.filter(r => r !== id)), 800)
|
|
629
|
+
|
|
630
|
+
spawnBurst(6)
|
|
631
|
+
}, [reduced, spawnBurst])
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
<span
|
|
635
|
+
ref={rootRef}
|
|
636
|
+
className={cn(
|
|
637
|
+
"relative inline-flex items-center justify-center shrink-0 cursor-pointer select-none",
|
|
638
|
+
sz.root,
|
|
639
|
+
)}
|
|
640
|
+
onMouseDown={onDown}
|
|
641
|
+
onMouseUp={onUp}
|
|
642
|
+
onClick={onClick}
|
|
643
|
+
>
|
|
644
|
+
{/* Breathing aura — subtle background presence */}
|
|
645
|
+
<motion.span
|
|
646
|
+
aria-hidden
|
|
647
|
+
className="pointer-events-none absolute inset-[-22%] rounded-full"
|
|
648
|
+
style={{
|
|
649
|
+
background: `radial-gradient(circle, ${GLOW} 0%, transparent 65%)`,
|
|
650
|
+
opacity: reduced ? 0.04 : auraOpacity,
|
|
651
|
+
scale: reduced ? 1 : auraScale,
|
|
652
|
+
}}
|
|
653
|
+
/>
|
|
654
|
+
|
|
655
|
+
{/* Click ring waves — complementary gold */}
|
|
656
|
+
<AnimatePresence>
|
|
657
|
+
{rings.map(id => (
|
|
658
|
+
<motion.span
|
|
659
|
+
key={id}
|
|
660
|
+
aria-hidden
|
|
661
|
+
className="pointer-events-none absolute rounded-full"
|
|
662
|
+
style={{
|
|
663
|
+
width: sz.px * 0.5,
|
|
664
|
+
height: sz.px * 0.5,
|
|
665
|
+
border: `1px solid ${GLOW}`,
|
|
666
|
+
}}
|
|
667
|
+
initial={{ opacity: 0.7, scale: 0.35 }}
|
|
668
|
+
animate={{ opacity: 0, scale: 2.4 }}
|
|
669
|
+
exit={{ opacity: 0 }}
|
|
670
|
+
transition={{ duration: 0.75, ease: EASE_SOFT }}
|
|
671
|
+
/>
|
|
672
|
+
))}
|
|
673
|
+
</AnimatePresence>
|
|
674
|
+
|
|
675
|
+
{/* Firefly twinkles — biased toward cursor direction */}
|
|
676
|
+
<AnimatePresence>
|
|
677
|
+
{twinkles.map(t => (
|
|
678
|
+
<TwinkleDot key={t.id} t={t} onDone={removeTwinkle} />
|
|
679
|
+
))}
|
|
680
|
+
</AnimatePresence>
|
|
681
|
+
|
|
682
|
+
<LeoStarSVG
|
|
683
|
+
px={sz.px}
|
|
684
|
+
reduced={reduced}
|
|
685
|
+
pressed={pressed}
|
|
686
|
+
cast={cast}
|
|
687
|
+
mx={mx}
|
|
688
|
+
my={my}
|
|
689
|
+
engage={engage}
|
|
690
|
+
/>
|
|
691
|
+
</span>
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ─── Public export ───────────────────────────────────────────────────────────
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Animated Ask Leo icon.
|
|
699
|
+
*
|
|
700
|
+
* @example
|
|
701
|
+
* // Ambient — subtle always-on presence (no cursor reactions)
|
|
702
|
+
* <LeoIcon variant="ambient" size="md" />
|
|
703
|
+
*
|
|
704
|
+
* // Interactive — cursor-aware, for hero/welcome surfaces
|
|
705
|
+
* <LeoIcon variant="interactive" size="xl" />
|
|
706
|
+
*/
|
|
707
|
+
export function LeoIcon({
|
|
708
|
+
variant = "ambient",
|
|
709
|
+
size = "md",
|
|
710
|
+
className,
|
|
711
|
+
style,
|
|
712
|
+
}: LeoIconProps) {
|
|
713
|
+
const reduced = useReducedMotion() ?? false
|
|
714
|
+
const sz = SIZES[size]
|
|
715
|
+
|
|
716
|
+
return (
|
|
717
|
+
<span
|
|
718
|
+
className={cn("inline-flex items-center justify-center", className)}
|
|
719
|
+
style={style}
|
|
720
|
+
>
|
|
721
|
+
{variant === "interactive"
|
|
722
|
+
? <InteractiveIcon sz={sz} reduced={reduced} />
|
|
723
|
+
: <AmbientIcon sz={sz} reduced={reduced} />}
|
|
724
|
+
</span>
|
|
725
|
+
)
|
|
726
|
+
}
|