@voltro/ui-shadcn 0.1.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +3016 -0
  6. package/dist/brand.d.ts +73 -0
  7. package/dist/brand.js +177 -0
  8. package/dist/cn.d.ts +11 -0
  9. package/dist/cn.js +6 -0
  10. package/dist/index.d.ts +1183 -0
  11. package/dist/index.js +2636 -0
  12. package/dist/tokens.css +532 -0
  13. package/package.json +64 -0
  14. package/src/brand/README.md +60 -0
  15. package/src/brand/assets/voltro-favicon.svg +30 -0
  16. package/src/brand/assets/voltro-icon-dark.svg +26 -0
  17. package/src/brand/assets/voltro-icon.svg +14 -0
  18. package/src/brand/assets/voltro-mark-mono.svg +5 -0
  19. package/src/brand/assets/voltro-mark.svg +14 -0
  20. package/src/brand/voltroLogo.tsx +244 -0
  21. package/src/cn.ts +10 -0
  22. package/src/compositions/appShell.tsx +72 -0
  23. package/src/compositions/codeCompare.tsx +88 -0
  24. package/src/compositions/docShell.tsx +112 -0
  25. package/src/compositions/docsLayout.tsx +577 -0
  26. package/src/compositions/featureBento.tsx +103 -0
  27. package/src/compositions/featureGrid.tsx +41 -0
  28. package/src/compositions/heroSection.tsx +55 -0
  29. package/src/compositions/landingCta.tsx +85 -0
  30. package/src/compositions/landingHero.tsx +174 -0
  31. package/src/compositions/landingStats.tsx +99 -0
  32. package/src/compositions/loginCard.tsx +139 -0
  33. package/src/compositions/pageHeader.tsx +58 -0
  34. package/src/compositions/profileMenu.tsx +316 -0
  35. package/src/compositions/siteFooter.tsx +250 -0
  36. package/src/compositions/themeToggle.tsx +82 -0
  37. package/src/cookies.ts +109 -0
  38. package/src/index.ts +160 -0
  39. package/src/primitives/animatedNumber.tsx +73 -0
  40. package/src/primitives/avatar.tsx +39 -0
  41. package/src/primitives/badge.tsx +39 -0
  42. package/src/primitives/button.tsx +53 -0
  43. package/src/primitives/callout.tsx +97 -0
  44. package/src/primitives/card.tsx +68 -0
  45. package/src/primitives/checkbox.tsx +55 -0
  46. package/src/primitives/codeBlock.tsx +134 -0
  47. package/src/primitives/codeWindow.tsx +84 -0
  48. package/src/primitives/dialog.tsx +43 -0
  49. package/src/primitives/docCard.tsx +109 -0
  50. package/src/primitives/docIcons.tsx +268 -0
  51. package/src/primitives/dropdownMenu.tsx +162 -0
  52. package/src/primitives/gridOverlay.tsx +51 -0
  53. package/src/primitives/highlightedCode.tsx +112 -0
  54. package/src/primitives/input.tsx +25 -0
  55. package/src/primitives/label.tsx +19 -0
  56. package/src/primitives/localeSwitcher.tsx +90 -0
  57. package/src/primitives/meshBackdrop.tsx +62 -0
  58. package/src/primitives/scrollReveal.tsx +70 -0
  59. package/src/primitives/searchModal.tsx +304 -0
  60. package/src/primitives/select.tsx +24 -0
  61. package/src/primitives/separator.tsx +24 -0
  62. package/src/primitives/skeleton.tsx +12 -0
  63. package/src/primitives/sparkles.tsx +105 -0
  64. package/src/primitives/steps.tsx +55 -0
  65. package/src/primitives/tabs.tsx +102 -0
  66. package/src/primitives/textarea.tsx +24 -0
  67. package/src/primitives/toast.tsx +44 -0
  68. package/src/primitives/tocScrollSpy.tsx +110 -0
  69. package/src/primitives/toggle.tsx +50 -0
  70. package/src/primitives/toggleGroup.tsx +62 -0
  71. package/src/tokens.css +532 -0
  72. package/src/widgets.tsx +297 -0
