@olwiba/ui 0.1.4 → 0.1.7
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/dist/index.d.ts +246 -8
- package/dist/index.js +1126 -197
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/app/AuthSection.tsx +23 -1
- package/src/blog/ChangelogCard.tsx +73 -15
- package/src/components/BrandColorSwitchMinimal.tsx +172 -0
- package/src/components/FlowBracket.tsx +166 -0
- package/src/components/FlowConnector.tsx +135 -0
- package/src/components/PricingCard.tsx +7 -3
- package/src/components/ThemeSwitchMinimal.tsx +7 -7
- package/src/index.ts +16 -2
- package/src/marketing/ComparisonSection.tsx +109 -0
- package/src/marketing/ContactSection.tsx +74 -33
- package/src/marketing/CtaCardSection.tsx +95 -0
- package/src/marketing/FeatureMarqueeSection.tsx +120 -0
- package/src/marketing/Footer.tsx +2 -6
- package/src/marketing/GroupedFeaturesSection.tsx +71 -0
- package/src/marketing/Navbar.tsx +2 -10
- package/src/marketing/NewsletterSection.tsx +7 -6
- package/src/marketing/PricingSection.tsx +66 -39
- package/src/marketing/QualificationSection.tsx +86 -0
- package/src/marketing/StepsSection.tsx +97 -0
- package/src/marketing/TeamSection.tsx +50 -47
- package/src/marketing/TechStackSection.tsx +83 -0
- package/src/motion/AnimatedPill.tsx +71 -0
- package/src/motion/CountdownTimer.tsx +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olwiba/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
42
42
|
"@dnd-kit/sortable": "^10.0.0",
|
|
43
43
|
"@dnd-kit/utilities": "^3.2.2",
|
|
44
|
-
"@olwiba/docs": "0.1.
|
|
44
|
+
"@olwiba/docs": "0.1.29",
|
|
45
45
|
"@olwiba/dx": "0.0.18",
|
|
46
46
|
"@tanstack/react-table": "^8.21.3",
|
|
47
47
|
"clsx": "^2.1.1",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@content-collections/mdx": "^0.2.2",
|
|
67
|
-
"@olwiba/cn": "0.1.
|
|
67
|
+
"@olwiba/cn": "0.1.21",
|
|
68
68
|
"@tailwindcss/vite": "^4.1.18",
|
|
69
69
|
"@tanstack/react-router": "1.154.8",
|
|
70
70
|
"@tanstack/react-router-devtools": "1.154.8",
|
package/src/app/AuthSection.tsx
CHANGED
|
@@ -84,6 +84,10 @@ export interface AuthFormProps {
|
|
|
84
84
|
renderLink?: AppShellRenderLink;
|
|
85
85
|
/** Optional content rendered below the form — use for social auth buttons or other additions */
|
|
86
86
|
footer?: React.ReactNode;
|
|
87
|
+
/** Pre-fill the email field (e.g. from demo credential links). Triggers a brief border highlight animation. */
|
|
88
|
+
defaultEmail?: string;
|
|
89
|
+
/** Pre-fill the password field (e.g. from demo credential links). Triggers a brief border highlight animation. */
|
|
90
|
+
defaultPassword?: string;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
function DefaultForm({
|
|
@@ -98,11 +102,19 @@ function DefaultForm({
|
|
|
98
102
|
loading,
|
|
99
103
|
renderLink = defaultRenderLink,
|
|
100
104
|
footer,
|
|
105
|
+
defaultEmail,
|
|
106
|
+
defaultPassword,
|
|
101
107
|
}: AuthFormProps) {
|
|
102
108
|
const isSignUp = mode === 'signup';
|
|
109
|
+
const hasPrefill = !!(defaultEmail || defaultPassword);
|
|
110
|
+
const prefillStyle = (active: boolean): React.CSSProperties | undefined =>
|
|
111
|
+
active ? { animation: 'auth-prefill 1.6s ease-out 0.35s 1 both' } : undefined;
|
|
103
112
|
|
|
104
113
|
return (
|
|
105
114
|
<Card className="w-full">
|
|
115
|
+
{hasPrefill && (
|
|
116
|
+
<style>{`@keyframes auth-prefill{0%{box-shadow:0 0 0 0 hsl(var(--primary)/0)}40%{box-shadow:0 0 0 3px hsl(var(--primary)/0.4)}100%{box-shadow:0 0 0 0 hsl(var(--primary)/0)}}`}</style>
|
|
117
|
+
)}
|
|
106
118
|
<CardHeader>
|
|
107
119
|
{brand && <div className="mb-2">{brand}</div>}
|
|
108
120
|
<CardTitle>{isSignUp ? 'Create an account' : 'Sign in'}</CardTitle>
|
|
@@ -122,7 +134,15 @@ function DefaultForm({
|
|
|
122
134
|
)}
|
|
123
135
|
<div className="space-y-2">
|
|
124
136
|
<Label htmlFor="auth-email">Email address</Label>
|
|
125
|
-
<Input
|
|
137
|
+
<Input
|
|
138
|
+
id="auth-email"
|
|
139
|
+
name="email"
|
|
140
|
+
type="email"
|
|
141
|
+
placeholder="name@company.com"
|
|
142
|
+
autoComplete="email"
|
|
143
|
+
defaultValue={defaultEmail}
|
|
144
|
+
style={prefillStyle(!!defaultEmail)}
|
|
145
|
+
/>
|
|
126
146
|
</div>
|
|
127
147
|
<div className="space-y-2">
|
|
128
148
|
<div className="flex items-center justify-between">
|
|
@@ -139,6 +159,8 @@ function DefaultForm({
|
|
|
139
159
|
type="password"
|
|
140
160
|
placeholder="••••••••"
|
|
141
161
|
autoComplete={isSignUp ? 'new-password' : 'current-password'}
|
|
162
|
+
defaultValue={defaultPassword}
|
|
163
|
+
style={prefillStyle(!!defaultPassword)}
|
|
142
164
|
/>
|
|
143
165
|
</div>
|
|
144
166
|
{error && (
|
|
@@ -2,18 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { Badge, cn } from '@olwiba/cn';
|
|
5
|
+
import { ArrowRight } from 'lucide-react';
|
|
5
6
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
6
7
|
|
|
8
|
+
export type ChangelogReleaseType = 'release' | 'fix' | 'enhancement';
|
|
9
|
+
|
|
10
|
+
export interface ChangelogHighlight {
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
export interface ChangelogCardProps {
|
|
8
16
|
title: string;
|
|
9
17
|
summary: string;
|
|
10
18
|
date: string;
|
|
11
19
|
slug: string;
|
|
12
20
|
version?: string;
|
|
21
|
+
releaseType?: ChangelogReleaseType;
|
|
22
|
+
highlights?: ChangelogHighlight[];
|
|
13
23
|
hrefPrefix?: string;
|
|
14
24
|
renderLink?: AppShellRenderLink;
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
const releaseTypeLabel: Record<ChangelogReleaseType, string> = {
|
|
28
|
+
release: 'Major Release',
|
|
29
|
+
fix: 'Fix',
|
|
30
|
+
enhancement: 'Enhancement',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const releaseTypeVariant: Record<ChangelogReleaseType, 'default' | 'secondary' | 'outline'> = {
|
|
34
|
+
release: 'default',
|
|
35
|
+
fix: 'outline',
|
|
36
|
+
enhancement: 'secondary',
|
|
37
|
+
};
|
|
38
|
+
|
|
17
39
|
function formatDate(dateStr: string) {
|
|
18
40
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
19
41
|
year: 'numeric',
|
|
@@ -28,6 +50,8 @@ export function ChangelogCard({
|
|
|
28
50
|
date,
|
|
29
51
|
slug,
|
|
30
52
|
version,
|
|
53
|
+
releaseType,
|
|
54
|
+
highlights,
|
|
31
55
|
hrefPrefix = '/changelog',
|
|
32
56
|
renderLink,
|
|
33
57
|
}: ChangelogCardProps) {
|
|
@@ -48,26 +72,60 @@ export function ChangelogCard({
|
|
|
48
72
|
</a>
|
|
49
73
|
);
|
|
50
74
|
|
|
75
|
+
const hasHighlights = highlights && highlights.length > 0;
|
|
76
|
+
|
|
51
77
|
return (
|
|
52
|
-
<article
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
<article
|
|
79
|
+
className={cn(
|
|
80
|
+
'flex flex-col gap-5 rounded-2xl border border-border bg-card p-6 sm:p-8',
|
|
81
|
+
!hasHighlights && 'border-b border-border/60 rounded-none bg-transparent p-0 pb-8 last:pb-0 last:border-0',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{/* Header row */}
|
|
85
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
86
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
87
|
+
<time className="text-sm text-muted-foreground" dateTime={date}>
|
|
88
|
+
{formatDate(date)}
|
|
89
|
+
</time>
|
|
90
|
+
{version && (
|
|
58
91
|
<Badge variant="secondary" className="font-mono text-xs">
|
|
59
92
|
{version}
|
|
60
93
|
</Badge>
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
)}
|
|
95
|
+
{releaseType && (
|
|
96
|
+
<Badge variant={releaseTypeVariant[releaseType]} className="text-xs">
|
|
97
|
+
{releaseTypeLabel[releaseType]}
|
|
98
|
+
</Badge>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
<LinkWrapper className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
|
102
|
+
Read update
|
|
103
|
+
<ArrowRight className="size-3.5" />
|
|
104
|
+
</LinkWrapper>
|
|
63
105
|
</div>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
106
|
+
|
|
107
|
+
{/* Title + summary */}
|
|
108
|
+
<div>
|
|
109
|
+
<h2 className="text-lg font-semibold leading-snug tracking-tight">
|
|
110
|
+
<LinkWrapper className="transition-colors hover:text-primary">{title}</LinkWrapper>
|
|
111
|
+
</h2>
|
|
112
|
+
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Highlights grid */}
|
|
116
|
+
{hasHighlights && (
|
|
117
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
118
|
+
{highlights.map((h) => (
|
|
119
|
+
<div
|
|
120
|
+
key={h.title}
|
|
121
|
+
className="rounded-xl border border-border/70 bg-background p-4"
|
|
122
|
+
>
|
|
123
|
+
<p className="text-sm font-semibold leading-snug">{h.title}</p>
|
|
124
|
+
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{h.description}</p>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
71
129
|
</article>
|
|
72
130
|
);
|
|
73
131
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Palette } from 'lucide-react';
|
|
5
|
+
import { Button, Popover, PopoverContent, PopoverTrigger } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
interface BrandColor {
|
|
8
|
+
name: string;
|
|
9
|
+
label: string;
|
|
10
|
+
css: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BRAND_COLORS: BrandColor[] = [
|
|
14
|
+
{
|
|
15
|
+
name: 'zinc',
|
|
16
|
+
label: 'Zinc',
|
|
17
|
+
css: `
|
|
18
|
+
:root { --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.708 0 0); }
|
|
19
|
+
.dark { --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --ring: oklch(0.556 0 0); }
|
|
20
|
+
`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'blue',
|
|
24
|
+
label: 'Blue',
|
|
25
|
+
css: `
|
|
26
|
+
:root { --primary: oklch(0.546 0.245 262.881); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.546 0.245 262.881); }
|
|
27
|
+
.dark { --primary: oklch(0.623 0.214 259.815); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.623 0.214 259.815); }
|
|
28
|
+
`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'emerald',
|
|
32
|
+
label: 'Emerald',
|
|
33
|
+
css: `
|
|
34
|
+
:root { --primary: oklch(0.596 0.145 163.225); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.596 0.145 163.225); }
|
|
35
|
+
.dark { --primary: oklch(0.765 0.177 163.223); --primary-foreground: oklch(0.145 0 0); --ring: oklch(0.765 0.177 163.223); }
|
|
36
|
+
`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'purple',
|
|
40
|
+
label: 'Purple',
|
|
41
|
+
css: `
|
|
42
|
+
:root { --primary: oklch(0.558 0.288 302.321); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.558 0.288 302.321); }
|
|
43
|
+
.dark { --primary: oklch(0.714 0.203 305.504); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.714 0.203 305.504); }
|
|
44
|
+
`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'rose',
|
|
48
|
+
label: 'Rose',
|
|
49
|
+
css: `
|
|
50
|
+
:root { --primary: oklch(0.645 0.246 16.439); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.645 0.246 16.439); }
|
|
51
|
+
.dark { --primary: oklch(0.717 0.194 17.428); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.717 0.194 17.428); }
|
|
52
|
+
`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'orange',
|
|
56
|
+
label: 'Orange',
|
|
57
|
+
css: `
|
|
58
|
+
:root { --primary: oklch(0.705 0.213 47.604); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.705 0.213 47.604); }
|
|
59
|
+
.dark { --primary: oklch(0.792 0.184 70.08); --primary-foreground: oklch(0.145 0 0); --ring: oklch(0.792 0.184 70.08); }
|
|
60
|
+
`,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'slate',
|
|
64
|
+
label: 'Slate',
|
|
65
|
+
css: `
|
|
66
|
+
:root { --primary: oklch(0.446 0.043 257.281); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.446 0.043 257.281); }
|
|
67
|
+
.dark { --primary: oklch(0.704 0.04 256.788); --primary-foreground: oklch(0.129 0.042 264.695); --ring: oklch(0.704 0.04 256.788); }
|
|
68
|
+
`,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Preview hex values matched to each preset (used for swatch circles only)
|
|
73
|
+
const PREVIEW_HEX: Record<string, string> = {
|
|
74
|
+
zinc: '#27272a',
|
|
75
|
+
blue: '#3b82f6',
|
|
76
|
+
emerald: '#10b981',
|
|
77
|
+
purple: '#a855f7',
|
|
78
|
+
rose: '#f43f5e',
|
|
79
|
+
orange: '#f97316',
|
|
80
|
+
slate: '#64748b',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const STORAGE_KEY = 'brand-color';
|
|
84
|
+
const STYLE_ID = 'brand-color-override';
|
|
85
|
+
|
|
86
|
+
function applyColor(color: BrandColor) {
|
|
87
|
+
let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
|
88
|
+
if (!el) {
|
|
89
|
+
el = document.createElement('style');
|
|
90
|
+
el.id = STYLE_ID;
|
|
91
|
+
document.head.appendChild(el);
|
|
92
|
+
}
|
|
93
|
+
el.textContent = color.css;
|
|
94
|
+
localStorage.setItem(STORAGE_KEY, color.name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getInitialColorName(): string {
|
|
98
|
+
if (typeof localStorage === 'undefined') return 'emerald';
|
|
99
|
+
return localStorage.getItem(STORAGE_KEY) ?? 'emerald';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function BrandColorSwitchMinimal() {
|
|
103
|
+
const [active, setActive] = React.useState<string>(getInitialColorName);
|
|
104
|
+
const [open, setOpen] = React.useState(false);
|
|
105
|
+
|
|
106
|
+
React.useEffect(() => {
|
|
107
|
+
const saved = localStorage.getItem(STORAGE_KEY) ?? 'emerald';
|
|
108
|
+
const color = BRAND_COLORS.find((c) => c.name === saved);
|
|
109
|
+
if (color) applyColor(color);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
function select(color: BrandColor) {
|
|
113
|
+
applyColor(color);
|
|
114
|
+
setActive(color.name);
|
|
115
|
+
setOpen(false);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const activeHex = PREVIEW_HEX[active] ?? '#27272a';
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
122
|
+
<PopoverTrigger asChild>
|
|
123
|
+
<Button
|
|
124
|
+
variant="ghost"
|
|
125
|
+
size="icon"
|
|
126
|
+
className="size-8"
|
|
127
|
+
aria-label="Change brand color"
|
|
128
|
+
>
|
|
129
|
+
<span className="relative inline-flex size-4 items-center justify-center">
|
|
130
|
+
<Palette className="size-4" />
|
|
131
|
+
<span
|
|
132
|
+
className="absolute -bottom-0.5 -right-0.5 size-1.5 rounded-full ring-1 ring-background"
|
|
133
|
+
style={{ background: activeHex }}
|
|
134
|
+
/>
|
|
135
|
+
</span>
|
|
136
|
+
</Button>
|
|
137
|
+
</PopoverTrigger>
|
|
138
|
+
<PopoverContent className="w-auto p-3" align="end">
|
|
139
|
+
<p className="mb-2.5 text-xs font-medium text-muted-foreground">Brand color</p>
|
|
140
|
+
<div className="grid grid-cols-4 gap-1.5">
|
|
141
|
+
{BRAND_COLORS.map((color) => (
|
|
142
|
+
<button
|
|
143
|
+
key={color.name}
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={() => select(color)}
|
|
146
|
+
title={color.label}
|
|
147
|
+
className="group flex flex-col items-center gap-1"
|
|
148
|
+
>
|
|
149
|
+
<span
|
|
150
|
+
className="flex size-7 items-center justify-center rounded-full ring-offset-background transition-all group-hover:scale-110"
|
|
151
|
+
style={{
|
|
152
|
+
background: PREVIEW_HEX[color.name],
|
|
153
|
+
boxShadow:
|
|
154
|
+
active === color.name
|
|
155
|
+
? `0 0 0 2px var(--background), 0 0 0 4px ${PREVIEW_HEX[color.name]}`
|
|
156
|
+
: undefined,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{active === color.name && (
|
|
160
|
+
<svg className="size-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
|
161
|
+
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
|
162
|
+
</svg>
|
|
163
|
+
)}
|
|
164
|
+
</span>
|
|
165
|
+
<span className="text-[10px] text-muted-foreground">{color.label}</span>
|
|
166
|
+
</button>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
</PopoverContent>
|
|
170
|
+
</Popover>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface FlowBracketProps {
|
|
6
|
+
/**
|
|
7
|
+
* Vertical anchor for the top connection as a percentage of the wrapper height (0–100).
|
|
8
|
+
* Defaults to 8 (near the top of the first child).
|
|
9
|
+
*/
|
|
10
|
+
anchorTop?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Vertical anchor for the bottom connection as a percentage of the wrapper height (0–100).
|
|
13
|
+
* Defaults to 92 (near the bottom of the last child).
|
|
14
|
+
*/
|
|
15
|
+
anchorBottom?: number;
|
|
16
|
+
/**
|
|
17
|
+
* How far outside the wrapper the bracket extends, in SVG units (viewBox width = 1000).
|
|
18
|
+
* Defaults to 22.
|
|
19
|
+
*/
|
|
20
|
+
extent?: number;
|
|
21
|
+
/** Show open-chevron arrowhead at the top (destination) end. Default: true. */
|
|
22
|
+
arrow?: boolean;
|
|
23
|
+
/** CSS color for both bracket lines and animated dots. Defaults to muted slate. */
|
|
24
|
+
color?: string;
|
|
25
|
+
/** Override color for the left bracket only. */
|
|
26
|
+
colorLeft?: string;
|
|
27
|
+
/** Override color for the right bracket only. */
|
|
28
|
+
colorRight?: string;
|
|
29
|
+
/** Show left bracket. Default: true. */
|
|
30
|
+
left?: boolean;
|
|
31
|
+
/** Show right bracket. Default: true. */
|
|
32
|
+
right?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Animate a dot traveling the L-path from the bottom anchor up to the top anchor
|
|
35
|
+
* (sync → genesis/docs direction).
|
|
36
|
+
* Dots are placed after {children} in DOM so positive-z-index card stacking contexts
|
|
37
|
+
* naturally render above them.
|
|
38
|
+
*/
|
|
39
|
+
animate?: boolean;
|
|
40
|
+
/** Duration of one full dot journey. Default: '4s'. */
|
|
41
|
+
animateDur?: string;
|
|
42
|
+
/** Reverse the right bracket path direction so its arrowhead points DOWN (into sync). Default: false. */
|
|
43
|
+
reverseRight?: boolean;
|
|
44
|
+
style?: React.CSSProperties;
|
|
45
|
+
className?: string;
|
|
46
|
+
children: React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MARKER_L = 'fb-ml';
|
|
50
|
+
const MARKER_R = 'fb-mr';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wraps children in a relative container and draws square bracket lines on the
|
|
54
|
+
* left and/or right outside edges — connecting the top of the first child down
|
|
55
|
+
* to the bottom of the last child (e.g. docs → SYNC direction).
|
|
56
|
+
*
|
|
57
|
+
* Bracket lines use `overflow: visible` on the SVG to draw into the parent's padding.
|
|
58
|
+
* Ensure the parent section/container has enough horizontal padding for `extent`.
|
|
59
|
+
*/
|
|
60
|
+
export function FlowBracket({
|
|
61
|
+
anchorTop = 8,
|
|
62
|
+
anchorBottom = 92,
|
|
63
|
+
extent = 22,
|
|
64
|
+
arrow = true,
|
|
65
|
+
color = 'rgba(148,163,184,0.4)',
|
|
66
|
+
colorLeft,
|
|
67
|
+
colorRight,
|
|
68
|
+
left = true,
|
|
69
|
+
right = true,
|
|
70
|
+
animate = false,
|
|
71
|
+
animateDur = '4s',
|
|
72
|
+
reverseRight = false,
|
|
73
|
+
style,
|
|
74
|
+
className,
|
|
75
|
+
children,
|
|
76
|
+
}: FlowBracketProps) {
|
|
77
|
+
const L = -extent;
|
|
78
|
+
const R = 1000 + extent;
|
|
79
|
+
|
|
80
|
+
const cl = colorLeft ?? color;
|
|
81
|
+
const cr = colorRight ?? color;
|
|
82
|
+
|
|
83
|
+
// Left bracket: bottom → top (arrowhead at top, pointing into genesis)
|
|
84
|
+
const leftD = `M 0,${anchorBottom} L ${L},${anchorBottom} L ${L},${anchorTop} L 0,${anchorTop}`;
|
|
85
|
+
// Right bracket: reversed = top → bottom (arrowhead at bottom, pointing into sync)
|
|
86
|
+
const rightD = reverseRight
|
|
87
|
+
? `M 1000,${anchorTop} L ${R},${anchorTop} L ${R},${anchorBottom} L 1000,${anchorBottom}`
|
|
88
|
+
: `M 1000,${anchorBottom} L ${R},${anchorBottom} L ${R},${anchorTop} L 1000,${anchorTop}`;
|
|
89
|
+
|
|
90
|
+
// CSS dot: extent as % of container width
|
|
91
|
+
const ep = `${(extent / 10).toFixed(1)}%`;
|
|
92
|
+
// Dots travel bottom → top (sync → genesis/docs direction), fade in/out at card edges
|
|
93
|
+
const dotKeyframes = animate ? `
|
|
94
|
+
@keyframes fb-dl{
|
|
95
|
+
0%{top:${anchorBottom}%;left:0;opacity:0}
|
|
96
|
+
6%{opacity:1}
|
|
97
|
+
20%{top:${anchorBottom}%;left:-${ep}}
|
|
98
|
+
80%{top:${anchorTop}%;left:-${ep};opacity:1}
|
|
99
|
+
94%{top:${anchorTop}%;left:0;opacity:0}
|
|
100
|
+
100%{top:${anchorTop}%;left:0;opacity:0}
|
|
101
|
+
}
|
|
102
|
+
@keyframes fb-dr{
|
|
103
|
+
0%{top:${anchorBottom}%;left:100%;opacity:0}
|
|
104
|
+
6%{opacity:1}
|
|
105
|
+
20%{top:${anchorBottom}%;left:calc(100% + ${ep})}
|
|
106
|
+
80%{top:${anchorTop}%;left:calc(100% + ${ep});opacity:1}
|
|
107
|
+
94%{top:${anchorTop}%;left:100%;opacity:0}
|
|
108
|
+
100%{top:${anchorTop}%;left:100%;opacity:0}
|
|
109
|
+
}` : '';
|
|
110
|
+
|
|
111
|
+
const dotBase: React.CSSProperties = {
|
|
112
|
+
position: 'absolute',
|
|
113
|
+
width: 5,
|
|
114
|
+
height: 5,
|
|
115
|
+
borderRadius: '50%',
|
|
116
|
+
transform: 'translate(-50%, -50%)',
|
|
117
|
+
pointerEvents: 'none',
|
|
118
|
+
opacity: 0,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className={className} style={{ position: 'relative', ...style }}>
|
|
123
|
+
{animate && <style>{dotKeyframes}</style>}
|
|
124
|
+
<svg
|
|
125
|
+
viewBox="0 0 1000 100"
|
|
126
|
+
preserveAspectRatio="none"
|
|
127
|
+
aria-hidden
|
|
128
|
+
className="pointer-events-none absolute inset-0"
|
|
129
|
+
style={{ width: '100%', height: '100%', overflow: 'visible' }}
|
|
130
|
+
>
|
|
131
|
+
<defs>
|
|
132
|
+
<marker id={MARKER_L} markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
|
|
133
|
+
<polyline points="1,1 8,4 1,7" fill="none" stroke={cl} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
134
|
+
</marker>
|
|
135
|
+
<marker id={MARKER_R} markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
|
|
136
|
+
<polyline points="1,1 8,4 1,7" fill="none" stroke={cr} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
137
|
+
</marker>
|
|
138
|
+
</defs>
|
|
139
|
+
{left && (
|
|
140
|
+
<path d={leftD} fill="none" stroke={cl} strokeWidth={1.5} strokeDasharray="5,4"
|
|
141
|
+
vectorEffect="non-scaling-stroke" {...(arrow ? { markerEnd: `url(#${MARKER_L})` } : {})} />
|
|
142
|
+
)}
|
|
143
|
+
{right && (
|
|
144
|
+
<path d={rightD} fill="none" stroke={cr} strokeWidth={1.5} strokeDasharray="5,4"
|
|
145
|
+
vectorEffect="non-scaling-stroke" {...(arrow ? { markerEnd: `url(#${MARKER_R})` } : {})} />
|
|
146
|
+
)}
|
|
147
|
+
</svg>
|
|
148
|
+
|
|
149
|
+
{children}
|
|
150
|
+
|
|
151
|
+
{/* Dots placed after children so card stacking contexts (z-index ≥ 1) render above */}
|
|
152
|
+
{animate && left && (
|
|
153
|
+
<div
|
|
154
|
+
aria-hidden
|
|
155
|
+
style={{ ...dotBase, backgroundColor: cl, animation: `fb-dl ${animateDur} ease-in-out 0s infinite` }}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{animate && right && (
|
|
159
|
+
<div
|
|
160
|
+
aria-hidden
|
|
161
|
+
style={{ ...dotBase, backgroundColor: cr, animation: `fb-dr ${animateDur} ease-in-out 1.8s infinite` }}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
export interface FlowConnectorProps {
|
|
7
|
+
/** Line axis. Default: 'vertical'. */
|
|
8
|
+
direction?: 'vertical' | 'horizontal';
|
|
9
|
+
/**
|
|
10
|
+
* CSS color string for the line and arrowhead.
|
|
11
|
+
* Falls back to currentColor at reduced opacity when omitted.
|
|
12
|
+
*/
|
|
13
|
+
color?: string;
|
|
14
|
+
/** Show an open-chevron arrowhead at the destination end. Default: false. */
|
|
15
|
+
arrow?: boolean;
|
|
16
|
+
/** Show a traveling dot animation along the connector. Default: false. */
|
|
17
|
+
animate?: boolean;
|
|
18
|
+
/** Duration of the traveling dot animation. Default: '1.4s'. */
|
|
19
|
+
animateDur?: string;
|
|
20
|
+
/** CSS delay before the traveling dot animation starts. Default: '0s'. */
|
|
21
|
+
animateDelay?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DASH_ON = '5px';
|
|
27
|
+
const DASH_OFF = '4px';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dashed connecting line between diagram nodes.
|
|
31
|
+
* Control size via className (e.g. `h-10` for vertical, `w-16` for horizontal).
|
|
32
|
+
* Pass `style` for fade/entrance animations — the wrapper inherits it.
|
|
33
|
+
*/
|
|
34
|
+
export function FlowConnector({
|
|
35
|
+
direction = 'vertical',
|
|
36
|
+
color,
|
|
37
|
+
arrow = false,
|
|
38
|
+
animate = false,
|
|
39
|
+
animateDur = '1.4s',
|
|
40
|
+
animateDelay = '0s',
|
|
41
|
+
className,
|
|
42
|
+
style,
|
|
43
|
+
}: FlowConnectorProps) {
|
|
44
|
+
const stroke = color ?? 'currentColor';
|
|
45
|
+
const opacity = color ? 0.75 : 0.4;
|
|
46
|
+
|
|
47
|
+
if (direction === 'vertical') {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cn('relative flex flex-col items-center', className)}
|
|
51
|
+
style={style}
|
|
52
|
+
aria-hidden
|
|
53
|
+
>
|
|
54
|
+
{animate && (
|
|
55
|
+
<style>{`@keyframes fc-dot-v{0%{top:-4px;opacity:0}8%{opacity:1}88%{opacity:1}100%{top:calc(100% + 4px);opacity:0}}`}</style>
|
|
56
|
+
)}
|
|
57
|
+
<div
|
|
58
|
+
className="flex-1"
|
|
59
|
+
style={{
|
|
60
|
+
width: 2,
|
|
61
|
+
opacity,
|
|
62
|
+
background: `repeating-linear-gradient(to bottom, ${stroke} 0, ${stroke} ${DASH_ON}, transparent ${DASH_ON}, transparent calc(${DASH_ON} + ${DASH_OFF}))`,
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
{animate && (
|
|
66
|
+
<div
|
|
67
|
+
style={{
|
|
68
|
+
position: 'absolute',
|
|
69
|
+
left: '50%',
|
|
70
|
+
transform: 'translateX(-50%)',
|
|
71
|
+
width: 5,
|
|
72
|
+
height: 5,
|
|
73
|
+
borderRadius: '50%',
|
|
74
|
+
backgroundColor: stroke,
|
|
75
|
+
animation: `fc-dot-v ${animateDur} ease-in-out ${animateDelay} infinite both`,
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
{arrow && (
|
|
80
|
+
<svg
|
|
81
|
+
width="12"
|
|
82
|
+
height="7"
|
|
83
|
+
viewBox="0 0 12 7"
|
|
84
|
+
style={{ display: 'block', opacity }}
|
|
85
|
+
aria-hidden
|
|
86
|
+
>
|
|
87
|
+
<polyline
|
|
88
|
+
points="1,1 6,6 11,1"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke={stroke}
|
|
91
|
+
strokeWidth="1.5"
|
|
92
|
+
strokeLinecap="round"
|
|
93
|
+
strokeLinejoin="round"
|
|
94
|
+
/>
|
|
95
|
+
</svg>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={cn('flex items-center', className)}
|
|
104
|
+
style={style}
|
|
105
|
+
aria-hidden
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
className="flex-1"
|
|
109
|
+
style={{
|
|
110
|
+
height: 2,
|
|
111
|
+
opacity,
|
|
112
|
+
background: `repeating-linear-gradient(to right, ${stroke} 0, ${stroke} ${DASH_ON}, transparent ${DASH_ON}, transparent calc(${DASH_ON} + ${DASH_OFF}))`,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
{arrow && (
|
|
116
|
+
<svg
|
|
117
|
+
width="7"
|
|
118
|
+
height="12"
|
|
119
|
+
viewBox="0 0 7 12"
|
|
120
|
+
style={{ display: 'block', opacity }}
|
|
121
|
+
aria-hidden
|
|
122
|
+
>
|
|
123
|
+
<polyline
|
|
124
|
+
points="1,1 6,6 1,11"
|
|
125
|
+
fill="none"
|
|
126
|
+
stroke={stroke}
|
|
127
|
+
strokeWidth="1.5"
|
|
128
|
+
strokeLinecap="round"
|
|
129
|
+
strokeLinejoin="round"
|
|
130
|
+
/>
|
|
131
|
+
</svg>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|