@atelic-action/ui 0.17.2 → 1.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 (188) hide show
  1. package/README.md +432 -45
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Note.tsx +9 -39
  104. package/src/artifact/Paragraph.tsx +65 -0
  105. package/src/artifact/Read.tsx +406 -0
  106. package/src/artifact/Writeup.tsx +7 -7
  107. package/src/artifact/index.ts +1 -0
  108. package/src/chrome/SiteShell.tsx +141 -0
  109. package/src/chrome/index.ts +2 -0
  110. package/src/components/Disclosure.tsx +80 -14
  111. package/src/components/GateLogin.tsx +1 -1
  112. package/src/components/Watermark.tsx +1 -1
  113. package/src/components/index.ts +6 -2
  114. package/src/gate/index.ts +1 -1
  115. package/src/gate/middleware.ts +17 -6
  116. package/src/gate/wall.ts +60 -2
  117. package/src/lib/basePathAudit.ts +96 -0
  118. package/src/lib/displayDate.ts +25 -0
  119. package/src/lib/honeypot.ts +11 -0
  120. package/src/lib/hours.ts +28 -0
  121. package/src/lib/sitemap.ts +34 -0
  122. package/src/lib/trackEvent.ts +26 -0
  123. package/src/lib/useOpenHeight.ts +28 -0
  124. package/src/primitives/Button.tsx +3 -1
  125. package/src/primitives/Chip.tsx +3 -1
  126. package/src/primitives/ImageFrame.tsx +48 -0
  127. package/src/primitives/ProseBlocks.tsx +67 -0
  128. package/src/primitives/QuoteCard.tsx +20 -0
  129. package/src/primitives/StarRow.tsx +37 -0
  130. package/src/primitives/TickList.tsx +24 -0
  131. package/src/primitives/index.ts +5 -0
  132. package/src/routing/BasePathProvider.tsx +47 -0
  133. package/src/routing/basePath.ts +57 -0
  134. package/src/routing/index.ts +8 -0
  135. package/src/scripts/check-base-path.ts +130 -0
  136. package/src/scripts/clean-sitemap.ts +27 -0
  137. package/src/scripts/contact-canary.ts +98 -0
  138. package/src/scripts/serve-static.ts +29 -3
  139. package/src/sections/BlogIndex.tsx +57 -0
  140. package/src/sections/BlogPost.tsx +65 -0
  141. package/src/sections/CTABanner.tsx +93 -0
  142. package/src/sections/CTASection.tsx +60 -0
  143. package/src/sections/ComparisonTable.tsx +114 -0
  144. package/src/sections/ContactForm.tsx +163 -0
  145. package/src/sections/ContactInfoCard.tsx +92 -0
  146. package/src/sections/CredentialList.tsx +28 -0
  147. package/src/sections/DeviceShowcase.tsx +39 -0
  148. package/src/sections/ExpandList.tsx +58 -0
  149. package/src/sections/FAQBrowser.tsx +283 -0
  150. package/src/sections/FeatureDetail.tsx +60 -0
  151. package/src/sections/FeatureGrid.tsx +83 -0
  152. package/src/sections/Gallery.tsx +84 -0
  153. package/src/sections/Hero.tsx +110 -0
  154. package/src/sections/HoursTable.tsx +23 -0
  155. package/src/sections/JumpRail.tsx +35 -0
  156. package/src/sections/LegalPage.tsx +43 -0
  157. package/src/sections/LocationSection.tsx +66 -0
  158. package/src/sections/Locations.tsx +71 -0
  159. package/src/sections/LogoMarquee.tsx +27 -0
  160. package/src/sections/MapEmbed.tsx +75 -0
  161. package/src/sections/MediaBand.tsx +20 -0
  162. package/src/sections/MenuList.tsx +44 -0
  163. package/src/sections/PageHero.tsx +25 -0
  164. package/src/sections/PressQuotes.tsx +51 -0
  165. package/src/sections/PricingBand.tsx +43 -0
  166. package/src/sections/PricingCard.tsx +36 -0
  167. package/src/sections/ProcessSteps.tsx +45 -0
  168. package/src/sections/ProfileCards.tsx +73 -0
  169. package/src/sections/RevealSlider.tsx +156 -0
  170. package/src/sections/ReviewCarousel.tsx +223 -0
  171. package/src/sections/ReviewWall.tsx +220 -0
  172. package/src/sections/SocialStrip.tsx +83 -0
  173. package/src/sections/SplitSection.tsx +56 -0
  174. package/src/sections/index.ts +76 -0
  175. package/src/sections/siteConfig.ts +201 -0
  176. package/src/styles/app.css +673 -0
  177. package/src/styles/artifact.css +759 -401
  178. package/src/styles/base.css +49 -47
  179. package/src/styles/chrome.css +71 -71
  180. package/src/styles/components.css +150 -140
  181. package/src/styles/gate.css +29 -29
  182. package/src/styles/primitives.css +190 -25
  183. package/src/styles/sections.css +2652 -0
  184. package/src/styles/star-rating.css +2 -2
  185. package/src/vite/index.ts +9 -2
  186. package/src/vite/marketing-config.ts +128 -0
  187. package/src/components/Collapsible.tsx +0 -43
  188. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,156 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { LogOut, Menu } from "lucide-react";
