@olwiba/ui 0.2.19 → 0.2.21

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.2.19",
3
+ "version": "0.2.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -35,8 +35,14 @@ export function AppPageHero({
35
35
  <section
36
36
  className={cn(
37
37
  'relative overflow-hidden rounded-3xl border bg-card/80 p-5 shadow-sm sm:p-6',
38
- 'before:absolute before:-right-16 before:-top-16 before:size-44 before:rounded-full before:bg-primary/15 before:blur-3xl',
39
- 'after:absolute after:-bottom-20 after:left-10 after:size-52 after:rounded-full after:bg-primary/10 after:blur-3xl',
38
+ // The glow blobs are decoration, and `::after` is the section's last
39
+ // child so with everything on `z-index: auto` it paints above the
40
+ // header content and swallows clicks on whatever sits under it. At
41
+ // narrow widths the action row stacks below the copy, right into the
42
+ // bottom-left blob, which is how a visible button stops responding.
43
+ // pointer-events-none keeps them purely visual.
44
+ 'before:pointer-events-none before:absolute before:-right-16 before:-top-16 before:size-44 before:rounded-full before:bg-primary/15 before:blur-3xl',
45
+ 'after:pointer-events-none after:absolute after:-bottom-20 after:left-10 after:size-52 after:rounded-full after:bg-primary/10 after:blur-3xl',
40
46
  className,
41
47
  )}
42
48
  >
@@ -4,10 +4,12 @@ import type { LucideIcon } from 'lucide-react';
4
4
  import { useCallback, type CSSProperties, type ReactNode } from 'react';
5
5
  import {
6
6
  BellIcon,
7
+ BirdIcon,
7
8
  CreditCardIcon,
8
9
  LogOutIcon,
9
10
  MoreVerticalIcon,
10
11
  PlusCircleIcon,
12
+ SettingsIcon,
11
13
  } from 'lucide-react';
