@stevederico/skateboard-ui 4.6.0 → 4.9.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/README.md CHANGED
@@ -1,16 +1,8 @@
1
1
  # skateboard-ui
2
2
 
3
- React component library for rapid application development. Built with TypeScript, TailwindCSS v4, and shadcn/ui.
3
+ React component library for rapid application development. Built with TypeScript, TailwindCSS v4, and shadcn-style primitives.
4
4
 
5
- **Zero runtime npm dependencies.** Only React, React-DOM, and React Router are peer-resolved. Everything else — the 47 UI components, lucide icons, tailwind-merge, drag physics, command palette, date picker, theme provider — is authored, ported, or recreated inside the repo.
6
-
7
- **Self-contained components (v4.0).** Every component is now hand-written with no primitive framework underneath — the previously-vendored `@base-ui/react` bundle (~31k lines) is gone. Floating positioning, focus trapping, dismiss layers, and animations are small in-house hooks; modals use the native `<dialog>` element. The legacy `shadcn/ui/*` import paths still work — they re-export the new `ui/*` components — so existing apps need no changes.
8
-
9
- **Accessibility-hardened (v4.1–4.3).** The self-contained tier was put through three rounds of a11y work: menu/select typeahead, correct keyboard focus management and tab-stops (menus, tabs, radios, popovers, navigation menu), ARIA wiring (`aria-controls`, labelled-by, indeterminate checkbox mixed state, named popover/slider dialogs), scroll-lock and exit-animation fixes, plus matching DOMException error names. Each release adds to a Playwright a11y regression suite that runs `workers: 1`.
10
-
11
- **TypeScript with full type declarations.** Source is TypeScript, compiled to plain JavaScript + `.d.ts` in `dist/` (`npm run build`). JavaScript apps consume the compiled JS exactly as before — import paths are unchanged — and TypeScript apps get real types for every export (`SkateboardConstants`, `User`, component props, `VariantProps`, typed icons).
12
-
13
- **Node.js 24+** is required in the app repo for build tooling and backend (see `engines` in `package.json`). The UI package itself runs in the browser.
5
+ Requires **Node.js 24+** in the app repo (see `engines` in `package.json`).
14
6
 
15
7
  ## Installation
16
8
 
