@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,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none" role="img" aria-label="Voltro">
2
+ <!-- Dark variant: glowing violet bolt on a near-black violet-tinted squircle.
3
+ Matches the dark product aesthetic; great for dark-mode favicons / OG. -->
4
+ <defs>
5
+ <linearGradient id="voltroIconDarkBg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
6
+ <stop stop-color="#1B1726"/>
7
+ <stop offset="1" stop-color="#120F1A"/>
8
+ </linearGradient>
9
+ <radialGradient id="voltroIconDarkGlow" cx="0.5" cy="0.46" r="0.5">
10
+ <stop stop-color="#7C3AED" stop-opacity="0.55"/>
11
+ <stop offset="1" stop-color="#7C3AED" stop-opacity="0"/>
12
+ </radialGradient>
13
+ <linearGradient id="voltroIconDarkFront" x1="13" y1="5" x2="33" y2="43" gradientUnits="userSpaceOnUse">
14
+ <stop stop-color="#C4A6FF"/>
15
+ <stop offset="1" stop-color="#9D5BFF"/>
16
+ </linearGradient>
17
+ <linearGradient id="voltroIconDarkBack" x1="22" y1="5" x2="35" y2="43" gradientUnits="userSpaceOnUse">
18
+ <stop stop-color="#A874FF"/>
19
+ <stop offset="1" stop-color="#7C3AED"/>
20
+ </linearGradient>
21
+ </defs>
22
+ <rect width="64" height="64" rx="14.5" fill="url(#voltroIconDarkBg)"/>
23
+ <ellipse cx="32" cy="30" rx="26" ry="26" fill="url(#voltroIconDarkGlow)"/>
24
+ <path transform="translate(8 8)" d="M28 5 13 27 22 26 20 43Z" fill="url(#voltroIconDarkFront)"/>
25
+ <path transform="translate(8 8)" d="M28 5 20 43 35 21 26 22Z" fill="url(#voltroIconDarkBack)"/>
26
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none" role="img" aria-label="Voltro">
2
+ <!-- Primary app / advertising icon: two-tone bolt (white + pale violet) on
3
+ the brand violet squircle. Keeps the folded-light look — not a flat
4
+ glyph — while staying legible. Self-contained bg → works anywhere. -->
5
+ <defs>
6
+ <linearGradient id="voltroIconBg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
7
+ <stop stop-color="#8B5CFF"/>
8
+ <stop offset="1" stop-color="#6D28D9"/>
9
+ </linearGradient>
10
+ </defs>
11
+ <rect width="64" height="64" rx="14.5" fill="url(#voltroIconBg)"/>
12
+ <path transform="translate(8 8)" d="M28 5 13 27 22 26 20 43Z" fill="#FFFFFF"/>
13
+ <path transform="translate(8 8)" d="M28 5 20 43 35 21 26 22Z" fill="#D7C4FF"/>
14
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="currentColor" role="img" aria-label="Voltro">
2
+ <!-- Single-colour mark. Inherits `color` via currentColor — drop it on any
3
+ surface and it adapts to light/dark. Override with fill="#7C3AED" etc. -->
4
+ <path d="M28 5 13 27 22 26 20 43 35 21 26 22Z"/>
5
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" role="img" aria-label="Voltro">
2
+ <defs>
3
+ <linearGradient id="voltroMarkFront" x1="13" y1="5" x2="33" y2="43" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#A874FF"/>
5
+ <stop offset="1" stop-color="#7C3AED"/>
6
+ </linearGradient>
7
+ <linearGradient id="voltroMarkBack" x1="22" y1="5" x2="35" y2="43" gradientUnits="userSpaceOnUse">
8
+ <stop stop-color="#6D28D9"/>
9
+ <stop offset="1" stop-color="#4C1D95"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <path d="M28 5 13 27 22 26 20 43Z" fill="url(#voltroMarkFront)"/>
13
+ <path d="M28 5 20 43 35 21 26 22Z" fill="url(#voltroMarkBack)"/>
14
+ </svg>
@@ -0,0 +1,244 @@
1
+ // Voltro brand mark — the company + framework logo.
2
+ //
3
+ // The mark is a faceted lightning bolt ("volt") that also reads as the
4
+ // "V" of Voltro: two folded facets catch light off a central crease, so
5
+ // it has depth at billboard size yet stays legible as a 16px favicon.
6
+ // On-brand violet (oklch(0.55 0.25 290) ≈ #7C3AED — the same hue the
7
+ // text-gradient + mesh backdrop use).
8
+ //
9
+ // ▲ VoltroMark — the bolt alone. variant: gradient | mono | white.
10
+ // ◳ VoltroIcon — app/ad icon: bolt on a rounded "squircle".
11
+ // ▦ VoltroWordmark — mark + "Voltro" set in the current font.
12
+ //
13
+ // Every app consumes ONE source: `import { VoltroMark } from
14
+ // '@voltro/ui-shadcn'`. `mono` paints in `currentColor`, so it inherits
15
+ // the surrounding text colour and is automatically correct in light AND
16
+ // dark mode. The gradient + icon variants carry their own colour and
17
+ // work on any background. For static assets (favicon, OG image, app-store
18
+ // icon) use the sibling `assets/*.svg` files.
19
+
20
+ import type { CSSProperties, ReactNode, SVGProps } from 'react'
21
+ import { useId } from 'react'
22
+ import { cn } from '../cn'
23
+
24
+ // The bolt geometry, authored once in a 48×48 box and reused everywhere.
25
+ // Sharp joins read as "electric"; the facets share the crease T→C.
26
+ const BOLT_FULL = 'M28 5 13 27 22 26 20 43 35 21 26 22Z' // outer silhouette (one fill)
27
+ const BOLT_FRONT = 'M28 5 13 27 22 26 20 43Z' // left / lit facet
28
+ const BOLT_BACK = 'M28 5 20 43 35 21 26 22Z' // right / shaded facet
29
+
30
+ /** Brand violet — the canonical primary. Exposed for ad-hoc usage. */
31
+ export const VOLTRO_VIOLET = '#7C3AED'
32
+
33
+ export type VoltroMarkVariant = 'gradient' | 'mono' | 'white'
34
+
35
+ // The component owns the structural attrs (viewBox / fill / xmlns / children) and
36
+ // the a11y trio (role / aria-label / aria-hidden, driven by `title`), so they are
37
+ // excluded from the pass-through: leaving them in would let the last-spread `rest`
38
+ // clobber the component's own literals with `T | undefined`, which trips a strict
39
+ // consumer's `exactOptionalPropertyTypes` (the mark typechecks in its own project but
40
+ // broke when a downstream app compiled this source under that flag).
41
+ //
42
+ // `ref` is excluded too, and for a different reason: this is a plain function
43
+ // component (not `forwardRef`), so it never wires a ref anywhere — the prop was
44
+ // never honoured. Keeping it in the pass-through also spreads `SVGProps['ref']`
45
+ // onto the `<svg>`; when a downstream app compiles THIS source, that `ref` type
46
+ // resolves against the framework's `@types/react` copy while the intrinsic `<svg>`
47
+ // resolves against the app's own copy. Same version, but two structurally-distinct
48
+ // `Ref`/`VoidOrUndefinedOnly` types → TS reports them as "unrelated" and the spread
49
+ // fails. Dropping `ref` from the surface removes the dead prop and the clash at once.
50
+ export interface VoltroMarkProps
51
+ extends Omit<
52
+ SVGProps<SVGSVGElement>,
53
+ 'width' | 'height' | 'viewBox' | 'fill' | 'xmlns' | 'children' | 'ref' | 'role' | 'aria-label' | 'aria-hidden'
54
+ > {
55
+ /** Pixel size (square). Default 32. Pass a string like '1em' to track font-size. */
56
+ readonly size?: number | string
57
+ /** `gradient` (default, two-facet violet) · `mono` (currentColor) · `white`. */
58
+ readonly variant?: VoltroMarkVariant
59
+ /** Accessible label. Omit + set `aria-hidden` when paired with a text label. */
60
+ readonly title?: string
61
+ }
62
+
63
+ /** The bolt mark on a transparent background. */
64
+ export const VoltroMark = ({
65
+ size = 32,
66
+ variant = 'gradient',
67
+ title,
68
+ className,
69
+ ...rest
70
+ }: VoltroMarkProps): ReactNode => {
71
+ const uid = useId()
72
+ const front = `vmF-${uid}`
73
+ const back = `vmB-${uid}`
74
+ const labelled = title !== undefined
75
+ // One non-union type: explicit `role={... : undefined}` props violate
76
+ // exactOptionalPropertyTypes, and a conditional-spread union chokes older
77
+ // checkers in cross-repo consumers.
78
+ const a11y: Pick<SVGProps<SVGSVGElement>, 'role' | 'aria-label' | 'aria-hidden'> = labelled
79
+ ? { role: 'img', 'aria-label': title }
80
+ : { 'aria-hidden': true }
81
+
82
+ return (
83
+ <svg
84
+ width={size}
85
+ height={size}
86
+ viewBox="0 0 48 48"
87
+ fill="none"
88
+ xmlns="http://www.w3.org/2000/svg"
89
+ {...a11y}
90
+ className={cn('inline-block shrink-0', className)}
91
+ {...rest}
92
+ >
93
+ {variant === 'gradient' ? (
94
+ <>
95
+ <defs>
96
+ <linearGradient id={front} x1="13" y1="5" x2="33" y2="43" gradientUnits="userSpaceOnUse">
97
+ <stop stopColor="#A874FF" />
98
+ <stop offset="1" stopColor="#7C3AED" />
99
+ </linearGradient>
100
+ <linearGradient id={back} x1="22" y1="5" x2="35" y2="43" gradientUnits="userSpaceOnUse">
101
+ <stop stopColor="#6D28D9" />
102
+ <stop offset="1" stopColor="#4C1D95" />
103
+ </linearGradient>
104
+ </defs>
105
+ <path d={BOLT_FRONT} fill={`url(#${front})`} />
106
+ <path d={BOLT_BACK} fill={`url(#${back})`} />
107
+ </>
108
+ ) : (
109
+ <path d={BOLT_FULL} fill={variant === 'white' ? '#FFFFFF' : 'currentColor'} />
110
+ )}
111
+ </svg>
112
+ )
113
+ }
114
+
115
+ export type VoltroIconTone = 'brand' | 'dark'
116
+
117
+ export interface VoltroIconProps {
118
+ /** Pixel size (square). Default 40. */
119
+ readonly size?: number
120
+ /** `brand` (violet squircle + white bolt — works on any surface) · `dark` (near-black squircle + glowing violet bolt). */
121
+ readonly tone?: VoltroIconTone
122
+ /** Corner radius as a fraction of size. Default 0.225 (iOS-style squircle look). */
123
+ readonly radius?: number
124
+ readonly title?: string
125
+ readonly className?: string
126
+ readonly style?: CSSProperties
127
+ }
128
+
129
+ /**
130
+ * The app / advertising icon: the bolt centred on a rounded square with
131
+ * its own background, so it stands on a home screen, store listing, or
132
+ * ad placement regardless of the surface behind it.
133
+ */
134
+ export const VoltroIcon = ({
135
+ size = 40,
136
+ tone = 'brand',
137
+ radius = 0.225,
138
+ title = 'Voltro',
139
+ className,
140
+ style,
141
+ }: VoltroIconProps): ReactNode => {
142
+ const uid = useId()
143
+ const bg = `viBg-${uid}`
144
+ const glow = `viGlow-${uid}`
145
+ const front = `viF-${uid}`
146
+ const back = `viB-${uid}`
147
+ const rx = (64 * radius).toFixed(2)
148
+
149
+ return (
150
+ <svg
151
+ width={size}
152
+ height={size}
153
+ viewBox="0 0 64 64"
154
+ fill="none"
155
+ xmlns="http://www.w3.org/2000/svg"
156
+ role="img"
157
+ aria-label={title}
158
+ className={cn('inline-block shrink-0', className)}
159
+ style={style}
160
+ >
161
+ <defs>
162
+ <linearGradient id={bg} x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
163
+ {tone === 'brand' ? (
164
+ <>
165
+ <stop stopColor="#8B5CFF" />
166
+ <stop offset="1" stopColor="#6D28D9" />
167
+ </>
168
+ ) : (
169
+ <>
170
+ <stop stopColor="#1B1726" />
171
+ <stop offset="1" stopColor="#120F1A" />
172
+ </>
173
+ )}
174
+ </linearGradient>
175
+ {tone === 'dark' ? (
176
+ <radialGradient id={glow} cx="0.5" cy="0.46" r="0.5">
177
+ <stop stopColor="#7C3AED" stopOpacity="0.55" />
178
+ <stop offset="1" stopColor="#7C3AED" stopOpacity="0" />
179
+ </radialGradient>
180
+ ) : null}
181
+ <linearGradient id={front} x1="13" y1="5" x2="33" y2="43" gradientUnits="userSpaceOnUse">
182
+ <stop stopColor="#C4A6FF" />
183
+ <stop offset="1" stopColor="#9D5BFF" />
184
+ </linearGradient>
185
+ <linearGradient id={back} x1="22" y1="5" x2="35" y2="43" gradientUnits="userSpaceOnUse">
186
+ <stop stopColor="#A874FF" />
187
+ <stop offset="1" stopColor="#7C3AED" />
188
+ </linearGradient>
189
+ </defs>
190
+
191
+ <rect width="64" height="64" rx={rx} fill={`url(#${bg})`} />
192
+ {tone === 'brand' ? (
193
+ // Two-tone bolt — white front facet + pale-violet back facet keep the
194
+ // folded-light identity readable on the violet field (not a flat glyph).
195
+ <>
196
+ <path transform="translate(8 8)" d={BOLT_FRONT} fill="#FFFFFF" />
197
+ <path transform="translate(8 8)" d={BOLT_BACK} fill="#D7C4FF" />
198
+ </>
199
+ ) : (
200
+ <>
201
+ <ellipse cx="32" cy="30" rx="26" ry="26" fill={`url(#${glow})`} />
202
+ <path transform="translate(8 8)" d={BOLT_FRONT} fill={`url(#${front})`} />
203
+ <path transform="translate(8 8)" d={BOLT_BACK} fill={`url(#${back})`} />
204
+ </>
205
+ )}
206
+ </svg>
207
+ )
208
+ }
209
+
210
+ export interface VoltroWordmarkProps {
211
+ /** Mark height in px; the wordmark text scales with it. Default 28. */
212
+ readonly size?: number
213
+ readonly markVariant?: VoltroMarkVariant
214
+ /** Wordmark label. Default "Voltro". */
215
+ readonly label?: string
216
+ readonly className?: string
217
+ readonly style?: CSSProperties
218
+ }
219
+
220
+ /**
221
+ * Mark + wordmark lock-up for headers / marketing. The text is real
222
+ * type set in the surrounding font and `currentColor`, so it inherits
223
+ * the app's typography and is correct in light + dark automatically.
224
+ */
225
+ export const VoltroWordmark = ({
226
+ size = 28,
227
+ markVariant = 'gradient',
228
+ label = 'Voltro',
229
+ className,
230
+ style,
231
+ }: VoltroWordmarkProps): ReactNode => (
232
+ <span
233
+ className={cn('inline-flex items-center font-semibold tracking-tight leading-none', className)}
234
+ style={{ gap: size * 0.28, fontSize: size * 0.74, ...style }}
235
+ >
236
+ {/* Mark rendered larger than the cap height so the bolt reads as the
237
+ lead element, not a small adornment beside the wordmark. */}
238
+ <VoltroMark size={Math.round(size * 1.18)} variant={markVariant} aria-hidden />
239
+ <span>{label}</span>
240
+ </span>
241
+ )
242
+
243
+ /** Raw bolt path data, for apps that need to compose their own SVG. */
244
+ export const VOLTRO_BOLT_PATH = { full: BOLT_FULL, front: BOLT_FRONT, back: BOLT_BACK } as const
package/src/cn.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Class-name composition helper used by every shadcn primitive.
2
+ // clsx handles conditional / array / object forms; tailwind-merge
3
+ // dedupes conflicting Tailwind utilities (e.g. `px-2 px-4` → `px-4`).
4
+ // Exported as a tiny subpath so consumers can grab it without
5
+ // pulling the entire `@voltro/ui-shadcn` surface.
6
+
7
+ import { clsx, type ClassValue } from 'clsx'
8
+ import { twMerge } from 'tailwind-merge'
9
+
10
+ export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
@@ -0,0 +1,72 @@
1
+ // AppShell composition — topbar + main scroll region. Used by the
2
+ // dashboard, portal, admin templates as the authenticated-app skeleton.
3
+ // The shell itself is dumb; auth gates / sidebar collapse / search /
4
+ // command palette layer on top.
5
+ //
6
+ // `LinkComponent` lets apps pass `Link` from `@voltro/web` so the
7
+ // top nav uses client-side routing + loader prefetch instead of full
8
+ // page reloads. Default is plain `<a>` so the kit stays
9
+ // router-agnostic.
10
+ //
11
+ // The top bar chrome (sticky, glass-on-scroll, h-16, max-w-7xl) is
12
+ // the shared <PageHeader> — same one consumed by DocsLayout and by
13
+ // landing / marketplace pages directly. That's the single visual
14
+ // source of truth across the four sibling apps; do not duplicate
15
+ // the header treatment here.
16
+
17
+ import type { ComponentType, ReactNode } from 'react'
18
+ import { cn } from '../cn'
19
+ import type { ShellLinkProps } from './docShell'
20
+ import { PageHeader } from './pageHeader'
21
+
22
+ interface NavLink {
23
+ readonly label: string
24
+ readonly href: string
25
+ }
26
+
27
+ interface AppShellProps {
28
+ readonly brand: ReactNode
29
+ readonly nav?: ReadonlyArray<NavLink>
30
+ readonly right?: ReactNode
31
+ readonly children: ReactNode
32
+ readonly className?: string
33
+ readonly LinkComponent?: ComponentType<ShellLinkProps>
34
+ }
35
+
36
+ const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
37
+ <a href={to} className={className}>{children}</a>
38
+ )
39
+
40
+ export const AppShell = ({
41
+ brand, nav, right, children, className,
42
+ LinkComponent = PlainLink,
43
+ }: AppShellProps): ReactNode => {
44
+ const Link = LinkComponent
45
+ return (
46
+ <div className={cn('min-h-screen flex flex-col bg-background text-foreground', className)}>
47
+ <PageHeader>
48
+ <div className="font-semibold flex items-center gap-2">{brand}</div>
49
+ <div className="flex items-center gap-6">
50
+ {nav && nav.length > 0 ? (
51
+ <nav className="hidden md:flex gap-6 text-sm">
52
+ {nav.map((l) => (
53
+ <Link
54
+ key={l.href}
55
+ to={l.href}
56
+ prefetch
57
+ className="text-muted-foreground hover:text-foreground transition-colors"
58
+ >
59
+ {l.label}
60
+ </Link>
61
+ ))}
62
+ </nav>
63
+ ) : null}
64
+ {right ? <div>{right}</div> : null}
65
+ </div>
66
+ </PageHeader>
67
+ <main className="flex-1">{children}</main>
68
+ </div>
69
+ )
70
+ }
71
+
72
+
@@ -0,0 +1,88 @@
1
+ // CodeCompare — side-by-side "before / after" code panels. Common
2
+ // landing-page pattern for dev tooling: left = the messy world,
3
+ // right = your product's clean replacement. The dividing label sits
4
+ // in the centre on desktop, between the two panels on mobile.
5
+
6
+ import type { ReactNode } from 'react'
7
+ import { cn } from '../cn'
8
+ import { HighlightedCode, type ShikiLang } from '../primitives/highlightedCode'
9
+
10
+ interface CodeComparePanelProps {
11
+ readonly label: ReactNode
12
+ /** Pre-highlighted HTML (Shiki) — skips internal highlighting. */
13
+ readonly html?: string
14
+ /** Plain code text. When `lang` is also provided, the panel
15
+ * runs Shiki syntax highlighting automatically. */
16
+ readonly code?: string
17
+ /** Shiki language. Triggers syntax highlighting on `code`. */
18
+ readonly lang?: ShikiLang
19
+ /** Visual treatment — `'muted'` is dimmed (the "before"),
20
+ * `'highlight'` keeps the full styling + adds a primary border. */
21
+ readonly tone?: 'muted' | 'highlight'
22
+ }
23
+
24
+ interface CodeCompareProps {
25
+ readonly before: CodeComparePanelProps
26
+ readonly after: CodeComparePanelProps
27
+ readonly className?: string
28
+ }
29
+
30
+ const Panel = ({ label, html, code, lang, tone = 'muted' }: CodeComparePanelProps): ReactNode => (
31
+ <div
32
+ className={cn(
33
+ 'relative rounded-xl border overflow-hidden bg-card/40 backdrop-blur-sm',
34
+ tone === 'highlight'
35
+ ? 'border-primary/40 shadow-[0_0_0_1px_oklch(0.55_0.25_290_/_0.15),0_12px_40px_-12px_oklch(0.55_0.25_290_/_0.35)]'
36
+ : 'border-border',
37
+ )}
38
+ >
39
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border/60 bg-background/40">
40
+ <div className="text-xs font-mono text-muted-foreground">{label}</div>
41
+ <span
42
+ className={cn(
43
+ 'text-[0.65rem] uppercase tracking-[0.08em] font-semibold',
44
+ tone === 'highlight' ? 'text-primary' : 'text-muted-foreground/70',
45
+ )}
46
+ >
47
+ {tone === 'highlight' ? 'With Voltro' : 'Before'}
48
+ </span>
49
+ </div>
50
+ <div
51
+ className={cn(
52
+ 'text-sm leading-relaxed [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:p-5 overflow-x-auto',
53
+ tone === 'muted' && 'opacity-60',
54
+ )}
55
+ >
56
+ {html ? (
57
+ <div dangerouslySetInnerHTML={{ __html: html }} />
58
+ ) : code && lang ? (
59
+ <HighlightedCode code={code} lang={lang} />
60
+ ) : (
61
+ <pre className="p-5 font-mono"><code>{code}</code></pre>
62
+ )}
63
+ </div>
64
+ </div>
65
+ )
66
+
67
+ export const CodeCompare = ({
68
+ before, after, className,
69
+ }: CodeCompareProps): ReactNode => (
70
+ <div className={cn('relative grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-4 lg:gap-6 items-stretch', className)}>
71
+ <Panel {...before} tone="muted" />
72
+ {/* Arrow / divider — sits between the two panels */}
73
+ <div
74
+ className="flex lg:flex-col items-center justify-center gap-2 text-muted-foreground"
75
+ aria-hidden="true"
76
+ >
77
+ <div className="hidden lg:block w-px h-12 bg-gradient-to-b from-transparent via-primary/60 to-transparent" />
78
+ <svg
79
+ width="20" height="20" viewBox="0 0 20 20" fill="none"
80
+ className="rotate-90 lg:rotate-0 text-primary"
81
+ >
82
+ <path d="M4 10 H16 M11 5 L16 10 L11 15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
83
+ </svg>
84
+ <div className="hidden lg:block w-px h-12 bg-gradient-to-b from-primary/60 via-transparent to-transparent" />
85
+ </div>
86
+ <Panel {...after} tone="highlight" />
87
+ </div>
88
+ )
@@ -0,0 +1,112 @@
1
+ // DocShell composition — sidebar + main grid for documentation.
2
+ // Sidebar nav is data-driven (group → entry tree); apps pass their
3
+ // own structure. Sticky sidebar, scrollable main, responsive collapse
4
+ // to single-column under 800px.
5
+ //
6
+ // `LinkComponent` lets the app inject a client-side router (e.g.
7
+ // `Link` from `@voltro/web`) so sidebar nav doesn't trigger full
8
+ // page reloads. Default is plain `<a>` so the kit stays
9
+ // router-agnostic and works in any host.
10
+
11
+ import type { ComponentType, ReactNode } from 'react'
12
+ import { cn } from '../cn'
13
+
14
+ export interface DocNavEntry {
15
+ readonly label: string
16
+ readonly href: string
17
+ }
18
+
19
+ export interface DocNavGroup {
20
+ readonly section: string
21
+ readonly entries: ReadonlyArray<DocNavEntry>
22
+ }
23
+
24
+ // Minimal Link prop surface — matches @voltro/web's Link with `to`,
25
+ // and degrades cleanly to a plain <a href> when not provided.
26
+ export interface ShellLinkProps {
27
+ readonly to: string
28
+ readonly className?: string
29
+ readonly children: ReactNode
30
+ /** Pre-warm the destination on hover/focus. */
31
+ readonly prefetch?: boolean
32
+ }
33
+
34
+ interface DocShellProps {
35
+ readonly brand: ReactNode
36
+ readonly nav: ReadonlyArray<DocNavGroup>
37
+ readonly children: ReactNode
38
+ readonly currentPath?: string
39
+ readonly className?: string
40
+ /** Search form GET action. When set, renders a search input that
41
+ * POSTs ?q=… to this URL. */
42
+ readonly searchAction?: string
43
+ /** Placeholder for the search input. Default `'Search docs…'`. */
44
+ readonly searchPlaceholder?: string
45
+ /** `aria-label` for the search input. Default `'Search docs'`. */
46
+ readonly searchLabel?: string
47
+ /** Optional client-router Link. Passing `Link` from `@voltro/web`
48
+ * upgrades every sidebar entry to client-side navigation with
49
+ * loader prefetch. */
50
+ readonly LinkComponent?: ComponentType<ShellLinkProps>
51
+ }
52
+
53
+ const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
54
+ <a href={to} className={className}>{children}</a>
55
+ )
56
+
57
+ export const DocShell = ({
58
+ brand, nav, children, currentPath, className, searchAction,
59
+ searchPlaceholder = 'Search docs…', searchLabel = 'Search docs',
60
+ LinkComponent = PlainLink,
61
+ }: DocShellProps): ReactNode => {
62
+ const Link = LinkComponent
63
+ return (
64
+ <div className={cn('min-h-screen grid grid-cols-1 md:grid-cols-[260px_1fr] bg-background text-foreground', className)}>
65
+ <aside className="border-r bg-card/30 p-6 md:sticky md:top-0 md:h-screen md:overflow-y-auto">
66
+ <div className="mb-6">{brand}</div>
67
+ {searchAction ? (
68
+ <form method="get" action={searchAction} className="mb-6">
69
+ <input
70
+ type="search"
71
+ name="q"
72
+ placeholder={searchPlaceholder}
73
+ aria-label={searchLabel}
74
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
75
+ />
76
+ </form>
77
+ ) : null}
78
+ {nav.map((group) => (
79
+ <div key={group.section} className="mb-6">
80
+ <h4 className="text-xs uppercase tracking-wider text-muted-foreground font-medium mb-2">
81
+ {group.section}
82
+ </h4>
83
+ <ul className="space-y-0.5">
84
+ {group.entries.map((e) => {
85
+ const active = currentPath === e.href
86
+ return (
87
+ <li key={e.href}>
88
+ <Link
89
+ to={e.href}
90
+ prefetch
91
+ className={cn(
92
+ 'block rounded-md px-2 py-1.5 text-sm transition-colors',
93
+ active
94
+ ? 'bg-accent text-accent-foreground font-medium'
95
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
96
+ )}
97
+ >
98
+ {e.label}
99
+ </Link>
100
+ </li>
101
+ )
102
+ })}
103
+ </ul>
104
+ </div>
105
+ ))}
106
+ </aside>
107
+ <main className="p-8 md:p-12 lg:p-16 max-w-3xl text-foreground">
108
+ {children}
109
+ </main>
110
+ </div>
111
+ )
112
+ }