@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,139 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { createContext, type ReactNode, useContext, useEffect, useRef } from "react";
3
+ import { session } from "../lib/session";
4
+ import type { UnitPreferences } from "../lib/units";
5
+ import { useAuthStore } from "../stores/authStore";
6
+ import { DEFAULT_SYSTEMS, useUnitsStore } from "../stores/unitsStore";
7
+ import type { AppUser, RegisteredUser } from "../types";
8
+
9
+ export interface UserContextValue<U extends AppUser = RegisteredUser> {
10
+ user: U | null;
11
+ isLoading: boolean;
12
+ error: Error | null;
13
+ }
14
+
15
+ const UserContext = createContext<UserContextValue | null>(null);
16
+
17
+ export function UserProvider({ children }: { children: ReactNode }) {
18
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
19
+ const authUser = useAuthStore((state) => state.user);
20
+ const queryClient = useQueryClient();
21
+
22
+ // Every way out (the menu, a 401, a failed refresh) lands here: a signed out
23
+ // shell holds no copy of the last person, so the next account never sees it.
24
+ useEffect(() => {
25
+ if (!isAuthenticated) queryClient.removeQueries({ queryKey: ["user", "me"] });
26
+ }, [isAuthenticated, queryClient]);
27
+
28
+ const {
29
+ data: apiUser,
30
+ isLoading,
31
+ error,
32
+ } = useQuery({
33
+ queryKey: ["user", "me"],
34
+ queryFn: () => session.me(),
35
+ enabled: isAuthenticated,
36
+ });
37
+
38
+ // Use auth store user as fallback while fetching, or API user when available
39
+ const user = isAuthenticated ? (apiUser ?? authUser) : null;
40
+
41
+ useUnitsSync(apiUser ?? null);
42
+
43
+ const value: UserContextValue = {
44
+ user,
45
+ isLoading: isAuthenticated && isLoading,
46
+ error: error as Error | null,
47
+ };
48
+
49
+ return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
50
+ }
51
+
52
+ const sameSystems = (a: Partial<UnitPreferences> | undefined, b: UnitPreferences) =>
53
+ (Object.keys(b) as (keyof UnitPreferences)[]).every((measure) => a?.[measure] === b[measure]);
54
+
55
+ /** The account's snapshot as last hydrated into the store, or null while signed out. */
56
+ interface Hydrated {
57
+ id: string;
58
+ units: Partial<UnitPreferences> | undefined;
59
+ }
60
+
61
+ const sameSnapshot = (
62
+ a: Hydrated | null,
63
+ id: string,
64
+ units: Partial<UnitPreferences> | undefined,
65
+ ) =>
66
+ a !== null &&
67
+ a.id === id &&
68
+ (Object.keys(DEFAULT_SYSTEMS) as (keyof UnitPreferences)[]).every(
69
+ (measure) => a.units?.[measure] === units?.[measure],
70
+ );
71
+
72
+ /**
73
+ * Keeps the units store and the person's account in step. When the api user
74
+ * arrives, or comes back from a refetch holding a different choice (made on
75
+ * another device), their saved choices hydrate the store; from then on a
76
+ * change in the store is written back, so the same choice follows them to
77
+ * every device and to what the api itself writes in their units (a Strava
78
+ * description). Nothing is written while signed out, and nothing is written
79
+ * for a change the account already holds, so hydration never echoes.
80
+ *
81
+ * Saves go one at a time, in order, and only the newest one's response lands
82
+ * in the query cache: each save carries the whole snapshot, so an older
83
+ * response arriving late would otherwise hydrate the store backwards. A save
84
+ * outlives no account: after a sign out or a switch, what the old account
85
+ * queued is dropped and what it had in flight is not written to the cache.
86
+ */
87
+ function useUnitsSync(apiUser: AppUser | null) {
88
+ const queryClient = useQueryClient();
89
+ const hydrated = useRef<Hydrated | null>(null);
90
+ const account = useRef<string | null>(null);
91
+ const queue = useRef<Promise<unknown>>(Promise.resolve());
92
+ const latest = useRef(0);
93
+ const { mutate: save } = useMutation({
94
+ // A save belongs to the account that made it: one still queued when
95
+ // that account signs out is dropped rather than written to the next.
96
+ mutationFn: (units: UnitPreferences) => {
97
+ const owner = account.current;
98
+ const run = queue.current.then(() =>
99
+ owner !== null && owner === account.current ? session.updateUnits(units) : null,
100
+ );
101
+ queue.current = run.catch(() => undefined);
102
+ return run;
103
+ },
104
+ onMutate: () => ({ seq: ++latest.current, owner: account.current }),
105
+ onSuccess: (saved, _units, context) => {
106
+ if (saved && context.seq === latest.current && context.owner === account.current) {
107
+ queryClient.setQueryData(["user", "me"], saved);
108
+ }
109
+ },
110
+ });
111
+
112
+ useEffect(() => {
113
+ account.current = apiUser?.id ?? null;
114
+ if (!apiUser) {
115
+ hydrated.current = null;
116
+ return;
117
+ }
118
+ if (!sameSnapshot(hydrated.current, apiUser.id, apiUser.units)) {
119
+ hydrated.current = { id: apiUser.id, units: apiUser.units };
120
+ useUnitsStore.getState().hydrate(apiUser.units);
121
+ }
122
+ return useUnitsStore.subscribe((state) => {
123
+ if (!sameSystems(apiUser.units, state.systems)) save(state.systems);
124
+ });
125
+ }, [apiUser, save]);
126
+ }
127
+
128
+ /**
129
+ * The signed in user: the api's copy once it arrives, the one stored at sign
130
+ * in until then. Typed as the app's registered user; pass a type to narrow it
131
+ * further at one call site.
132
+ */
133
+ export function useUser<U extends AppUser = RegisteredUser>(): UserContextValue<U> {
134
+ const context = useContext(UserContext);
135
+ if (!context) {
136
+ throw new Error("useUser must be used within a UserProvider");
137
+ }
138
+ return context as unknown as UserContextValue<U>;
139
+ }
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebounce<T>(value: T, delayMs: number): T {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setDebouncedValue(value), delayMs);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delayMs]);
10
+
11
+ return debouncedValue;
12
+ }
@@ -0,0 +1,31 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /** The shell's one breakpoint: the desktop rail appears at Tailwind's `lg`.
4
+ * Every layout decision keyed on viewport reads this, so CSS and React agree. */
5
+ export const DESKTOP_QUERY = "(min-width: 1024px)";
6
+
7
+ function subscribe(onChange: () => void) {
8
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
9
+ return () => {};
10
+ }
11
+ const media = window.matchMedia(DESKTOP_QUERY);
12
+ media.addEventListener("change", onChange);
13
+ return () => media.removeEventListener("change", onChange);
14
+ }
15
+
16
+ function getSnapshot() {
17
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
18
+ return false;
19
+ }
20
+ return window.matchMedia(DESKTOP_QUERY).matches;
21
+ }
22
+
23
+ /**
24
+ * True at the desktop breakpoint (lg and up), false below it and in any
25
+ * environment without matchMedia (jsdom), where the mobile layout is the safe
26
+ * default. Backed by matchMedia rather than a resize listener so it flips
27
+ * exactly when the CSS does and never lags a debounce.
28
+ */
29
+ export function useIsDesktop(): boolean {
30
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
31
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Keep the screen awake while the consuming surface is mounted (the cook view:
5
+ * saucy fingers should never have to tap a dimming screen). Progressive
6
+ * enhancement over the Screen Wake Lock API:
7
+ * - Silently no-ops where unsupported (Firefox, older Safari).
8
+ * - The browser auto-releases the lock when the tab is hidden, so a
9
+ * visibilitychange listener re-acquires it when the user returns.
10
+ */
11
+ export function useWakeLock(): void {
12
+ useEffect(() => {
13
+ if (!("wakeLock" in navigator)) return;
14
+
15
+ let sentinel: WakeLockSentinel | null = null;
16
+ let released = false;
17
+ let acquiring = false;
18
+
19
+ const acquire = async () => {
20
+ // Guard against overlapping requests (rapid visibility flips) and
21
+ // redundant ones while a lock is already held.
22
+ if (acquiring || (sentinel && !sentinel.released)) return;
23
+ acquiring = true;
24
+ try {
25
+ const lock = await navigator.wakeLock.request("screen");
26
+ sentinel = lock;
27
+ // The effect may have cleaned up while the request was in flight.
28
+ if (released) await lock.release();
29
+ } catch {
30
+ // Denied (low battery, permissions policy) — cook on without it.
31
+ } finally {
32
+ acquiring = false;
33
+ }
34
+ };
35
+
36
+ const onVisibilityChange = () => {
37
+ if (document.visibilityState === "visible") void acquire();
38
+ };
39
+
40
+ void acquire();
41
+ document.addEventListener("visibilitychange", onVisibilityChange);
42
+
43
+ return () => {
44
+ released = true;
45
+ document.removeEventListener("visibilitychange", onVisibilityChange);
46
+ void sentinel?.release().catch(() => {});
47
+ };
48
+ }, []);
49
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The app shell: the frame (rail, tab bar, menu sheet), the `AppPage`
3
+ * scaffold, the provider that hands the shell the app's config and nav, the
4
+ * signed in user, the transport and session, and the stores they share. The
5
+ * shadcn kit is its own entry, `@atelic-action/ui/app/ui`.
6
+ */
7
+
8
+ // The provider, the config, and the frame
9
+ export { AppProvider, type AppProviderProps } from "./AppProvider";
10
+ export { AuthGuard } from "./AuthGuard";
11
+ export { CardErrorBoundary } from "./CardErrorBoundary";
12
+ export { type AppConfig, getAppConfig } from "./config";
13
+ export { configureApp } from "./configure";
14
+ export {
15
+ AppConfigProvider,
16
+ type AppConfigProviderProps,
17
+ useAppConfig,
18
+ useNav,
19
+ } from "./context/AppContext";
20
+ export { SidebarProvider, useRail, useSidebar } from "./context/SidebarContext";
21
+ export { type UserContextValue, UserProvider, useUser } from "./context/UserContext";
22
+ export { ErrorFallback, type ErrorFallbackProps } from "./ErrorFallback";
23
+ export { GoogleGlyph } from "./GoogleGlyph";
24
+ // Hooks
25
+ export { useDebounce } from "./hooks/useDebounce";
26
+ export { DESKTOP_QUERY, useIsDesktop } from "./hooks/useIsDesktop";
27
+ export { useWakeLock } from "./hooks/useWakeLock";
28
+ export * from "./layout";
29
+ export { avatarInitials, displayName } from "./layout/identity";
30
+ // The library: dates and phrasing, the transport, the session, units
31
+ export * from "./lib/dates";
32
+ export { type AppEnv, envSchema, parseEnv } from "./lib/env";
33
+ export * from "./lib/format";
34
+ export { apiFetch, buildQueryString, publicFetch, uploadFormData } from "./lib/http";
35
+ export { type LastAuth, readLastAuth, writeLastAuth } from "./lib/lastAuth";
36
+ export { postNavigate, startGoogleAuth } from "./lib/oauth";
37
+ export { isPublicPage, isPublicRoute } from "./lib/publicRoutes";
38
+ export { queryClient } from "./lib/queryClient";
39
+ export { matchesSearch } from "./lib/search";
40
+ export { session } from "./lib/session";
41
+ export {
42
+ ensureValidToken,
43
+ handleUnauthorized,
44
+ refreshAccessToken,
45
+ resetRefreshState,
46
+ } from "./lib/tokenRefresh";
47
+ export { toSnakeCase, transformKeys } from "./lib/transformKeys";
48
+ export * from "./lib/units";
49
+ export * from "./lib/unwrap";
50
+ export { cn } from "./lib/utils";
51
+ // The stores
52
+ export { type AuthState, DEFAULT_EXPIRY_BUFFER_MS, useAuthStore } from "./stores/authStore";
53
+ export { DEFAULT_SYSTEMS, type UnitsState, useUnitsStore } from "./stores/unitsStore";
54
+ // The wire protocol and the user
55
+ export type {
56
+ AppUser,
57
+ PaginatedResponse,
58
+ PaginationLinks,
59
+ RefreshResponse,
60
+ Register,
61
+ RegisteredUser,
62
+ TokenResponse,
63
+ } from "./types";
64
+ export type { ToastMeta } from "./types/query";
@@ -0,0 +1,67 @@
1
+ import type { LucideIcon } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Button } from "../ui/button";
4
+
5
+ interface ActionButtonProps {
6
+ /** Lucide icon component */
7
+ icon: LucideIcon;
8
+ /** Button label (visible on larger screens) */
9
+ label: string;
10
+ /** Click handler */
11
+ onClick?: () => void;
12
+ /** Button variant */
13
+ variant?: "default" | "outline" | "ghost" | "destructive" | "secondary";
14
+ /** Additional className */
15
+ className?: string;
16
+ /** Disabled state */
17
+ disabled?: boolean;
18
+ /** Loading state */
19
+ loading?: boolean;
20
+ /** Accessible label (defaults to label) */
21
+ "aria-label"?: string;
22
+ /** For use with Link components */
23
+ asChild?: boolean;
24
+ /** Button type */
25
+ type?: "button" | "submit" | "reset";
26
+ /** Children (for asChild usage) */
27
+ children?: React.ReactNode;
28
+ }
29
+
30
+ /**
31
+ * Standardized responsive action button.
32
+ * Shows icon only on mobile, icon + text on larger screens.
33
+ */
34
+ export function ActionButton({
35
+ icon: Icon,
36
+ label,
37
+ onClick,
38
+ variant = "outline",
39
+ className,
40
+ disabled,
41
+ loading,
42
+ "aria-label": ariaLabel,
43
+ asChild,
44
+ type = "button",
45
+ children,
46
+ }: ActionButtonProps) {
47
+ return (
48
+ <Button
49
+ variant={variant}
50
+ onClick={onClick}
51
+ className={cn("gap-2", className)}
52
+ aria-label={ariaLabel || label}
53
+ disabled={disabled || loading}
54
+ asChild={asChild}
55
+ type={type}
56
+ >
57
+ {asChild ? (
58
+ children
59
+ ) : (
60
+ <>
61
+ <Icon className={cn("h-4 w-4 flex-shrink-0", loading && "animate-spin")} />
62
+ <span className="hidden sm:inline">{label}</span>
63
+ </>
64
+ )}
65
+ </Button>
66
+ );
67
+ }
@@ -0,0 +1,110 @@
1
+ import { Link, useLocation, useNavigate } from "@tanstack/react-router";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { useAppConfig } from "../context/AppContext";
5
+ import { useSidebar } from "../context/SidebarContext";
6
+ import { useIsDesktop } from "../hooks/useIsDesktop";
7
+ import { isPublicPage } from "../lib/publicRoutes";
8
+ import { cn } from "../lib/utils";
9
+ import { useAuthStore } from "../stores/authStore";
10
+ import { DesktopRail, RAIL_WIDTH, RAIL_WIDTH_COLLAPSED } from "./DesktopRail";
11
+ import { MobileMenu } from "./MobileMenu";
12
+ import { MobileTabBar } from "./MobileTabBar";
13
+
14
+ interface AppLayoutProps {
15
+ children: ReactNode;
16
+ }
17
+
18
+ /**
19
+ * The chrome around a public page for a visitor who is not signed in: the
20
+ * brand on the left, a sign in link on the right, and the page. No rail, no
21
+ * tab bar, nothing that assumes an account.
22
+ */
23
+ function PublicShell({ children }: AppLayoutProps) {
24
+ const appConfig = useAppConfig();
25
+ return (
26
+ <div className="min-h-screen bg-background">
27
+ <header className="sticky top-0 z-30 border-b border-border/40 bg-background">
28
+ <div className="flex h-14 items-center justify-between px-4 lg:px-6">
29
+ <Link
30
+ to="/"
31
+ className="flex items-center space-x-2 transition-opacity hover:opacity-80"
32
+ aria-label={`${appConfig.name} home`}
33
+ >
34
+ <span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
35
+ <span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
36
+ </Link>
37
+ <Link to="/" className="text-sm font-medium text-muted-foreground hover:text-foreground">
38
+ Sign in
39
+ </Link>
40
+ </div>
41
+ </header>
42
+ <main className="flex min-h-[calc(100vh-3.5rem)] flex-col">{children}</main>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The app shell around every authenticated page. Mobile first: below the
49
+ * desktop breakpoint it is a slim top bar (brand, hamburger menu) over the
50
+ * page with the section tab bar pinned to the bottom. At lg and up the top
51
+ * bar and tab bar give way to the navigation rail on the left. Every nav
52
+ * surface reads the one nav the app hands `AppProvider`.
53
+ */
54
+ export function AppLayout({ children }: AppLayoutProps) {
55
+ const appConfig = useAppConfig();
56
+ const navigate = useNavigate();
57
+ const { pathname } = useLocation();
58
+ const { isCollapsed, toggleSidebar } = useSidebar();
59
+ const isDesktop = useIsDesktop();
60
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
61
+ const user = useAuthStore((state) => state.user);
62
+ const logout = useAuthStore((state) => state.logout);
63
+
64
+ const handleLogout = () => {
65
+ logout();
66
+ navigate({ to: "/", replace: true });
67
+ };
68
+
69
+ if (!isAuthenticated) {
70
+ return isPublicPage(pathname) ? <PublicShell>{children}</PublicShell> : children;
71
+ }
72
+
73
+ return (
74
+ <div className="min-h-screen bg-background">
75
+ <DesktopRail
76
+ pathname={pathname}
77
+ collapsed={isCollapsed}
78
+ onToggle={toggleSidebar}
79
+ user={user}
80
+ onLogout={handleLogout}
81
+ />
82
+
83
+ <div
84
+ style={{ paddingLeft: isDesktop ? (isCollapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH) : 0 }}
85
+ className={cn(
86
+ "flex min-h-screen flex-col transition-[padding-left] duration-200 ease-out",
87
+ "pb-16 lg:pb-0",
88
+ )}
89
+ >
90
+ <header className="sticky top-0 z-30 border-b border-border/40 bg-background lg:hidden">
91
+ <div className="flex h-14 items-center justify-between px-4">
92
+ <Link
93
+ to="/"
94
+ className="flex items-center space-x-2 transition-opacity hover:opacity-80"
95
+ aria-label={`${appConfig.name} home`}
96
+ >
97
+ <span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
98
+ <span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
99
+ </Link>
100
+ <MobileMenu pathname={pathname} user={user} onLogout={handleLogout} />
101
+ </div>
102
+ </header>
103
+
104
+ <main className="flex flex-1 flex-col">{children}</main>
105
+ </div>
106
+
107
+ {!isDesktop && <MobileTabBar pathname={pathname} />}
108
+ </div>
109
+ );
110
+ }