@voltro/ui-shadcn 0.1.0
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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +3016 -0
- package/dist/brand.d.ts +73 -0
- package/dist/brand.js +177 -0
- package/dist/cn.d.ts +11 -0
- package/dist/cn.js +6 -0
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +2636 -0
- package/dist/tokens.css +532 -0
- package/package.json +64 -0
- package/src/brand/README.md +60 -0
- package/src/brand/assets/voltro-favicon.svg +30 -0
- package/src/brand/assets/voltro-icon-dark.svg +26 -0
- package/src/brand/assets/voltro-icon.svg +14 -0
- package/src/brand/assets/voltro-mark-mono.svg +5 -0
- package/src/brand/assets/voltro-mark.svg +14 -0
- package/src/brand/voltroLogo.tsx +244 -0
- package/src/cn.ts +10 -0
- package/src/compositions/appShell.tsx +72 -0
- package/src/compositions/codeCompare.tsx +88 -0
- package/src/compositions/docShell.tsx +112 -0
- package/src/compositions/docsLayout.tsx +577 -0
- package/src/compositions/featureBento.tsx +103 -0
- package/src/compositions/featureGrid.tsx +41 -0
- package/src/compositions/heroSection.tsx +55 -0
- package/src/compositions/landingCta.tsx +85 -0
- package/src/compositions/landingHero.tsx +174 -0
- package/src/compositions/landingStats.tsx +99 -0
- package/src/compositions/loginCard.tsx +139 -0
- package/src/compositions/pageHeader.tsx +58 -0
- package/src/compositions/profileMenu.tsx +316 -0
- package/src/compositions/siteFooter.tsx +250 -0
- package/src/compositions/themeToggle.tsx +82 -0
- package/src/cookies.ts +109 -0
- package/src/index.ts +160 -0
- package/src/primitives/animatedNumber.tsx +73 -0
- package/src/primitives/avatar.tsx +39 -0
- package/src/primitives/badge.tsx +39 -0
- package/src/primitives/button.tsx +53 -0
- package/src/primitives/callout.tsx +97 -0
- package/src/primitives/card.tsx +68 -0
- package/src/primitives/checkbox.tsx +55 -0
- package/src/primitives/codeBlock.tsx +134 -0
- package/src/primitives/codeWindow.tsx +84 -0
- package/src/primitives/dialog.tsx +43 -0
- package/src/primitives/docCard.tsx +109 -0
- package/src/primitives/docIcons.tsx +268 -0
- package/src/primitives/dropdownMenu.tsx +162 -0
- package/src/primitives/gridOverlay.tsx +51 -0
- package/src/primitives/highlightedCode.tsx +112 -0
- package/src/primitives/input.tsx +25 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/localeSwitcher.tsx +90 -0
- package/src/primitives/meshBackdrop.tsx +62 -0
- package/src/primitives/scrollReveal.tsx +70 -0
- package/src/primitives/searchModal.tsx +304 -0
- package/src/primitives/select.tsx +24 -0
- package/src/primitives/separator.tsx +24 -0
- package/src/primitives/skeleton.tsx +12 -0
- package/src/primitives/sparkles.tsx +105 -0
- package/src/primitives/steps.tsx +55 -0
- package/src/primitives/tabs.tsx +102 -0
- package/src/primitives/textarea.tsx +24 -0
- package/src/primitives/toast.tsx +44 -0
- package/src/primitives/tocScrollSpy.tsx +110 -0
- package/src/primitives/toggle.tsx +50 -0
- package/src/primitives/toggleGroup.tsx +62 -0
- package/src/tokens.css +532 -0
- package/src/widgets.tsx +297 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// FeatureGrid composition — auto-fitting grid of feature cards.
|
|
2
|
+
// Tile shape inherits from the shadcn Card primitive.
|
|
3
|
+
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import { Card, CardHeader, CardTitle, CardDescription } from '../primitives/card'
|
|
6
|
+
import { cn } from '../cn'
|
|
7
|
+
|
|
8
|
+
interface Feature {
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly body: string
|
|
11
|
+
readonly icon?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FeatureGridProps {
|
|
15
|
+
readonly sectionTitle?: string
|
|
16
|
+
readonly features: ReadonlyArray<Feature>
|
|
17
|
+
readonly className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const FeatureGrid = ({
|
|
21
|
+
sectionTitle, features, className,
|
|
22
|
+
}: FeatureGridProps): ReactNode => (
|
|
23
|
+
<section className={cn('py-16 max-w-6xl mx-auto px-6', className)}>
|
|
24
|
+
{sectionTitle ? (
|
|
25
|
+
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-12 text-foreground">
|
|
26
|
+
{sectionTitle}
|
|
27
|
+
</h2>
|
|
28
|
+
) : null}
|
|
29
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
30
|
+
{features.map((f) => (
|
|
31
|
+
<Card key={f.title} className="hover:border-primary/40 transition-colors">
|
|
32
|
+
<CardHeader>
|
|
33
|
+
{f.icon ? <div className="text-primary mb-2">{f.icon}</div> : null}
|
|
34
|
+
<CardTitle className="text-base">{f.title}</CardTitle>
|
|
35
|
+
<CardDescription>{f.body}</CardDescription>
|
|
36
|
+
</CardHeader>
|
|
37
|
+
</Card>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// HeroSection composition — opinionated marketing hero with eyebrow
|
|
2
|
+
// pill, gradient title, sub copy, and dual CTAs.
|
|
3
|
+
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import { Button } from '../primitives/button'
|
|
6
|
+
import { Badge } from '../primitives/badge'
|
|
7
|
+
import { cn } from '../cn'
|
|
8
|
+
|
|
9
|
+
interface HeroCta {
|
|
10
|
+
readonly label: string
|
|
11
|
+
readonly href: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface HeroSectionProps {
|
|
15
|
+
readonly eyebrow?: string
|
|
16
|
+
readonly title: ReactNode
|
|
17
|
+
readonly subtitle?: ReactNode
|
|
18
|
+
readonly primaryCta?: HeroCta
|
|
19
|
+
readonly secondaryCta?: HeroCta
|
|
20
|
+
readonly footnote?: ReactNode
|
|
21
|
+
readonly className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const HeroSection = ({
|
|
25
|
+
eyebrow, title, subtitle, primaryCta, secondaryCta, footnote, className,
|
|
26
|
+
}: HeroSectionProps): ReactNode => (
|
|
27
|
+
<section className={cn('py-20 text-center max-w-4xl mx-auto px-6', className)}>
|
|
28
|
+
{eyebrow ? (
|
|
29
|
+
<Badge variant="outline" className="mb-6 border-primary/30 text-primary">{eyebrow}</Badge>
|
|
30
|
+
) : null}
|
|
31
|
+
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight mb-4 text-foreground">
|
|
32
|
+
{title}
|
|
33
|
+
</h1>
|
|
34
|
+
{subtitle ? (
|
|
35
|
+
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
|
36
|
+
{subtitle}
|
|
37
|
+
</p>
|
|
38
|
+
) : null}
|
|
39
|
+
<div className="flex gap-3 justify-center flex-wrap mb-4">
|
|
40
|
+
{primaryCta ? (
|
|
41
|
+
<a href={primaryCta.href}>
|
|
42
|
+
<Button size="lg">{primaryCta.label}</Button>
|
|
43
|
+
</a>
|
|
44
|
+
) : null}
|
|
45
|
+
{secondaryCta ? (
|
|
46
|
+
<a href={secondaryCta.href}>
|
|
47
|
+
<Button size="lg" variant="outline">{secondaryCta.label}</Button>
|
|
48
|
+
</a>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
{footnote ? (
|
|
52
|
+
<p className="text-sm text-muted-foreground">{footnote}</p>
|
|
53
|
+
) : null}
|
|
54
|
+
</section>
|
|
55
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// LandingCta — big final "start shipping" section with a deploy-style
|
|
2
|
+
// animated gradient backdrop. Use once near the footer.
|
|
3
|
+
|
|
4
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
5
|
+
import { cn } from '../cn'
|
|
6
|
+
import type { ShellLinkProps } from './docShell'
|
|
7
|
+
|
|
8
|
+
interface LandingCtaProps {
|
|
9
|
+
readonly title: ReactNode
|
|
10
|
+
readonly subtitle?: ReactNode
|
|
11
|
+
readonly primaryCta: { readonly label: string; readonly href: string }
|
|
12
|
+
readonly secondaryCta?: { readonly label: string; readonly href: string }
|
|
13
|
+
readonly footnote?: ReactNode
|
|
14
|
+
readonly className?: string
|
|
15
|
+
readonly LinkComponent?: ComponentType<ShellLinkProps>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A schemed href (`https://…`) points off-origin → open in a new tab with a safe
|
|
19
|
+
// rel; relative/hash hrefs stay same-tab.
|
|
20
|
+
const isSchemed = (href: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//i.test(href)
|
|
21
|
+
|
|
22
|
+
const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode =>
|
|
23
|
+
isSchemed(to)
|
|
24
|
+
? <a href={to} className={className} target="_blank" rel="noopener noreferrer">{children}</a>
|
|
25
|
+
: <a href={to} className={className}>{children}</a>
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export const LandingCta = ({
|
|
29
|
+
title, subtitle, primaryCta, secondaryCta, footnote,
|
|
30
|
+
className, LinkComponent = PlainLink,
|
|
31
|
+
}: LandingCtaProps): ReactNode => {
|
|
32
|
+
const Link = LinkComponent
|
|
33
|
+
return (
|
|
34
|
+
<section
|
|
35
|
+
className={cn(
|
|
36
|
+
'relative overflow-hidden rounded-2xl border border-border',
|
|
37
|
+
'bg-gradient-to-br from-card/60 via-card/30 to-card/60 backdrop-blur-sm',
|
|
38
|
+
// Slow-panning gradient layer behind the content
|
|
39
|
+
'before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_20%_30%,oklch(0.55_0.25_290_/_0.25),transparent_60%),radial-gradient(circle_at_80%_70%,oklch(0.55_0.2_220_/_0.2),transparent_60%)]',
|
|
40
|
+
// Hairline highlight along top
|
|
41
|
+
'after:absolute after:inset-x-12 after:top-0 after:h-px after:bg-gradient-to-r after:from-transparent after:via-primary/60 after:to-transparent',
|
|
42
|
+
'p-12 md:p-16 lg:p-20 text-center',
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<div className="relative max-w-2xl mx-auto">
|
|
47
|
+
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] leading-[1.1] text-foreground">
|
|
48
|
+
{title}
|
|
49
|
+
</h2>
|
|
50
|
+
{subtitle ? (
|
|
51
|
+
<p className="mt-5 text-base md:text-lg text-muted-foreground leading-relaxed">
|
|
52
|
+
{subtitle}
|
|
53
|
+
</p>
|
|
54
|
+
) : null}
|
|
55
|
+
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
56
|
+
<Link
|
|
57
|
+
to={primaryCta.href}
|
|
58
|
+
prefetch
|
|
59
|
+
className={cn(
|
|
60
|
+
'group inline-flex items-center gap-2 px-6 py-3 rounded-md',
|
|
61
|
+
'bg-foreground text-background font-medium text-sm',
|
|
62
|
+
'hover:opacity-90 active:scale-[0.98] transition-all',
|
|
63
|
+
'shadow-[0_0_0_1px_oklch(1_0_0_/_0.08),0_8px_32px_-8px_oklch(0.55_0.25_290_/_0.45)]',
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{primaryCta.label}
|
|
67
|
+
<span aria-hidden="true" className="transition-transform group-hover:translate-x-0.5">→</span>
|
|
68
|
+
</Link>
|
|
69
|
+
{secondaryCta ? (
|
|
70
|
+
<Link
|
|
71
|
+
to={secondaryCta.href}
|
|
72
|
+
prefetch
|
|
73
|
+
className="inline-flex items-center gap-2 px-6 py-3 rounded-md border border-border bg-card/40 backdrop-blur-sm text-foreground font-medium text-sm hover:bg-card/70 hover:border-input transition-colors"
|
|
74
|
+
>
|
|
75
|
+
{secondaryCta.label}
|
|
76
|
+
</Link>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
{footnote ? (
|
|
80
|
+
<div className="mt-6 text-xs text-muted-foreground">{footnote}</div>
|
|
81
|
+
) : null}
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// LandingHero — cinematic above-the-fold for dev-tooling landings.
|
|
2
|
+
//
|
|
3
|
+
// Layout (desktop):
|
|
4
|
+
// ┌───────────────────────────────────────────────────────────────┐
|
|
5
|
+
// │ • eyebrow │
|
|
6
|
+
// │ ▌ │
|
|
7
|
+
// │ Big gradient heading (2 lines) ┌────────────────────┐│
|
|
8
|
+
// │ │ CodeWindow (right) ││
|
|
9
|
+
// │ subtitle, body-size │ filename.tsx ││
|
|
10
|
+
// │ │ …syntax… ││
|
|
11
|
+
// │ [Primary CTA] [Secondary CTA] └────────────────────┘│
|
|
12
|
+
// │ │
|
|
13
|
+
// │ footnote │
|
|
14
|
+
// └───────────────────────────────────────────────────────────────┘
|
|
15
|
+
//
|
|
16
|
+
// On mobile the code window moves below the text. The component
|
|
17
|
+
// itself does not render the background — wrap it with MeshBackdrop +
|
|
18
|
+
// GridOverlay in the parent so multiple sections can share the same
|
|
19
|
+
// ambient layers.
|
|
20
|
+
|
|
21
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
22
|
+
import { cn } from '../cn'
|
|
23
|
+
import type { ShellLinkProps } from './docShell'
|
|
24
|
+
|
|
25
|
+
interface CtaSpec {
|
|
26
|
+
readonly label: string
|
|
27
|
+
readonly href: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface LandingHeroProps {
|
|
31
|
+
readonly eyebrow?: ReactNode
|
|
32
|
+
/** Two-line heading: the second line gets the gradient + animation. */
|
|
33
|
+
readonly title: ReactNode
|
|
34
|
+
/** Optional second line — when set, the layout renders title on
|
|
35
|
+
* line 1 and this on line 2 with the gradient/animation. */
|
|
36
|
+
readonly titleAccent?: ReactNode
|
|
37
|
+
readonly subtitle?: ReactNode
|
|
38
|
+
readonly primaryCta?: CtaSpec
|
|
39
|
+
readonly secondaryCta?: CtaSpec
|
|
40
|
+
readonly footnote?: ReactNode
|
|
41
|
+
/** Code window / illustration slot — sits on the right at md+, below
|
|
42
|
+
* the CTAs on mobile. */
|
|
43
|
+
readonly aside?: ReactNode
|
|
44
|
+
/** Anchor link for "scroll down" hint. */
|
|
45
|
+
readonly scrollAnchor?: string
|
|
46
|
+
/** Visible caption on the scroll-down hint. Default `'Scroll'`. */
|
|
47
|
+
readonly scrollLabel?: ReactNode
|
|
48
|
+
/** `aria-label` for the scroll-down hint. Default `'Scroll down'`. */
|
|
49
|
+
readonly scrollAriaLabel?: string
|
|
50
|
+
readonly className?: string
|
|
51
|
+
readonly LinkComponent?: ComponentType<ShellLinkProps>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A schemed href (`https://…`, `mailto:` …) points off-origin, so open it in a
|
|
55
|
+
// new tab with a safe rel; relative/hash hrefs stay same-tab.
|
|
56
|
+
const isSchemed = (href: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//i.test(href)
|
|
57
|
+
|
|
58
|
+
const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode =>
|
|
59
|
+
isSchemed(to)
|
|
60
|
+
? <a href={to} className={className} target="_blank" rel="noopener noreferrer">{children}</a>
|
|
61
|
+
: <a href={to} className={className}>{children}</a>
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
export const LandingHero = ({
|
|
65
|
+
eyebrow, title, titleAccent, subtitle, primaryCta, secondaryCta,
|
|
66
|
+
footnote, aside, scrollAnchor, scrollLabel = 'Scroll',
|
|
67
|
+
scrollAriaLabel = 'Scroll down', className,
|
|
68
|
+
LinkComponent = PlainLink,
|
|
69
|
+
}: LandingHeroProps): ReactNode => {
|
|
70
|
+
const Link = LinkComponent
|
|
71
|
+
return (
|
|
72
|
+
<section
|
|
73
|
+
className={cn(
|
|
74
|
+
'relative isolate overflow-hidden',
|
|
75
|
+
// Extra bottom padding leaves room for the scroll hint to sit BELOW the
|
|
76
|
+
// content (it's anchored to the section, not the content column).
|
|
77
|
+
'pt-24 md:pt-32 pb-24 md:pb-32',
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<div className="relative mx-auto max-w-7xl px-6">
|
|
82
|
+
<div className="grid grid-cols-1 lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
|
|
83
|
+
{/* ---- Left column: copy + CTAs ---- */}
|
|
84
|
+
<div className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-4 motion-safe:duration-700">
|
|
85
|
+
{eyebrow ? (
|
|
86
|
+
<div className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.12em] text-primary font-semibold mb-5">
|
|
87
|
+
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary motion-safe:animate-pulse" />
|
|
88
|
+
{eyebrow}
|
|
89
|
+
</div>
|
|
90
|
+
) : null}
|
|
91
|
+
|
|
92
|
+
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-[-0.02em] leading-[1.05] text-foreground">
|
|
93
|
+
{title}
|
|
94
|
+
{titleAccent ? (
|
|
95
|
+
<>
|
|
96
|
+
<br />
|
|
97
|
+
<span className="text-gradient motion-safe:animate-gradient-pan">
|
|
98
|
+
{titleAccent}
|
|
99
|
+
</span>
|
|
100
|
+
</>
|
|
101
|
+
) : null}
|
|
102
|
+
</h1>
|
|
103
|
+
|
|
104
|
+
{subtitle ? (
|
|
105
|
+
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed max-w-xl">
|
|
106
|
+
{subtitle}
|
|
107
|
+
</p>
|
|
108
|
+
) : null}
|
|
109
|
+
|
|
110
|
+
{(primaryCta || secondaryCta) ? (
|
|
111
|
+
<div className="mt-8 flex flex-wrap items-center gap-3">
|
|
112
|
+
{primaryCta ? (
|
|
113
|
+
<Link
|
|
114
|
+
to={primaryCta.href}
|
|
115
|
+
prefetch
|
|
116
|
+
className={cn(
|
|
117
|
+
'group relative inline-flex items-center gap-2 px-5 py-2.5 rounded-md',
|
|
118
|
+
'bg-foreground text-background font-medium text-sm',
|
|
119
|
+
'hover:opacity-90 active:scale-[0.98] transition-all',
|
|
120
|
+
'shadow-[0_0_0_1px_oklch(1_0_0_/_0.08),0_8px_24px_-8px_oklch(0_0_0_/_0.5)]',
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{primaryCta.label}
|
|
124
|
+
<span aria-hidden="true" className="transition-transform group-hover:translate-x-0.5">→</span>
|
|
125
|
+
</Link>
|
|
126
|
+
) : null}
|
|
127
|
+
{secondaryCta ? (
|
|
128
|
+
<Link
|
|
129
|
+
to={secondaryCta.href}
|
|
130
|
+
prefetch
|
|
131
|
+
className={cn(
|
|
132
|
+
'inline-flex items-center gap-2 px-5 py-2.5 rounded-md',
|
|
133
|
+
'border border-border bg-card/40 backdrop-blur-sm',
|
|
134
|
+
'text-foreground font-medium text-sm',
|
|
135
|
+
'hover:bg-card/70 hover:border-input transition-colors',
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{secondaryCta.label}
|
|
139
|
+
</Link>
|
|
140
|
+
) : null}
|
|
141
|
+
</div>
|
|
142
|
+
) : null}
|
|
143
|
+
|
|
144
|
+
{footnote ? (
|
|
145
|
+
<div className="mt-8 text-sm text-muted-foreground">{footnote}</div>
|
|
146
|
+
) : null}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* ---- Right column: code/illustration ---- */}
|
|
150
|
+
{aside ? (
|
|
151
|
+
<div className="lg:pl-4 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-6 motion-safe:duration-700 motion-safe:delay-150">
|
|
152
|
+
{aside}
|
|
153
|
+
</div>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Scroll hint — anchored to the SECTION (not the content column), so it
|
|
159
|
+
sits at the bottom of the full-height hero, at the fold. */}
|
|
160
|
+
{scrollAnchor ? (
|
|
161
|
+
<a
|
|
162
|
+
href={scrollAnchor}
|
|
163
|
+
className="hidden md:flex absolute left-1/2 -translate-x-1/2 bottom-8 text-xs text-muted-foreground items-center gap-1.5 hover:text-foreground transition-colors"
|
|
164
|
+
aria-label={scrollAriaLabel}
|
|
165
|
+
>
|
|
166
|
+
<span>{scrollLabel}</span>
|
|
167
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true" className="motion-safe:animate-bounce">
|
|
168
|
+
<path d="M7 2 V12 M3 8 L7 12 L11 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
169
|
+
</svg>
|
|
170
|
+
</a>
|
|
171
|
+
) : null}
|
|
172
|
+
</section>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// LandingStats — strip of 3-4 big numbers under a "by the numbers"
|
|
2
|
+
// section. Each value uses AnimatedNumber so the digits roll up when
|
|
3
|
+
// the section scrolls into view.
|
|
4
|
+
//
|
|
5
|
+
// Layout per cell (top → bottom):
|
|
6
|
+
// 1. Eyebrow row: icon chip + small uppercase label aligned together
|
|
7
|
+
// so the icon reads AS a label, not as decoration. Chip is sized
|
|
8
|
+
// to match cap-height of the label (gives the icon real weight
|
|
9
|
+
// without competing with the number).
|
|
10
|
+
// 2. Big number with tabular figures, generous breathing room.
|
|
11
|
+
// Prefix + unit are visually subordinate via size, not opacity
|
|
12
|
+
// collapse (kept above the 4.5:1 contrast floor).
|
|
13
|
+
// 3. Sub-caption — one line of detail, muted but readable.
|
|
14
|
+
//
|
|
15
|
+
// The previous version had a 16px svg next to a 60px number, a
|
|
16
|
+
// freestanding hairline "accent rail" that aligned to nothing, and a
|
|
17
|
+
// glass background with a decorative hover-gradient hairline. All
|
|
18
|
+
// three were AI-grammar tells; this revision removes them. Hierarchy
|
|
19
|
+
// is carried by the size step between the number and the eyebrow,
|
|
20
|
+
// plus the kit's normal hover state on the cell.
|
|
21
|
+
|
|
22
|
+
import type { ReactNode } from 'react'
|
|
23
|
+
import { cn } from '../cn'
|
|
24
|
+
import { AnimatedNumber } from '../primitives/animatedNumber'
|
|
25
|
+
|
|
26
|
+
export interface StatSpec {
|
|
27
|
+
/** Numeric value — drives the count-up animation. */
|
|
28
|
+
readonly value: number
|
|
29
|
+
/** Optional unit suffix shown next to the value (e.g. "ms", "%"). */
|
|
30
|
+
readonly unit?: string
|
|
31
|
+
/** Static prefix (e.g. "<", "~"). Useful for things like "<5ms". */
|
|
32
|
+
readonly prefix?: string
|
|
33
|
+
/** Formatter — passed through to AnimatedNumber. */
|
|
34
|
+
readonly format?: 'integer' | 'percent' | ((n: number) => string)
|
|
35
|
+
/** Caption below the number (becomes the eyebrow label at the top
|
|
36
|
+
* of the cell). */
|
|
37
|
+
readonly label: ReactNode
|
|
38
|
+
/** Small sub-caption below the number. */
|
|
39
|
+
readonly sub?: ReactNode
|
|
40
|
+
/** Optional icon — pass any kit icon. Sized to the eyebrow's
|
|
41
|
+
* cap-height so it reads as part of the label, not as decoration. */
|
|
42
|
+
readonly icon?: ReactNode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface LandingStatsProps {
|
|
46
|
+
readonly stats: ReadonlyArray<StatSpec>
|
|
47
|
+
readonly className?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const LandingStats = ({
|
|
51
|
+
stats, className,
|
|
52
|
+
}: LandingStatsProps): ReactNode => (
|
|
53
|
+
<div
|
|
54
|
+
className={cn(
|
|
55
|
+
// grid-cols-N gives a clean wall of cells with hairline separators
|
|
56
|
+
// (the gap-px + bg-border below the grid layer). The outer
|
|
57
|
+
// rounded-2xl frame ties them into one composed strip.
|
|
58
|
+
'grid grid-cols-2 lg:grid-cols-4 gap-px overflow-hidden rounded-2xl border border-border bg-border',
|
|
59
|
+
className,
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{stats.map((s, i) => (
|
|
63
|
+
<div
|
|
64
|
+
key={i}
|
|
65
|
+
className="relative bg-card p-7 lg:p-9 transition-colors duration-300 hover:bg-accent/40"
|
|
66
|
+
>
|
|
67
|
+
{/* Eyebrow row — icon + label travel together. Icon is sized
|
|
68
|
+
* to the label's cap-height so it reads as a sibling, not a
|
|
69
|
+
* floating chip. */}
|
|
70
|
+
<div className="flex items-center gap-2 mb-6 text-xs uppercase tracking-[0.14em] text-muted-foreground font-medium">
|
|
71
|
+
{s.icon ? (
|
|
72
|
+
<span className="text-foreground/80 [&_svg]:w-3.5 [&_svg]:h-3.5">
|
|
73
|
+
{s.icon}
|
|
74
|
+
</span>
|
|
75
|
+
) : null}
|
|
76
|
+
<span>{s.label}</span>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Big number — tabular figures, leading-none for tight
|
|
80
|
+
* vertical rhythm against the eyebrow above and the sub
|
|
81
|
+
* below. Prefix/unit are subordinate via SIZE (not via
|
|
82
|
+
* pushing opacity below readable contrast). */}
|
|
83
|
+
<div className="text-5xl lg:text-6xl font-semibold tracking-[-0.035em] text-foreground inline-flex items-baseline gap-1 font-tabular leading-none">
|
|
84
|
+
{s.prefix ? (
|
|
85
|
+
<span className="text-3xl lg:text-4xl text-muted-foreground font-medium">{s.prefix}</span>
|
|
86
|
+
) : null}
|
|
87
|
+
<AnimatedNumber value={s.value} {...(s.format ? { format: s.format } : {})} />
|
|
88
|
+
{s.unit ? (
|
|
89
|
+
<span className="text-2xl lg:text-3xl text-muted-foreground font-medium ml-0.5">{s.unit}</span>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{s.sub ? (
|
|
94
|
+
<div className="mt-4 text-sm text-muted-foreground leading-relaxed">{s.sub}</div>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// LoginCard composition — pairs email + password inputs with an
|
|
2
|
+
// optional OAuth row. The card is stylistically opinionated (max
|
|
3
|
+
// width, centred, raised) so apps don't reinvent the same shell.
|
|
4
|
+
//
|
|
5
|
+
// Submit handling stays with the consumer (form action / onSubmit).
|
|
6
|
+
// The component just renders the surface.
|
|
7
|
+
|
|
8
|
+
import type { FormHTMLAttributes, ReactNode } from 'react'
|
|
9
|
+
import { Button } from '../primitives/button'
|
|
10
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '../primitives/card'
|
|
11
|
+
import { Input } from '../primitives/input'
|
|
12
|
+
import { Label } from '../primitives/label'
|
|
13
|
+
import { cn } from '../cn'
|
|
14
|
+
|
|
15
|
+
interface OAuthProvider {
|
|
16
|
+
readonly id: string
|
|
17
|
+
readonly label: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** User-facing strings — pass catalog values to localize. Defaults are
|
|
21
|
+
* English so the card works unconfigured. */
|
|
22
|
+
export interface LoginCardLabels {
|
|
23
|
+
/** Email field label. Default `'Email'`. */
|
|
24
|
+
readonly email?: string
|
|
25
|
+
/** Email input placeholder. Default `'you@company.com'`. */
|
|
26
|
+
readonly emailPlaceholder?: string
|
|
27
|
+
/** Password field label. Default `'Password'`. */
|
|
28
|
+
readonly password?: string
|
|
29
|
+
/** "Forgot password?" link text (shown when `forgotHref` is set).
|
|
30
|
+
* Default `'Forgot?'`. */
|
|
31
|
+
readonly forgot?: string
|
|
32
|
+
/** OAuth divider caption. Default `'or continue with'`. */
|
|
33
|
+
readonly oauthDivider?: string
|
|
34
|
+
/** Sign-up footer prompt (shown when `signUpHref` is set).
|
|
35
|
+
* Default `'New here?'`. */
|
|
36
|
+
readonly signUpPrompt?: string
|
|
37
|
+
/** Sign-up footer link text. Default `'Create an account'`. */
|
|
38
|
+
readonly signUp?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_LOGIN_LABELS: Required<LoginCardLabels> = {
|
|
42
|
+
email: 'Email',
|
|
43
|
+
emailPlaceholder: 'you@company.com',
|
|
44
|
+
password: 'Password',
|
|
45
|
+
forgot: 'Forgot?',
|
|
46
|
+
oauthDivider: 'or continue with',
|
|
47
|
+
signUpPrompt: 'New here?',
|
|
48
|
+
signUp: 'Create an account',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface LoginCardProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'children'> {
|
|
52
|
+
readonly title?: string
|
|
53
|
+
readonly description?: string
|
|
54
|
+
readonly eyebrow?: string
|
|
55
|
+
readonly submitLabel?: string
|
|
56
|
+
readonly oauthProviders?: ReadonlyArray<OAuthProvider>
|
|
57
|
+
readonly onOAuth?: (providerId: string) => void
|
|
58
|
+
readonly signUpHref?: string
|
|
59
|
+
readonly forgotHref?: string
|
|
60
|
+
readonly className?: string
|
|
61
|
+
/** Override the card's own chrome strings. Defaults are English. */
|
|
62
|
+
readonly labels?: LoginCardLabels
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const LoginCard = ({
|
|
66
|
+
title = 'Welcome back',
|
|
67
|
+
description = 'Sign in to your account to continue.',
|
|
68
|
+
eyebrow,
|
|
69
|
+
submitLabel = 'Sign in',
|
|
70
|
+
oauthProviders,
|
|
71
|
+
onOAuth,
|
|
72
|
+
signUpHref,
|
|
73
|
+
forgotHref,
|
|
74
|
+
className,
|
|
75
|
+
labels,
|
|
76
|
+
...formProps
|
|
77
|
+
}: LoginCardProps): ReactNode => {
|
|
78
|
+
const t = { ...DEFAULT_LOGIN_LABELS, ...labels }
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn('w-full max-w-md mx-auto', className)}>
|
|
81
|
+
<Card>
|
|
82
|
+
<CardHeader>
|
|
83
|
+
{eyebrow ? (
|
|
84
|
+
<div className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
|
|
85
|
+
{eyebrow}
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
<CardTitle className="text-2xl">{title}</CardTitle>
|
|
89
|
+
<CardDescription>{description}</CardDescription>
|
|
90
|
+
</CardHeader>
|
|
91
|
+
<CardContent>
|
|
92
|
+
<form className="space-y-4" {...formProps}>
|
|
93
|
+
<div className="space-y-1.5">
|
|
94
|
+
<Label htmlFor="email">{t.email}</Label>
|
|
95
|
+
<Input id="email" name="email" type="email" autoComplete="email" placeholder={t.emailPlaceholder} />
|
|
96
|
+
</div>
|
|
97
|
+
<div className="space-y-1.5">
|
|
98
|
+
<div className="flex items-center justify-between">
|
|
99
|
+
<Label htmlFor="password">{t.password}</Label>
|
|
100
|
+
{forgotHref ? (
|
|
101
|
+
<a href={forgotHref} className="text-xs text-muted-foreground hover:text-foreground">{t.forgot}</a>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
<Input id="password" name="password" type="password" autoComplete="current-password" placeholder="••••••••" />
|
|
105
|
+
</div>
|
|
106
|
+
<Button type="submit" className="w-full">{submitLabel}</Button>
|
|
107
|
+
</form>
|
|
108
|
+
|
|
109
|
+
{oauthProviders && oauthProviders.length > 0 ? (
|
|
110
|
+
<>
|
|
111
|
+
<div className="my-5 flex items-center gap-3 text-xs text-muted-foreground">
|
|
112
|
+
<div className="h-px flex-1 bg-border" />
|
|
113
|
+
<span>{t.oauthDivider}</span>
|
|
114
|
+
<div className="h-px flex-1 bg-border" />
|
|
115
|
+
</div>
|
|
116
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${oauthProviders.length}, minmax(0, 1fr))` }}>
|
|
117
|
+
{oauthProviders.map((p) => (
|
|
118
|
+
<Button
|
|
119
|
+
key={p.id}
|
|
120
|
+
type="button"
|
|
121
|
+
variant="outline"
|
|
122
|
+
onClick={() => onOAuth?.(p.id)}
|
|
123
|
+
>
|
|
124
|
+
{p.label}
|
|
125
|
+
</Button>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</>
|
|
129
|
+
) : null}
|
|
130
|
+
</CardContent>
|
|
131
|
+
{signUpHref ? (
|
|
132
|
+
<CardFooter className="justify-center text-xs text-muted-foreground">
|
|
133
|
+
{t.signUpPrompt} <a href={signUpHref} className="ml-1 text-foreground hover:underline">{t.signUp}</a>
|
|
134
|
+
</CardFooter>
|
|
135
|
+
) : null}
|
|
136
|
+
</Card>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared page header chrome for every Voltro-Cloud-style app:
|
|
2
|
+
// landing, docs, dashboard, marketplace. The single source of truth
|
|
3
|
+
// for the sticky top bar that wraps brand + navigation across all
|
|
4
|
+
// four web apps — keeps height, glass treatment, max-width, and
|
|
5
|
+
// padding identical so they read as one product.
|
|
6
|
+
//
|
|
7
|
+
// Treatment:
|
|
8
|
+
// • sticky top-0, z-40 (above the docs sidebar's top-16 sticky)
|
|
9
|
+
// • Fixed `h-16` (64px). NOT scroll-shrinking — DocsLayout's
|
|
10
|
+
// sidebar uses `sticky top-16` and a height-changing header
|
|
11
|
+
// would leave a gap or overlap on scroll. The visual
|
|
12
|
+
// "compactness on scroll" effect is delivered by the glass
|
|
13
|
+
// fade-in instead.
|
|
14
|
+
// • Glass + border fade in once `scrollY > 24`. At rest the
|
|
15
|
+
// header is fully transparent → the hero / page background
|
|
16
|
+
// bleeds through.
|
|
17
|
+
//
|
|
18
|
+
// The children prop is the row contents (brand on the left, nav /
|
|
19
|
+
// actions on the right). Consumers compose freely; the kit just
|
|
20
|
+
// owns the wrapper.
|
|
21
|
+
|
|
22
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
23
|
+
import { cn } from '../cn'
|
|
24
|
+
|
|
25
|
+
interface PageHeaderProps {
|
|
26
|
+
readonly children: ReactNode
|
|
27
|
+
readonly className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PageHeader = ({ children, className }: PageHeaderProps): ReactNode => {
|
|
31
|
+
const [scrolled, setScrolled] = useState(false)
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// 24px threshold — smaller flickers on subpixel scroll;
|
|
34
|
+
// bigger leaves a moment where content touches the bare header.
|
|
35
|
+
const onScroll = (): void => setScrolled(window.scrollY > 24)
|
|
36
|
+
onScroll()
|
|
37
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
38
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<header className={cn('sticky top-0 z-40', className)}>
|
|
43
|
+
{/* Glass + hairline border, fade-in on scroll. Separate <div>
|
|
44
|
+
* so opacity transitions don't affect the foreground row. */}
|
|
45
|
+
<div
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
className={cn(
|
|
48
|
+
'absolute inset-0 -z-10 transition-opacity duration-300 ease-out',
|
|
49
|
+
'bg-background/80 backdrop-blur-lg border-b border-border',
|
|
50
|
+
scrolled ? 'opacity-100' : 'opacity-0',
|
|
51
|
+
)}
|
|
52
|
+
/>
|
|
53
|
+
<div className="mx-auto max-w-7xl px-6 h-16 flex items-center justify-between gap-4">
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
</header>
|
|
57
|
+
)
|
|
58
|
+
}
|