@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,316 @@
1
+ // ProfileMenu — circular avatar trigger + Radix-backed DropdownMenu
2
+ // with theme + language switcher, app-specific links, and an optional
3
+ // sign-out action. Dropped into every Voltro-Cloud sibling app's
4
+ // header (dashboard / marketplace / docs) as the rightmost element.
5
+ //
6
+ // Architecture:
7
+ // • Built on the kit's <DropdownMenu> primitive (Radix wrapper) —
8
+ // get focus management, arrow-key nav, portal, escape, click-
9
+ // outside, click-outside-on-overlay-elements for free.
10
+ // • The kit OWNS theme + language switcher rendering. Theme writes
11
+ // the `voltro:theme` cookie (matches `themeBootScript`) and
12
+ // toggles `<html class="dark">` so the change is instant + survives
13
+ // reload via the boot script.
14
+ // • Language writes the `voltro:lang` cookie, then reloads (the SSR
15
+ // re-runs in the new locale) unless `onLanguageChange` returns
16
+ // `false`. Hidden when `languages` is omitted.
17
+ // • Every user-facing string is a prop (`labels`) with English
18
+ // defaults — the consuming app's catalog can localize the menu's
19
+ // own chrome (it IS the language switcher, after all).
20
+ // • Identity is optional. When absent the avatar shows a neutral
21
+ // glyph and the menu omits the identity header — useful on public
22
+ // pages (marketplace / docs) where the visitor is anonymous.
23
+ // • Sign-out is a real form-POST inside a `DropdownMenuItem`'s
24
+ // `onSelect` handler so the browser handles redirects + Set-Cookie.
25
+ // • Sign-in is a regular menu item that navigates.
26
+
27
+ import { useCallback, useEffect, useState, type ReactNode } from 'react'
28
+ import { Button } from '../primitives/button'
29
+ import {
30
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
31
+ DropdownMenuItem, DropdownMenuSeparator,
32
+ } from '../primitives/dropdownMenu'
33
+ import { ToggleGroup, ToggleGroupItem } from '../primitives/toggleGroup'
34
+ import {
35
+ THEME_COOKIE, LANG_COOKIE, getCookie, setCookie, deleteCookie, applyTheme,
36
+ type ThemePreference,
37
+ } from '../cookies'
38
+
39
+ const readStoredTheme = (): ThemePreference => {
40
+ const v = getCookie(THEME_COOKIE)
41
+ if (v === 'dark' || v === 'light' || v === 'system') return v
42
+ return 'system'
43
+ }
44
+
45
+ const persistTheme = (choice: ThemePreference): void => {
46
+ // 'system' deletes the cookie so the next request falls back to
47
+ // server default / matchMedia (matches themeBootScript semantics).
48
+ if (choice === 'system') deleteCookie(THEME_COOKIE)
49
+ else setCookie(THEME_COOKIE, choice)
50
+ applyTheme(choice)
51
+ }
52
+
53
+ // ---- Public types ----
54
+
55
+ export interface ProfileMenuLink {
56
+ readonly key: string
57
+ readonly label: string
58
+ readonly href: string
59
+ readonly icon?: ReactNode
60
+ readonly external?: boolean
61
+ }
62
+
63
+ export interface ProfileMenuLanguage {
64
+ readonly code: string
65
+ readonly label: string
66
+ }
67
+
68
+ export interface ProfileMenuProps {
69
+ readonly identity?: {
70
+ readonly name: string
71
+ readonly sublabel?: string
72
+ }
73
+ readonly links?: ReadonlyArray<ProfileMenuLink>
74
+ readonly languages?: ReadonlyArray<ProfileMenuLanguage>
75
+ /** Override the cookie-derived language (e.g. when the consumer
76
+ * knows the SSR-resolved value before the cookie round-trips). */
77
+ readonly currentLanguage?: string
78
+ /** Optional hook that fires AFTER the cookie is written. Default
79
+ * behaviour: `window.location.reload()` so the SSR re-runs in the
80
+ * new locale. Return `false` to suppress the reload (useful when
81
+ * a client-side i18n runtime can hot-swap). */
82
+ readonly onLanguageChange?: (code: string) => boolean | void
83
+ /** URL the logout form posts to. Triggers a full page nav. For
84
+ * apps that have a soft-logout RPC + in-place UI swap (no reload),
85
+ * pass `onLogout` instead — it takes precedence over `logoutUrl`. */
86
+ readonly logoutUrl?: string
87
+ /** Callback-style logout — fires when the user picks "Log out". When
88
+ * supplied, replaces the form-post-to-`logoutUrl` flow with a pure
89
+ * JS handler. Use this for soft-logout flows that re-issue
90
+ * subscriptions on the same WebSocket without navigating away. */
91
+ readonly onLogout?: () => void
92
+ readonly signInUrl?: string
93
+ /** User-facing strings — pass values from the consuming app's catalog
94
+ * to localize the menu's own chrome. Defaults are English. */
95
+ readonly labels?: ProfileMenuLabels
96
+ }
97
+
98
+ export interface ProfileMenuLabels {
99
+ /** Section label for the theme switch. Default `'Theme'`. */
100
+ readonly theme?: string
101
+ /** Section label for the language switch. Default `'Language'`. */
102
+ readonly language?: string
103
+ /** Sign-out menu item. Default `'Sign out'`. */
104
+ readonly signOut?: string
105
+ /** Sign-in menu item. Default `'Sign in'`. */
106
+ readonly signIn?: string
107
+ /** `aria-label` of the avatar trigger. Default `'Open profile menu'`. */
108
+ readonly openMenu?: string
109
+ /** Labels of the three theme options. Defaults `'System'`/`'Light'`/`'Dark'`. */
110
+ readonly themes?: { readonly system?: string; readonly light?: string; readonly dark?: string }
111
+ }
112
+
113
+ // ---- Avatar glyph ----
114
+ //
115
+ // Both branches use `text-current` so the glyph inherits whatever
116
+ // color the wrapping <Button> sets — keeps initial + SVG visually
117
+ // indistinguishable across authed / anonymous states.
118
+
119
+ const AvatarGlyph = ({ initial }: { initial?: string }): ReactNode =>
120
+ initial ? (
121
+ <span className="text-xs font-semibold select-none">{initial}</span>
122
+ ) : (
123
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
124
+ <circle cx="8" cy="5.5" r="2.5" stroke="currentColor" strokeWidth="1.4" />
125
+ <path
126
+ d="M2.8 13.4c0-2.6 2.3-4.4 5.2-4.4s5.2 1.8 5.2 4.4"
127
+ stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"
128
+ />
129
+ </svg>
130
+ )
131
+
132
+ // ---- Menu ----
133
+
134
+ const THEME_VALUES: ReadonlyArray<ThemePreference> = ['system', 'light', 'dark']
135
+
136
+ const DEFAULT_THEME_LABELS: Record<ThemePreference, string> = {
137
+ system: 'System',
138
+ light: 'Light',
139
+ dark: 'Dark',
140
+ }
141
+
142
+ const navigateTo = (href: string, external?: boolean): void => {
143
+ if (external) window.open(href, '_blank', 'noopener,noreferrer')
144
+ else window.location.assign(href)
145
+ }
146
+
147
+ export const ProfileMenu = ({
148
+ identity, links, languages, currentLanguage, onLanguageChange,
149
+ logoutUrl, onLogout, signInUrl, labels,
150
+ }: ProfileMenuProps): ReactNode => {
151
+ const [theme, setTheme] = useState<ThemePreference | undefined>(undefined)
152
+ // Hydrate language from cookie on mount; the prop overrides if set.
153
+ const [cookieLang, setCookieLang] = useState<string | undefined>(undefined)
154
+ const lang = currentLanguage ?? cookieLang
155
+
156
+ useEffect(() => {
157
+ setTheme(readStoredTheme())
158
+ setCookieLang(getCookie(LANG_COOKIE) ?? undefined)
159
+ }, [])
160
+
161
+ const onThemeChange = useCallback((next: string): void => {
162
+ const choice = next as ThemePreference
163
+ setTheme(choice)
164
+ persistTheme(choice)
165
+ }, [])
166
+
167
+ const onLangSelect = useCallback((next: string): void => {
168
+ setCookie(LANG_COOKIE, next)
169
+ setCookieLang(next)
170
+ // Consumer can suppress the reload by returning `false` (e.g.
171
+ // when they have a hot-swap i18n runtime).
172
+ const suppress = onLanguageChange?.(next) === false
173
+ if (!suppress) window.location.reload()
174
+ }, [onLanguageChange])
175
+
176
+ const onLogoutSelect = useCallback((): void => {
177
+ // Callback-style soft-logout wins when present. Lets the consumer
178
+ // run its own in-place teardown (clear cookie/storage, refresh
179
+ // subscriptions on the existing WS, swap UI to signed-out) with
180
+ // no page navigation.
181
+ if (onLogout) {
182
+ onLogout()
183
+ return
184
+ }
185
+ if (!logoutUrl) return
186
+ // Build a real form so the browser handles redirects + Set-Cookie.
187
+ // DropdownMenuItem's onSelect closes the menu before the navigation
188
+ // fires, which is fine — the form is submitted synchronously below.
189
+ const form = document.createElement('form')
190
+ form.method = 'post'
191
+ form.action = logoutUrl
192
+ document.body.appendChild(form)
193
+ form.submit()
194
+ }, [logoutUrl, onLogout])
195
+
196
+ const initial = identity?.name?.trim().charAt(0).toUpperCase()
197
+ const hasLanguages = languages && languages.length > 0
198
+ const hasLinks = links && links.length > 0
199
+ const hasFooterAction = (identity && (logoutUrl || onLogout)) || (!identity && signInUrl)
200
+ const themeLabel = (v: ThemePreference): string =>
201
+ labels?.themes?.[v] ?? DEFAULT_THEME_LABELS[v]
202
+
203
+ return (
204
+ <DropdownMenu>
205
+ <DropdownMenuTrigger asChild>
206
+ <Button
207
+ type="button"
208
+ variant="outline"
209
+ size="icon"
210
+ aria-label={labels?.openMenu ?? 'Open profile menu'}
211
+ className="rounded-full size-8 text-muted-foreground hover:text-foreground"
212
+ >
213
+ <AvatarGlyph {...(initial ? { initial } : {})} />
214
+ </Button>
215
+ </DropdownMenuTrigger>
216
+
217
+ <DropdownMenuContent align="end" className="w-64">
218
+ {identity ? (
219
+ <>
220
+ <div className="px-2 py-1.5">
221
+ <div className="text-sm font-medium text-foreground truncate">{identity.name}</div>
222
+ {identity.sublabel ? (
223
+ <div className="text-xs text-muted-foreground truncate mt-0.5">
224
+ {identity.sublabel}
225
+ </div>
226
+ ) : null}
227
+ </div>
228
+ <DropdownMenuSeparator />
229
+ </>
230
+ ) : null}
231
+
232
+ {hasLinks ? (
233
+ <>
234
+ {links.map((link) => (
235
+ <DropdownMenuItem
236
+ key={link.key}
237
+ onSelect={() => navigateTo(link.href, link.external)}
238
+ >
239
+ {link.icon ?? null}
240
+ <span className="flex-1 truncate">{link.label}</span>
241
+ </DropdownMenuItem>
242
+ ))}
243
+ <DropdownMenuSeparator />
244
+ </>
245
+ ) : null}
246
+
247
+ {/* Theme + language rendered as inline <ToggleGroup>s — same
248
+ * shadcn primitive used elsewhere for chip-radio rows. NOT
249
+ * DropdownMenuItems: keyboard nav steps over each ToggleGroup
250
+ * as one focusable region, then ←/→ inside Radix's
251
+ * RovingFocusGroup picks an option. */}
252
+ <div className="flex items-center justify-between gap-2 px-2 py-1.5">
253
+ <span className="text-[0.65rem] font-semibold uppercase tracking-wider text-muted-foreground">
254
+ {labels?.theme ?? 'Theme'}
255
+ </span>
256
+ <ToggleGroup
257
+ type="single"
258
+ size="sm"
259
+ variant="outline"
260
+ {...(theme ? { value: theme } : {})}
261
+ onValueChange={(v: string) => { if (v) onThemeChange(v) }}
262
+ >
263
+ {THEME_VALUES.map((value) => (
264
+ <ToggleGroupItem
265
+ key={value}
266
+ value={value}
267
+ aria-label={themeLabel(value)}
268
+ className="h-7 px-2 text-[11px] uppercase tracking-wider"
269
+ >
270
+ {themeLabel(value)}
271
+ </ToggleGroupItem>
272
+ ))}
273
+ </ToggleGroup>
274
+ </div>
275
+
276
+ {hasLanguages ? (
277
+ <div className="flex items-center justify-between gap-2 px-2 py-1.5">
278
+ <span className="text-[0.65rem] font-semibold uppercase tracking-wider text-muted-foreground">
279
+ {labels?.language ?? 'Language'}
280
+ </span>
281
+ <ToggleGroup
282
+ type="single"
283
+ size="sm"
284
+ variant="outline"
285
+ {...(lang ? { value: lang } : {})}
286
+ onValueChange={(v: string) => { if (v) onLangSelect(v) }}
287
+ >
288
+ {languages.map((l) => (
289
+ <ToggleGroupItem
290
+ key={l.code}
291
+ value={l.code}
292
+ className="h-7 px-2 text-[11px] uppercase tracking-wider"
293
+ >
294
+ {l.label}
295
+ </ToggleGroupItem>
296
+ ))}
297
+ </ToggleGroup>
298
+ </div>
299
+ ) : null}
300
+
301
+ {hasFooterAction ? <DropdownMenuSeparator /> : null}
302
+
303
+ {identity && (logoutUrl || onLogout) ? (
304
+ <DropdownMenuItem destructive onSelect={onLogoutSelect}>
305
+ {labels?.signOut ?? 'Sign out'}
306
+ </DropdownMenuItem>
307
+ ) : !identity && signInUrl ? (
308
+ <DropdownMenuItem onSelect={() => navigateTo(signInUrl)}>
309
+ <span className="flex-1">{labels?.signIn ?? 'Sign in'}</span>
310
+ <span aria-hidden="true">→</span>
311
+ </DropdownMenuItem>
312
+ ) : null}
313
+ </DropdownMenuContent>
314
+ </DropdownMenu>
315
+ )
316
+ }
@@ -0,0 +1,250 @@
1
+ // SiteFooter — the canonical public-facing footer for marketing /
2
+ // docs / marketplace pages. NOT used inside the authenticated
3
+ // dashboard (that surface has its own compact app-chrome footer).
4
+ //
5
+ // ┌──────────────────────────────────────────────────────────────┐
6
+ // │ • hairline top highlight (primary tint) │
7
+ // ├──────────────────────────────────────────────────────────────┤
8
+ // │ │
9
+ // │ ┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
10
+ // │ │ brand │ │ Product │ │ Docs │ │ Company │ │
11
+ // │ │ tagline │ │ Demo │ │ Getting… │ │ About │ │
12
+ // │ │ social row │ │ Pricing │ │ Plugins │ │ Blog │ │
13
+ // │ │ │ │ Status │ │ Reference│ │ Careers │ │
14
+ // │ └────────────┘ └──────────┘ └──────────┘ └──────────┘ │
15
+ // │ │
16
+ // ├──────────────────────────────────────────────────────────────┤
17
+ // │ © Year · brand status badge · lang · theme │
18
+ // └──────────────────────────────────────────────────────────────┘
19
+ //
20
+ // Layout: data-driven via `brand`, `tagline`, `social`, `columns`,
21
+ // `bottomLeft`, `bottomRight`. All ReactNodes — apps inject their own
22
+ // router Link via the `LinkComponent` prop (consistent with DocsLayout
23
+ // / DocShell / AppShell pattern).
24
+
25
+ import type { ComponentType, ReactNode } from 'react'
26
+ import { cn } from '../cn'
27
+ import type { ShellLinkProps } from './docShell'
28
+
29
+ export interface SiteFooterLink {
30
+ readonly label: ReactNode
31
+ readonly href: string
32
+ /** External links bypass the internal Link (router) so they navigate
33
+ * via plain <a>. Defaults to detecting `http:`/`https:` prefix in
34
+ * the href. */
35
+ readonly external?: boolean
36
+ /** Small label shown next to the link text (e.g. "new", "beta"). */
37
+ readonly badge?: ReactNode
38
+ }
39
+
40
+ export interface SiteFooterColumn {
41
+ /** Section heading — rendered as small uppercase tracking. */
42
+ readonly title: ReactNode
43
+ readonly links: ReadonlyArray<SiteFooterLink>
44
+ }
45
+
46
+ export interface SiteFooterSocial {
47
+ /** Accessible label, e.g. "GitHub". */
48
+ readonly label: string
49
+ readonly href: string
50
+ readonly icon: ReactNode
51
+ }
52
+
53
+ interface SiteFooterProps {
54
+ /** Brand mark — usually a small wordmark + Badge. */
55
+ readonly brand: ReactNode
56
+ /** Short paragraph under the brand. ~2 lines of prose. */
57
+ readonly tagline?: ReactNode
58
+ /** Social / external profile links. Rendered as a row of 32×32
59
+ * icon buttons under the tagline. */
60
+ readonly social?: ReadonlyArray<SiteFooterSocial>
61
+ /** The column groups. 3 columns is the visual sweet spot; 4 fits
62
+ * but starts feeling busy. */
63
+ readonly columns: ReadonlyArray<SiteFooterColumn>
64
+
65
+ /** Bottom strip slots. Left typically holds the copyright; right
66
+ * holds status / language / theme toggle. When `bottomLeft` is
67
+ * omitted, a default `© {year} {copyrightHolder}` line renders. */
68
+ readonly bottomLeft?: ReactNode
69
+ readonly bottomRight?: ReactNode
70
+ /** Copyright holder shown in the default bottom-left line (only when
71
+ * `bottomLeft` is not supplied). Default `'Voltro. Built on the
72
+ * framework.'`. Pass your own so the shipped footer isn't Voltro-
73
+ * branded. */
74
+ readonly copyrightHolder?: ReactNode
75
+ /** `aria-label` for the social-links list. Default `'Social links'`. */
76
+ readonly socialLabel?: string
77
+
78
+ /** Optional client-router Link. Same contract as the other kit
79
+ * compositions. Defaults to plain <a>. */
80
+ readonly LinkComponent?: ComponentType<ShellLinkProps>
81
+
82
+ readonly className?: string
83
+ }
84
+
85
+ const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
86
+ <a href={to} className={className}>{children}</a>
87
+ )
88
+
89
+ const isSchemed = (href: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//i.test(href)
90
+
91
+ // FooterLink wraps the user's link with the right element: external
92
+ // → plain <a target="_blank">, internal → LinkComponent.
93
+ const FooterLink = ({
94
+ link, Link,
95
+ }: {
96
+ readonly link: SiteFooterLink
97
+ readonly Link: ComponentType<ShellLinkProps>
98
+ }): ReactNode => {
99
+ const external = link.external ?? isSchemed(link.href)
100
+ const baseClass = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors'
101
+ if (external) {
102
+ return (
103
+ <a
104
+ href={link.href}
105
+ target="_blank"
106
+ rel="noopener noreferrer"
107
+ className={baseClass}
108
+ >
109
+ {link.label}
110
+ {link.badge ? <FooterBadge>{link.badge}</FooterBadge> : null}
111
+ </a>
112
+ )
113
+ }
114
+ return (
115
+ <Link to={link.href} className={baseClass}>
116
+ {link.label}
117
+ {link.badge ? <FooterBadge>{link.badge}</FooterBadge> : null}
118
+ </Link>
119
+ )
120
+ }
121
+
122
+ const FooterBadge = ({ children }: { children: ReactNode }): ReactNode => (
123
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[0.6rem] uppercase tracking-wider font-semibold bg-primary/10 text-primary">
124
+ {children}
125
+ </span>
126
+ )
127
+
128
+ export const SiteFooter = ({
129
+ brand, tagline, social, columns,
130
+ bottomLeft, bottomRight,
131
+ copyrightHolder = 'Voltro. Built on the framework.',
132
+ socialLabel = 'Social links',
133
+ LinkComponent = PlainLink, className,
134
+ }: SiteFooterProps): ReactNode => {
135
+ const Link = LinkComponent
136
+ const year = new Date().getFullYear()
137
+ // Default copyright when neither slot fills the bottom-left.
138
+ const defaultLeft = (
139
+ <span className="text-xs text-muted-foreground">
140
+ © {year} {copyrightHolder}
141
+ </span>
142
+ )
143
+
144
+ return (
145
+ <footer
146
+ className={cn(
147
+ 'relative border-t border-border',
148
+ // Hairline highlight along the top — same language as bento
149
+ // cells, ties the page chrome together.
150
+ 'before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/50 before:to-transparent',
151
+ className,
152
+ )}
153
+ >
154
+ <div className="mx-auto max-w-7xl px-6 py-14 lg:py-16">
155
+ {/* ---- Main grid: brand + columns ---- */}
156
+ <div className="grid grid-cols-2 md:grid-cols-12 gap-8 lg:gap-12">
157
+ {/* Brand block — spans 4 columns on md, full width on mobile */}
158
+ <div className="col-span-2 md:col-span-4 lg:col-span-5">
159
+ <div className="text-base font-semibold text-foreground">{brand}</div>
160
+ {tagline ? (
161
+ <p className="mt-3 text-sm text-muted-foreground leading-relaxed max-w-xs">
162
+ {tagline}
163
+ </p>
164
+ ) : null}
165
+ {social && social.length > 0 ? (
166
+ <ul className="mt-5 flex items-center gap-2" aria-label={socialLabel}>
167
+ {social.map((s) => (
168
+ <li key={s.href}>
169
+ <a
170
+ href={s.href}
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ aria-label={s.label}
174
+ className="inline-flex items-center justify-center w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-card transition-colors [&_svg]:w-4 [&_svg]:h-4"
175
+ >
176
+ {s.icon}
177
+ </a>
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ ) : null}
182
+ </div>
183
+
184
+ {/* Link columns. Each column takes equal slice of the
185
+ * remaining 8 cols on md (12-col grid). On mobile they
186
+ * stack into the 2-col grid below the brand. */}
187
+ {columns.map((col, i) => (
188
+ <div
189
+ key={i}
190
+ className={cn(
191
+ 'col-span-1',
192
+ // 3 cols → md:col-span-2 (so 3 × 2 + brand 4 = 10, leaves room)
193
+ // Use lg:col-span-2 for tighter packing on big screens.
194
+ columns.length === 3 ? 'md:col-span-2 lg:col-span-2' : 'md:col-span-2',
195
+ )}
196
+ >
197
+ <h3 className="text-xs uppercase tracking-[0.08em] text-muted-foreground font-semibold mb-4">
198
+ {col.title}
199
+ </h3>
200
+ <ul className="space-y-2.5">
201
+ {col.links.map((link, j) => (
202
+ <li key={j}>
203
+ <FooterLink link={link} Link={Link} />
204
+ </li>
205
+ ))}
206
+ </ul>
207
+ </div>
208
+ ))}
209
+ </div>
210
+
211
+ {/* ---- Bottom strip ---- */}
212
+ <div className="mt-12 lg:mt-16 pt-6 border-t border-border flex flex-col-reverse sm:flex-row items-start sm:items-center justify-between gap-4">
213
+ <div>{bottomLeft ?? defaultLeft}</div>
214
+ {bottomRight ? <div className="flex items-center gap-3">{bottomRight}</div> : null}
215
+ </div>
216
+ </div>
217
+ </footer>
218
+ )
219
+ }
220
+
221
+ // ---- StatusBadge — handy bottom-right slot piece ----
222
+ // Solid green dot + "All systems operational" style label. Drop into
223
+ // `bottomRight` when the app has a real status URL to link to.
224
+
225
+ interface StatusBadgeProps {
226
+ readonly href: string
227
+ readonly label?: ReactNode
228
+ /** `'ok' | 'degraded' | 'down'` — colours the dot accordingly. */
229
+ readonly tone?: 'ok' | 'degraded' | 'down'
230
+ }
231
+
232
+ const STATUS_DOT: Record<NonNullable<StatusBadgeProps['tone']>, string> = {
233
+ ok: 'bg-success shadow-[0_0_8px_2px_oklch(0.7_0.15_162_/_0.5)]',
234
+ degraded: 'bg-warning shadow-[0_0_8px_2px_oklch(0.78_0.18_70_/_0.5)]',
235
+ down: 'bg-destructive shadow-[0_0_8px_2px_oklch(0.7_0.19_22_/_0.5)]',
236
+ }
237
+
238
+ export const StatusBadge = ({
239
+ href, label = 'All systems operational', tone = 'ok',
240
+ }: StatusBadgeProps): ReactNode => (
241
+ <a
242
+ href={href}
243
+ target="_blank"
244
+ rel="noopener noreferrer"
245
+ className="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
246
+ >
247
+ <span className={cn('w-1.5 h-1.5 rounded-full motion-safe:animate-pulse', STATUS_DOT[tone])} aria-hidden="true" />
248
+ {label}
249
+ </a>
250
+ )
@@ -0,0 +1,82 @@
1
+ // ThemeToggle — flips the `.dark` class on <html>. Persists choice
2
+ // to the `voltro:theme` cookie so the server can bake the correct
3
+ // class on the next request (and `themeBootScript` can sync the
4
+ // initial paint without round-tripping localStorage).
5
+ //
6
+ // Component is interactive — when used inside a static page, mark
7
+ // the surrounding island as `interaction` strategy.
8
+
9
+ import { useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
10
+ import { Button } from '../primitives/button'
11
+ import { cn } from '../cn'
12
+ import { THEME_COOKIE, setCookie } from '../cookies'
13
+
14
+ /**
15
+ * Inline script to drop into <head>. Reads the `voltro:theme` cookie
16
+ * (set by ThemeToggle / ProfileMenu) BEFORE React hydrates so the
17
+ * initial paint matches — no FOUC even when the server hasn't yet
18
+ * baked the cookie-derived class.
19
+ *
20
+ * <script dangerouslySetInnerHTML={{ __html: themeBootScript() }} />
21
+ *
22
+ * Cookie wins over matchMedia: 'system' (or absent) falls back to
23
+ * the OS preference; 'light' / 'dark' are absolute.
24
+ */
25
+ export const themeBootScript = (): string => `
26
+ (function(){
27
+ try {
28
+ var rx = new RegExp('(?:^|;\\\\s*)${THEME_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}=([^;]*)');
29
+ var m = document.cookie.match(rx);
30
+ var saved = m ? decodeURIComponent(m[1]) : '';
31
+ var dark =
32
+ saved === 'dark' ||
33
+ (saved !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
34
+ document.documentElement.classList.toggle('dark', dark);
35
+ } catch (_) { /* should be unreachable */ }
36
+ })();
37
+ `
38
+
39
+ interface ThemeToggleProps extends HTMLAttributes<HTMLButtonElement> {
40
+ readonly lightLabel?: ReactNode
41
+ readonly darkLabel?: ReactNode
42
+ /** `aria-label` while dark is active (the action = go light).
43
+ * Default `'Switch to light theme'` — pass a catalog value to localize. */
44
+ readonly switchToLightLabel?: string
45
+ /** `aria-label` while light is active (the action = go dark).
46
+ * Default `'Switch to dark theme'` — pass a catalog value to localize. */
47
+ readonly switchToDarkLabel?: string
48
+ }
49
+
50
+ export const ThemeToggle = ({
51
+ className, lightLabel = '☀', darkLabel = '☾',
52
+ switchToLightLabel = 'Switch to light theme',
53
+ switchToDarkLabel = 'Switch to dark theme',
54
+ ...props
55
+ }: ThemeToggleProps): ReactNode => {
56
+ const [isDark, setIsDark] = useState<boolean | undefined>(undefined)
57
+
58
+ useEffect(() => {
59
+ setIsDark(document.documentElement.classList.contains('dark'))
60
+ }, [])
61
+
62
+ const toggle = (): void => {
63
+ const next = !document.documentElement.classList.contains('dark')
64
+ document.documentElement.classList.toggle('dark', next)
65
+ setCookie(THEME_COOKIE, next ? 'dark' : 'light')
66
+ setIsDark(next)
67
+ }
68
+
69
+ return (
70
+ <Button
71
+ type="button"
72
+ variant="ghost"
73
+ size="icon"
74
+ onClick={toggle}
75
+ className={cn(className)}
76
+ aria-label={isDark ? switchToLightLabel : switchToDarkLabel}
77
+ {...props}
78
+ >
79
+ {isDark === undefined ? null : isDark ? lightLabel : darkLabel}
80
+ </Button>
81
+ )
82
+ }