@olwiba/ui 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,7 +41,6 @@
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.29",
45
44
  "@olwiba/dx": "0.0.18",
46
45
  "@tanstack/react-table": "^8.21.3",
47
46
  "clsx": "^2.1.1",
@@ -65,6 +64,7 @@
65
64
  "devDependencies": {
66
65
  "@content-collections/mdx": "^0.2.2",
67
66
  "@olwiba/cn": "0.1.21",
67
+ "@olwiba/docs": "0.1.29",
68
68
  "@tailwindcss/vite": "^4.1.18",
69
69
  "@tanstack/react-router": "1.154.8",
70
70
  "@tanstack/react-router-devtools": "1.154.8",
@@ -90,6 +90,7 @@ export function FlowBracket({
90
90
  // CSS dot: extent as % of container width
91
91
  const ep = `${(extent / 10).toFixed(1)}%`;
92
92
  // Dots travel bottom → top (sync → genesis/docs direction), fade in/out at card edges
93
+ // fb-drr: reversed — top → bottom (docs → sync direction) used when reverseRight=true
93
94
  const dotKeyframes = animate ? `
94
95
  @keyframes fb-dl{
95
96
  0%{top:${anchorBottom}%;left:0;opacity:0}
@@ -106,6 +107,14 @@ export function FlowBracket({
106
107
  80%{top:${anchorTop}%;left:calc(100% + ${ep});opacity:1}
107
108
  94%{top:${anchorTop}%;left:100%;opacity:0}
108
109
  100%{top:${anchorTop}%;left:100%;opacity:0}
110
+ }
111
+ @keyframes fb-drr{
112
+ 0%{top:${anchorTop}%;left:100%;opacity:0}
113
+ 6%{opacity:1}
114
+ 20%{top:${anchorTop}%;left:calc(100% + ${ep})}
115
+ 80%{top:${anchorBottom}%;left:calc(100% + ${ep});opacity:1}
116
+ 94%{top:${anchorBottom}%;left:100%;opacity:0}
117
+ 100%{top:${anchorBottom}%;left:100%;opacity:0}
109
118
  }` : '';
110
119
 
111
120
  const dotBase: React.CSSProperties = {
@@ -158,7 +167,7 @@ export function FlowBracket({
158
167
  {animate && right && (
159
168
  <div
160
169
  aria-hidden
161
- style={{ ...dotBase, backgroundColor: cr, animation: `fb-dr ${animateDur} ease-in-out 1.8s infinite` }}
170
+ style={{ ...dotBase, backgroundColor: cr, animation: `${reverseRight ? 'fb-drr' : 'fb-dr'} ${animateDur} ease-in-out 1.8s infinite` }}
162
171
  />
163
172
  )}
164
173
  </div>
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
 
3
- import { Check, Minus } from 'lucide-react';
4
3
  import { cn, useUIVariant } from '@olwiba/cn';
5
4
  import { SectionTitle } from './SectionTitle';
6
5
  import { StaggerChildren } from '../motion/StaggerChildren';
@@ -45,9 +44,9 @@ export function ComparisonSection({
45
44
  <div
46
45
  key={col.label}
47
46
  className={cn(
48
- 'flex flex-col rounded-2xl border p-6',
47
+ 'flex h-full flex-col rounded-2xl border p-6',
49
48
  col.highlighted
50
- ? 'border-foreground bg-foreground text-background'
49
+ ? 'border-primary bg-primary/10 ring-1 ring-primary/20'
51
50
  : 'border-border bg-muted/40',
52
51
  )}
53
52
  >
@@ -55,26 +54,16 @@ export function ComparisonSection({
55
54
  <p
56
55
  className={cn(
57
56
  'text-xs font-semibold uppercase tracking-widest',
58
- col.highlighted ? 'text-background/60' : 'text-muted-foreground',
57
+ col.highlighted ? 'text-primary/80' : 'text-muted-foreground',
59
58
  )}
60
59
  >
61
60
  {col.label}
62
61
  </p>
63
- <p
64
- className={cn(
65
- 'mt-2 text-2xl font-bold tracking-tight',
66
- col.highlighted ? 'text-background' : 'text-foreground',
67
- )}
68
- >
62
+ <p className="mt-2 text-2xl font-bold tracking-tight text-foreground">
69
63
  {col.cost}
70
64
  </p>
71
65
  {col.costDetail && (
72
- <p
73
- className={cn(
74
- 'mt-1 text-sm',
75
- col.highlighted ? 'text-background/60' : 'text-muted-foreground',
76
- )}
77
- >
66
+ <p className="mt-1 text-sm text-muted-foreground">
78
67
  {col.costDetail}
79
68
  </p>
80
69
  )}
@@ -84,14 +73,14 @@ export function ComparisonSection({
84
73
  {col.items.map((item) => (
85
74
  <li key={item} className="flex items-start gap-2.5">
86
75
  {col.highlighted ? (
87
- <Check className="mt-0.5 size-4 shrink-0 text-background/70" />
76
+ <span className="mt-0.5 shrink-0 text-sm leading-none">⚡</span>
88
77
  ) : (
89
- <Minus className="mt-0.5 size-4 shrink-0 text-muted-foreground/50" />
78
+ <span className="mt-0.5 shrink-0 text-sm leading-none">❌</span>
90
79
  )}
91
80
  <span
92
81
  className={cn(
93
82
  'text-sm leading-snug',
94
- col.highlighted ? 'text-background/90' : 'text-muted-foreground',
83
+ col.highlighted ? 'text-foreground' : 'text-muted-foreground',
95
84
  )}
96
85
  >
97
86
  {item}
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
- import { Mail, MapPin, MessageSquare, Send, type LucideIcon } from 'lucide-react';
5
- import { Badge, Button, Checkbox, Input, Label, Separator, Textarea } from '@olwiba/cn';
4
+ import { ChevronDown, Mail, MessageSquare, Send, type LucideIcon } from 'lucide-react';
5
+ import { Badge, Button, Input, Label, Textarea } from '@olwiba/cn';
6
6
 
7
7
  export type ContactInfoItem = {
8
8
  label: string;
@@ -22,9 +22,13 @@ export interface ContactSectionProps {
22
22
  onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
23
23
  }
24
24
 
25
- const defaultContactInfoResolved: ContactInfoItem[] = [
26
- { icon: Mail, label: 'Email', value: 'hello@olwiba.com' },
27
- { icon: MapPin, label: 'Base', value: 'Aotearoa New Zealand' },
25
+ const PHONE_PREFIXES = [
26
+ { label: 'US +1', value: '+1-US' },
27
+ { label: 'NZ +64', value: '+64-NZ' },
28
+ { label: 'AU +61', value: '+61-AU' },
29
+ { label: 'UK +44', value: '+44-UK' },
30
+ { label: 'CA +1', value: '+1-CA' },
31
+ { label: 'EU', value: '+0-EU' },
28
32
  ];
29
33
 
30
34
  const defaults = {
@@ -41,7 +45,7 @@ export function ContactSection({
41
45
  badge = defaults.badge,
42
46
  title = defaults.title,
43
47
  description = defaults.description,
44
- contactInfo = defaultContactInfoResolved,
48
+ contactInfo,
45
49
  privacyHref,
46
50
  successTitle = defaults.successTitle,
47
51
  successDescription = defaults.successDescription,
@@ -58,137 +62,212 @@ export function ContactSection({
58
62
 
59
63
  return (
60
64
  <section className="overflow-hidden rounded-2xl border bg-card">
61
- <div className="grid lg:grid-cols-[1fr_1.4fr]">
62
-
63
- {/* Left panel */}
64
- <div className="relative overflow-hidden bg-primary px-8 py-12 text-primary-foreground">
65
- <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary-foreground)/0.12),transparent_55%)]" />
66
- <div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,hsl(var(--primary-foreground)/0.06),transparent_60%)]" />
67
- <div className="relative flex h-full flex-col gap-8">
68
- <div>
69
- <Badge
70
- variant="secondary"
71
- className="mb-4 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/20"
72
- >
73
- {badge}
74
- </Badge>
75
- <h2 className="text-3xl font-semibold leading-tight">{title}</h2>
76
- <p className="mt-3 text-sm leading-relaxed text-primary-foreground/70">{description}</p>
77
- </div>
65
+ <div className="px-6 py-14 sm:px-10 sm:py-20">
78
66
 
79
- <Separator className="bg-primary-foreground/20" />
67
+ {/* Header */}
68
+ <div className="mx-auto max-w-2xl text-center">
69
+ {badge && (
70
+ <div className="mb-4 flex justify-center">
71
+ <Badge variant="secondary">{badge}</Badge>
72
+ </div>
73
+ )}
74
+ <h2 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
75
+ {title}
76
+ </h2>
77
+ <p className="mt-4 text-lg text-muted-foreground">{description}</p>
80
78
 
81
- <div className="space-y-5">
79
+ {contactInfo && contactInfo.length > 0 && (
80
+ <div className="mt-8 flex flex-wrap justify-center gap-6">
82
81
  {contactInfo.map(({ icon: Icon = Mail, label, value }) => (
83
- <div key={label} className="flex items-start gap-3">
84
- <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/10">
85
- <Icon className="size-4" />
86
- </div>
87
- <div>
88
- <div className="text-xs font-medium uppercase tracking-wider text-primary-foreground/50">{label}</div>
89
- <div className="mt-0.5 text-sm font-medium">{value}</div>
90
- </div>
82
+ <div key={label} className="flex items-center gap-2 text-sm text-muted-foreground">
83
+ <Icon className="size-4 shrink-0" />
84
+ <span>{value}</span>
91
85
  </div>
92
86
  ))}
93
87
  </div>
88
+ )}
89
+ </div>
94
90
 
95
- <div className="mt-auto">
96
- <p className="text-xs text-primary-foreground/40">
97
- Response within one business day.
98
- </p>
91
+ {/* Body */}
92
+ {submitted ? (
93
+ <div className="mx-auto mt-16 max-w-xl text-center">
94
+ <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
95
+ <MessageSquare className="size-6" />
99
96
  </div>
97
+ <h3 className="mt-5 text-lg font-semibold">{successTitle}</h3>
98
+ <p className="mt-2 text-sm text-muted-foreground">{successDescription}</p>
99
+ <Button variant="outline" size="sm" className="mt-6" onClick={() => setSubmitted(false)}>
100
+ {sendAnotherLabel}
101
+ </Button>
100
102
  </div>
101
- </div>
103
+ ) : (
104
+ <form onSubmit={handleSubmit} className="mx-auto mt-16 max-w-xl sm:mt-20">
105
+ {/* Honeypot */}
106
+ <input
107
+ type="text"
108
+ name="_hp"
109
+ tabIndex={-1}
110
+ autoComplete="off"
111
+ aria-hidden="true"
112
+ className="hidden"
113
+ />
102
114
 
103
- {/* Right panel form */}
104
- <div className="p-8 sm:p-10">
105
- {submitted ? (
106
- <div className="flex h-full min-h-[360px] flex-col items-center justify-center gap-5 text-center">
107
- <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
108
- <MessageSquare className="size-6" />
109
- </div>
115
+ <div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
116
+ {/* First name */}
110
117
  <div>
111
- <h3 className="text-lg font-semibold">{successTitle}</h3>
112
- <p className="mt-1.5 text-sm text-muted-foreground">{successDescription}</p>
113
- </div>
114
- <Button variant="outline" size="sm" onClick={() => setSubmitted(false)}>
115
- {sendAnotherLabel}
116
- </Button>
117
- </div>
118
- ) : (
119
- <form onSubmit={handleSubmit} className="space-y-5">
120
- {/* honeypot */}
121
- <input type="text" name="_hp" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hidden" />
122
-
123
- <div className="grid gap-4 sm:grid-cols-2">
124
- <div className="space-y-1.5">
125
- <Label htmlFor="contact-first">First name</Label>
126
- <Input id="contact-first" name="firstName" placeholder="Olivia" required />
127
- </div>
128
- <div className="space-y-1.5">
129
- <Label htmlFor="contact-last">Last name</Label>
130
- <Input id="contact-last" name="lastName" placeholder="Reed" required />
118
+ <Label htmlFor="contact-first" className="block text-sm/6 font-semibold">
119
+ First name
120
+ </Label>
121
+ <div className="mt-2.5">
122
+ <Input
123
+ id="contact-first"
124
+ name="firstName"
125
+ autoComplete="given-name"
126
+ placeholder="First"
127
+ required
128
+ />
131
129
  </div>
132
130
  </div>
133
131
 
134
- <div className="grid gap-4 sm:grid-cols-2">
135
- <div className="space-y-1.5">
136
- <Label htmlFor="contact-company" className="flex gap-1">
137
- Company
138
- <span className="text-muted-foreground">(optional)</span>
139
- </Label>
140
- <Input id="contact-company" name="company" placeholder="Acme Inc." />
132
+ {/* Last name */}
133
+ <div>
134
+ <Label htmlFor="contact-last" className="block text-sm/6 font-semibold">
135
+ Last name
136
+ </Label>
137
+ <div className="mt-2.5">
138
+ <Input
139
+ id="contact-last"
140
+ name="lastName"
141
+ autoComplete="family-name"
142
+ placeholder="Last"
143
+ required
144
+ />
141
145
  </div>
142
- <div className="space-y-1.5">
143
- <Label htmlFor="contact-phone" className="flex gap-1">
144
- Phone
145
- <span className="text-muted-foreground">(optional)</span>
146
- </Label>
147
- <Input id="contact-phone" name="phone" type="tel" placeholder="+1 (555) 000-0000" />
146
+ </div>
147
+
148
+ {/* Company */}
149
+ <div className="sm:col-span-2">
150
+ <Label htmlFor="contact-company" className="block text-sm/6 font-semibold">
151
+ Company{' '}
152
+ <span className="font-normal text-muted-foreground">(optional)</span>
153
+ </Label>
154
+ <div className="mt-2.5">
155
+ <Input
156
+ id="contact-company"
157
+ name="company"
158
+ autoComplete="organization"
159
+ placeholder="Acme Inc."
160
+ />
148
161
  </div>
149
162
  </div>
150
163
 
151
- <div className="space-y-1.5">
152
- <Label htmlFor="contact-email">Work email</Label>
153
- <Input id="contact-email" name="email" type="email" placeholder="olivia@company.com" required />
164
+ {/* Email */}
165
+ <div className="sm:col-span-2">
166
+ <Label htmlFor="contact-email" className="block text-sm/6 font-semibold">
167
+ Email
168
+ </Label>
169
+ <div className="mt-2.5">
170
+ <Input
171
+ id="contact-email"
172
+ name="email"
173
+ type="email"
174
+ autoComplete="email"
175
+ placeholder="you@company.com"
176
+ required
177
+ />
178
+ </div>
154
179
  </div>
155
180
 
156
- <div className="space-y-1.5">
157
- <Label htmlFor="contact-subject">Subject</Label>
158
- <Input id="contact-subject" name="subject" placeholder="How can we help?" required />
181
+ {/* Phone */}
182
+ <div className="sm:col-span-2">
183
+ <Label htmlFor="contact-phone" className="block text-sm/6 font-semibold">
184
+ Phone number{' '}
185
+ <span className="font-normal text-muted-foreground">(optional)</span>
186
+ </Label>
187
+ <div className="mt-2.5 flex overflow-hidden rounded-md border border-input bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
188
+ <div className="relative flex shrink-0 items-center border-r border-input">
189
+ <select
190
+ name="countryCode"
191
+ aria-label="Country code"
192
+ defaultValue="+1-US"
193
+ className="h-full appearance-none bg-transparent py-2 pl-3 pr-7 text-sm text-foreground focus:outline-none"
194
+ >
195
+ {PHONE_PREFIXES.map(({ label, value }) => (
196
+ <option key={value} value={value}>
197
+ {label}
198
+ </option>
199
+ ))}
200
+ </select>
201
+ <ChevronDown className="pointer-events-none absolute right-1.5 size-3.5 shrink-0 text-muted-foreground" />
202
+ </div>
203
+ <input
204
+ id="contact-phone"
205
+ name="phone"
206
+ type="tel"
207
+ placeholder="123-456-7890"
208
+ className="min-w-0 grow bg-transparent px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
209
+ />
210
+ </div>
159
211
  </div>
160
212
 
161
- <div className="space-y-1.5">
162
- <Label htmlFor="contact-message">Message</Label>
163
- <Textarea
164
- id="contact-message"
165
- name="message"
166
- placeholder="Tell us what you're working on..."
167
- className="min-h-32 resize-none"
168
- required
169
- />
213
+ {/* Message */}
214
+ <div className="sm:col-span-2">
215
+ <Label htmlFor="contact-message" className="block text-sm/6 font-semibold">
216
+ Message
217
+ </Label>
218
+ <div className="mt-2.5">
219
+ <Textarea
220
+ id="contact-message"
221
+ name="message"
222
+ rows={4}
223
+ placeholder="Tell us what you're working on..."
224
+ className="resize-none"
225
+ required
226
+ />
227
+ </div>
170
228
  </div>
171
229
 
230
+ {/* Privacy toggle */}
172
231
  {privacyHref && (
173
- <div className="flex items-start gap-3">
174
- <Checkbox id="contact-privacy" name="privacy" required className="mt-0.5" />
175
- <Label htmlFor="contact-privacy" className="text-sm font-normal leading-snug text-muted-foreground">
176
- By submitting this form you agree to our{' '}
177
- <a href={privacyHref} className="font-medium text-foreground underline-offset-2 hover:underline">
232
+ <div className="flex gap-x-4 sm:col-span-2">
233
+ <div className="flex h-6 items-center">
234
+ <div className="group relative inline-flex h-5 w-8 shrink-0 cursor-pointer rounded-full border border-input bg-muted p-px transition-colors duration-200 ease-in-out focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 has-[:checked]:border-primary has-[:checked]:bg-primary">
235
+ <span className="size-4 rounded-full bg-background shadow-sm transition-transform duration-200 ease-in-out group-has-[:checked]:translate-x-3" />
236
+ <input
237
+ id="contact-privacy"
238
+ name="privacy"
239
+ type="checkbox"
240
+ required
241
+ aria-label="Agree to privacy policy"
242
+ className="absolute inset-0 size-full cursor-pointer appearance-none focus:outline-none"
243
+ />
244
+ </div>
245
+ </div>
246
+ <Label
247
+ htmlFor="contact-privacy"
248
+ className="cursor-pointer text-sm font-normal leading-snug text-muted-foreground"
249
+ >
250
+ By selecting this, you agree to our{' '}
251
+ <a
252
+ href={privacyHref}
253
+ className="font-medium text-foreground underline-offset-2 hover:underline"
254
+ >
178
255
  privacy policy
179
256
  </a>
180
257
  .
181
258
  </Label>
182
259
  </div>
183
260
  )}
261
+ </div>
184
262
 
263
+ <div className="mt-10">
185
264
  <Button type="submit" className="w-full">
186
- Send message
265
+ Let's talk
187
266
  <Send className="ml-2 size-4" />
188
267
  </Button>
189
- </form>
190
- )}
191
- </div>
268
+ </div>
269
+ </form>
270
+ )}
192
271
 
193
272
  </div>
194
273
  </section>
@@ -63,11 +63,8 @@ export function FeatureMarqueeSection({
63
63
  {rows.map((row, i) => {
64
64
  const direction = row.direction ?? (i % 2 === 0 ? 'left' : 'right');
65
65
  const reversed = direction === 'right';
66
- // For right-scroll rows: reverse source order so item 0 starts on the right
67
- // and items enter from the right in natural reading order.
68
- const source = reversed ? [...row.items].reverse() : row.items;
69
66
  // Repeat enough that one set exceeds 4K viewport (~3840px).
70
- const set = Array.from({ length: 4 }, () => source).flat();
67
+ const set = Array.from({ length: 4 }, () => row.items).flat();
71
68
  const items = [...set, ...set];
72
69
 
73
70
  return (
@@ -87,7 +84,7 @@ export function FeatureMarqueeSection({
87
84
  pauseOnHover && 'group-hover:[animation-play-state:paused]',
88
85
  )}
89
86
  style={{
90
- animation: `feature-marquee-left ${duration} linear infinite`,
87
+ animation: `${reversed ? 'feature-marquee-right' : 'feature-marquee-left'} ${duration} linear infinite`,
91
88
  }}
92
89
  >
93
90
  {items.map((item, j) => {
@@ -114,6 +111,10 @@ export function FeatureMarqueeSection({
114
111
  from { transform: translateX(0); }
115
112
  to { transform: translateX(-50%); }
116
113
  }
114
+ @keyframes feature-marquee-right {
115
+ from { transform: translateX(-50%); }
116
+ to { transform: translateX(0); }
117
+ }
117
118
  `}</style>
118
119
  </section>
119
120
  );
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type { ReactNode } from 'react';
4
+ import { StatusIndicator } from '@olwiba/cn';
4
5
  import type { AppShellRenderLink } from '../app/AppShell';
5
6
 
6
7
  export interface FooterProps {
@@ -8,6 +9,7 @@ export interface FooterProps {
8
9
  navLinks?: Array<{ label: string; href: string }>;
9
10
  socialLinks?: Array<{ label: string; href: string; icon: ReactNode }>;
10
11
  legal?: string;
12
+ status?: { label: string; operational?: boolean };
11
13
  renderLink?: AppShellRenderLink;
12
14
  }
13
15
 
@@ -20,6 +22,7 @@ export function Footer({
20
22
  navLinks,
21
23
  socialLinks,
22
24
  legal,
25
+ status,
23
26
  renderLink = defaultRenderLink,
24
27
  }: FooterProps) {
25
28
  const brandHref = brand.href ?? '/';
@@ -77,6 +80,21 @@ export function Footer({
77
80
  <p className="mt-10 text-center text-sm/6 text-muted-foreground/70">
78
81
  {copyrightText}
79
82
  </p>
83
+
84
+ {/* Status */}
85
+ {status && (
86
+ <div className="mt-4 flex justify-center">
87
+ <div className="inline-flex items-center rounded-full border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
88
+ <StatusIndicator
89
+ pulse={status.operational !== false}
90
+ dotClassName={status.operational !== false ? 'text-emerald-500' : 'text-destructive'}
91
+ size="sm"
92
+ >
93
+ {status.label}
94
+ </StatusIndicator>
95
+ </div>
96
+ </div>
97
+ )}
80
98
  </div>
81
99
  </footer>
82
100
  );