12
14
  import {
13
15
  Avatar,
@@ -63,6 +65,7 @@ export interface AppShellUser {
63
65
  onSignOut?: () => void;
64
66
  onBilling?: () => void;
65
67
  onNotifications?: () => void;
68
+ onSettings?: () => void;
66
69
  }
67
70
 
68
71
  export interface AppShellBrand {
@@ -142,41 +145,35 @@ const RAIL_SQUARE =
142
145
  'group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!min-w-8 group-data-[collapsible=icon]:!max-w-8 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:transition-[width,height]';
143
146
 
144
147
  /**
145
- * Initials from a name, or from the local part of an email.
148
+ * A stable miniature identity artwork, instead of one grey square or a pair
149
+ * of initials for everybody.
146
150
  *
147
- * "Ollie Bishop" gives OB; "ollie@nestrrr.com" gives OL rather than the OL@
148
- * that slicing the raw string produced and `hello@` no longer reads as HE
149
- * for every address at a domain when a name is absent.
150
- */
151
- function initialsOf(source: string): string {
152
- const base = source.includes('@') ? (source.split('@')[0] ?? source) : source;
153
- const words = base.split(/[\s._-]+/).filter(Boolean);
154
- if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
155
- return (words[0] ?? base).slice(0, 2).toUpperCase();
156
- }
157
-
158
- /**
159
- * A stable colour per person, instead of one grey square for everybody.
160
- *
161
- * The hue comes from a hash of the email, so an avatar is always the same
162
- * colour for the same account — recognisable at a glance in a member list, and
163
- * identical across devices without storing anything.
151
+ * FNV-1a gives the same unsigned hash for the same name/email on every device.
152
+ * Different byte ranges choose three hues and the gradient angle, so nearby
153
+ * names do not merely receive nearby shades of the same two-colour gradient.
164
154
  *
165
- * Deliberately not random per render, and deliberately not the brand colour:
166
- * every user wearing `--primary` would make the avatar read as a piece of app
167
- * chrome rather than as a person. Saturation and lightness are fixed so the
168
- * whole set sits at one weight and white text clears contrast on all of them;
169
- * only the hue moves. A gradient across a small hue span gives it some depth
170
- * without becoming a second brand.
155
+ * Lightness stays in a bounded range so the white bird remains legible. The
156
+ * radial highlight supplies depth without adding a fourth random colour.
171
157
  */
172
158
  function identityGradient(seed: string): CSSProperties {
173
- let hash = 0;
159
+ let hash = 2166136261;
174
160
  for (let i = 0; i < seed.length; i += 1) {
175
- hash = (hash * 31 + seed.charCodeAt(i)) | 0;
161
+ hash ^= seed.charCodeAt(i);
162
+ hash = Math.imul(hash, 16777619);
176
163
  }
177
- const hue = Math.abs(hash) % 360;
164
+ hash >>>= 0;
165
+
166
+ const firstHue = hash % 360;
167
+ const secondHue = (firstHue + 55 + ((hash >>> 8) % 70)) % 360;
168
+ const thirdHue = (firstHue + 190 + ((hash >>> 16) % 110)) % 360;
169
+ const angleHash = Math.imul(hash ^ (hash >>> 15), 2246822519) >>> 0;
170
+ const angle = angleHash % 360;
171
+
178
172
  return {
179
- backgroundImage: `linear-gradient(135deg, oklch(0.62 0.15 ${hue}), oklch(0.52 0.16 ${(hue + 28) % 360}))`,
173
+ backgroundImage: [
174
+ `radial-gradient(circle at 28% 18%, oklch(0.94 0.06 ${thirdHue} / 0.78), transparent 34%)`,
175
+ `linear-gradient(${angle}deg, oklch(0.68 0.18 ${firstHue}), oklch(0.55 0.2 ${secondHue}) 52%, oklch(0.62 0.18 ${thirdHue}))`,
176
+ ].join(', '),
180
177
  color: 'oklch(0.98 0 0)',
181
178
  };
182
179
  }
@@ -185,8 +182,8 @@ function NavUser({ user }: { user: AppShellUser }) {
185
182
  const { isMobile } = useSidebar();
186
183
  const uiMode = useUIMode();
187
184
  const avatarMode = uiMode !== 'default' ? (uiMode as 'playful' | 'smooth') : undefined;
188
- const initials = initialsOf(user.name ?? user.email);
189
- const identityTint = identityGradient(user.email);
185
+ const identitySeed = `${user.name?.trim().toLocaleLowerCase() ?? ''}|${user.email.toLocaleLowerCase()}`;
186
+ const identityTint = identityGradient(identitySeed);
190
187
 
191
188
  return (
192
189
  <SidebarMenu>
@@ -206,8 +203,11 @@ function NavUser({ user }: { user: AppShellUser }) {
206
203
  >
207
204
  <Avatar mode={avatarMode} size="sm">
208
205
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
209
- <AvatarFallback className="font-medium" style={identityTint}>
210
- {initials}
206
+ <AvatarFallback className="text-white" style={identityTint}>
207
+ <BirdIcon
208
+ aria-hidden
209
+ className="size-4 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
210
+ />
211
211
  </AvatarFallback>
212
212
  </Avatar>
213
213
  <div className="grid flex-1 text-left text-sm leading-tight">
@@ -229,8 +229,11 @@ function NavUser({ user }: { user: AppShellUser }) {
229
229
  <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
230
230
  <Avatar mode={avatarMode} size="sm">
231
231
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
232
- <AvatarFallback className="font-medium" style={identityTint}>
233
- {initials}
232
+ <AvatarFallback className="text-white" style={identityTint}>
233
+ <BirdIcon
234
+ aria-hidden
235
+ className="size-4 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
236
+ />
234
237
  </AvatarFallback>
235
238
  </Avatar>
236
239
  <div className="grid flex-1 text-left text-sm leading-tight">
@@ -239,7 +242,7 @@ function NavUser({ user }: { user: AppShellUser }) {
239
242
  </div>
240
243
  </div>
241
244
  </DropdownMenuLabel>
242
- {(user.onBilling || user.onNotifications) && (
245
+ {(user.onBilling || user.onNotifications || user.onSettings) && (
243
246
  <>
244
247
  <DropdownMenuSeparator />
245
248
  <DropdownMenuGroup>
@@ -255,6 +258,12 @@ function NavUser({ user }: { user: AppShellUser }) {
255
258
  Notifications
256
259
  </DropdownMenuItem>
257
260
  )}
261
+ {user.onSettings && (
262
+ <DropdownMenuItem onClick={user.onSettings}>
263
+ <SettingsIcon />
264
+ Settings
265
+ </DropdownMenuItem>
266
+ )}
258
267
  </DropdownMenuGroup>
259
268
  </>
260
269
  )}
@@ -500,7 +509,7 @@ export function AppShell({
500
509
  side={side}
501
510
  sidebarPosition={sidebarPosition}
502
511
  />
503
- <SidebarInset className={isContained ? undefined : 'overflow-y-auto'}>
512
+ <SidebarInset className="overflow-y-auto">
504
513
  <ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
505
514
  <div className={contentClassName}>{children}</div>
506
515
  </SidebarInset>
@@ -35,10 +35,11 @@ export interface OlwibaUIProviderProps {
35
35
  mode?: UIMode;
36
36
  }
37
37
 
38
- export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode: initialMode = 'default' }: OlwibaUIProviderProps) {
38
+ export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode: modeProp }: OlwibaUIProviderProps) {
39
39
  const detectedMobile = useIsMobile();
40
40
  const isMobile = isMobileProp ?? detectedMobile;
41
- const [mode, setMode] = React.useState<UIMode>(initialMode);
41
+ const [uncontrolledMode, setMode] = React.useState<UIMode>('default');
42
+ const mode = modeProp ?? uncontrolledMode;
42
43
 
43
44
  const variant = mode !== 'default' ? (mode as UIVariant) : undefined;
44
45
 
package/src/index.ts CHANGED
@@ -83,6 +83,12 @@ export { UpdateBanner, type UpdateBannerProps } from './app/UpdateBanner';
83
83
  // ─── Marketing — page sections ────────────────────────────────────────────────
84
84
  export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
85
85
  export { marketingSectionSpacing, type MarketingSectionSpacing } from './marketing/section-spacing';
86
+ export {
87
+ MarketingSurfaceProvider,
88
+ useMarketingSurface,
89
+ useSectionSurface,
90
+ type MarketingSurface,
91
+ } from './marketing/section-surface';
86
92
  export { HeroSection, type HeroSectionProps } from './marketing/HeroSection';
87
93
  export { FeaturesSection, type FeaturesSectionProps } from './marketing/FeaturesSection';
88
94
  export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { cn, useUIVariant } from '@olwiba/cn';
3
+ import { cn } from '@olwiba/cn';
4
4
  import { SectionTitle } from './SectionTitle';
5
5
  import { StaggerChildren } from '../motion/StaggerChildren';
6
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
6
7
 
7
8
  export interface ComparisonColumn {
8
9
  label: string;
@@ -17,6 +18,8 @@ export interface ComparisonSectionProps {
17
18
  title?: string;
18
19
  description?: string;
19
20
  columns: ComparisonColumn[];
21
+ /** How the section sits on the page. @default 'card' */
22
+ surface?: MarketingSurface;
20
23
  }
21
24
 
22
25
  export function ComparisonSection({
@@ -24,14 +27,9 @@ export function ComparisonSection({
24
27
  title = 'The math is simple',
25
28
  description,
26
29
  columns,
30
+ surface,
27
31
  }: ComparisonSectionProps) {
28
- const mode = useUIVariant();
29
- const sectionClasses = cn(
30
- 'overflow-hidden bg-card',
31
- mode === 'smooth' && 'rounded-3xl border',
32
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
33
- !mode && 'rounded-2xl border',
34
- );
32
+ const sectionClasses = useSectionSurface(surface);
35
33
 
36
34
  return (
37
35
  <section className={sectionClasses}>
@@ -2,7 +2,8 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { ChevronDown, Mail, MessageSquare, Send, type LucideIcon } from 'lucide-react';
5
- import { cn, Label, useUIVariant } from '@olwiba/cn';
5
+ import { cn, Label } from '@olwiba/cn';
6
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
6
7
  import { Badge } from '../primitives/Badge';
7
8
  import { Button } from '../primitives/Button';
8
9
  import { Input } from '../primitives/Input';
@@ -24,6 +25,8 @@ export interface ContactSectionProps {
24
25
  successDescription?: string;
25
26
  sendAnotherLabel?: string;
26
27
  onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
28
+ /** How the section sits on the page. @default 'card' */
29
+ surface?: MarketingSurface;
27
30
  }
28
31
 
29
32
  const PHONE_PREFIXES = [
@@ -55,15 +58,10 @@ export function ContactSection({
55
58
  successDescription = defaults.successDescription,
56
59
  sendAnotherLabel = defaults.sendAnotherLabel,
57
60
  onSubmit,
61
+ surface,
58
62
  }: ContactSectionProps = {}) {
59
63
  const [submitted, setSubmitted] = React.useState(false);
60
- const mode = useUIVariant();
61
- const sectionClasses = cn(
62
- 'overflow-hidden bg-card',
63
- mode === 'smooth' && 'rounded-3xl border',
64
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
65
- !mode && 'rounded-2xl border',
66
- );
64
+ const sectionClasses = useSectionSurface(surface);
67
65
 
68
66
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
69
67
  e.preventDefault();
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react';
4
4
  import { ArrowRight, Rocket, Sparkles } from 'lucide-react';
5
5
  import { cn, useUIVariant } from '@olwiba/cn';
6
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
6
7
  import { Button } from '../primitives/Button';
7
8
  import { FadeIn } from '../motion/FadeIn';
8
9
  import { useIntersectionObserver } from '../hooks/use-intersection-observer';
@@ -24,6 +25,8 @@ export interface CtaSectionProps {
24
25
  /** (showcase) Watermark icon rendered behind the content. @default <Rocket /> */
25
26
  icon?: React.ReactNode;
26
27
  renderLink?: AppShellRenderLink;
28
+ /** How the section sits on the page. @default 'card' */
29
+ surface?: MarketingSurface;
27
30
  }
28
31
 
29
32
  const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
@@ -102,14 +105,11 @@ export function CtaSection(props: CtaSectionProps) {
102
105
  footnote,
103
106
  variant = 'default',
104
107
  renderLink = defaultRenderLink,
108
+ surface,
105
109
  } = props;
106
- const mode = useUIVariant()
107
- const sectionClasses = cn(
108
- 'overflow-hidden bg-card',
109
- mode === 'smooth' && 'rounded-3xl border',
110
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
111
- !mode && 'rounded-2xl border',
112
- )
110
+ // Still read directly: `mode` also drives the badge icon's rounding below.
111
+ const mode = useUIVariant();
112
+ const sectionClasses = useSectionSurface(surface);
113
113
 
114
114
  if (variant === 'showcase') {
115
115
  return <ShowcaseCta {...props} sectionClasses={sectionClasses} />;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, cn, useUIVariant } from '@olwiba/cn';
3
+ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@olwiba/cn';
4
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
4
5
  import { SectionTitle } from './SectionTitle';
5
6
  import { FadeIn } from '../motion/FadeIn';
6
7
 
@@ -9,6 +10,8 @@ export interface FaqSectionProps {
9
10
  description?: string;
10
11
  badge?: string;
11
12
  items: Array<{ question: string; answer: string }>;
13
+ /** How the section sits on the page. @default 'card' */
14
+ surface?: MarketingSurface;
12
15
  }
13
16
 
14
17
  export function FaqSection({
@@ -16,14 +19,9 @@ export function FaqSection({
16
19
  description = 'Everything you need to know before getting started.',
17
20
  badge = 'FAQ',
18
21
  items,
22
+ surface,
19
23
  }: FaqSectionProps) {
20
- const mode = useUIVariant()
21
- const sectionClasses = cn(
22
- 'overflow-hidden bg-card',
23
- mode === 'smooth' && 'rounded-3xl border',
24
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
25
- !mode && 'rounded-2xl border',
26
- )
24
+ const sectionClasses = useSectionSurface(surface);
27
25
  return (
28
26
  <section className={sectionClasses}>
29
27
  <div className="px-6 py-14 sm:px-10 sm:py-20">
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import type { LucideIcon } from 'lucide-react';
4
- import { cn, useUIVariant } from '@olwiba/cn';
4
+ import { cn } from '@olwiba/cn';
5
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
5
6
  import { SectionTitle } from './SectionTitle';
6
7
  import { FadeIn } from '../motion/FadeIn';
7
8
 
@@ -22,6 +23,8 @@ export interface FeatureMarqueeSectionProps {
22
23
  rows: FeatureMarqueeRow[];
23
24
  speed?: 'slow' | 'normal' | 'fast';
24
25
  pauseOnHover?: boolean;
26
+ /** How the section sits on the page. @default 'card' */
27
+ surface?: MarketingSurface;
25
28
  }
26
29
 
27
30
  const speedDuration = {
@@ -37,16 +40,10 @@ export function FeatureMarqueeSection({
37
40
  rows,
38
41
  speed = 'normal',
39
42
  pauseOnHover = true,
43
+ surface,
40
44
  }: FeatureMarqueeSectionProps) {
41
- const mode = useUIVariant();
42
45
  const duration = speedDuration[speed];
43
-
44
- const sectionClasses = cn(
45
- 'overflow-hidden bg-card',
46
- mode === 'smooth' && 'rounded-3xl border',
47
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
48
- !mode && 'rounded-2xl border',
49
- );
46
+ const sectionClasses = useSectionSurface(surface);
50
47
 
51
48
  return (
52
49
  <section className={sectionClasses}>
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
 
3
3
  import type { LucideIcon } from 'lucide-react';
4
- import { cn, useUIVariant } from '@olwiba/cn';
4
+ import { cn } from '@olwiba/cn';
5
5
  import { FeatureCard } from '../components/FeatureCard';
6
6
  import { Carousel } from '../mechanics/Carousel';
7
7
  import { SectionTitle } from './SectionTitle';
8
8
  import { StaggerChildren } from '../motion/StaggerChildren';
9
9
  import { FadeIn } from '../motion/FadeIn';
10
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
10
11
 
11
12
  export interface FeaturesSectionProps {
12
13
  title?: string;
@@ -15,6 +16,8 @@ export interface FeaturesSectionProps {
15
16
  features: Array<{ icon: LucideIcon; title: string; description: string; href?: string }>;
16
17
  /** How the feature cards are arranged. @default 'grid' */
17
18
  layout?: 'grid' | 'carousel';
19
+ /** How the section sits on the page. @default 'card' */
20
+ surface?: MarketingSurface;
18
21
  }
19
22
 
20
23
  export function FeaturesSection({
@@ -23,14 +26,9 @@ export function FeaturesSection({
23
26
  badge = 'Features',
24
27
  features,
25
28
  layout = 'grid',
29
+ surface,
26
30
  }: FeaturesSectionProps) {
27
- const mode = useUIVariant()
28
- const sectionClasses = cn(
29
- 'overflow-hidden bg-card',
30
- mode === 'smooth' && 'rounded-3xl border',
31
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
32
- !mode && 'rounded-2xl border',
33
- )
31
+ const sectionClasses = useSectionSurface(surface);
34
32
  const cards = features.map((feature) => (
35
33
  <FeatureCard
36
34
  key={feature.title}
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import type { LucideIcon } from 'lucide-react';
4
- import { cn, useUIVariant } from '@olwiba/cn';
5
4
  import { FeatureCard } from '../components/FeatureCard';
6
5
  import { SectionTitle } from './SectionTitle';
7
6
  import { StaggerChildren } from '../motion/StaggerChildren';
8
7
  import { FadeIn } from '../motion/FadeIn';
8
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
9
9
 
10
10
  export interface GroupedFeatureGroup {
11
11
  label: string;
@@ -17,6 +17,8 @@ export interface GroupedFeaturesSectionProps {
17
17
  title?: string;
18
18
  description?: string;
19
19
  groups: GroupedFeatureGroup[];
20
+ /** How the section sits on the page. @default 'card' */
21
+ surface?: MarketingSurface;
20
22
  }
21
23
 
22
24
  export function GroupedFeaturesSection({
@@ -24,14 +26,9 @@ export function GroupedFeaturesSection({
24
26
  title = 'Everything you need to ship',
25
27
  description,
26
28
  groups,
29
+ surface,
27
30
  }: GroupedFeaturesSectionProps) {
28
- const mode = useUIVariant();
29
- const sectionClasses = cn(
30
- 'overflow-hidden bg-card',
31
- mode === 'smooth' && 'rounded-3xl border',
32
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
33
- !mode && 'rounded-2xl border',
34
- );
31
+ const sectionClasses = useSectionSurface(surface);
35
32
 
36
33
  return (
37
34
  <section className={sectionClasses}>
@@ -2,7 +2,8 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { ArrowRight, Mail } from 'lucide-react';
5
- import { cn, useUIVariant } from '@olwiba/cn';
5
+ import { cn } from '@olwiba/cn';
6
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
6
7
  import { Badge } from '../primitives/Badge';
7
8
  import { Button } from '../primitives/Button';
8
9
  import { Input } from '../primitives/Input';
@@ -18,6 +19,8 @@ export interface NewsletterSectionProps {
18
19
  successTitle?: string;
19
20
  successDescription?: string;
20
21
  onSubscribe?: (email: string) => void | Promise<void>;
22
+ /** How the section sits on the page. @default 'card' */
23
+ surface?: MarketingSurface;
21
24
  }
22
25
 
23
26
  const defaults = {
@@ -44,16 +47,11 @@ export function NewsletterSection({
44
47
  successTitle = defaults.successTitle,
45
48
  successDescription = defaults.successDescription,
46
49
  onSubscribe,
50
+ surface,
47
51
  }: NewsletterSectionProps = {}) {
48
52
  const [email, setEmail] = React.useState('');
49
53
  const [submitted, setSubmitted] = React.useState(false);
50
- const mode = useUIVariant();
51
- const sectionClasses = cn(
52
- 'overflow-hidden bg-card',
53
- mode === 'smooth' && 'rounded-3xl border',
54
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
55
- !mode && 'rounded-2xl border',
56
- );
54
+ const sectionClasses = useSectionSurface(surface);
57
55
 
58
56
  async function handleSubmit(e: React.FormEvent) {
59
57
  e.preventDefault();
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
- import { Badge, Button, cn, useUIVariant } from '@olwiba/cn';
4
+ import { Badge, Button, cn } from '@olwiba/cn';
5
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
5
6
  import { PricingCard, type PricingCardProps, type PricingFeature } from '../components/PricingCard';
6
7
  import { StaggerChildren } from '../motion/StaggerChildren';
7
8
  import { CountdownTimer } from '../motion/CountdownTimer';
@@ -147,6 +148,14 @@ export interface PricingSectionProps {
147
148
  * mode.
148
149
  */
149
150
  priceEffect?: PricingCardProps['priceEffect'];
151
+ /**
152
+ * How the section sits on the page. @default 'card'
153
+ *
154
+ * Named `surface` rather than following the `mode` convention the other
155
+ * sections use, because `mode` on this component already means
156
+ * subscription-vs-one-time.
157
+ */
158
+ surface?: MarketingSurface;
150
159
  }
151
160
 
152
161
  const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
@@ -172,6 +181,7 @@ export function PricingSection({
172
181
  onSelectPlan,
173
182
  pendingPlanName,
174
183
  priceEffect,
184
+ surface,
175
185
  }: PricingSectionProps) {
176
186
  const [annual, setAnnual] = React.useState(false);
177
187
  const isOneTime = mode === 'one-time';
@@ -190,13 +200,7 @@ export function PricingSection({
190
200
  );
191
201
  // A single cadence is just a label for the price — nothing to switch between.
192
202
  const showToggle = useCadences ? cadences!.length > 1 : !isOneTime;
193
- const uiMode = useUIVariant();
194
- const sectionClasses = cn(
195
- 'overflow-hidden bg-card',
196
- uiMode === 'smooth' && 'rounded-3xl border',
197
- uiMode === 'playful' && 'rounded-2xl border-primary/25 border',
198
- !uiMode && 'rounded-2xl border',
199
- );
203
+ const sectionClasses = useSectionSurface(surface);
200
204
 
201
205
  return (
202
206
  <section className={sectionClasses}>
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { Check, X } from 'lucide-react';
4
- import { cn, useUIVariant } from '@olwiba/cn';
4
+ import { cn } from '@olwiba/cn';
5
5
  import { SectionTitle } from './SectionTitle';
6
6
  import { FadeIn } from '../motion/FadeIn';
7
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
7
8
 
8
9
  export interface QualificationItem {
9
10
  text: string;
@@ -20,6 +21,8 @@ export interface QualificationSectionProps {
20
21
  title?: string;
21
22
  description?: string;
22
23
  columns: [QualificationColumn, QualificationColumn];
24
+ /** How the section sits on the page. @default 'card' */
25
+ surface?: MarketingSurface;
23
26
  }
24
27
 
25
28
  export function QualificationSection({
@@ -27,14 +30,9 @@ export function QualificationSection({
27
30
  title = 'Is this for you?',
28
31
  description,
29
32
  columns,
33
+ surface,
30
34
  }: QualificationSectionProps) {
31
- const mode = useUIVariant();
32
- const sectionClasses = cn(
33
- 'overflow-hidden bg-card',
34
- mode === 'smooth' && 'rounded-3xl border',
35
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
36
- !mode && 'rounded-2xl border',
37
- );
35
+ const sectionClasses = useSectionSurface(surface);
38
36
 
39
37
  return (
40
38
  <section className={sectionClasses}>
@@ -1,15 +1,18 @@
1
1
  'use client';
2
2
 
3
- import { Separator, cn, useUIVariant } from '@olwiba/cn';
3
+ import { Separator } from '@olwiba/cn';
4
4
  import { SectionTitle } from './SectionTitle';
5
5
  import { StaggerChildren } from '../motion/StaggerChildren';
6
6
  import { CountUp } from '../motion/CountUp';
7
+ import { useSectionSurface, type MarketingSurface } from './section-surface';
7
8
 
8
9
  export interface StatsSectionProps {
9
10
  title?: string;
10
11
  description?: string;
11
12
  badge?: string;
12
13
  stats: Array<{ value: string; label: string; description?: string }>;
14
+ /** How the section sits on the page. @default 'card' */
15
+ surface?: MarketingSurface;
13
16
  }
14
17
 
15
18
  function parseStatValue(value: string) {
@@ -32,14 +35,9 @@ export function StatsSection({
32
35
  description,
33
36
  badge = 'By the numbers',
34
37
  stats,
38
+ surface,
35
39
  }: StatsSectionProps) {
36
- const mode = useUIVariant()
37
- const sectionClasses = cn(
38
- 'overflow-hidden bg-card',
39
- mode === 'smooth' && 'rounded-3xl border',
40
- mode === 'playful' && 'rounded-2xl border-primary/25 border',
41
- !mode && 'rounded-2xl border',
42
- )
40
+ const sectionClasses = useSectionSurface(surface);
43
41
  return (
44
42
  <section className={sectionClasses}>
45
43
  <div className="relative px-6 py-14 sm:px-10 sm:py-20">