@@ -37,150 +29,21 @@ const appRoutes = [
37
29
  createSkateboardApp({ constants, appRoutes });
38
30
  ```
39
31
 
40
- That's it! You get routing, auth, layout, landing page, settings, and payments.
41
-
42
- ## Dependency Footprint
43
-
44
- Across the v3.x series, every npm runtime dep was either vendored, recreated, or dropped. v3.5 finished the job by vendoring `@base-ui/react`; **v4.0 removes that vendored bundle entirely** by rewriting every component to be self-contained.
45
-
46
- | | Hard deps | Optional peer deps | Total |
47
- |---|---|---|---|
48
- | Before (v2.23) | 15 | 0 | 15 |
49
- | v3.0 | 4 | 3 | 7 |
50
- | v3.1 | 3 | 1 | 4 |
51
- | v3.2 | 2 | 1 | 3 |
52
- | v3.3 | 2 | 0 | 2 |
53
- | v3.4 | 1 | 0 | 1 |
54
- | v3.5 | 0 | 0 | 0 |
55
- | **Now (v4.0+)** | **0** | **0** | **0** |
56
-
57
- v3.10 converts the package to TypeScript — compiled JS + `.d.ts` ship in `dist/`; the dependency count is unchanged (`typescript` is a devDependency only).
58
-
59
- **No hard deps remain.** React + React-DOM + React Router are the only `peerDependencies`. Every other piece of code that runs in a consumer browser is either authored here, recreated as a drop-in, or vendored from upstream.
60
-
61
- ### Vendored packages
62
-
63
- Pre-built copies live in this repo so consumers don't pull them from npm. Refresh scripts in `scripts/` pin a version and re-bundle.
64
-
65
- | Source | Lives at | Refresh script |
66
- |---|---|---|
67
- | `lucide` icons (1700+) | `icons/*.tsx` | `node scripts/vendor-icons.js` (bump `LUCIDE_TAG`) |
68
- | `tailwind-merge` | `shadcn/lib/tailwind-merge.js` | `node scripts/vendor-tailwind-merge.js` (bump `TM_VERSION`) |
69
-
70
- `@base-ui/react` was vendored in v3.5 and **removed in v4.0** — the 47 components are now self-contained (see _Self-contained components_ above).
71
-
72
- ### Ported / recreated / inlined
73
-
74
- | Replaces | Lives at | Approach |
75
- |---|---|---|
76
- | `@base-ui/react` (behavior) | `ui/*.tsx` + `ui/{use-floating,use-dismiss,use-presence,use-controllable-state,slot,portal}.tsx` | rewritten — small in-house hooks for positioning, dismiss, presence, focus; native `<dialog>` for modals |
77
- | `vaul` (drag gesture) | `ui/drawer.tsx` | ported — drag math copied from vaul `src/index.tsx` (MIT, Emil Kowalski) on a native `<dialog>` shell |
78
- | `cmdk` | `components/core/Command.tsx` | rewritten drop-in |
79
- | `react-day-picker` | `components/core/Calendar.tsx` | rewritten drop-in |
80
- | `next-themes` | `components/core/ThemeProvider.tsx` | rewritten drop-in |
81
- | `class-variance-authority` | `shadcn/lib/cva.ts` | rewritten drop-in |
82
- | `clsx` | `shadcn/lib/clsx.ts` | rewritten drop-in |
83
- | `tailwindcss-animate` | `styles.css` | inlined as plain CSS utilities |
84
- | `sonner` | — | removed; use `Dialog` / `Alert` |
85
-
86
- ### Removed components (by version)
87
-
88
- | Version | Dropped | Reason |
89
- |---|---|---|
90
- | v3.1 | `Carousel`, `Resizable` | unused; consumers can install `embla-carousel-react` / `react-resizable-panels` directly |
91
- | v3.3 | `Chart` | unused; consumers can install `recharts` directly |
92
- | v3.4 | `vaul` | drag physics ported; `Drawer` rebuilt on base-ui Dialog |
93
- | v3.5 | `@base-ui/react` | vendored; see table above |
94
- | v4.0 | `@base-ui/react` (vendored bundle) | removed; components rewritten self-contained on `ui/*` |
95
-
96
- ## Migrating to 3.0
97
-
98
- Version 3.0 vendors all 1700+ lucide icons into the package and drops the `lucide-react` npm dependency. Icons are pin-versioned, auditable, and re-runnable via `node scripts/vendor-icons.js`.
99
-
100
- **One change in your app:**
101
-
102
- ```diff
103
- - import { ArrowUp, X } from 'lucide-react';
104
- + import { ArrowUp, X } from '@stevederico/skateboard-ui/icons';
105
- ```
106
-
107
- One-line migration:
108
-
109
- ```bash
110
- find src -type f -name "*.jsx" -exec sed -i '' \
111
- "s|from 'lucide-react'|from '@stevederico/skateboard-ui/icons'|g" {} +
112
- ```
113
-
114
- Then remove `lucide-react` from your app's `package.json`.
115
-
116
- **API is identical** — same component names (`ArrowUp`, `XIcon`, etc.), same props (`size`, `color`, `strokeWidth`, `className`), same `Icon`-suffix aliases shadcn uses. Legacy lucide aliases (e.g., `Loader2` → `LoaderCircle`) are preserved.
117
-
118
- To refresh the icon set against a newer lucide release: bump `LUCIDE_TAG` in `scripts/vendor-icons.js` and re-run.
119
-
120
- The vendored icons keep their original [Lucide ISC license](icons/LICENSE) (some legacy icons inherit Feather's MIT license — both notices are in that file).
121
-
122
- ## Refreshing vendored packages
123
-
124
- Each vendored library has a script that pins a version, fetches the upstream release, and re-emits the local copy. Trust model: vendoring upgrades from "trust npm on every install" to "trust npm at refresh time." Audit the diff between refreshes.
125
-
126
- | Library | Bump | Command | Host requirement |
127
- |---|---|---|---|
128
- | `lucide` icons | `LUCIDE_TAG` in `scripts/vendor-icons.js` | `node scripts/vendor-icons.js` | Node |
129
- | `tailwind-merge` | `TM_VERSION` in `scripts/vendor-tailwind-merge.js` | `node scripts/vendor-tailwind-merge.js` | Node |
130
-
131
- ## Dark Mode Setup
132
-
133
- To prevent flash of unstyled content (FOUC) when using dark mode, add this script to your `index.html` **before** your app loads:
134
-
135
- ```html
136
- <!DOCTYPE html>
137
- <html lang="en">
138
- <head>
139
- <meta charset="UTF-8" />
140
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
141
-
142
- <!-- Prevent dark mode FOUC -->
143
- <script>
144
- try {
145
- const theme = localStorage.getItem('theme');
146
- const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
147
- if (theme === 'dark' || (!theme && systemDark)) {
148
- document.documentElement.classList.add('dark');
149
- }
150
- } catch (e) {}
151
- </script>
152
-
153
- <title>Your App</title>
154
- </head>
155
- <body>
156
- <div id="root"></div>
157
- <script type="module" src="/src/main.jsx"></script>
158
- </body>
159
- </html>
160
- ```
161
-
162
- This ensures the correct theme is applied before React renders, eliminating any flash between light and dark modes.
32
+ That's it — routing, auth, layout, landing page, settings, and payments are wired up.
163
33
 
164
34
  ## Configuration
165
35
 
166
- skateboard-ui requires a `constants` object that configures your application:
36
+ skateboard-ui requires a `constants` object:
167
37
 
168
38
  ```javascript
169
- // constants.json or constants.js
170
39
  const constants = {
171
- // Required: Backend URLs (include /api prefix)
40
+ // Required
172
41
  devBackendURL: "http://localhost:8000/api",
173
42
  backendURL: "https://api.myapp.com/api",
174
-
175
- // Required: App identity
176
43
  appName: "MyApp",
177
- appIcon: "sparkles", // Lucide icon name
178
-
179
- // Required: Landing page content
44
+ appIcon: "sparkles",
180
45
  tagline: "Build apps faster with skateboard-ui",
181
46
  cta: "Get Started",
182
-
183
- // Required: Features section
184
47
  features: {
185
48
  title: "Everything you need",
186
49
  items: [
@@ -188,29 +51,19 @@ const constants = {
188
51
  { icon: "Shield", title: "Secure", description: "Authentication included" }
189
52
  ]
190
53
  },
191
-
192
- // Required: Company information
193
54
  companyName: "Your Company",
194
55
  companyWebsite: "https://yourcompany.com",
195
56
  companyEmail: "hello@yourcompany.com",
196
57
 
197
- // Optional: Navigation pages (sidebar + tabbar)
58
+ // Optional
198
59
  pages: [
199
60
  { title: "Home", icon: "home", url: "home" },
200
61
  { title: "Search", icon: "search", url: "search" }
201
62
  ],
202
-
203
- // Optional: Authentication
204
- noLogin: false, // Set true to disable authentication entirely
205
- // authOverlay defaults to true: lazy auth via the sign-in overlay.
206
- // Set `authOverlay: false` to require eager sign-in before /app access.
207
-
208
- // Optional: UI visibility
63
+ noLogin: false,
209
64
  hideSidebar: false,
210
65
  hideTabBar: false,
211
66
  hideSidebarHeader: false,
212
-
213
- // Optional: Payments (Stripe)
214
67
  stripeProducts: [
215
68
  {
216
69
  name: "Pro Plan",
@@ -219,749 +72,240 @@ const constants = {
219
72
  lookup_key: "pro_plan",
220
73
  title: "Go Pro",
221
74
  interval: "month",
222
- features: ["Unlimited usage", "Priority support", "Advanced features"]
75
+ features: ["Unlimited usage", "Priority support"]
223
76
  }
224
77
  ],
225
-
226
- // Optional: Landing page customization
227
- navLinks: [ // Override header nav links
78
+ navLinks: [
228
79
  { label: "Features", href: "#features" },
229
- { label: "Pricing", href: "#pricing" },
230
- { label: "Blog", href: "/blog" }
80
+ { label: "Pricing", href: "#pricing" }
231
81
  ],
232
- pricing: {
233
- title: "Simple Pricing", // Pricing section heading
234
- extras: ["Priority Customer Support", "Cancel anytime"] // Extra bullets after product features
235
- },
236
- ctaHeading: "Ready To Build?", // CTA section heading
237
- footerLinks: [ // Override footer links
82
+ pricing: { title: "Simple Pricing", extras: ["Priority support", "Cancel anytime"] },
83
+ ctaHeading: "Ready To Build?",
84
+ footerLinks: [
238
85
  { label: "Privacy", href: "/privacy" },
239
- { label: "Terms", href: "/terms" },
240
- { label: "EULA", href: "/eula" }
86
+ { label: "Terms", href: "/terms" }
241
87
  ],
242
- copyrightText: "All rights reserved.", // Copyright suffix after "© {year} {companyName}."
243
-
244
- // Optional: Legal documents (plain text, supports _COMPANY_, _WEBSITE_, _EMAIL_ placeholders)
88
+ copyrightText: "All rights reserved.",
245
89
  termsOfService: "Terms of Service for _COMPANY_...",
246
90
  privacyPolicy: "Privacy Policy for _COMPANY_...",
247
91
  EULA: "End User License Agreement...",
248
92
  subscriptionDetails: "Subscription details...",
249
-
250
- // Optional: App metadata
251
93
  version: "1.0.0"
252
94
  }
253
95
  ```
254
96
 
255
- ### Backend URL Pattern
256
-
257
- The `devBackendURL` and `backendURL` should include your full API base path (including the `/api` prefix):
258
-
259
- ```javascript
260
- const constants = {
261
- devBackendURL: "http://localhost:8000/api", // Include /api prefix
262
- backendURL: "https://api.myapp.com/api",
263
- }
264
- ```
97
+ `devBackendURL` and `backendURL` should include the full API base path (e.g. `/api`). Endpoints are relative to that base:
265
98
 
266
- Endpoints are relative to this base URL:
267
99
  - `${getBackendURL()}/signup` → `http://localhost:8000/api/signup`
268
100
  - `${getBackendURL()}/me` → `http://localhost:8000/api/me`
269
- - `${getBackendURL()}/deals` → `http://localhost:8000/api/deals`
270
-
271
- **Tip:** Include API versioning in the base URL (e.g., `/api/v2`) rather than in each endpoint path.
272
101
 
273
102
  ## createSkateboardApp
274
103
 
275
- The bootstrap function that sets up routing, auth, theming, state, toasts, and error handling.
276
-
277
104
  ```javascript
278
105
  import { createSkateboardApp } from '@stevederico/skateboard-ui/App';
279
106
 
280
107
  createSkateboardApp({
281
- constants, // Required: App configuration object
108
+ constants, // Required
282
109
  appRoutes, // Required: [{ path: string, element: JSX.Element }]
283
- defaultRoute, // Optional: Default route path (defaults to first appRoute path)
284
- landingPage, // Optional: Custom landing page JSX element
285
- wrapper, // Optional: React component to wrap the router (e.g., for providers)
110
+ defaultRoute, // Optional: defaults to first appRoute path
111
+ landingPage, // Optional: custom landing page element
112
+ wrapper, // Optional: component to wrap the router
286
113
  });
287
114
  ```
288
115
 
289
- ### What It Sets Up
290
-
291
- - **Routes:** Landing, signin, signup, signout, app routes, settings, payment, legal pages (terms, privacy, EULA, subscription)
292
- - **Authentication:** ProtectedRoute wrapping `/app/*`, AuthOverlay for lazy auth
293
- - **Theming:** in-house `ThemeProvider` (system / light / dark) — `components/core/ThemeProvider.tsx`
294
- - **State:** ContextProvider with user, UI, and auth overlay state
295
- - **Error Boundary:** Catches render errors, unhandled rejections, and global errors
296
-
297
- ### Generated Routes
116
+ Sets up routing, auth, theming, state, and error handling.
298
117
 
299
118
  | Route | Component | Protected |
300
119
  |-------|-----------|-----------|
301
120
  | `/` | LandingView (or custom `landingPage`) | No |
302
- | `/signin` | SignInView | No |
303
- | `/signup` | SignUpView | No |
304
- | `/signout` | SignOutView | No |
121
+ | `/signin`, `/signup`, `/signout` | SignIn/SignUp/SignOutView | No |
305
122
  | `/app/:path` | Your appRoutes | Yes |
306
- | `/app/settings` | SettingsView | Yes |
307
- | `/app/payment` | PaymentView | Yes |
308
- | `/terms` | TextView | No |
309
- | `/privacy` | TextView | No |
310
- | `/eula` | TextView | No |
311
- | `/subscription` | TextView | No |
123
+ | `/app/settings`, `/app/payment` | SettingsView, PaymentView | Yes |
124
+ | `/terms`, `/privacy`, `/eula`, `/subscription` | TextView | No |
312
125
  | `*` | NotFound | No |
313
126
 
314
127
  ## Authentication
315
128
 
316
- ### Overview
317
-
318
- skateboard-ui uses a **hybrid cookie + localStorage authentication system** that combines security with performance:
319
-
320
- ```
321
- Frontend Backend Storage
322
- ──────── ─────── ───────
323
-
324
- 1. POST /signin → Validate credentials
325
- credentials
326
- ← Set-Cookie: {appName}_token (HttpOnly)
327
- ← Set-Cookie: csrf_token
328
- ← Response: { csrfToken, ...user }
329
-
330
- 2. Extract tokens → localStorage:
331
- - CSRF from cookie {appName}_csrf
332
- - User from response {appName}_user
333
-
334
- 3. isAuthenticated() → Check localStorage
335
- (client-side) (fast, no network)
336
-
337
- 4. ProtectedRoute → GET /me
338
- (server validation) Validate cookies
339
- ← 200 OK or 401 Unauthorized
340
-
341
- 5. API requests → Protected endpoints
342
- + cookies (automatic) Validate {appName}_token
343
- + X-CSRF-Token header Validate CSRF header
344
- ```
345
-
346
- ### Cookie-Based Session Management
347
- - **Session token** stored in `{appName}_token` cookie (HttpOnly, Secure, SameSite=Strict)
348
- - Automatically sent with every request via browser
349
- - Cannot be accessed by JavaScript (XSS protection)
350
- - Backend validates cookie on each protected endpoint
351
-
352
- ### localStorage for Client-Side Validation
353
- - **CSRF token** and **user data** stored in localStorage
354
- - Enables instant `isAuthenticated()` checks without network calls
355
- - Used by client-side routing logic (ProtectedRoute initial check)
356
- - Not used for actual authentication (cookies handle that)
357
-
358
- ### CSRF Protection
359
- - Dual-token system prevents CSRF attacks
360
- - **CSRF token** sent in `X-CSRF-Token` header with state-changing requests
361
- - Backend validates header matches stored session CSRF token
362
- - Separate from session cookie to prevent cookie-based CSRF
363
-
364
- ### CSRF Error Handling
365
-
366
- The `apiRequest` utility automatically handles CSRF token failures:
367
-
368
- 1. **Auto-Regeneration**: Backend auto-regenerates tokens after server restart
369
- 2. **Retry Logic**: Frontend automatically retries failed requests once after refreshing the session
370
- 3. **User Experience**: Transparent recovery without forcing sign-out or page refresh
371
-
372
- **Error Flow**:
373
- ```
374
- POST /api/keys → 403 CSRF error
375
- ↓
376
- Fetch /me (triggers backend auto-regeneration)
377
- ↓
378
- Retry POST /api/keys with fresh token
379
- ↓
380
- Success
381
- ```
382
-
383
- ### Required Backend Endpoints
384
-
385
- #### POST /signup
386
- Create new user account.
387
-
388
- **Request:**
389
- ```json
390
- {
391
- "email": "user@example.com",
392
- "password": "securePassword123",
393
- "name": "John Doe"
394
- }
395
- ```
396
-
397
- **Response:**
398
- - Status: 201 Created
399
- - Headers:
400
- - `Set-Cookie: {appName}_token={sessionToken}; HttpOnly; Secure; SameSite=Strict; Path=/`
401
- - `Set-Cookie: csrf_token={csrfToken}; Secure; SameSite=Lax; Path=/`
402
- - Body:
403
- ```json
404
- {
405
- "csrfToken": "csrf_abc123...",
406
- "user": {
407
- "id": "user123",
408
- "email": "user@example.com",
409
- "name": "John Doe"
410
- }
411
- }
412
- ```
413
-
414
- #### POST /signin
415
- Authenticate existing user.
416
-
417
- **Request:**
418
- ```json
419
- {
420
- "email": "user@example.com",
421
- "password": "securePassword123"
422
- }
423
- ```
424
-
425
- **Response:**
426
- - Status: 200 OK
427
- - Headers: Same as /signup
428
- - Body: Same as /signup
429
-
430
- #### GET /me
431
- Validate current session and return user data.
432
-
433
- **Request:**
434
- - Headers: Cookies automatically sent by browser
435
-
436
- **Response (authenticated):**
437
- - Status: 200 OK
438
- - Body:
439
- ```json
440
- {
441
- "user": {
442
- "id": "user123",
443
- "email": "user@example.com",
444
- "name": "John Doe",
445
- "subscription": {
446
- "status": "active",
447
- "expires": 1735689600,
448
- "stripeID": "cus_abc123"
449
- }
450
- }
451
- }
452
- ```
453
-
454
- **Response (not authenticated):**
455
- - Status: 401 Unauthorized
456
-
457
- #### POST /signout
458
- End current session.
459
-
460
- **Request:**
461
- - Headers:
462
- - Cookies automatically sent
463
- - `X-CSRF-Token: {csrfToken}`
129
+ Hybrid cookie + localStorage auth:
464
130
 
465
- **Response:**
466
- - Status: 200 OK
467
- - Headers:
468
- - `Set-Cookie: {appName}_token=; Max-Age=0; Path=/` (clear cookie)
469
- - `Set-Cookie: csrf_token=; Max-Age=0; Path=/` (clear cookie)
131
+ 1. **Sign in** — backend sets `{appName}_token` (HttpOnly) and `csrf_token` cookies; frontend stores CSRF + user in localStorage
132
+ 2. **Client check** — `isAuthenticated()` reads localStorage (fast, no network)
133
+ 3. **Route guard** — `ProtectedRoute` validates via `GET /me`
134
+ 4. **API calls** — cookies sent automatically; `X-CSRF-Token` header on state-changing requests
470
135
 
471
- ### Optional Backend Endpoints
136
+ ### Required backend endpoints
472
137
 
473
- #### POST /usage
474
- Track and check feature usage limits.
138
+ **POST /signup** and **POST /signin**
475
139
 
476
- **Request:**
477
140
  ```json
478
- { "operation": "check" }
479
- ```
480
- or
481
- ```json
482
- { "operation": "track" }
483
- ```
141
+ // Request
142
+ { "email": "user@example.com", "password": "securePassword123", "name": "John Doe" }
484
143
 
485
- **Response:**
486
- ```json
487
- { "remaining": 15, "total": 20, "isSubscriber": false }
144
+ // Response (201 / 200)
145
+ // Set-Cookie: {appName}_token=...; HttpOnly; Secure; SameSite=Strict
146
+ // Set-Cookie: csrf_token=...
147
+ { "csrfToken": "...", "user": { "id": "...", "email": "...", "name": "..." } }
488
148
  ```
489
149
 
490
- #### GET /isSubscriber
491
- Check subscription status.
150
+ **GET /me** — validate session, return user (401 if not authenticated)
492
151
 
493
- **Response:**
494
- ```json
495
- { "isSubscriber": true }
496
- ```
152
+ **POST /signout** — requires `X-CSRF-Token`, clears cookies
497
153
 
498
- #### POST /checkout (Stripe)
499
- Create a Stripe checkout session.
154
+ ### Optional endpoints
500
155
 
501
- **Request:**
502
- ```json
503
- { "lookup_key": "pro_plan", "email": "user@example.com" }
504
- ```
156
+ | Endpoint | Purpose |
157
+ |----------|---------|
158
+ | `POST /usage` | Track/check feature usage limits |
159
+ | `GET /isSubscriber` | Subscription status |
160
+ | `POST /checkout` | Stripe checkout session |
161
+ | `POST /portal` | Stripe billing portal |
505
162
 
506
- **Response:**
507
- ```json
508
- { "url": "https://checkout.stripe.com/..." }
509
- ```
163
+ ### Lazy auth (default)
510
164
 
511
- #### POST /portal (Stripe)
512
- Open Stripe billing portal.
165
+ Users can browse `/app` without signing in. Protected actions trigger the auth overlay via `useAuthGate`:
513
166
 
514
- **Request:**
515
- ```json
516
- { "customerID": "cus_abc123" }
517
- ```
518
-
519
- **Response:**
520
- ```json
521
- { "url": "https://billing.stripe.com/..." }
522
- ```
523
-
524
- ### Cookie Configuration
525
-
526
- **Session Token Cookie:**
527
167
  ```javascript
528
- {
529
- name: '{appName}_token',
530
- httpOnly: true, // Prevents JavaScript access (XSS protection)
531
- secure: true, // HTTPS only (production)
532
- sameSite: 'Strict', // Strongest CSRF protection
533
- path: '/',
534
- maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days (configurable)
535
- }
536
- ```
168
+ import { useAuthGate } from '@stevederico/skateboard-ui/useAuthGate';
537
169
 
538
- **CSRF Token Cookie:**
539
- ```javascript
540
- {
541
- name: 'csrf_token',
542
- httpOnly: false, // Must be readable by JavaScript
543
- secure: true, // HTTPS only (production)
544
- sameSite: 'Lax', // Allow top-level navigation
545
- path: '/',
546
- maxAge: 7 * 24 * 60 * 60 * 1000 // Match session token
170
+ function SaveButton() {
171
+ const requireAuth = useAuthGate();
172
+ return <button onClick={() => requireAuth(() => saveThing())}>Save</button>;
547
173
  }
548
174
  ```
549
175
 
550
- ### Protected Endpoints
551
- All authenticated endpoints must:
552
- 1. Validate `{appName}_token` cookie exists and is valid
553
- 2. For state-changing operations (POST, PUT, DELETE), validate `X-CSRF-Token` header
554
- 3. Return 401 if authentication fails
555
- 4. Return 403 if CSRF validation fails
556
-
557
- ### Security Considerations
176
+ Set `authOverlay: false` in constants to require sign-in before `/app` access.
558
177
 
559
- - **XSS Protection**: Session token is HttpOnly — JavaScript cannot access it
560
- - **CSRF Protection**: Dual-token pattern prevents cookie-based CSRF attacks
561
- - **SameSite Policy**: Session token (Strict), CSRF token (Lax)
562
- - **HTTPS Requirement**: All cookies marked `Secure` in production
563
- - **localStorage Trade-offs**: Acceptable for CSRF token (cannot authenticate alone), never store session token
564
-
565
- ### Example Backend Implementation (Express.js)
178
+ ### No-login mode
566
179
 
567
180
  ```javascript
568
- import express from 'express';
569
- import cookieParser from 'cookie-parser';
570
- import crypto from 'crypto';
571
-
572
- const app = express();
573
- app.use(express.json());
574
- app.use(cookieParser());
575
-
576
- // In-memory session store (use Redis in production)
577
- const sessions = new Map();
578
-
579
- function generateToken() {
580
- return crypto.randomBytes(32).toString('hex');
581
- }
582
-
583
- function requireAuth(req, res, next) {
584
- const sessionToken = req.cookies.myapp_token;
585
- const session = sessions.get(sessionToken);
586
- if (!session) return res.status(401).json({ error: 'Not authenticated' });
587
- req.session = session;
588
- next();
589
- }
590
-
591
- function requireCSRF(req, res, next) {
592
- const csrfToken = req.headers['x-csrf-token'];
593
- if (!req.session || req.session.csrfToken !== csrfToken) {
594
- return res.status(403).json({ error: 'Invalid CSRF token' });
595
- }
596
- next();
597
- }
598
-
599
- app.post('/api/signup', async (req, res) => {
600
- const { email, password, name } = req.body;
601
- if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
602
-
603
- const user = await createUser(email, password, name);
604
- const sessionToken = generateToken();
605
- const csrfToken = generateToken();
181
+ const constants = { noLogin: true };
182
+ ```
606
183
 
607
- sessions.set(sessionToken, { userId: user.id, csrfToken, createdAt: Date.now() });
184
+ ### Troubleshooting
608
185
 
609
- res.cookie('myapp_token', sessionToken, {
610
- httpOnly: true, secure: process.env.NODE_ENV === 'production',
611
- sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000
612
- });
613
- res.cookie('csrf_token', csrfToken, {
614
- httpOnly: false, secure: process.env.NODE_ENV === 'production',
615
- sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000
616
- });
186
+ | Problem | Fix |
187
+ |---------|-----|
188
+ | "Not authenticated" after signin | Ensure `credentials: 'include'` in fetch calls |
189
+ | CSRF 403 errors | Verify `X-CSRF-Token` header and `csrf_token` cookie |
190
+ | Cookies not persisting (dev) | Set `secure: false` in dev, check domain match |
617
191
 
618
- res.status(201).json({ csrfToken, user: { id: user.id, email: user.email, name: user.name } });
619
- });
192
+ ## Dark Mode
620
193
 
621
- app.get('/api/me', requireAuth, async (req, res) => {
622
- const user = await getUserById(req.session.userId);
623
- res.json({ user: { id: user.id, email: user.email, name: user.name } });
624
- });
194
+ Add this script to `index.html` **before** your app loads to prevent FOUC:
625
195
 
626
- app.post('/api/signout', requireAuth, requireCSRF, (req, res) => {
627
- sessions.delete(req.cookies.myapp_token);
628
- res.clearCookie('myapp_token');
629
- res.clearCookie('csrf_token');
630
- res.json({ message: 'Signed out successfully' });
631
- });
196
+ ```html
197
+ <script>
198
+ try {
199
+ const theme = localStorage.getItem('theme');
200
+ const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
201
+ if (theme === 'dark' || (!theme && systemDark)) {
202
+ document.documentElement.classList.add('dark');
203
+ }
204
+ } catch (e) {}
205
+ </script>
632
206
  ```
633
207
 
634
- ### Auth Troubleshooting
208
+ ## Styling
635
209
 
636
- | Problem | Cause | Fix |
637
- |---------|-------|-----|
638
- | "Not authenticated" after signin | Cookies not sent | Verify `credentials: 'include'` in fetch calls |
639
- | CSRF 403 errors | Token mismatch | Check `X-CSRF-Token` header, verify cookie exists |
640
- | Cookies not persisting | SameSite/Secure flags | Set `secure: false` in dev, check domain match |
641
- | `isAuthenticated()` false but cookie exists | localStorage cleared | Re-fetch from `/me` endpoint |
210
+ ```css
211
+ @import "@stevederico/skateboard-ui/styles.css";
642
212
 
643
- ### No-Login Mode
213
+ @source '../../node_modules/@stevederico/skateboard-ui';
644
214
 
645
- ```javascript
646
- const constants = { noLogin: true };
215
+ @theme {
216
+ --color-app: var(--color-purple-500);
217
+ }
647
218
  ```
648
219
 
649
- Effects: `isAuthenticated()` always returns `true`, ProtectedRoute allows all access.
220
+ Key variables: `--color-app` (brand), `--background`, `--foreground`, `--accent`, `--radius`.
650
221
 
651
222
  ## Components
652
223
 
653
- ### Core Components
224
+ ### App components
654
225
 
655
226
  | Component | Import | Description |
656
227
  |-----------|--------|-------------|
657
- | Sidebar | `@stevederico/skateboard-ui/Sidebar` | Desktop navigation sidebar with collapsible icon mode |
658
- | Header | `@stevederico/skateboard-ui/Header` | App header with title and action button |
659
- | Layout | `@stevederico/skateboard-ui/Layout` | Page layout with sidebar (desktop) and tabbar (mobile) |
660
- | TabBar | `@stevederico/skateboard-ui/TabBar` | Mobile bottom navigation with labels |
228
+ | Sidebar | `@stevederico/skateboard-ui/Sidebar` | Desktop navigation sidebar |
229
+ | Header | `@stevederico/skateboard-ui/Header` | Page header with optional action button |
230
+ | Layout | `@stevederico/skateboard-ui/Layout` | Sidebar (desktop) + tabbar (mobile) |
231
+ | TabBar | `@stevederico/skateboard-ui/TabBar` | Mobile bottom navigation |
661
232
  | DynamicIcon | `@stevederico/skateboard-ui/DynamicIcon` | Lucide icon by name string |
662
- | ThemeToggle | `@stevederico/skateboard-ui/ThemeToggle` | Dark/light mode toggle button |
233
+ | ThemeToggle | `@stevederico/skateboard-ui/ThemeToggle` | Dark/light mode toggle |
663
234
  | Sheet | `@stevederico/skateboard-ui/Sheet` | Slide-out panel |
664
235
  | UpgradeSheet | `@stevederico/skateboard-ui/UpgradeSheet` | Premium upgrade drawer |
665
236
  | ErrorBoundary | `@stevederico/skateboard-ui/ErrorBoundary` | Error boundary wrapper |
666
237
 
667
- ### View Components
668
-
669
- | Component | Import | Description |
670
- |-----------|--------|-------------|
671
- | LandingView | `@stevederico/skateboard-ui/LandingView` | Landing page — sticky header, hero, features, pricing, CTA, footer |
672
- | SignInView | `@stevederico/skateboard-ui/SignInView` | Sign in form with Card layout |
673
- | SignUpView | `@stevederico/skateboard-ui/SignUpView` | Sign up form with password validation |
674
- | SignOutView | `@stevederico/skateboard-ui/SignOutView` | Sign out handler with redirect |
675
- | SettingsView | `@stevederico/skateboard-ui/SettingsView` | User settings, billing, theme |
676
- | PaymentView | `@stevederico/skateboard-ui/PaymentView` | Stripe payment redirect handler |
677
- | TextView | `@stevederico/skateboard-ui/TextView` | Legal document viewer with placeholder replacement |
678
- | NotFound | `@stevederico/skateboard-ui/NotFound` | 404 page |
679
-
680
- ### Auth Components
681
-
682
- | Export | Import | Description |
683
- |--------|--------|-------------|
684
- | AuthOverlay | `@stevederico/skateboard-ui/AuthOverlay` | Modal sign-in/sign-up dialog |
685
- | useAuthGate | `@stevederico/skateboard-ui/useAuthGate` | Hook to gate actions behind auth |
686
-
687
- ### State & Utilities
688
-
689
- | Export | Import | Description |
690
- |--------|--------|-------------|
691
- | Context | `@stevederico/skateboard-ui/Context` | App state provider and accessor |
692
- | Utilities | `@stevederico/skateboard-ui/Utilities` | API, auth, formatting, and UI utilities |
693
- | App | `@stevederico/skateboard-ui/App` | createSkateboardApp bootstrap function |
694
- | ProtectedRoute | `@stevederico/skateboard-ui/ProtectedRoute` | Route guard with server validation |
238
+ ### Views
695
239
 
696
- ## Component Details
240
+ | Component | Import |
241
+ |-----------|--------|
242
+ | LandingView | `@stevederico/skateboard-ui/LandingView` |
243
+ | SignInView | `@stevederico/skateboard-ui/SignInView` |
244
+ | SignUpView | `@stevederico/skateboard-ui/SignUpView` |
245
+ | SignOutView | `@stevederico/skateboard-ui/SignOutView` |
246
+ | SettingsView | `@stevederico/skateboard-ui/SettingsView` |
247
+ | PaymentView | `@stevederico/skateboard-ui/PaymentView` |
248
+ | TextView | `@stevederico/skateboard-ui/TextView` |
249
+ | NotFound | `@stevederico/skateboard-ui/NotFound` |
697
250
 
698
- ### Sidebar
251
+ ### Auth
699
252
 
700
- Desktop navigation sidebar with collapsible icon mode, user dropdown, and settings link.
253
+ | Export | Import |
254
+ |--------|--------|
255
+ | AuthOverlay | `@stevederico/skateboard-ui/AuthOverlay` |
256
+ | useAuthGate | `@stevederico/skateboard-ui/useAuthGate` |
257
+ | ProtectedRoute | `@stevederico/skateboard-ui/ProtectedRoute` |
701
258
 
702
- ```javascript
703
- import Sidebar from '@stevederico/skateboard-ui/Sidebar';
704
-
705
- // Used internally by Layout. Renders automatically based on constants.
706
- ```
707
-
708
- **Reads from constants:**
709
- - `pages` — Navigation items rendered as sidebar menu buttons
710
- - `appName` — Displayed in sidebar header
711
- - `appIcon` — Icon in sidebar header
712
- - `hideSidebarHeader` — Hides the header when `true`
713
-
714
- **Features:**
715
- - Collapsible to icon-only mode via rail
716
- - Active page highlighting based on current route
717
- - Tooltip labels when collapsed
718
- - Footer with Settings button and user dropdown (account, billing, notifications, sign out)
719
-
720
- ### Header
721
-
722
- ```javascript
723
- import Header from '@stevederico/skateboard-ui/Header';
724
-
725
- <Header
726
- title="Dashboard"
727
- buttonTitle="Add"
728
- onButtonTitleClick={() => console.log('clicked')}
729
- buttonClass="bg-app text-white"
730
- className="sticky top-0"
731
- >
732
- {/* Optional: custom right-side content */}
733
- </Header>
734
- ```
735
-
736
- **Props:**
737
-
738
- | Prop | Type | Default | Description |
739
- |------|------|---------|-------------|
740
- | title | string | required | Header title |
741
- | buttonTitle | string | — | Action button text (omit to hide) |
742
- | onButtonTitleClick | function | — | Button click handler |
743
- | buttonClass | string | — | Additional button CSS classes |
744
- | className | string | — | Additional header CSS classes |
745
- | children | ReactNode | — | Custom right-side content |
746
-
747
- ### DynamicIcon
259
+ ### UI primitives
748
260
 
749
- Renders a Lucide icon by name string. Accepts kebab-case, snake_case, or PascalCase.
261
+ Import from `@stevederico/skateboard-ui/ui/*`.
750
262
 
751
263
  ```javascript
752
- import DynamicIcon from '@stevederico/skateboard-ui/DynamicIcon';
753
-
754
- <DynamicIcon name="home" size={24} />
755
- <DynamicIcon name="arrow-right" size={20} color="red" />
756
- <DynamicIcon name="settings" className="text-muted-foreground" />
757
- ```
758
-
759
- **Props:**
760
-
761
- | Prop | Type | Default | Description |
762
- |------|------|---------|-------------|
763
- | name | string | required | Icon name (e.g. "home", "arrow-right", "Settings") |
764
- | size | number | 24 | Icon size in pixels |
765
- | color | string | 'currentColor' | Stroke color |
766
- | strokeWidth | number | 2 | Stroke width |
767
- | className | string | — | Additional CSS classes |
768
-
769
- Icons vendored from [lucide](https://lucide.dev/icons/) at `scripts/vendor-icons.js`. Returns null if icon name not found.
770
-
771
- ### ThemeToggle
772
-
773
- ```javascript
774
- import ThemeToggle from '@stevederico/skateboard-ui/ThemeToggle';
775
-
776
- <ThemeToggle />
777
- <ThemeToggle variant="landing" iconSize={18} />
778
- ```
779
-
780
- **Props:**
781
-
782
- | Prop | Type | Default | Description |
783
- |------|------|---------|-------------|
784
- | className | string | "" | Additional CSS classes |
785
- | iconSize | number | 16 | Icon size in pixels |
786
- | variant | string | "settings" | "settings" (ghost) or "landing" (outline) |
787
-
788
- ### TabBar
789
-
790
- Mobile bottom navigation bar. Hidden on `md+` screens. Renders pages from `constants.pages` plus a Settings link.
791
-
792
- ```javascript
793
- import TabBar from '@stevederico/skateboard-ui/TabBar';
794
-
795
- // Used internally by Layout. Renders automatically.
264
+ import { Button } from '@stevederico/skateboard-ui/ui/button';
265
+ import { Card, CardHeader, CardTitle, CardContent } from '@stevederico/skateboard-ui/ui/card';
266
+ import { Input } from '@stevederico/skateboard-ui/ui/input';
267
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@stevederico/skateboard-ui/ui/dialog';
268
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@stevederico/skateboard-ui/ui/select';
269
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@stevederico/skateboard-ui/ui/dropdown-menu';
270
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@stevederico/skateboard-ui/ui/tabs';
796
271
  ```
797
272
 
798
- **Features:**
799
- - Fixed bottom position on mobile
800
- - Active page highlighting with bold stroke
801
- - Text labels under each icon
802
- - Settings link appended automatically
803
-
804
- ### UpgradeSheet
805
-
806
- Drawer component for premium upgrade prompts. Controlled via ref.
807
-
808
273
  ```javascript
809
- import { useRef } from 'react';
810
- import UpgradeSheet from '@stevederico/skateboard-ui/UpgradeSheet';
811
- import { showUpgradeSheet } from '@stevederico/skateboard-ui/Utilities';
812
-
813
- function MyComponent() {
814
- const upgradeRef = useRef();
815
-
816
- return (
817
- <>
818
- <button onClick={() => showUpgradeSheet(upgradeRef)}>
819
- Upgrade
820
- </button>
821
- <UpgradeSheet ref={upgradeRef} userEmail={user.email} />
822
- </>
823
- );
824
- }
274
+ import { cn } from '@stevederico/skateboard-ui/shadcn/lib/utils';
275
+ import { useIsMobile } from '@stevederico/skateboard-ui/shadcn/hooks/use-mobile';
825
276
  ```
826
277
 
827
- **Ref Methods:** `show()`, `open()`, `hide()`, `close()`, `toggle()`
828
-
829
- **Props:**
830
-
831
- | Prop | Type | Description |
832
- |------|------|-------------|
833
- | userEmail | string | User's email for Stripe checkout |
834
-
835
- **Reads from constants:** `stripeProducts[0]` (title, price, features)
836
-
837
- ### TextView
838
-
839
- Renders legal documents with placeholder replacement.
278
+ ### Icons
840
279
 
841
280
  ```javascript
842
- import TextView from '@stevederico/skateboard-ui/TextView';
843
-
844
- <TextView details={constants.termsOfService} />
281
+ import { ArrowUp, X } from '@stevederico/skateboard-ui/icons';
845
282
  ```
846
283
 
847
- **Props:**
848
-
849
- | Prop | Type | Description |
850
- |------|------|-------------|
851
- | details | string | Text content with optional placeholders |
852
- | className | string | Additional CSS classes |
853
-
854
- **Placeholders:** `_COMPANY_` → companyName, `_WEBSITE_` → companyWebsite, `_EMAIL_` → companyEmail
855
-
856
- ### ErrorBoundary
857
-
858
- Catches render errors, unhandled promise rejections, and global errors. Shows an error card with retry options.
859
-
860
- ```javascript
861
- import ErrorBoundary from '@stevederico/skateboard-ui/ErrorBoundary';
862
-
863
- <ErrorBoundary>
864
- <App />
865
- </ErrorBoundary>
866
- ```
867
-
868
- ## Context (State Management)
284
+ ## Context
869
285
 
870
286
  ```javascript
871
287
  import { getState, useUser, useDispatch } from '@stevederico/skateboard-ui/Context';
872
288
 
873
- function MyComponent() {
874
- const { state, dispatch } = getState();
875
-
876
- // Access state
877
- const user = state.user;
878
- const constants = state.constants;
879
-
880
- // Dispatch actions
881
- dispatch({ type: 'SET_USER', payload: userData });
882
- dispatch({ type: 'CLEAR_USER' });
883
- }
289
+ const { state, dispatch } = getState();
290
+ dispatch({ type: 'SET_USER', payload: userData });
291
+ dispatch({ type: 'CLEAR_USER' });
884
292
  ```
885
293
 
886
- ### Optimized Hooks
887
-
888
- Use these hooks to avoid unnecessary re-renders:
889
-
890
- ```javascript
891
- import { useUser, useDispatch } from '@stevederico/skateboard-ui/Context';
892
-
893
- // Only re-renders when user changes (not on sidebar/theme changes)
894
- function ProfileCard() {
895
- const user = useUser();
896
- if (!user) return null;
897
- return <div>{user.name}</div>;
898
- }
899
-
900
- // Stable dispatch reference, never causes re-renders
901
- function SignOutButton() {
902
- const dispatch = useDispatch();
903
- return <button onClick={() => dispatch({ type: 'CLEAR_USER' })}>Sign Out</button>;
904
- }
905
- ```
906
-
907
- | Hook | Returns | Re-renders on |
908
- |------|---------|---------------|
909
- | `getState()` | `{ state, dispatch }` | Any state change |
910
- | `useUser()` | `user` or `null` | User changes only |
911
- | `useDispatch()` | `dispatch` | Never (stable) |
912
-
913
- ### State Shape
914
-
915
- ```javascript
916
- {
917
- user: {
918
- id: string,
919
- email: string,
920
- name: string,
921
- subscription: {
922
- status: 'active' | 'canceled' | null,
923
- expires: number, // Unix timestamp (seconds)
924
- stripeID: string
925
- }
926
- } | null,
927
-
928
- ui: {
929
- sidebarVisible: boolean,
930
- tabBarVisible: boolean
931
- },
932
-
933
- authOverlay: {
934
- visible: boolean,
935
- pendingCallbacks: Function[] // queued 401 retries; run on sign-in, rejected on dismiss
936
- },
937
-
938
- constants: Object // App configuration
939
- }
940
- ```
941
-
942
- ### Available Actions
294
+ | Hook | Re-renders on |
295
+ |------|---------------|
296
+ | `getState()` | Any state change |
297
+ | `useUser()` | User changes only |
298
+ | `useDispatch()` | Never (stable) |
943
299
 
944
300
  | Action | Payload | Description |
945
301
  |--------|---------|-------------|
946
302
  | `SET_USER` | user object | Set authenticated user |
947
- | `CLEAR_USER` | — | Clear user (logout) |
303
+ | `CLEAR_USER` | — | Clear user |
948
304
  | `SET_SIDEBAR_VISIBLE` | boolean | Show/hide sidebar |
949
305
  | `SET_TABBAR_VISIBLE` | boolean | Show/hide tab bar |
950
- | `SET_UI_VISIBILITY` | `{ sidebar?, tabBar? }` | Batch update UI visibility |
951
- | `SHOW_AUTH_OVERLAY` | callback or null | Show auth dialog, optionally queue callback |
306
+ | `SHOW_AUTH_OVERLAY` | callback or null | Show auth dialog |
952
307
  | `HIDE_AUTH_OVERLAY` | — | Hide auth dialog |
953
- | `AUTH_OVERLAY_SUCCESS` | — | Auth success, run pending callback and close |
954
-
955
- ### localStorage Keys
956
-
957
- All keys are namespaced with `{appName}_`:
958
-
959
- | Key | Description |
960
- |-----|-------------|
961
- | `{appName}_user` | Persisted user object |
962
- | `{appName}_csrf` | CSRF token (fallback, primary is cookie) |
963
- | `{appName}_beforeCheckoutURL` | Redirect URL after Stripe checkout |
964
- | `{appName}_beforeManageURL` | Redirect URL after Stripe portal |
308
+ | `AUTH_OVERLAY_SUCCESS` | — | Run pending callback and close |
965
309
 
966
310
  ## Utilities
967
311
 
@@ -974,7 +318,6 @@ import {
974
318
  isSubscriber,
975
319
  getCSRFToken,
976
320
  getBackendURL,
977
- getAppKey,
978
321
  getConstants,
979
322
  getRemainingUsage,
980
323
  trackUsage,
@@ -985,338 +328,38 @@ import {
985
328
  hideSidebar,
986
329
  showTabBar,
987
330
  hideTabBar,
988
- setSidebarVisible,
989
- setTabBarVisible,
990
- setUIVisibility,
991
331
  timestampToString,
992
332
  useListData,
993
333
  useForm,
994
- useAppSetup,
995
334
  isAppMode,
996
- validateConstants,
997
335
  } from '@stevederico/skateboard-ui/Utilities';
998
336
  ```
999
337
 
1000
- ### API Requests
1001
-
1002
338
  ```javascript
1003
- // GET
339
+ // API — auto-includes credentials, CSRF header, auth overlay on 401
1004
340
  const data = await apiRequest('/deals');
341
+ await apiRequest('/deals', { method: 'POST', body: JSON.stringify({ name: 'New Deal' }) });
1005
342
 
1006
- // POST
1007
- const newDeal = await apiRequest('/deals', {
1008
- method: 'POST',
1009
- body: JSON.stringify({ name: 'New Deal', amount: 5000 })
1010
- });
1011
-
1012
- // GET with query params
1013
- const filtered = await apiRequestWithParams('/deals', { status: 'active', limit: 10 });
1014
- ```
1015
-
1016
- **Features:**
1017
- - Auto-includes credentials (cookies)
1018
- - Auto-adds `X-CSRF-Token` header for POST, PUT, DELETE, PATCH
1019
- - On 401, shows the auth overlay and retries after sign-in (default); redirects to `/signout` only when `authOverlay: false`
1020
- - Auto-retries once on CSRF 403 failure
1021
-
1022
- ### Auth Utilities
1023
-
1024
- ```javascript
1025
- // Client-side check (fast, no network)
1026
- if (isAuthenticated()) {
1027
- const user = getCurrentUser();
1028
- }
1029
-
1030
- // Server-side validation
1031
- const user = await getCurrentUser(); // Calls GET /me
1032
-
1033
- // Check subscription
1034
- const subscribed = await isSubscriber(); // Calls GET /isSubscriber
1035
-
1036
- // Get CSRF token (from cookie, falls back to localStorage)
1037
- const token = getCSRFToken();
1038
-
1039
- // Get backend URL (devBackendURL in dev, backendURL in production)
1040
- const url = getBackendURL();
1041
-
1042
- // Generate app-namespaced localStorage key
1043
- const key = getAppKey('user'); // → "{appName}_user"
1044
- ```
1045
-
1046
- ### Usage Tracking
1047
-
1048
- ```javascript
1049
- // Check remaining usage for an action
1050
- const usage = await getRemainingUsage('messages');
1051
- // { remaining: 15, total: 20, isSubscriber: false }
1052
-
1053
- // Track usage (decrements remaining)
1054
- const updated = await trackUsage('messages');
1055
- ```
343
+ // Data fetching
344
+ const { data, loading, error, refetch } = useListData('/deals');
1056
345
 
1057
- ### Stripe Payments
346
+ // Timestamps
347
+ timestampToString(1706000000, 'ago'); // "2 hours ago"
348
+ timestampToString(1706000000, 'DOB'); // "Jan 23, 2024"
1058
349
 
1059
- ```javascript
1060
- // Redirect to Stripe checkout
1061
- showCheckout('user@example.com', 0); // productIndex defaults to 0
1062
-
1063
- // Open Stripe billing portal
350
+ // Stripe
351
+ showCheckout('user@example.com', 0);
1064
352
  showManage('cus_abc123');
1065
-
1066
- // Show upgrade sheet if not subscriber
1067
- showUpgradeSheet(upgradeSheetRef);
1068
353
  ```
1069
354
 
1070
- ### Data Fetching Hook
1071
-
1072
- ```javascript
1073
- import { useListData } from '@stevederico/skateboard-ui/Utilities';
1074
-
1075
- function DealsList() {
1076
- const { data, loading, error, refetch } = useListData('/deals');
1077
-
1078
- if (loading) return <div>Loading...</div>;
1079
- if (error) return <div>Error: {error}</div>;
1080
-
1081
- return data.map(deal => <DealCard key={deal.id} {...deal} />);
1082
- }
1083
-
1084
- // With custom sort
1085
- const { data } = useListData('/deals', (a, b) => b.amount - a.amount);
1086
- ```
1087
-
1088
- ### Form Hook
1089
-
1090
- ```javascript
1091
- import { useForm } from '@stevederico/skateboard-ui/Utilities';
1092
-
1093
- function ContactForm() {
1094
- const { values, handleChange, handleSubmit, reset, submitting, error } = useForm(
1095
- { name: '', email: '', message: '' },
1096
- async (formValues) => {
1097
- await apiRequest('/contact', {
1098
- method: 'POST',
1099
- body: JSON.stringify(formValues)
1100
- });
1101
- }
1102
- );
1103
-
1104
- return (
1105
- <form onSubmit={handleSubmit}>
1106
- <input name="name" value={values.name} onChange={handleChange} />
1107
- <input name="email" value={values.email} onChange={handleChange} />
1108
- <textarea name="message" value={values.message} onChange={handleChange} />
1109
- <button type="submit" disabled={submitting}>Send</button>
1110
- {error && <p>{error}</p>}
1111
- </form>
1112
- );
1113
- }
1114
- ```
355
+ ## Peer dependencies
1115
356
 
1116
- ### Timestamp Formatting
1117
-
1118
- ```javascript
1119
- import { timestampToString } from '@stevederico/skateboard-ui/Utilities';
1120
-
1121
- timestampToString(1706000000, 'ago'); // "2 hours ago"
1122
- timestampToString(1706000000, 'DOB'); // "Jan 23, 2024"
1123
- timestampToString(1706000000, 'DOBT'); // "Jan 23, 2024 3:00 PM"
1124
- timestampToString(1706000000, 'ISO'); // "2024-01-23"
1125
- timestampToString(1706000000, 'day-month-time');// "23 Jan 3:00 PM"
1126
- timestampToString(1706000000, 'day'); // "Monday"
1127
- timestampToString(1706000000, 'time'); // "3:00 PM"
1128
- timestampToString(1706000000, 'full'); // "Monday, Jan 23, 2024 3:00 PM"
1129
- ```
1130
-
1131
- ### UI Visibility Control
1132
-
1133
- ```javascript
1134
- // Programmatic control
1135
- hideSidebar();
1136
- showSidebar();
1137
- hideTabBar();
1138
- showTabBar();
1139
-
1140
- // Set directly
1141
- setSidebarVisible(false);
1142
- setTabBarVisible(true);
1143
-
1144
- // Batch control
1145
- setUIVisibility({ sidebar: false, tabBar: false });
1146
- ```
1147
-
1148
- ### Other Utilities
1149
-
1150
- ```javascript
1151
- // Check if running inside native WebKit wrapper (iOS/macOS app)
1152
- if (isAppMode()) { /* native context */ }
1153
-
1154
- // Validate constants object (called internally by createSkateboardApp)
1155
- validateConstants(constants);
1156
-
1157
- // Get constants object
1158
- const constants = getConstants();
1159
- ```
1160
-
1161
- ## Lazy Authentication (Auth Overlay)
1162
-
1163
- Let users explore `/app` without signing in — prompt them only when they perform a protected action. This is the **default** behavior.
1164
-
1165
- ### Setup
1166
-
1167
- Lazy auth is on by default — no config needed. The `AuthOverlay` component is rendered automatically by `createSkateboardApp` and unauthenticated visitors can reach `/app` routes.
1168
-
1169
- To opt out and require eager sign-in instead, set `authOverlay: false` in your constants. `ProtectedRoute` then validates the session up front and redirects unauthenticated users to `/signin`:
1170
-
1171
- ```json
1172
- {
1173
- "authOverlay": false
1174
- }
1175
- ```
1176
-
1177
- ### Usage
1178
-
1179
- ```javascript
1180
- import { useAuthGate } from '@stevederico/skateboard-ui/useAuthGate';
1181
-
1182
- function SaveButton() {
1183
- const requireAuth = useAuthGate();
1184
-
1185
- function handleSave() {
1186
- requireAuth(() => {
1187
- // Only runs if user is authenticated
1188
- // If not signed in, auth overlay appears first
1189
- saveThing();
1190
- });
1191
- }
1192
-
1193
- return <button onClick={handleSave}>Save</button>;
1194
- }
1195
- ```
1196
-
1197
- ### How It Works
1198
-
1199
- 1. User clicks a protected action (Save, Like, Post, etc.)
1200
- 2. `requireAuth()` checks if user is signed in
1201
- 3. If signed in — callback runs immediately
1202
- 4. If not — a modal dialog appears with sign-in/sign-up forms
1203
- 5. After successful auth, the original callback executes automatically
1204
- 6. User stays on the same page throughout — no navigation
1205
-
1206
- The dialog supports toggling between sign-in and sign-up modes inline, and can be dismissed with the X button (cancels the action).
1207
-
1208
- ## Toast Notifications
1209
-
1210
- Removed in v3.0. The Sonner dependency is gone — surface short-lived feedback with `Dialog` or `Alert` from `shadcn/ui` instead.
1211
-
1212
- ## Styling
1213
-
1214
- Import base theme and override as needed:
1215
-
1216
- ```css
1217
- /* styles.css */
1218
- @import "@stevederico/skateboard-ui/styles.css";
1219
-
1220
- @source '../../node_modules/@stevederico/skateboard-ui';
1221
-
1222
- @theme {
1223
- --color-app: var(--color-purple-500);
1224
- }
1225
- ```
1226
-
1227
- ### Theme Variables
1228
-
1229
- | Variable | Description |
1230
- |----------|-------------|
1231
- | `--color-app` | Primary brand color (used for app icon backgrounds, gradient buttons) |
1232
- | `--background` | Page background |
1233
- | `--foreground` | Text color |
1234
- | `--accent` | Secondary backgrounds |
1235
- | `--radius` | Border radius |
1236
-
1237
- Dark mode is automatic via CSS custom properties and the in-house `ThemeProvider` (`components/core/ThemeProvider.tsx`).
1238
-
1239
- ## Components
1240
-
1241
- 47 self-contained components available at `@stevederico/skateboard-ui/ui/*`. The legacy `@stevederico/skateboard-ui/ui/*` paths still resolve to the same components (re-export shims), so existing imports keep working.
1242
-
1243
- Components follow the shadcn API. `asChild` renders the styling onto a single child element; the old base-ui `render={<El/>}` / `nativeButton` props are still accepted for backward compatibility.
1244
-
1245
- ```javascript
1246
- import { Button } from '@stevederico/skateboard-ui/ui/button';
1247
- import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from '@stevederico/skateboard-ui/ui/card';
1248
- import { Input } from '@stevederico/skateboard-ui/ui/input';
1249
- import { Label } from '@stevederico/skateboard-ui/ui/label';
1250
- import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@stevederico/skateboard-ui/ui/dialog';
1251
- import { Avatar, AvatarFallback, AvatarImage } from '@stevederico/skateboard-ui/ui/avatar';
1252
- import { Badge } from '@stevederico/skateboard-ui/ui/badge';
1253
- import { Separator } from '@stevederico/skateboard-ui/ui/separator';
1254
- import { ScrollArea } from '@stevederico/skateboard-ui/ui/scroll-area';
1255
- import { Skeleton } from '@stevederico/skateboard-ui/ui/skeleton';
1256
- import { Alert, AlertDescription, AlertTitle } from '@stevederico/skateboard-ui/ui/alert';
1257
- import { Progress } from '@stevederico/skateboard-ui/ui/progress';
1258
- import { Switch } from '@stevederico/skateboard-ui/ui/switch';
1259
- import { Checkbox } from '@stevederico/skateboard-ui/ui/checkbox';
1260
- import { Textarea } from '@stevederico/skateboard-ui/ui/textarea';
1261
- import { Tooltip, TooltipContent, TooltipTrigger } from '@stevederico/skateboard-ui/ui/tooltip';
1262
- ```
1263
-
1264
- ```javascript
1265
- import {
1266
- Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
1267
- } from '@stevederico/skateboard-ui/ui/select';
1268
-
1269
- import {
1270
- DropdownMenu, DropdownMenuContent, DropdownMenuItem,
1271
- DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
1272
- } from '@stevederico/skateboard-ui/ui/dropdown-menu';
1273
-
1274
- import {
1275
- AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
1276
- AlertDialogDescription, AlertDialogFooter, AlertDialogHeader,
1277
- AlertDialogTitle, AlertDialogTrigger,
1278
- } from '@stevederico/skateboard-ui/ui/alert-dialog';
1279
-
1280
- import {
1281
- Accordion, AccordionContent, AccordionItem, AccordionTrigger,
1282
- } from '@stevederico/skateboard-ui/ui/accordion';
1283
-
1284
- import {
1285
- Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
1286
- } from '@stevederico/skateboard-ui/ui/table';
1287
-
1288
- import {
1289
- Tabs, TabsContent, TabsList, TabsTrigger,
1290
- } from '@stevederico/skateboard-ui/ui/tabs';
1291
- ```
1292
-
1293
- ### Utilities
1294
-
1295
- ```javascript
1296
- // Tailwind className merger
1297
- import { cn } from '@stevederico/skateboard-ui/shadcn/lib/utils';
1298
-
1299
- cn('px-2 py-1', condition && 'bg-red-500', 'px-4'); // Merges without conflicts
1300
-
1301
- // Mobile detection hook (< 768px)
1302
- import { useIsMobile } from '@stevederico/skateboard-ui/shadcn/hooks/use-mobile';
1303
-
1304
- const isMobile = useIsMobile();
1305
- ```
1306
-
1307
- All components support dark mode automatically and accept a `className` prop for customization.
1308
-
1309
- ## Dependencies
1310
-
1311
- ### Required peer dependencies
1312
357
  - React 19.1+
1313
358
  - react-dom 19.1+
1314
359
  - react-router 7.0+
1315
360
 
1316
- ### Hard dependencies
1317
-
1318
- None. As of v4.0 the components are self-contained — `@base-ui/react` is no longer vendored or required. See the dep-count table near the top of this README for the full history.
1319
-
1320
361
  ## Repository
1321
362
 
1322
363
  https://github.com/stevederico/skateboard-ui
364
+
365
+ Version history and migration notes: [CHANGELOG.md](./CHANGELOG.md)