@stevederico/skateboard-ui 3.7.0 → 3.8.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
+ 3.8.0
4
+
5
+ Redesign: `LandingView` now uses the SpecSheet layout — sticky header with nav + theme toggle, quiet hero with CTA buttons, icon-leading feature cards (responsive 1–3 columns), optional pricing card, dark CTA section, and footer. Reads all copy from `constants` (tagline, cta, navLinks, features, stripeProducts, pricing, ctaHeading, footerLinks, companyName, copyrightText)
6
+ Feature/app icons resolve via `DynamicIcon` (Lucide names); legacy emoji/text values fall back to rendering as raw text so they never silently disappear
7
+ Apps that vendored `LandingSpecSheet.jsx` can delete the local copy and rely on the package default (omit `landingPage`) or import `@stevederico/skateboard-ui/LandingView`
8
+
9
+ 3.7.1
10
+
11
+ Fix: `Button` now bridges shadcn-style `asChild` onto Base UI's `render` prop instead of leaking it onto the DOM `<button>` — removes the "React does not recognize the `asChild` prop" warning for `<Button asChild><a/></Button>` (e.g. LandingSpecSheet "Learn more")
12
+
3
13
  3.7.0
4
14
 
5
15
  Breaking (peer dep rename): `react-router-dom` → `react-router`
package/README.md CHANGED
@@ -655,8 +655,7 @@ Effects: `isAuthenticated()` always returns `true`, ProtectedRoute allows all ac
655
655
 
656
656
  | Component | Import | Description |
657
657
  |-----------|--------|-------------|
658
- | LandingView | `@stevederico/skateboard-ui/LandingView` | Landing page with hero, features, pricing |
659
- | LandingViewSimple | `@stevederico/skateboard-ui/LandingViewSimple` | Minimal landing page |
658
+ | LandingView | `@stevederico/skateboard-ui/LandingView` | Landing page sticky header, hero, features, pricing, CTA, footer |
660
659
  | SignInView | `@stevederico/skateboard-ui/SignInView` | Sign in form with Card layout |
661
660
  | SignUpView | `@stevederico/skateboard-ui/SignUpView` | Sign up form with password validation |
662
661
  | SignOutView | `@stevederico/skateboard-ui/SignOutView` | Sign out handler with redirect |
@@ -1,184 +1,189 @@
1
- import React from 'react';
2
1
  import { useNavigate } from 'react-router';
3
- import { useTheme } from '../core/ThemeProvider.jsx';
4
- import { getState } from "../core/Context.jsx";
2
+ import { getState } from '../core/Context.jsx';
5
3
  import DynamicIcon from '../core/DynamicIcon.jsx';
6
- import { Sun, Moon, Check } from '../../icons';
4
+ import ThemeToggle from '../ThemeToggle.jsx';
5
+ import { Check, ArrowRight } from '../../icons';
7
6
  import { Button } from '../../shadcn/ui/button.jsx';
8
7
  import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../../shadcn/ui/card.jsx';
9
- import { Badge } from '../../shadcn/ui/badge.jsx';
10
8
  import { Separator } from '../../shadcn/ui/separator.jsx';
9
+ import { cn } from '../../shadcn/lib/utils.js';
11
10
 
12
11
  const CURRENT_YEAR = new Date().getFullYear();
13
12
 
14
- const DEFAULT_NAV_LINKS = [
15
- { label: 'Features', href: '#features' },
16
- ];
17
-
18
- const DEFAULT_NAV_LINKS_SUFFIX = [
19
- { label: 'Terms', href: '/terms' },
20
- ];
21
-
22
- const PRICING_LINK = { label: 'Pricing', href: '#pricing' };
23
-
24
13
  const PRIVACY_LINK = { label: 'Privacy', href: '/privacy' };
25
14
  const TERMS_LINK = { label: 'Terms', href: '/terms' };
26
15
  const EULA_LINK = { label: 'EULA', href: '/eula' };
27
16
 