@@ -0,0 +1,577 @@
1
+ // DocsLayout — Fumadocs-style three-pane shell for documentation.
2
+ //
3
+ // ┌─────────────────────────────────────────────────────┐
4
+ // │ TopBar (sticky) │ ← brand · topNav · search · theme
5
+ // ├──────────┬───────────────────────────────────────┬──┤
6
+ // │ Sidebar │ Main content │T │ ← TOC pane (right)
7
+ // │ (sticky) │ - breadcrumbs │O │
8
+ // │ nav │ - title + description │C │
9
+ // │ groups │ - children │ │
10
+ // │ │ - footer pager (prev / next) │ │
11
+ // │ │ - edit-on-github │ │
12
+ // └──────────┴───────────────────────────────────────┴──┘
13
+ //
14
+ // The shell is dumb data-in / JSX-out — apps pass nav, brand, current
15
+ // page metadata, and Link from their router. All visual primitives
16
+ // (callouts, code blocks, steps) are siblings of this file and
17
+ // composable in any combination.
18
+
19
+ import { useEffect, useState, type ComponentType, type ReactNode } from 'react'
20
+ import { cn } from '../cn'
21
+ import type { ShellLinkProps } from './docShell'
22
+ import { ChevronLeftIcon, ChevronRightIcon } from '../primitives/docIcons'
23
+ import { PageHeader } from './pageHeader'
24
+
25
+ // ---------- Public data types ----------
26
+
27
+ export interface DocsNavItem {
28
+ readonly label: string
29
+ readonly href: string
30
+ /** Visual hint shown next to the label (e.g. "new", "beta"). */
31
+ readonly badge?: string
32
+ }
33
+
34
+ /** A collapsible sub-heading nested one level under a section. Its
35
+ * entries render indented; the chevron + open/close affordance mirror
36
+ * the top-level section. */
37
+ export interface DocsNavSubGroup {
38
+ readonly label: string
39
+ readonly entries: ReadonlyArray<DocsNavItem>
40
+ readonly defaultOpen?: boolean
41
+ }
42
+
43
+ export interface DocsNavGroup {
44
+ readonly section: string
45
+ /** Entries shown directly under the section header, ABOVE the first
46
+ * sub-group. Use for a section's 1-2 intro pages. May be empty. */
47
+ readonly entries: ReadonlyArray<DocsNavItem>
48
+ /** Optional nested sub-groups. When present they render as
49
+ * collapsible sub-headings below `entries`. Sections with no
50
+ * meaningful taxonomy leave this empty and render flat. */
51
+ readonly subgroups?: ReadonlyArray<DocsNavSubGroup>
52
+ /** Flat entries rendered AFTER the sub-groups — a section's trailing
53
+ * ungrouped tail (e.g. plugins not assigned to a sub-group). Keeps
54
+ * them visible + in order without forcing every page into a group. */
55
+ readonly trailingEntries?: ReadonlyArray<DocsNavItem>
56
+ /** Collapse the group by default. Open groups stay open across
57
+ * navigations because the layout keeps its state in React. */
58
+ readonly defaultOpen?: boolean
59
+ /** Render the contents WITHOUT the section header (promoted one level),
60
+ * always expanded. Use when the sidebar is already scoped to this one
61
+ * section by an external switcher, so repeating the section name as a
62
+ * header would be redundant. */
63
+ readonly headerless?: boolean
64
+ }
65
+
66
+ export interface DocsTocEntry {
67
+ readonly id: string
68
+ readonly text: string
69
+ readonly level: 2 | 3
70
+ }
71
+
72
+ export interface DocsBreadcrumb {
73
+ readonly label: string
74
+ readonly href?: string
75
+ }
76
+
77
+ /** User-facing strings for the layout's own chrome. Defaults are English
78
+ * so the layout works unconfigured; pass catalog values to localize. */
79
+ export interface DocsLayoutLabels {
80
+ /** `aria-label` of the mobile menu-open button. Default `'Toggle menu'`. */
81
+ readonly toggleMenu?: string
82
+ /** `aria-label` of the mobile drawer backdrop. Default `'Close menu'`. */
83
+ readonly closeMenu?: string
84
+ /** `aria-label` of the drawer close button. Default `'Close'`. */
85
+ readonly close?: string
86
+ /** Heading of the mobile drawer. Default `'Menu'`. */
87
+ readonly menu?: string
88
+ /** Heading above the table of contents. Default `'On this page'`. */
89
+ readonly onThisPage?: string
90
+ /** Edit-on-GitHub link text. Default `'Edit this page on GitHub →'`. */
91
+ readonly editPage?: string
92
+ /** Prefix before the last-updated date. Default `'Updated'`. */
93
+ readonly updated?: string
94
+ /** "Previous page" pager caption. Default `'Previous'`. */
95
+ readonly previous?: string
96
+ /** "Next page" pager caption. Default `'Next'`. */
97
+ readonly next?: string
98
+ /** `aria-label` of the prev/next pager nav. Default `'Pagination'`. */
99
+ readonly pagination?: string
100
+ /** `aria-label` of the breadcrumb nav. Default `'Breadcrumb'`. */
101
+ readonly breadcrumb?: string
102
+ }
103
+
104
+ const DEFAULT_DOCS_LABELS: Required<DocsLayoutLabels> = {
105
+ toggleMenu: 'Toggle menu',
106
+ closeMenu: 'Close menu',
107
+ close: 'Close',
108
+ menu: 'Menu',
109
+ onThisPage: 'On this page',
110
+ editPage: 'Edit this page on GitHub →',
111
+ updated: 'Updated',
112
+ previous: 'Previous',
113
+ next: 'Next',
114
+ pagination: 'Pagination',
115
+ breadcrumb: 'Breadcrumb',
116
+ }
117
+
118
+ // ---------- Layout props ----------
119
+
120
+ interface DocsLayoutProps {
121
+ readonly brand: ReactNode
122
+ readonly nav: ReadonlyArray<DocsNavGroup>
123
+ readonly children: ReactNode
124
+ /** Current pathname — used to highlight the active nav entry +
125
+ * decide which group opens by default. */
126
+ readonly currentPath?: string
127
+
128
+ /** Top-bar slots — render between brand and the right-hand actions. */
129
+ readonly topNav?: ReactNode
130
+ /** Right-hand actions in the top bar (theme toggle, github link, …). */
131
+ readonly topRight?: ReactNode
132
+
133
+ /** Optional search slot in the top bar. Drop in `<SearchTrigger
134
+ * onClick={…} />` to wire up the kit's cmd-k modal, or any custom
135
+ * search button you want. */
136
+ readonly search?: ReactNode
137
+
138
+ /** Per-page metadata — when set, the layout renders the breadcrumbs,
139
+ * title, description, TOC, prev/next pager, and edit-on-github
140
+ * link. Drop this for landing-style index pages. */
141
+ readonly page?: {
142
+ readonly title: string
143
+ readonly description?: string
144
+ readonly section?: string
145
+ readonly breadcrumbs?: ReadonlyArray<DocsBreadcrumb>
146
+ readonly toc?: ReadonlyArray<DocsTocEntry>
147
+ readonly prev?: { readonly href: string; readonly label: string; readonly description?: string }
148
+ readonly next?: { readonly href: string; readonly label: string; readonly description?: string }
149
+ readonly editUrl?: string
150
+ readonly lastUpdated?: string
151
+ }
152
+
153
+ /** Optional client-router Link. Same contract as DocShell. */
154
+ readonly LinkComponent?: ComponentType<ShellLinkProps>
155
+
156
+ readonly className?: string
157
+ /** Override the layout's own chrome strings. Defaults are English. */
158
+ readonly labels?: DocsLayoutLabels
159
+ /** Optional footer node — rendered at the very bottom inside the
160
+ * content column. */
161
+ readonly footer?: ReactNode
162
+ /** Optional node pinned to the TOP of the sidebar (above the nav
163
+ * groups), in BOTH the desktop aside and the mobile drawer. Use for
164
+ * a section/category switcher ("root toggle"). Omitted → the sidebar
165
+ * renders exactly as before. */
166
+ readonly sidebarHeader?: ReactNode
167
+ }
168
+
169
+ const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
170
+ <a href={to} className={className}>{children}</a>
171
+ )
172
+
173
+ const isActiveHref = (href: string, currentPath: string | undefined): boolean => {
174
+ if (!currentPath) return false
175
+ if (currentPath === href) return true
176
+ // Treat /docs/foo/ as a match for /docs/foo (trailing slash tolerance).
177
+ if (currentPath.replace(/\/$/, '') === href.replace(/\/$/, '')) return true
178
+ return false
179
+ }
180
+
181
+ // ---------- Sidebar nav group (collapsible) ----------
182
+
183
+ // Shared row styling — leaf links AND collapsible sub-group triggers
184
+ // render identically (same size, colour, padding, left-rail indicator);
185
+ // a sub-group only adds a chevron on the right. Keeps the sidebar one
186
+ // uniform list instead of two visually distinct tiers.
187
+ const NAV_ROW = 'relative w-full py-1.5 pl-4 pr-2 -ml-px text-sm text-left transition-colors border-l-2'
188
+ const navRowColor = (active: boolean): string =>
189
+ active
190
+ ? 'text-foreground font-medium border-primary'
191
+ : 'text-muted-foreground hover:text-foreground border-transparent hover:border-border'
192
+
193
+ // One leaf link row.
194
+ const NavLeaf = ({
195
+ entry, currentPath, Link,
196
+ }: {
197
+ readonly entry: DocsNavItem
198
+ readonly currentPath?: string
199
+ readonly Link: ComponentType<ShellLinkProps>
200
+ }): ReactNode => {
201
+ const active = isActiveHref(entry.href, currentPath)
202
+ return (
203
+ <li>
204
+ <Link to={entry.href} prefetch className={cn(NAV_ROW, 'block', navRowColor(active))}>
205
+ <span className="inline-flex items-center gap-2">
206
+ {entry.label}
207
+ {entry.badge ? (
208
+ <span className="text-[0.6rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold">
209
+ {entry.badge}
210
+ </span>
211
+ ) : null}
212
+ </span>
213
+ </Link>
214
+ </li>
215
+ )
216
+ }
217
+
218
+ // A collapsible sub-group. Its trigger row is styled EXACTLY like a leaf
219
+ // (NAV_ROW) plus a right-aligned chevron; children render in a further-
220
+ // indented nested rail when open. Expanded by default when it holds the
221
+ // active page.
222
+ const SidebarSubGroup = ({
223
+ subgroup, currentPath, Link,
224
+ }: {
225
+ readonly subgroup: DocsNavSubGroup
226
+ readonly currentPath?: string
227
+ readonly Link: ComponentType<ShellLinkProps>
228
+ }): ReactNode => {
229
+ const containsActive = subgroup.entries.some((e) => isActiveHref(e.href, currentPath))
230
+ const [open, setOpen] = useState(subgroup.defaultOpen ?? containsActive)
231
+ useEffect(() => {
232
+ if (containsActive) setOpen(true)
233
+ }, [containsActive])
234
+
235
+ return (
236
+ <li>
237
+ <button
238
+ type="button"
239
+ onClick={() => setOpen(!open)}
240
+ aria-expanded={open}
241
+ className={cn(NAV_ROW, 'flex items-center justify-between gap-2', navRowColor(false))}
242
+ >
243
+ <span>{subgroup.label}</span>
244
+ <svg
245
+ width="10" height="10" viewBox="0 0 10 10" fill="none"
246
+ className={cn('shrink-0 transition-transform', open ? 'rotate-90' : '')}
247
+ aria-hidden="true"
248
+ >
249
+ <path d="M3 1.5 L6.5 5 L3 8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
250
+ </svg>
251
+ </button>
252
+ {open ? (
253
+ <ul className="border-l border-border ml-4">
254
+ {subgroup.entries.map((e) => (
255
+ <NavLeaf key={e.href} entry={e} {...(currentPath ? { currentPath } : {})} Link={Link} />
256
+ ))}
257
+ </ul>
258
+ ) : null}
259
+ </li>
260
+ )
261
+ }
262
+
263
+ const SidebarGroup = ({
264
+ group, currentPath, Link,
265
+ }: {
266
+ readonly group: DocsNavGroup
267
+ readonly currentPath?: string
268
+ readonly Link: ComponentType<ShellLinkProps>
269
+ }): ReactNode => {
270
+ // A section is "open" if explicitly defaultOpen OR contains the active
271
+ // entry (directly or in any sub-group). Local state lets the user
272
+ // toggle either way after that.
273
+ const subgroups = group.subgroups ?? []
274
+ const trailingEntries = group.trailingEntries ?? []
275
+ const headerless = group.headerless ?? false
276
+ const containsActive =
277
+ group.entries.some((e) => isActiveHref(e.href, currentPath)) ||
278
+ subgroups.some((sg) => sg.entries.some((e) => isActiveHref(e.href, currentPath))) ||
279
+ trailingEntries.some((e) => isActiveHref(e.href, currentPath))
280
+ const [open, setOpen] = useState(group.defaultOpen ?? containsActive ?? true)
281
+ useEffect(() => {
282
+ if (containsActive) setOpen(true)
283
+ }, [containsActive])
284
+
285
+ return (
286
+ <div className="mb-4">
287
+ {headerless ? null : (
288
+ <button
289
+ type="button"
290
+ onClick={() => setOpen(!open)}
291
+ className="flex w-full items-center justify-between text-[0.7rem] uppercase tracking-[0.08em] text-muted-foreground font-semibold py-1.5 hover:text-foreground transition-colors"
292
+ aria-expanded={open}
293
+ >
294
+ <span>{group.section}</span>
295
+ <svg
296
+ width="10" height="10" viewBox="0 0 10 10" fill="none"
297
+ className={cn('transition-transform', open ? 'rotate-90' : '')}
298
+ aria-hidden="true"
299
+ >
300
+ <path d="M3 1.5 L6.5 5 L3 8.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
301
+ </svg>
302
+ </button>
303
+ )}
304
+ {(headerless || open) && (group.entries.length > 0 || subgroups.length > 0 || trailingEntries.length > 0) ? (
305
+ <ul className="mt-1 border-l border-border ml-[3px]">
306
+ {group.entries.map((e) => (
307
+ <NavLeaf key={e.href} entry={e} {...(currentPath ? { currentPath } : {})} Link={Link} />
308
+ ))}
309
+ {subgroups.map((sg) => (
310
+ <SidebarSubGroup key={sg.label} subgroup={sg} {...(currentPath ? { currentPath } : {})} Link={Link} />
311
+ ))}
312
+ {trailingEntries.map((e) => (
313
+ <NavLeaf key={e.href} entry={e} {...(currentPath ? { currentPath } : {})} Link={Link} />
314
+ ))}
315
+ </ul>
316
+ ) : null}
317
+ </div>
318
+ )
319
+ }
320
+
321
+ // ---------- TOC aside (right column) ----------
322
+
323
+ const TocAside = ({ toc, editUrl, lastUpdated, t }: {
324
+ readonly toc: ReadonlyArray<DocsTocEntry>
325
+ readonly editUrl?: string
326
+ readonly lastUpdated?: string
327
+ readonly t: Required<DocsLayoutLabels>
328
+ }): ReactNode => (
329
+ <aside className="hidden xl:block w-56 shrink-0 pl-8">
330
+ <div className="sticky top-24">
331
+ {toc.length > 0 ? (
332
+ <>
333
+ <div className="text-[0.7rem] uppercase tracking-[0.08em] text-muted-foreground font-semibold mb-3">
334
+ {t.onThisPage}
335
+ </div>
336
+ <ul className="space-y-1.5 text-sm border-l border-border">
337
+ {toc.map((item) => (
338
+ <li key={item.id}>
339
+ <a
340
+ href={`#${item.id}`}
341
+ className={cn(
342
+ 'block py-0.5 -ml-px border-l-2 border-transparent text-muted-foreground hover:text-foreground hover:border-border transition-colors data-[active=true]:border-primary data-[active=true]:text-foreground data-[active=true]:font-medium',
343
+ item.level === 3 ? 'pl-6' : 'pl-3',
344
+ )}
345
+ data-toc-link={item.id}
346
+ >
347
+ {item.text}
348
+ </a>
349
+ </li>
350
+ ))}
351
+ </ul>
352
+ </>
353
+ ) : null}
354
+ {(editUrl || lastUpdated) ? (
355
+ <div className={cn('text-xs text-muted-foreground space-y-1.5', toc.length > 0 ? 'mt-6 pt-6 border-t border-border' : '')}>
356
+ {editUrl ? (
357
+ <a
358
+ href={editUrl}
359
+ target="_blank"
360
+ rel="noopener noreferrer"
361
+ className="block hover:text-foreground transition-colors"
362
+ >
363
+ {t.editPage}
364
+ </a>
365
+ ) : null}
366
+ {lastUpdated ? <div>{t.updated} {lastUpdated}</div> : null}
367
+ </div>
368
+ ) : null}
369
+ </div>
370
+ </aside>
371
+ )
372
+
373
+ // ---------- Footer pager ----------
374
+
375
+ const FooterPager = ({
376
+ prev, next, Link, t,
377
+ }: {
378
+ readonly prev?: { readonly href: string; readonly label: string; readonly description?: string }
379
+ readonly next?: { readonly href: string; readonly label: string; readonly description?: string }
380
+ readonly Link: ComponentType<ShellLinkProps>
381
+ readonly t: Required<DocsLayoutLabels>
382
+ }): ReactNode => {
383
+ if (!prev && !next) return null
384
+ return (
385
+ <nav aria-label={t.pagination} className="mt-16 pt-8 border-t border-border grid grid-cols-1 md:grid-cols-2 gap-4">
386
+ <div>
387
+ {prev ? (
388
+ <Link
389
+ to={prev.href}
390
+ prefetch
391
+ className="group block p-4 border border-border rounded-lg hover:border-primary/50 hover:bg-card/40 transition-colors h-full"
392
+ >
393
+ <div className="text-xs text-muted-foreground mb-1 inline-flex items-center gap-1">
394
+ <ChevronLeftIcon className="w-3 h-3 transition-transform group-hover:-translate-x-0.5" />
395
+ {t.previous}
396
+ </div>
397
+ <div className="font-medium group-hover:text-primary transition-colors">{prev.label}</div>
398
+ {prev.description ? (
399
+ <div className="text-xs text-muted-foreground mt-1 line-clamp-1">{prev.description}</div>
400
+ ) : null}
401
+ </Link>
402
+ ) : null}
403
+ </div>
404
+ <div className="md:text-right">
405
+ {next ? (
406
+ <Link
407
+ to={next.href}
408
+ prefetch
409
+ className="group block p-4 border border-border rounded-lg hover:border-primary/50 hover:bg-card/40 transition-colors h-full"
410
+ >
411
+ <div className="text-xs text-muted-foreground mb-1 inline-flex items-center gap-1 md:justify-end">
412
+ {t.next}
413
+ <ChevronRightIcon className="w-3 h-3 transition-transform group-hover:translate-x-0.5" />
414
+ </div>
415
+ <div className="font-medium group-hover:text-primary transition-colors">{next.label}</div>
416
+ {next.description ? (
417
+ <div className="text-xs text-muted-foreground mt-1 line-clamp-1">{next.description}</div>
418
+ ) : null}
419
+ </Link>
420
+ ) : null}
421
+ </div>
422
+ </nav>
423
+ )
424
+ }
425
+
426
+ // ---------- Breadcrumbs ----------
427
+
428
+ const Breadcrumbs = ({
429
+ items, Link, ariaLabel,
430
+ }: {
431
+ readonly items: ReadonlyArray<DocsBreadcrumb>
432
+ readonly Link: ComponentType<ShellLinkProps>
433
+ readonly ariaLabel: string
434
+ }): ReactNode => (
435
+ <nav aria-label={ariaLabel} className="text-xs text-muted-foreground mb-3 flex items-center gap-1.5 flex-wrap">
436
+ {items.map((c, i) => {
437
+ const isLast = i === items.length - 1
438
+ return (
439
+ <span key={i} className="inline-flex items-center gap-1.5">
440
+ {c.href && !isLast ? (
441
+ <Link to={c.href} className="hover:text-foreground transition-colors">{c.label}</Link>
442
+ ) : (
443
+ <span className={isLast ? 'text-foreground' : ''}>{c.label}</span>
444
+ )}
445
+ {!isLast ? <span className="text-muted-foreground/50" aria-hidden="true">/</span> : null}
446
+ </span>
447
+ )
448
+ })}
449
+ </nav>
450
+ )
451
+
452
+ // ---------- The layout itself ----------
453
+
454
+ export const DocsLayout = ({
455
+ brand, nav, children, currentPath, topNav, topRight, search,
456
+ page, LinkComponent = PlainLink, className, labels, footer, sidebarHeader,
457
+ }: DocsLayoutProps): ReactNode => {
458
+ const Link = LinkComponent
459
+ const t = { ...DEFAULT_DOCS_LABELS, ...labels }
460
+ // Mobile drawer state — only the inline state, no portal/escape
461
+ // handling here; the search-trigger button can call setMobileOpen
462
+ // via a parent island when full cmd-k lands.
463
+ const [mobileOpen, setMobileOpen] = useState(false)
464
+
465
+ return (
466
+ <div className={cn('min-h-screen bg-background text-foreground', className)}>
467
+ {/* Top bar — shared <PageHeader> chrome. The PageHeader owns
468
+ * sticky / max-width / h-16 / glass-on-scroll fade. The
469
+ * children below define the docs-specific row: mobile menu,
470
+ * brand, optional topNav, search, topRight slots. */}
471
+ <PageHeader>
472
+ <div className="flex items-center gap-4 min-w-0 flex-1">
473
+ <button
474
+ type="button"
475
+ onClick={() => setMobileOpen(!mobileOpen)}
476
+ className="md:hidden inline-flex items-center justify-center w-8 h-8 rounded-md hover:bg-card transition-colors"
477
+ aria-label={t.toggleMenu}
478
+ aria-expanded={mobileOpen}
479
+ >
480
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
481
+ <path d="M3 5 H15 M3 9 H15 M3 13 H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
482
+ </svg>
483
+ </button>
484
+ <div className="flex items-center gap-2 font-semibold">{brand}</div>
485
+ {topNav ? <div className="hidden md:flex items-center gap-1 ml-4">{topNav}</div> : null}
486
+ </div>
487
+ <div className="flex items-center gap-3">
488
+ {search ? <div className="hidden md:block">{search}</div> : null}
489
+ {topRight ? <div className="flex items-center gap-1">{topRight}</div> : null}
490
+ </div>
491
+ </PageHeader>
492
+
493
+ <div className="mx-auto max-w-7xl flex">
494
+ {/* Sidebar (desktop) */}
495
+ <aside className="hidden md:block w-64 shrink-0 border-r border-border">
496
+ <div className="sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 py-8">
497
+ {sidebarHeader ? <div className="mb-6">{sidebarHeader}</div> : null}
498
+ {nav.map((group) => (
499
+ <SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
500
+ ))}
501
+ </div>
502
+ </aside>
503
+
504
+ {/* Sidebar (mobile drawer) */}
505
+ {mobileOpen ? (
506
+ <div className="md:hidden fixed inset-0 z-40 flex">
507
+ {/* Backdrop */}
508
+ <div
509
+ role="button"
510
+ aria-label={t.closeMenu}
511
+ className="flex-1 bg-background/70 backdrop-blur-sm"
512
+ onClick={() => setMobileOpen(false)}
513
+ />
514
+ <aside className="w-72 max-w-[80vw] bg-card border-l border-border overflow-y-auto px-6 py-6">
515
+ <div className="mb-4 flex items-center justify-between">
516
+ <span className="font-semibold">{t.menu}</span>
517
+ <button
518
+ type="button"
519
+ onClick={() => setMobileOpen(false)}
520
+ className="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-background transition-colors"
521
+ aria-label={t.close}
522
+ >
523
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
524
+ <path d="M3 3 L11 11 M11 3 L3 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
525
+ </svg>
526
+ </button>
527
+ </div>
528
+ {sidebarHeader ? <div className="mb-4">{sidebarHeader}</div> : null}
529
+ {nav.map((group) => (
530
+ <SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
531
+ ))}
532
+ </aside>
533
+ </div>
534
+ ) : null}
535
+
536
+ {/* Content + TOC */}
537
+ <div className="flex-1 min-w-0 flex">
538
+ <main className="flex-1 min-w-0 px-6 md:px-10 py-10 max-w-3xl">
539
+ {page?.breadcrumbs && page.breadcrumbs.length > 0 ? (
540
+ <Breadcrumbs items={page.breadcrumbs} Link={Link} ariaLabel={t.breadcrumb} />
541
+ ) : null}
542
+ {page?.title ? (
543
+ <h1 className="text-3xl md:text-4xl font-bold tracking-tight text-foreground mb-3">
544
+ {page.title}
545
+ </h1>
546
+ ) : null}
547
+ {page?.description ? (
548
+ <p className="text-base md:text-lg text-muted-foreground mb-8 leading-relaxed">
549
+ {page.description}
550
+ </p>
551
+ ) : null}
552
+
553
+ {children}
554
+
555
+ <FooterPager
556
+ {...(page?.prev ? { prev: page.prev } : {})}
557
+ {...(page?.next ? { next: page.next } : {})}
558
+ Link={Link}
559
+ t={t}
560
+ />
561
+
562
+ {footer ? <div className="mt-16 pt-8 border-t border-border">{footer}</div> : null}
563
+ </main>
564
+
565
+ {page?.toc !== undefined ? (
566
+ <TocAside
567
+ toc={page.toc}
568
+ {...(page.editUrl ? { editUrl: page.editUrl } : {})}
569
+ {...(page.lastUpdated ? { lastUpdated: page.lastUpdated } : {})}
570
+ t={t}
571
+ />
572
+ ) : null}
573
+ </div>
574
+ </div>
575
+ </div>
576
+ )
577
+ }
@@ -0,0 +1,103 @@
1
+ // FeatureBento — Linear/Vercel-style mixed-size feature grid. Cells
2
+ // support three sizes (`wide`, `tall`, `square`) and arrange into a
3
+ // 4-column grid on desktop, single column on mobile.
4
+ //
5
+ // Each cell has its own hover treatment: a soft glow plus a subtle
6
+ // border lift. Pass an `accent` ReactNode to render a custom
7
+ // illustration / mini-demo as the cell's hero area.
8
+
9
+ import type { ReactNode } from 'react'
10
+ import { cn } from '../cn'
11
+
12
+ export interface BentoCellProps {
13
+ readonly title: ReactNode
14
+ readonly description: ReactNode
15
+ /** Icon shown in the top-left chip. */
16
+ readonly icon?: ReactNode
17
+ /** Custom hero/illustration on the cell. */
18
+ readonly accent?: ReactNode
19
+ /** Cell size: 'wide' (cols 1-2 on desktop), 'tall' (rows 1-2),
20
+ * 'square' (1×1). Default 'square'. */
21
+ readonly size?: 'wide' | 'tall' | 'square'
22
+ /** Optional extra content rendered below the description in the text
23
+ * column — e.g. a "Learn more" link. */
24
+ readonly children?: ReactNode
25
+ readonly className?: string
26
+ }
27
+
28
+ interface BentoProps {
29
+ readonly children: ReactNode
30
+ readonly className?: string
31
+ }
32
+
33
+ const sizeClass: Record<NonNullable<BentoCellProps['size']>, string> = {
34
+ wide: 'md:col-span-2',
35
+ tall: 'md:row-span-2',
36
+ square: '',
37
+ }
38
+
39
+ export const Bento = ({ children, className }: BentoProps): ReactNode => (
40
+ <div
41
+ className={cn(
42
+ // Rows are at least 240px tall but expand to fit content — so
43
+ // a long-copy cell never has its text mashed against the
44
+ // bottom edge, and short-copy cells keep visual rhythm.
45
+ 'not-prose grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-[minmax(240px,auto)]',
46
+ className,
47
+ )}
48
+ >
49
+ {children}
50
+ </div>
51
+ )
52
+
53
+ export const BentoCell = ({
54
+ title, description, icon, accent, size = 'square', className, children,
55
+ }: BentoCellProps): ReactNode => (
56
+ <div
57
+ className={cn(
58
+ 'group relative overflow-hidden rounded-xl border border-border',
59
+ 'bg-gradient-to-br from-card/60 to-card/20 backdrop-blur-sm',
60
+ 'p-6 flex flex-col',
61
+ // Inner highlight stroke for the lit-from-above feel
62
+ 'before:absolute before:inset-x-6 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/40 before:to-transparent',
63
+ // Hover treatment
64
+ 'hover:border-primary/40 hover:shadow-[0_8px_32px_-12px_oklch(0.55_0.25_290_/_0.4)]',
65
+ 'transition-[border-color,box-shadow] duration-300',
66
+ sizeClass[size],
67
+ className,
68
+ )}
69
+ >
70
+ {/* Hero illustration slot — pinned to the cell's right edge, full
71
+ * height, ~50% wide. Accents inside can use `top-1/2
72
+ * -translate-y-1/2` to vertically centre on the cell. Text on the
73
+ * left stays clear because it has its own constrained max-width
74
+ * below. */}
75
+ {accent ? (
76
+ <div
77
+ aria-hidden="true"
78
+ className="absolute right-0 inset-y-0 w-1/2 overflow-hidden pointer-events-none opacity-70 group-hover:opacity-100 transition-opacity duration-500"
79
+ >
80
+ {accent}
81
+ </div>
82
+ ) : null}
83
+
84
+ {/* Content column — explicit max-width so text always stays clear
85
+ * of the right-half accent slot. ~32ch reads as roughly 280px at
86
+ * text-sm, which leaves comfortable breathing room next to any
87
+ * w-28 / w-32 accent in the right half of the cell. */}
88
+ <div className="relative max-w-[32ch]">
89
+ {icon ? (
90
+ <div className="inline-flex items-center justify-center w-9 h-9 rounded-md bg-primary/10 text-primary mb-4 [&_svg]:w-5 [&_svg]:h-5">
91
+ {icon}
92
+ </div>
93
+ ) : null}
94
+ <h3 className="text-lg font-semibold text-foreground tracking-tight">
95
+ {title}
96
+ </h3>
97
+ <p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
98
+ {description}
99
+ </p>
100
+ {children}
101
+ </div>
102
+ </div>
103
+ )