3
+ import { useEffect, useState } from "react";
4
+ import { useAppConfig, useNav } from "../context/AppContext";
5
+ import { cn } from "../lib/utils";
6
+ import type { AppUser } from "../types";
7
+ import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
8
+ import { Button } from "../ui/button";
9
+ import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "../ui/sheet";
10
+ import { avatarInitials, displayName } from "./identity";
11
+ import { isActivePath, isActiveSection } from "./nav";
12
+
13
+ interface MobileMenuProps {
14
+ pathname: string;
15
+ user: AppUser | null;
16
+ onLogout: () => void;
17
+ }
18
+
19
+ const ROW =
20
+ "flex min-h-12 items-center gap-3 rounded-lg px-3 text-base transition-colors duration-150 active:bg-accent";
21
+
22
+ /**
23
+ * The hamburger menu for phones and tablets: a right side sheet holding the
24
+ * whole map (every section with its sub pages beneath it) and, at the foot,
25
+ * the signed in user with Profile and Log out. The tab bar stays the fast
26
+ * path between sections; this is where everything else lives. It closes
27
+ * itself on navigation.
28
+ */
29
+ export function MobileMenu({ pathname, user, onLogout }: MobileMenuProps) {
30
+ const appConfig = useAppConfig();
31
+ const profileHref = appConfig.profileHref ?? "/profile";
32
+ const navItems = useNav();
33
+ const [open, setOpen] = useState(false);
34
+ const name = displayName(user);
35
+
36
+ // Any navigation, by tap or by history, shuts the sheet.
37
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a dependency of the effect body
38
+ useEffect(() => {
39
+ setOpen(false);
40
+ }, [pathname]);
41
+
42
+ return (
43
+ <Sheet open={open} onOpenChange={setOpen}>
44
+ <SheetTrigger asChild>
45
+ <Button
46
+ variant="ghost"
47
+ size="icon"
48
+ className="-mr-2 h-11 w-11 text-foreground hover:bg-accent"
49
+ aria-label="Open menu"
50
+ aria-expanded={open}
51
+ >
52
+ <Menu className="h-6 w-6" />
53
+ </Button>
54
+ </SheetTrigger>
55
+ <SheetContent side="right" className="flex w-[84vw] max-w-sm flex-col gap-0 p-0">
56
+ <SheetTitle className="sr-only">Menu</SheetTitle>
57
+ <SheetDescription className="sr-only">
58
+ Every page in the app, and your account.
59
+ </SheetDescription>
60
+
61
+ <div className="flex h-14 items-center border-b border-border/40 px-5">
62
+ <Link
63
+ onClick={() => setOpen(false)}
64
+ to="/"
65
+ className="flex items-center space-x-2"
66
+ aria-label={`${appConfig.name} home`}
67
+ >
68
+ <span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
69
+ <span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
70
+ </Link>
71
+ </div>
72
+
73
+ <nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Menu">
74
+ <ul className="space-y-0.5">
75
+ {navItems.map((item) => {
76
+ const Icon = item.icon;
77
+ const active = isActiveSection(pathname, item);
78
+ // A section with sub pages lights its row only on its own landing
79
+ // page; deeper in, the sub page row carries the highlight.
80
+ const exact = pathname === item.href;
81
+ return (
82
+ <li key={item.href}>
83
+ <Link
84
+ onClick={() => setOpen(false)}
85
+ to={item.href}
86
+ className={cn(
87
+ ROW,
88
+ "font-medium",
89
+ active && !item.children?.length
90
+ ? "bg-primary/10 text-primary"
91
+ : "text-foreground",
92
+ item.children && exact && "bg-primary/10 text-primary",
93
+ )}
94
+ aria-current={exact ? "page" : undefined}
95
+ >
96
+ <Icon className="h-5 w-5 flex-shrink-0" />
97
+ <span>{item.label}</span>
98
+ </Link>
99
+ {item.children ? (
100
+ <ul className="mb-1 mt-0.5 space-y-0.5" aria-label={`${item.label} pages`}>
101
+ {item.children.map((child) => {
102
+ const ChildIcon = child.icon;
103
+ const childActive = isActivePath(pathname, child.href);
104
+ return (
105
+ <li key={child.href}>
106
+ <Link
107
+ onClick={() => setOpen(false)}
108
+ to={child.href}
109
+ className={cn(
110
+ ROW,
111
+ "min-h-11 pl-11 text-[15px]",
112
+ childActive
113
+ ? "bg-primary/10 font-medium text-primary"
114
+ : "text-muted-foreground",
115
+ )}
116
+ aria-current={childActive ? "page" : undefined}
117
+ >
118
+ <ChildIcon className="h-4 w-4 flex-shrink-0" />
119
+ <span>{child.label}</span>
120
+ </Link>
121
+ </li>
122
+ );
123
+ })}
124
+ </ul>
125
+ ) : null}
126
+ </li>
127
+ );
128
+ })}
129
+ </ul>
130
+ </nav>
131
+
132
+ <div className="border-t border-border/40 px-2 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
133
+ <Link
134
+ onClick={() => setOpen(false)}
135
+ to={profileHref}
136
+ className={cn(ROW, "min-h-14", pathname.startsWith(profileHref) && "bg-primary/10")}
137
+ aria-current={pathname.startsWith(profileHref) ? "page" : undefined}
138
+ >
139
+ <Avatar className="h-9 w-9 flex-shrink-0 border border-border/40">
140
+ <AvatarImage src={user?.avatar ?? undefined} alt={name} />
141
+ <AvatarFallback className="text-sm">{avatarInitials(name)}</AvatarFallback>
142
+ </Avatar>
143
+ <span className="flex min-w-0 flex-col">
144
+ <span className="truncate text-sm font-medium">{name}</span>
145
+ <span className="text-xs text-muted-foreground">Profile</span>
146
+ </span>
147
+ </Link>
148
+ <button type="button" onClick={onLogout} className={cn(ROW, "w-full text-destructive")}>
149
+ <LogOut className="h-5 w-5 flex-shrink-0" />
150
+ <span>Log out</span>
151
+ </button>
152
+ </div>
153
+ </SheetContent>
154
+ </Sheet>
155
+ );
156
+ }
@@ -0,0 +1,43 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { useNav } from "../context/AppContext";
3
+ import { cn } from "../lib/utils";
4
+ import { isActiveSection } from "./nav";
5
+
6
+ /**
7
+ * The bottom tab bar below the desktop breakpoint: one tab per section, the
8
+ * fast path between them. Sub pages live in the menu sheet and on each
9
+ * section's landing page. Padded for the home indicator on notched phones.
10
+ */
11
+ export function MobileTabBar({ pathname }: { pathname: string }) {
12
+ const navItems = useNav();
13
+ return (
14
+ <nav
15
+ className="fixed bottom-0 left-0 right-0 z-40 border-t border-border/40 bg-background pb-[env(safe-area-inset-bottom)] shadow-[0_-2px_10px_rgba(0,0,0,0.06)] lg:hidden"
16
+ aria-label="Mobile navigation"
17
+ >
18
+ <div className="flex h-16 items-center justify-around">
19
+ {navItems
20
+ .filter((item) => !item.desktopOnly)
21
+ .map((item) => {
22
+ const Icon = item.icon;
23
+ const active = isActiveSection(pathname, item);
24
+ return (
25
+ <Link
26
+ key={item.href}
27
+ to={item.href}
28
+ className={cn(
29
+ "flex h-full flex-1 flex-col items-center justify-center py-2 transition-colors duration-150 active:bg-accent",
30
+ active ? "bg-primary/5 text-primary" : "text-muted-foreground",
31
+ )}
32
+ aria-current={active ? "page" : undefined}
33
+ aria-label={item.label}
34
+ >
35
+ <Icon className={cn("mb-1 h-5 w-5", active && "text-primary")} />
36
+ <span className={cn("text-xs", active && "font-medium")}>{item.label}</span>
37
+ </Link>
38
+ );
39
+ })}
40
+ </div>
41
+ </nav>
42
+ );
43
+ }
@@ -0,0 +1,30 @@
1
+ import { AlertCircle } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Card, CardContent } from "../ui/card";
4
+
5
+ interface PageErrorProps {
6
+ /** Error message to display */
7
+ message: string;
8
+ /** Optional title (defaults to "Error") */
9
+ title?: string;
10
+ /** Additional className */
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Standardized error state for pages.
16
+ * Provides consistent error display across the application.
17
+ */
18
+ export function PageError({ message, title = "Error", className }: PageErrorProps) {
19
+ return (
20
+ <Card className={cn("border-destructive/50", className)}>
21
+ <CardContent className="flex items-center gap-3 py-6">
22
+ <AlertCircle className="h-5 w-5 flex-shrink-0 text-destructive" />
23
+ <div>
24
+ <p className="font-medium text-destructive">{title}</p>
25
+ <p className="text-sm text-muted-foreground">{message}</p>
26
+ </div>
27
+ </CardContent>
28
+ </Card>
29
+ );
30
+ }
@@ -0,0 +1,65 @@
1
+ import { cn } from "../lib/utils";
2
+ import { Card, CardContent, CardHeader } from "../ui/card";
3
+
4
+ interface PageSkeletonProps {
5
+ /** Show header skeleton with title and subtitle */
6
+ showHeader?: boolean;
7
+ /** Show action button skeleton in header */
8
+ showHeaderAction?: boolean;
9
+ /** Number of card skeletons to show */
10
+ cardCount?: number;
11
+ /** Layout for cards: single column, two columns, or grid */
12
+ cardLayout?: "single" | "two-column" | "grid";
13
+ /** Additional className */
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * Standardized loading skeleton for pages.
19
+ * Provides consistent loading states across the application.
20
+ */
21
+ export function PageSkeleton({
22
+ showHeader = true,
23
+ showHeaderAction = false,
24
+ cardCount = 3,
25
+ cardLayout = "single",
26
+ className,
27
+ }: PageSkeletonProps) {
28
+ const gridClasses = {
29
+ single: "space-y-6",
30
+ "two-column": "grid grid-cols-1 gap-6 lg:grid-cols-2",
31
+ grid: "grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3",
32
+ };
33
+
34
+ return (
35
+ <div className={cn("animate-pulse", className)}>
36
+ {showHeader && (
37
+ <div className="mb-6 sm:mb-8">
38
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
39
+ <div className="space-y-2">
40
+ <div className="h-8 w-48 rounded bg-muted sm:h-9" />
41
+ <div className="h-4 w-64 rounded bg-muted sm:h-5" />
42
+ </div>
43
+ {showHeaderAction && <div className="h-10 w-32 rounded bg-muted" />}
44
+ </div>
45
+ </div>
46
+ )}
47
+
48
+ <div className={gridClasses[cardLayout]}>
49
+ {Array.from({ length: cardCount }).map((_, i) => (
50
+ // biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items never reorder
51
+ <Card key={i}>
52
+ <CardHeader>
53
+ <div className="h-6 w-2/3 rounded bg-muted" />
54
+ <div className="h-4 w-1/2 rounded bg-muted" />
55
+ </CardHeader>
56
+ <CardContent className="space-y-3">
57
+ <div className="h-4 w-full rounded bg-muted" />
58
+ <div className="h-4 w-3/4 rounded bg-muted" />
59
+ </CardContent>
60
+ </Card>
61
+ ))}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,33 @@
1
+ import { Link } from "@tanstack/react-router";
2
+
3
+ import { useNav } from "../context/AppContext";
4
+
5
+ /**
6
+ * A section's sub pages as cards on its landing page, read from the nav
7
+ * config so the rail, the menu sheet, and the landing page never disagree.
8
+ * Renders nothing for a section without children.
9
+ */
10
+ export function SectionCards({ section }: { section: string }) {
11
+ const navItems = useNav();
12
+ const item = navItems.find((candidate) => candidate.href === section);
13
+ if (!item?.children?.length) return null;
14
+
15
+ return (
16
+ <nav className="ap-sections" aria-label={`${item.label} pages`}>
17
+ {item.children.map((child) => {
18
+ const Icon = child.icon;
19
+ return (
20
+ <Link key={child.href} to={child.href} className="ap-section-card">
21
+ <span className="ic">
22
+ <Icon size={20} />
23
+ </span>
24
+ <span className="flex flex-col gap-1">
25
+ <span className="lb">{child.label}</span>
26
+ <span className="bl">{child.blurb}</span>
27
+ </span>
28
+ </Link>
29
+ );
30
+ })}
31
+ </nav>
32
+ );
33
+ }
@@ -0,0 +1,104 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { ChevronDown, LogOut, User as UserIcon } from "lucide-react";
3
+ import { useState } from "react";
4
+ import { useAppConfig } from "../context/AppContext";
5
+ import { cn } from "../lib/utils";
6
+ import type { AppUser } from "../types";
7
+ import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
8
+ import { Button } from "../ui/button";
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ } from "../ui/dropdown-menu";
16
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
17
+ import { avatarInitials, displayName } from "./identity";
18
+
19
+ interface UserMenuProps {
20
+ user: AppUser | null;
21
+ onLogout: () => void;
22
+ /** Icon only, for the collapsed desktop rail. */
23
+ collapsed?: boolean;
24
+ }
25
+
26
+ /**
27
+ * The signed in user's block at the foot of the desktop rail: avatar and name
28
+ * opening a small menu with Profile and Log out. Collapsed, it is the avatar
29
+ * alone with the name in a tooltip.
30
+ */
31
+ export function UserMenu({ user, onLogout, collapsed = false }: UserMenuProps) {
32
+ const profileHref = useAppConfig().profileHref ?? "/profile";
33
+ const [open, setOpen] = useState(false);
34
+ const name = displayName(user);
35
+ const initials = avatarInitials(name);
36
+
37
+ const avatar = (
38
+ <Avatar className="h-9 w-9 border border-border/40 flex-shrink-0">
39
+ <AvatarImage src={user?.avatar ?? undefined} alt={name} />
40
+ <AvatarFallback className="text-sm">{initials}</AvatarFallback>
41
+ </Avatar>
42
+ );
43
+
44
+ const trigger = collapsed ? (
45
+ <Button
46
+ variant="ghost"
47
+ size="sm"
48
+ className="w-full p-2 hover:bg-accent"
49
+ aria-label="User menu"
50
+ aria-expanded={open}
51
+ aria-haspopup="true"
52
+ >
53
+ {avatar}
54
+ </Button>
55
+ ) : (
56
+ <Button
57
+ variant="ghost"
58
+ className="w-full justify-between px-3 py-2.5 h-auto hover:bg-accent overflow-hidden"
59
+ aria-label="User menu"
60
+ aria-expanded={open}
61
+ aria-haspopup="true"
62
+ >
63
+ <div className="flex items-center space-x-3 min-w-0">
64
+ {avatar}
65
+ <span className="text-sm font-medium truncate">{name}</span>
66
+ </div>
67
+ <ChevronDown
68
+ className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")}
69
+ />
70
+ </Button>
71
+ );
72
+
73
+ return (
74
+ <DropdownMenu open={open} onOpenChange={setOpen}>
75
+ {collapsed ? (
76
+ <TooltipProvider>
77
+ <Tooltip delayDuration={0}>
78
+ <TooltipTrigger asChild>
79
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
80
+ </TooltipTrigger>
81
+ <TooltipContent side="right" className="font-medium text-sm">
82
+ <p>{name}</p>
83
+ </TooltipContent>
84
+ </Tooltip>
85
+ </TooltipProvider>
86
+ ) : (
87
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
88
+ )}
89
+ <DropdownMenuContent align="end" className="w-56">
90
+ <DropdownMenuItem asChild>
91
+ <Link to={profileHref} className="cursor-pointer">
92
+ <UserIcon className="mr-2 h-4 w-4" />
93
+ <span>Profile</span>
94
+ </Link>
95
+ </DropdownMenuItem>
96
+ <DropdownMenuSeparator />
97
+ <DropdownMenuItem onClick={onLogout} className="cursor-pointer text-destructive">
98
+ <LogOut className="mr-2 h-4 w-4" />
99
+ <span>Log out</span>
100
+ </DropdownMenuItem>
101
+ </DropdownMenuContent>
102
+ </DropdownMenu>
103
+ );
104
+ }
@@ -0,0 +1,22 @@
1
+ import type { AppUser } from "../types";
2
+
3
+ /** The name the shell shows for the signed in user: "First Last", "First", or
4
+ * a neutral fallback while the profile is still loading. */
5
+ export function displayName(user: Pick<AppUser, "firstName" | "lastName"> | null | undefined) {
6
+ if (user?.firstName && user?.lastName) return `${user.firstName} ${user.lastName}`;
7
+ if (user?.firstName) return user.firstName;
8
+ return "User";
9
+ }
10
+
11
+ /** Up to two initials for the avatar fallback, "MF" for "Matthew Fornaciari". */
12
+ export function avatarInitials(name: string) {
13
+ return (
14
+ name
15
+ .split(" ")
16
+ .map((part) => part[0])
17
+ .filter(Boolean)
18
+ .slice(0, 2)
19
+ .join("")
20
+ .toUpperCase() || "U"
21
+ );
22
+ }
@@ -0,0 +1,20 @@
1
+ export { ActionButton } from "./ActionButton";
2
+ export { AppLayout } from "./AppLayout";
3
+ export {
4
+ AppPage,
5
+ AppPageToolbar,
6
+ type ToolbarAction,
7
+ type ToolbarFilter,
8
+ } from "./AppPage";
9
+ export { Breadcrumbs, type Crumb } from "./Breadcrumbs";
10
+ export { FormActionButtons } from "./FormActionButtons";
11
+ export {
12
+ activeSection,
13
+ isActivePath,
14
+ isActiveSection,
15
+ type NavChild,
16
+ type NavItem,
17
+ } from "./nav";
18
+ export { PageError } from "./PageError";
19
+ export { PageSkeleton } from "./PageSkeleton";
20
+ export { SectionCards } from "./SectionCards";
@@ -0,0 +1,52 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ /**
4
+ * Canonical navigation — the single source of truth for every nav
5
+ * surface the app shell renders: the desktop rail, the mobile tab bar, the
6
+ * mobile menu sheet, and a section's landing cards. Active state is derived
7
+ * from the current route against each item's `href`, so there is one place to
8
+ * add or reorder a destination. The items are the app's, handed to
9
+ * `AppProvider` as `nav`; this module holds the types and the active path
10
+ * helpers.
11
+ */
12
+ export interface NavChild {
13
+ label: string;
14
+ href: string;
15
+ icon: ComponentType<{ className?: string; size?: number }>;
16
+ /** One line for the section landing card. */
17
+ blurb: string;
18
+ }
19
+
20
+ export interface NavItem {
21
+ label: string;
22
+ href: string;
23
+ icon: ComponentType<{ className?: string; size?: number }>;
24
+ /** Sub destinations inside this section, rendered beneath it in the rail
25
+ * and the menu sheet, and as cards on the section's landing page. */
26
+ children?: NavChild[];
27
+ /** Show only in the mobile tab bar. */
28
+ mobileOnly?: boolean;
29
+ /** Show only in the desktop nav rail. */
30
+ desktopOnly?: boolean;
31
+ /** Other paths that belong to this section though they sit outside its
32
+ * `href`, such as a record's own page at a short top level path. */
33
+ matches?: string[];
34
+ }
35
+
36
+ /** True when `pathname` is `href` or sits beneath it. The root ("/") only
37
+ * matches itself, since every path sits beneath it. */
38
+ export function isActivePath(pathname: string, href: string): boolean {
39
+ if (href === "/") return pathname === "/";
40
+ return pathname === href || pathname.startsWith(`${href}/`);
41
+ }
42
+
43
+ /** True when `pathname` belongs to the section: its `href` or one of its
44
+ * `matches`, or anything beneath them. */
45
+ export function isActiveSection(pathname: string, item: NavItem): boolean {
46
+ return [item.href, ...(item.matches ?? [])].some((href) => isActivePath(pathname, href));
47
+ }
48
+
49
+ /** The section (top level nav item) the current path belongs to, if any. */
50
+ export function activeSection(pathname: string, items: NavItem[]): NavItem | undefined {
51
+ return items.find((item) => isActiveSection(pathname, item));
52
+ }
@@ -0,0 +1,57 @@
1
+ // Local calendar helpers shared across features. Everything here works on ISO
2
+ // date strings (YYYY-MM-DD) or local date parts, never on an ISO instant, so a
3
+ // date never shifts a day across time zones.
4
+
5
+ export const MONTHS = [
6
+ "Jan",
7
+ "Feb",
8
+ "Mar",
9
+ "Apr",
10
+ "May",
11
+ "Jun",
12
+ "Jul",
13
+ "Aug",
14
+ "Sep",
15
+ "Oct",
16
+ "Nov",
17
+ "Dec",
18
+ ];
19
+
20
+ // Today as a local YYYY-MM-DD string, for lexical comparison against other ISO
21
+ // dates and for asking the api about the day the user is standing in. `now` is
22
+ // injectable for tests.
23
+ export function todayIso(now: Date = new Date()): string {
24
+ const month = String(now.getMonth() + 1).padStart(2, "0");
25
+ const day = String(now.getDate()).padStart(2, "0");
26
+ return `${now.getFullYear()}-${month}-${day}`;
27
+ }
28
+
29
+ // The IANA zone the browser is running in, which is the zone the user is
30
+ // standing in for anything read from the app. Undefined where the runtime
31
+ // cannot say, so the caller can leave the parameter off and let the api resolve
32
+ // the zone from the user's own record instead of asserting UTC.
33
+ export function localTimeZone(): string | undefined {
34
+ try {
35
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ // Format an ISO date without constructing a Date. "2026-06-08" -> "Jun 8, 2026".
42
+ // Returns the input untouched when it does not parse, so a bad value is visible
43
+ // rather than blank.
44
+ export function formatIsoDate(iso: string | null): string | null {
45
+ if (!iso) return null;
46
+ const [year, month, day] = iso.split("-").map(Number);
47
+ if (!year || !month || !day || month < 1 || month > 12) return iso;
48
+ return `${MONTHS[month - 1]} ${day}, ${year}`;
49
+ }
50
+
51
+ // "Jun 8" from an ISO date, no year. Null on a bad or absent input.
52
+ export function monthDay(iso: string | null): string | null {
53
+ if (!iso) return null;
54
+ const [year, month, day] = iso.split("-").map(Number);
55
+ if (!year || !month || !day || month < 1 || month > 12) return null;
56
+ return `${MONTHS[month - 1]} ${day}`;
57
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The environment variables an app on this shell reads, with runtime
5
+ * validation. The package never reads `import.meta.env` itself, since a
6
+ * bundler replaces it in the app's own code and not reliably inside a
7
+ * dependency: the app parses its own, once, and hands the api's base URL to
8
+ * the shell through its config.
9
+ *
10
+ * ```ts
11
+ * // src/app/env.ts
12
+ * export const env = parseEnv(import.meta.env);
13
+ * ```
14
+ */
15
+ export const envSchema = z.object({
16
+ VITE_API_BASE_URL: z.string().url().optional(),
17
+ VITE_APP_URL: z.string().url().optional(),
18
+ VITE_SENTRY_DSN: z.string().optional(),
19
+ });
20
+
21
+ export type AppEnv = z.infer<typeof envSchema>;
22
+
23
+ /** Validate the environment. Throws, naming each bad variable, so the app fails at startup. */
24
+ export function parseEnv(source: Record<string, unknown>): AppEnv {
25
+ const result = envSchema.safeParse(source);
26
+
27
+ if (!result.success) {
28
+ const errors = result.error.flatten().fieldErrors;
29
+ const errorMessage = Object.entries(errors)
30
+ .map(([key, messages]) => ` ${key}: ${messages?.join(", ")}`)
31
+ .join("\n");
32
+
33
+ throw new Error(`Environment validation failed:\n${errorMessage}`);
34
+ }
35
+
36
+ return result.data;
37
+ }