28
17
  /**
29
- * Renders a feature icon from a name string or emoji.
30
- * If the name looks like an icon identifier (ASCII letters, digits, hyphens),
31
- * renders it via DynamicIcon with lazy loading. Otherwise treats it as
32
- * raw text (e.g. an emoji).
18
+ * Renders a constants icon value. Values that look like a Lucide name (ASCII
19
+ * letters, digits, hyphens) resolve via DynamicIcon; anything else (e.g. a
20
+ * legacy emoji icon) renders as raw text so it never silently disappears.
33
21
  *
34
22
  * @param {Object} props
35
- * @param {string} props.name - Icon name (kebab-case) or emoji string
36
- * @returns {JSX.Element} Icon component or text span
23
+ * @param {string} props.name - Lucide icon name or emoji/text
24
+ * @param {number} props.size - Pixel size (icon dimension or emoji font size)
25
+ * @param {number} [props.strokeWidth] - Stroke width for Lucide icons
26
+ * @returns {JSX.Element|null}
37
27
  */
38
- function FeatureIcon({ name }) {
39
- const isIconName = /^[a-z][a-z0-9-]*$/i.test(name);
40
- if (isIconName) return <DynamicIcon name={name} size={24} />;
41
- return <span>{name}</span>;
28
+ function ConstantIcon({ name, size, strokeWidth }) {
29
+ if (!name) return null;
30
+ if (/^[a-z][a-z0-9-]*$/i.test(name)) return <DynamicIcon name={name} size={size} strokeWidth={strokeWidth} />;
31
+ return <span className="leading-none" style={{ fontSize: size }}>{name}</span>;
42
32
  }
43
33
 
44
34
  /**
45
- * Default landing page with hero section, features grid, pricing card,
46
- * CTA section, and footer.
35
+ * Default landing page (SpecSheet design). Sticky header, quiet hero,
36
+ * icon-leading feature cards, optional pricing card, CTA, and footer.
37
+ * Rendered at "/" whenever an app passes no custom landingPage to
38
+ * createSkateboardApp. Reads all copy from constants (appName, appIcon,
39
+ * tagline, cta, navLinks, features, stripeProducts, pricing, ctaHeading,
40
+ * footerLinks, companyName, copyrightText).
47
41
  *
48
- * Reads app branding, tagline, features, pricing, and layout from constants.
49
- * All sections are configurable via optional constants keys with sensible defaults.
42
+ * Feature icons (constants.features.items[].icon) and appIcon are resolved
43
+ * via DynamicIcon as Lucide names (kebab/PascalCase). Emoji icons do NOT
44
+ * render — migrate legacy emoji icons in constants.json to Lucide names.
50
45
  *
51
- * @returns {JSX.Element} Full landing page
52
- *
53
- * @example
54
- * import LandingView from '@stevederico/skateboard-ui/LandingView';
55
- *
56
- * // Used automatically by createSkateboardApp, or pass as landingPage prop:
57
- * createSkateboardApp({ constants, appRoutes, landingPage: <LandingView /> });
46
+ * @returns {JSX.Element}
58
47
  */
59
48
  export default function LandingView() {
60
49
  const { state } = getState();
61
- const constants = state.constants;
50
+ const constants = state.constants || {};
62
51
  const navigate = useNavigate();
63
- const { theme, setTheme } = useTheme();
64
- const isDarkMode = theme === 'dark';
52
+ const goApp = () => navigate('/app');
53
+
54
+ const navLinks = constants.navLinks || [
55
+ { label: 'Features', href: '#features' },
56
+ ...(constants.stripeProducts?.length > 0 ? [{ label: 'Pricing', href: '#pricing' }] : []),
57
+ ];
58
+ const footerLinks = constants.footerLinks || [
59
+ ...(constants.privacyPolicy ? [PRIVACY_LINK] : []),
60
+ ...(constants.termsOfService ? [TERMS_LINK] : []),
61
+ ...(constants.EULA ? [EULA_LINK] : []),
62
+ ];
63
+ const items = constants.features?.items || [];
64
+ const sp = (constants.stripeProducts || [])[0];
65
65
 
66
66
  return (
67
67
  <div className="min-h-screen bg-background text-foreground">
68
-
69
68
  {/* Header */}
70
- <header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-sm">
71
- <nav className="max-w-6xl mx-auto px-6 py-4 flex justify-between items-center">
72
- <div className="flex items-center gap-2">
73
- <DynamicIcon name={constants.appIcon} size={28} className="text-primary" strokeWidth={2} />
74
- <span className="text-2xl font-bold text-foreground">{constants.appName}</span>
75
- </div>
76
-
77
- <div className="hidden md:flex gap-6">
78
- {(constants.navLinks || [
79
- ...DEFAULT_NAV_LINKS,
80
- ...(constants.stripeProducts?.length > 0 ? [PRICING_LINK] : []),
81
- ...DEFAULT_NAV_LINKS_SUFFIX,
82
- ]).map((link, index) => (
83
- <a key={index} href={link.href} className="text-muted-foreground hover:text-foreground transition-colors font-semibold">{link.label}</a>
69
+ <header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
70
+ <nav className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
71
+ <a href="/" className="flex items-center gap-2.5">
72
+ <span className="size-8 rounded-md bg-app/15 text-app flex items-center justify-center">
73
+ <ConstantIcon name={constants.appIcon} size={18} strokeWidth={2.25} />
74
+ </span>
75
+ <span className="text-base font-semibold tracking-tight">{constants.appName}</span>
76
+ </a>
77
+ <div className="hidden md:flex items-center gap-7 text-sm text-muted-foreground">
78
+ {navLinks.map((l, i) => (
79
+ <a key={i} href={l.href} className="hover:text-foreground transition-colors">{l.label}</a>
84
80
  ))}
85
81
  </div>
86
-
87
- <div className="flex gap-3 items-center">
88
- <Button variant="outline" size="icon" onClick={() => setTheme(isDarkMode ? 'light' : 'dark')} aria-label="Toggle dark mode">
89
- {isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
90
- </Button>
91
- <Button variant="default" onClick={() => navigate('/app')}>
92
- {constants.cta}
93
- </Button>
82
+ <div className="flex items-center gap-2">
83
+ <ThemeToggle variant="landing" iconSize={14} />
84
+ <Button size="default" onClick={goApp}>{constants.cta}</Button>
94
85
  </div>
95
86
  </nav>
96
87
  </header>
97
88
 
98
89
  <main>
99
- {/* Hero Section */}
100
- <section className="relative py-24 md:py-40 text-center overflow-hidden bg-app/15">
101
- <div className="absolute inset-0 bg-app/25" style={{ maskImage: 'radial-gradient(ellipse at 20% 50%, black 0%, transparent 70%)', WebkitMaskImage: 'radial-gradient(ellipse at 20% 50%, black 0%, transparent 70%)' }} />
102
- <div className="absolute inset-0 bg-app/20" style={{ maskImage: 'radial-gradient(ellipse at 80% 30%, black 0%, transparent 60%)', WebkitMaskImage: 'radial-gradient(ellipse at 80% 30%, black 0%, transparent 60%)' }} />
103
- <div className="relative max-w-4xl mx-auto px-6">
104
- <h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold mb-8 text-foreground leading-tight">
90
+ {/* Hero */}
91
+ <section className="relative border-b border-border">
92
+ <div className="absolute inset-0 -z-10 bg-gradient-to-b from-app/5 via-background to-background" />
93
+ <div className="max-w-3xl mx-auto px-6 py-24 md:py-36 text-center">
94
+ <h1 className="text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tight leading-[1.05] text-balance mb-12">
105
95
  {constants.tagline}
106
96
  </h1>
107
- <Button variant="default" size="cta" onClick={() => navigate('/app')}>
108
- {constants.cta}
109
- </Button>
97
+ <div className="flex flex-wrap gap-3 justify-center">
98
+ <Button onClick={goApp} className="gap-2 h-14 px-10 text-base font-medium">
99
+ {constants.cta} <ArrowRight size={18} />
100
+ </Button>
101
+ {navLinks[0] && (
102
+ <Button variant="outline" asChild className="h-14 px-8 text-base font-medium">
103
+ <a href={navLinks[0].href}>Learn more</a>
104
+ </Button>
105
+ )}
106
+ </div>
110
107
  </div>
111
108
  </section>
112
109
 
113
- {/* Features Section */}
114
- <section id="features" className="bg-muted py-16 md:py-24">
115
- <div className="max-w-7xl mx-auto px-6">
116
- <h2 className="text-center text-4xl md:text-5xl font-bold mb-16">{constants.features?.title || 'Features'}</h2>
117
- <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
118
- {(constants.features?.items || []).map((feature, index) => (
119
- <Card key={index} className="text-center">
120
- <CardHeader className="items-center">
121
- <div className="w-full flex justify-center">
122
- <Badge variant="secondary" className="text-2xl mb-2 h-auto px-3 py-1">
123
- <FeatureIcon name={feature.icon} />
124
- </Badge>
125
- </div>
126
- <CardTitle className="text-xl font-bold">{feature.title}</CardTitle>
127
- <CardDescription>{feature.description}</CardDescription>
128
- </CardHeader>
129
- </Card>
130
- ))}
110
+ {/* Features */}
111
+ {items.length > 0 && (
112
+ <section id="features" className="border-b border-border bg-muted/30">
113
+ <div className="max-w-6xl mx-auto px-6 py-20 md:py-28">
114
+ <div className="text-center mb-14">
115
+ <h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-balance">
116
+ {constants.features?.title || 'Features'}
117
+ </h2>
118
+ </div>
119
+ <div className={cn('grid gap-6', items.length >= 3 ? 'md:grid-cols-3' : items.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-1 max-w-md mx-auto')}>
120
+ {items.map((it, i) => (
121
+ <Card key={i} className="border-border/60 transition-colors hover:border-border">
122
+ <CardHeader>
123
+ <span className="size-10 rounded-md bg-app/10 text-app flex items-center justify-center mb-3">
124
+ <ConstantIcon name={it.icon} size={20} strokeWidth={2} />
125
+ </span>
126
+ <CardTitle className="text-lg">{it.title}</CardTitle>
127
+ <CardDescription className="leading-relaxed">{it.description}</CardDescription>
128
+ </CardHeader>
129
+ </Card>
130
+ ))}
131
+ </div>
131
132
  </div>
132
- </div>
133
- </section>
133
+ </section>
134
+ )}
134
135
 
135
- {/* Pricing Section */}
136
- {constants.stripeProducts?.length > 0 && (
137
- <section id="pricing" className="py-16 md:py-24">
138
- <div className="max-w-7xl mx-auto px-6">
139
- <h2 className="text-center text-4xl md:text-5xl font-bold mb-16">{constants.pricing?.title || 'Pricing'}</h2>
140
- <div className="max-w-md mx-auto">
141
- <Card>
142
- <CardHeader className="text-center">
143
- <CardTitle className="text-2xl font-bold">{constants.stripeProducts[0]?.title || 'Monthly Plan'}</CardTitle>
144
- <CardDescription>per {constants.stripeProducts[0]?.interval || 'month'}</CardDescription>
145
- </CardHeader>
146
- <CardContent className="text-center">
147
- <div className="text-5xl font-bold text-foreground mb-8">{constants.stripeProducts[0]?.price || '$5.00'}</div>
148
- <ul className="text-left space-y-4 mb-8">
149
- {(constants.stripeProducts[0]?.features || []).map((feature, index) => (
150
- <li key={index} className="flex items-center gap-2">
151
- <Check size={16} className="text-primary shrink-0" />
152
- {feature}
153
- </li>
154
- ))}
155
- {(constants.pricing?.extras || []).map((extra, index) => (
156
- <li key={`extra-${index}`} className="flex items-center gap-2">
157
- <Check size={16} className="text-primary shrink-0" />
158
- {extra}
159
- </li>
160
- ))}
161
- </ul>
162
- </CardContent>
163
- <CardFooter>
164
- <Button variant="default" size="cta" className="w-full" onClick={() => navigate('/app')}>
165
- {constants.cta}
166
- </Button>
167
- </CardFooter>
168
- </Card>
136
+ {/* Pricing */}
137
+ {sp && (
138
+ <section id="pricing" className="border-b border-border">
139
+ <div className="max-w-6xl mx-auto px-6 py-20 md:py-28">
140
+ <div className="text-center mb-14">
141
+ <h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-balance">
142
+ {constants.pricing?.title || 'Pricing'}
143
+ </h2>
169
144
  </div>
145
+ <Card className="max-w-md mx-auto border-app/30 shadow-sm">
146
+ <CardHeader className="text-center pb-4">
147
+ <CardTitle className="text-base font-medium text-muted-foreground">{sp.title}</CardTitle>
148
+ <div className="flex items-baseline justify-center gap-1.5 mt-2">
149
+ <span className="text-5xl font-semibold tracking-tight text-foreground">{sp.price}</span>
150
+ <span className="text-sm text-muted-foreground">/ {sp.interval}</span>
151
+ </div>
152
+ </CardHeader>
153
+ <CardContent>
154
+ <ul className="space-y-3 text-sm">
155
+ {(sp.features || []).map((f, i) => (
156
+ <li key={`sp-${i}`} className="flex items-start gap-2.5">
157
+ <Check size={16} className="text-app shrink-0 mt-0.5" />
158
+ <span>{f}</span>
159
+ </li>
160
+ ))}
161
+ {(constants.pricing?.extras || []).map((f, i) => (
162
+ <li key={`x-${i}`} className="flex items-start gap-2.5">
163
+ <Check size={16} className="text-app shrink-0 mt-0.5" />
164
+ <span>{f}</span>
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ </CardContent>
169
+ <CardFooter>
170
+ <Button size="lg" className="w-full" onClick={goApp}>{constants.cta}</Button>
171
+ </CardFooter>
172
+ </Card>
170
173
  </div>
171
174
  </section>
172
175
  )}
173
176
 
174
- {/* CTA Section */}
175
- <section className="py-16 md:py-24">
176
- <div className="max-w-7xl mx-auto px-6">
177
- <Card className="bg-primary text-primary-foreground py-16 text-center">
178
- <CardContent>
179
- <h2 className="text-4xl md:text-5xl font-bold mb-10">{constants.ctaHeading || 'Ready To Build?'}</h2>
180
- <Button variant="secondary" size="cta" onClick={() => navigate('/app')}>
181
- {constants.cta}
177
+ {/* CTA */}
178
+ <section className="border-b border-border">
179
+ <div className="max-w-6xl mx-auto px-6 py-20 md:py-28">
180
+ <Card className="bg-foreground text-background border-0 py-16 md:py-20">
181
+ <CardContent className="text-center">
182
+ <h2 className="text-3xl md:text-5xl font-semibold tracking-tight text-balance mb-8 max-w-2xl mx-auto">
183
+ {constants.ctaHeading || 'Ready to Build?'}
184
+ </h2>
185
+ <Button size="lg" variant="secondary" onClick={goApp} className="gap-1.5">
186
+ {constants.cta} <ArrowRight size={16} />
182
187
  </Button>
183
188
  </CardContent>
184
189
  </Card>
@@ -187,19 +192,25 @@ export default function LandingView() {
187
192
  </main>
188
193
 
189
194
  {/* Footer */}
190
- <footer className="py-10 bg-background">
191
- <div className="max-w-7xl mx-auto px-6">
192
- <Separator className="mb-8" />
193
- <div className="flex justify-center gap-8 mb-6">
194
- {(constants.footerLinks || [
195
- ...(constants.privacyPolicy ? [PRIVACY_LINK] : []),
196
- ...(constants.termsOfService ? [TERMS_LINK] : []),
197
- ...(constants.EULA ? [EULA_LINK] : []),
198
- ]).map((link, index) => (
199
- <a key={index} href={link.href} className="text-muted-foreground hover:text-foreground transition-colors font-semibold">{link.label}</a>
200
- ))}
195
+ <footer className="bg-background">
196
+ <div className="max-w-6xl mx-auto px-6 py-10">
197
+ <div className="flex flex-wrap items-center justify-between gap-6">
198
+ <div className="flex items-center gap-2.5">
199
+ <span className="size-7 rounded-md bg-app/15 text-app flex items-center justify-center">
200
+ <ConstantIcon name={constants.appIcon} size={16} strokeWidth={2.25} />
201
+ </span>
202
+ <span className="text-sm font-semibold tracking-tight">{constants.appName}</span>
203
+ </div>
204
+ <div className="flex items-center gap-6 text-sm text-muted-foreground">
205
+ {footerLinks.map((l, i) => (
206
+ <a key={i} href={l.href} className="hover:text-foreground transition-colors">{l.label}</a>
207
+ ))}
208
+ </div>
201
209
  </div>
202
- <p className="text-center text-muted-foreground">&copy; {CURRENT_YEAR} {constants.companyName}. {constants.copyrightText || 'All rights reserved.'}</p>
210
+ <Separator className="my-6" />
211
+ <p className="text-xs text-muted-foreground">
212
+ © {CURRENT_YEAR} {constants.companyName}. {constants.copyrightText || 'All rights reserved.'}
213
+ </p>
203
214
  </div>
204
215
  </footer>
205
216
  </div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "3.7.0",
4
+ "version": "3.8.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./Sidebar": {
@@ -1,3 +1,4 @@
1
+ import { Children } from "react";
1
2
  import { Button as ButtonPrimitive } from "../lib/base-ui/button.js"
2
3
  import { cva } from "../lib/cva.js";
3
4
 
@@ -42,19 +43,37 @@ const buttonVariants = cva(
42
43
  * @param {string} [props.className] - Additional CSS classes
43
44
  * @param {"default"|"outline"|"secondary"|"ghost"|"destructive"|"link"|"gradient"} [props.variant="default"] - Visual style
44
45
  * @param {"default"|"xs"|"sm"|"lg"|"icon"|"icon-xs"|"icon-sm"|"icon-lg"|"cta"} [props.size="default"] - Button size
46
+ * @param {boolean} [props.asChild=false] - Render the single child element styled as a button (shadcn-style). Bridged onto Base UI's `render` prop.
45
47
  * @returns {JSX.Element}
46
48
  */
47
49
  function Button({
48
50
  className,
49
51
  variant = "default",
50
52
  size = "default",
53
+ asChild = false,
54
+ children,
51
55
  ...props
52
56
  }) {
57
+ const classes = cn(buttonVariants({ variant, size, className }));
58
+ // Base UI has no `asChild` — it uses a `render` prop. Bridge the shadcn-style
59
+ // `asChild` API so `<Button asChild><a .../></Button>` renders the child element
60
+ // (merged with button props) instead of leaking `asChild` onto the DOM <button>.
61
+ if (asChild) {
62
+ return (
63
+ <ButtonPrimitive
64
+ data-slot="button"
65
+ className={classes}
66
+ render={Children.only(children)}
67
+ {...props} />
68
+ );
69
+ }
53
70
  return (
54
71
  <ButtonPrimitive
55
72
  data-slot="button"
56
- className={cn(buttonVariants({ variant, size, className }))}
57
- {...props} />
73
+ className={classes}
74
+ {...props}>
75
+ {children}
76
+ </ButtonPrimitive>
58
77
  );
59
78
  }
60
79