@atelic-action/ui 0.17.2 → 1.0.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 (184) hide show
  1. package/README.md +428 -44
  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/Writeup.tsx +7 -7
  104. package/src/chrome/SiteShell.tsx +141 -0
  105. package/src/chrome/index.ts +2 -0
  106. package/src/components/Disclosure.tsx +80 -14
  107. package/src/components/GateLogin.tsx +1 -1
  108. package/src/components/Watermark.tsx +1 -1
  109. package/src/components/index.ts +6 -2
  110. package/src/gate/index.ts +1 -1
  111. package/src/gate/middleware.ts +17 -6
  112. package/src/gate/wall.ts +60 -2
  113. package/src/lib/basePathAudit.ts +96 -0
  114. package/src/lib/displayDate.ts +25 -0
  115. package/src/lib/honeypot.ts +11 -0
  116. package/src/lib/hours.ts +28 -0
  117. package/src/lib/sitemap.ts +34 -0
  118. package/src/lib/trackEvent.ts +26 -0
  119. package/src/lib/useOpenHeight.ts +28 -0
  120. package/src/primitives/Button.tsx +3 -1
  121. package/src/primitives/Chip.tsx +3 -1
  122. package/src/primitives/ImageFrame.tsx +48 -0
  123. package/src/primitives/ProseBlocks.tsx +67 -0
  124. package/src/primitives/QuoteCard.tsx +20 -0
  125. package/src/primitives/StarRow.tsx +37 -0
  126. package/src/primitives/TickList.tsx +24 -0
  127. package/src/primitives/index.ts +5 -0
  128. package/src/routing/BasePathProvider.tsx +47 -0
  129. package/src/routing/basePath.ts +57 -0
  130. package/src/routing/index.ts +8 -0
  131. package/src/scripts/check-base-path.ts +130 -0
  132. package/src/scripts/clean-sitemap.ts +27 -0
  133. package/src/scripts/contact-canary.ts +98 -0
  134. package/src/scripts/serve-static.ts +29 -3
  135. package/src/sections/BlogIndex.tsx +57 -0
  136. package/src/sections/BlogPost.tsx +65 -0
  137. package/src/sections/CTABanner.tsx +93 -0
  138. package/src/sections/CTASection.tsx +60 -0
  139. package/src/sections/ComparisonTable.tsx +114 -0
  140. package/src/sections/ContactForm.tsx +163 -0
  141. package/src/sections/ContactInfoCard.tsx +92 -0
  142. package/src/sections/CredentialList.tsx +28 -0
  143. package/src/sections/DeviceShowcase.tsx +39 -0
  144. package/src/sections/ExpandList.tsx +58 -0
  145. package/src/sections/FAQBrowser.tsx +283 -0
  146. package/src/sections/FeatureDetail.tsx +60 -0
  147. package/src/sections/FeatureGrid.tsx +83 -0
  148. package/src/sections/Gallery.tsx +84 -0
  149. package/src/sections/Hero.tsx +110 -0
  150. package/src/sections/HoursTable.tsx +23 -0
  151. package/src/sections/JumpRail.tsx +35 -0
  152. package/src/sections/LegalPage.tsx +43 -0
  153. package/src/sections/LocationSection.tsx +66 -0
  154. package/src/sections/Locations.tsx +71 -0
  155. package/src/sections/LogoMarquee.tsx +27 -0
  156. package/src/sections/MapEmbed.tsx +75 -0
  157. package/src/sections/MediaBand.tsx +20 -0
  158. package/src/sections/MenuList.tsx +44 -0
  159. package/src/sections/PageHero.tsx +25 -0
  160. package/src/sections/PressQuotes.tsx +51 -0
  161. package/src/sections/PricingBand.tsx +43 -0
  162. package/src/sections/PricingCard.tsx +36 -0
  163. package/src/sections/ProcessSteps.tsx +45 -0
  164. package/src/sections/ProfileCards.tsx +73 -0
  165. package/src/sections/RevealSlider.tsx +156 -0
  166. package/src/sections/ReviewCarousel.tsx +223 -0
  167. package/src/sections/ReviewWall.tsx +220 -0
  168. package/src/sections/SocialStrip.tsx +83 -0
  169. package/src/sections/SplitSection.tsx +56 -0
  170. package/src/sections/index.ts +76 -0
  171. package/src/sections/siteConfig.ts +201 -0
  172. package/src/styles/app.css +673 -0
  173. package/src/styles/artifact.css +472 -399
  174. package/src/styles/base.css +49 -47
  175. package/src/styles/chrome.css +71 -71
  176. package/src/styles/components.css +150 -140
  177. package/src/styles/gate.css +29 -29
  178. package/src/styles/primitives.css +190 -25
  179. package/src/styles/sections.css +2652 -0
  180. package/src/styles/star-rating.css +2 -2
  181. package/src/vite/index.ts +9 -2
  182. package/src/vite/marketing-config.ts +128 -0
  183. package/src/components/Collapsible.tsx +0 -43
  184. /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The build's base path audit, kept pure so the tests can drive it; a site's
3
+ * build runs it over dist/client through `atelic-check-base-path`.
4
+ *
5
+ * Two failures it exists to catch. A root absolute path that skipped
6
+ * `withBase` still builds and still prerenders (the crawler prefixes it on its
7
+ * own), and only breaks once the site is served under its base, as a link to
8
+ * the host owner's page or an image that 404s. And a based cut whose
9
+ * vercel.json lacks the two rewrites serves fine through the host owner's
10
+ * proxy but 404s every asset on its own Vercel host, while a root cut that
11
+ * kept them carries a stray rule. Neither shows up in a root build, so both
12
+ * fail the build loudly instead.
13
+ */
14
+ import { hasBase } from "../routing/basePath";
15
+
16
+ const ATTRIBUTE = /\s(?:href|src|action|poster)\s*=\s*"([^"]*)"/g;
17
+ const SRCSET = /\ssrcset\s*=\s*"([^"]*)"/gi;
18
+ const CSS_URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
19
+
20
+ function isRootAbsolute(ref: string): boolean {
21
+ return ref.startsWith("/") && !ref.startsWith("//");
22
+ }
23
+
24
+ /** Every root absolute path an HTML document references through an attribute. */
25
+ export function htmlRefs(html: string): string[] {
26
+ const refs: string[] = [];
27
+ for (const match of html.matchAll(ATTRIBUTE)) refs.push(match[1]);
28
+ for (const match of html.matchAll(SRCSET)) {
29
+ for (const candidate of match[1].split(",")) refs.push(candidate.trim().split(/\s+/)[0]);
30
+ }
31
+ return refs.filter(isRootAbsolute);
32
+ }
33
+
34
+ /** Every root absolute path a stylesheet references through `url()`. */
35
+ export function cssRefs(css: string): string[] {
36
+ return [...css.matchAll(CSS_URL)].map((match) => match[2].trim()).filter(isRootAbsolute);
37
+ }
38
+
39
+ /** The root absolute references that do not carry the base. Empty at the root. */
40
+ export function unbasedRefs(refs: string[], base: string): string[] {
41
+ if (!base) return [];
42
+ return [...new Set(refs.filter((ref) => !hasBase(base, ref)))];
43
+ }
44
+
45
+ export interface Rewrite {
46
+ source: string;
47
+ destination: string;
48
+ }
49
+
50
+ /** The two rewrites a based cut's vercel.json carries, so its own host serves the prefixed URLs. */
51
+ export function expectedRewrites(base: string): Rewrite[] {
52
+ if (!base) return [];
53
+ return [
54
+ { source: `${base}/:path*`, destination: "/:path*" },
55
+ { source: base, destination: "/" },
56
+ ];
57
+ }
58
+
59
+ /**
60
+ * True for a rewrite of the base path shape: a prefix's subtree mapped back
61
+ * onto the root, or the bare prefix mapped onto "/" beside such a subtree
62
+ * rule (alone, "/home" to "/" is an ordinary rewrite and stays legal).
63
+ */
64
+ function isBaseRewrite(rewrite: Rewrite, all: Rewrite[]): boolean {
65
+ if (rewrite.destination === "/:path*" && rewrite.source.endsWith("/:path*")) return true;
66
+ return (
67
+ rewrite.destination === "/" &&
68
+ all.some((r) => r.source === `${rewrite.source}/:path*` && r.destination === "/:path*")
69
+ );
70
+ }
71
+
72
+ /**
73
+ * What is wrong with vercel.json's rewrites for this base: a missing expected
74
+ * rewrite, or a base path rewrite for a prefix the build does not carry.
75
+ */
76
+ export function rewriteProblems(vercel: { rewrites?: Rewrite[] }, base: string): string[] {
77
+ const rewrites = vercel.rewrites ?? [];
78
+ const expected = expectedRewrites(base);
79
+ const problems: string[] = [];
80
+ for (const want of expected) {
81
+ if (!rewrites.some((r) => r.source === want.source && r.destination === want.destination)) {
82
+ problems.push(`missing rewrite ${JSON.stringify(want)}`);
83
+ }
84
+ }
85
+ for (const rewrite of rewrites) {
86
+ const wanted = expected.some(
87
+ (want) => want.source === rewrite.source && want.destination === rewrite.destination,
88
+ );
89
+ if (!wanted && isBaseRewrite(rewrite, rewrites)) {
90
+ problems.push(
91
+ `stray base path rewrite ${JSON.stringify(rewrite)} for a build ${base ? `based at ${base}` : "at the root"}`,
92
+ );
93
+ }
94
+ }
95
+ return problems;
96
+ }
@@ -0,0 +1,25 @@
1
+ const MONTHS = [
2
+ "January",
3
+ "February",
4
+ "March",
5
+ "April",
6
+ "May",
7
+ "June",
8
+ "July",
9
+ "August",
10
+ "September",
11
+ "October",
12
+ "November",
13
+ "December",
14
+ ];
15
+
16
+ /**
17
+ * "2025-03-27" or a full ISO timestamp -> "March 27, 2025". The calendar date
18
+ * is read off the string, never through Date(), which lands a bare date a day
19
+ * early west of UTC. Anything unreadable is returned as given.
20
+ */
21
+ export function displayDate(iso: string): string {
22
+ const [year, month, day] = iso.slice(0, 10).split("-").map(Number);
23
+ if (!year || !month || !day || month > 12) return iso;
24
+ return `${MONTHS[month - 1]} ${day}, ${year}`;
25
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The contact form's honeypot. Humans never see the "company" field, so any
3
+ * value in it marks a bot. The relay a site runs (its api/contact.ts) drops
4
+ * such a submission behind the same quiet success a real send gets, so the
5
+ * bot learns nothing, and ContactForm, holding the value it just sent, skips
6
+ * the lead event that success would otherwise earn. A site's relay applies
7
+ * this same predicate, so the two never disagree about a bot.
8
+ */
9
+ export function isHoneypotFilled(value: unknown): boolean {
10
+ return String(value ?? "").trim() !== "";
11
+ }
@@ -0,0 +1,28 @@
1
+ import type { OpeningHours } from "../sections/siteConfig";
2
+
3
+ /**
4
+ * Formats a 24 hour "HH:MM" time as the display style used across the
5
+ * marketing components: "11:00" -> "11:00a", "19:00" -> "7:00p".
6
+ * With short: true, whole hours drop their minutes: "11a", "7p".
7
+ */
8
+ export function formatClock(time: string, opts: { short?: boolean } = {}): string {
9
+ const [hourStr, minuteStr = "00"] = time.split(":");
10
+ const hour24 = Number(hourStr);
11
+ const suffix = hour24 % 24 >= 12 ? "p" : "a";
12
+ const hour = hour24 % 12 || 12;
13
+ if (opts.short && minuteStr === "00") return `${hour}${suffix}`;
14
+ return `${hour}:${minuteStr}${suffix}`;
15
+ }
16
+
17
+ export function isClosed(row: OpeningHours): boolean {
18
+ return !row.opens || !row.closes;
19
+ }
20
+
21
+ /**
22
+ * "11:00a to 7:00p" (or "11a to 7p" with short: true). Rows without times
23
+ * render their note ("By appointment") when present, else "Closed".
24
+ */
25
+ export function formatHoursRange(row: OpeningHours, opts: { short?: boolean } = {}): string {
26
+ if (!row.opens || !row.closes) return row.note ?? "Closed";
27
+ return `${formatClock(row.opens, opts)} to ${formatClock(row.closes, opts)}`;
28
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Sitemap cleanup, run after the build writes sitemap.xml.
3
+ *
4
+ * The prerender crawler adds every internal link it follows to the page list,
5
+ * so an in page link like `/services#dry-needling` lands in the sitemap as its
6
+ * own URL, and an index route like `/blog/` lands beside the `/blog` the nav
7
+ * links to. Google ignores fragments, and every page declares its canonical
8
+ * without the trailing slash, so each one is a duplicate of a page already
9
+ * listed. This strips the fragment and the trailing slash from every `<loc>`
10
+ * and keeps the first entry per resulting URL, so a page reached only through
11
+ * a section link or its slashed form still stays listed. The root keeps its
12
+ * slash.
13
+ */
14
+
15
+ const URL_BLOCK = /[ \t]*<url>[\s\S]*?<\/url>\s*/g;
16
+ const LOC = /<loc>([^<]*)<\/loc>/;
17
+ const TRAILING_SLASH = /^(\w+:\/\/[^/]+\/.*?[^/])\/+$/;
18
+
19
+ export function dedupeSitemap(xml: string): { xml: string; removed: number } {
20
+ const seen = new Set<string>();
21
+ let removed = 0;
22
+ const cleaned = xml.replace(URL_BLOCK, (block) => {
23
+ const match = block.match(LOC);
24
+ if (!match) return block;
25
+ const loc = match[1].split("#")[0].replace(TRAILING_SLASH, "$1");
26
+ if (seen.has(loc)) {
27
+ removed++;
28
+ return "";
29
+ }
30
+ seen.add(loc);
31
+ return block.replace(LOC, () => `<loc>${loc}</loc>`);
32
+ });
33
+ return { xml: cleaned, removed };
34
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sends one GA4 event through the Google tag a site loads, and nothing else:
3
+ * the tag itself, and the delegated listener for the click events, stay in
4
+ * the site. A silent no op when the tag is absent (no tag id configured, the
5
+ * script blocked, prerender with no window), and it never throws, since
6
+ * analytics must never break the page. Beacon transport, so an event fired on
7
+ * the way out of the page survives the navigation.
8
+ *
9
+ * Params are closed vocabularies: never a name, a phone number, an email
10
+ * address, or a message.
11
+ */
12
+
13
+ type Gtag = (...args: unknown[]) => void;
14
+
15
+ export type EventParams = Record<string, string | number | boolean>;
16
+
17
+ export function trackEvent(name: string, params: EventParams = {}): void {
18
+ if (typeof window === "undefined") return;
19
+ try {
20
+ const gtag = (window as Window & { gtag?: Gtag }).gtag;
21
+ if (typeof gtag !== "function") return;
22
+ gtag("event", name, { ...params, transport_type: "beacon" });
23
+ } catch {
24
+ // Analytics must never break the page.
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+
3
+ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
4
+
5
+ /**
6
+ * The max height an expanding body animates to. Put `ref` on the content
7
+ * inside the clipped box: the height is measured before paint when the row
8
+ * opens (so a row that mounts already open is never clipped to nothing) and
9
+ * re-measured whenever the content resizes (rotation, font swap, late image).
10
+ * The layout effect runs before paint, so the 0 it starts from is never seen
11
+ * clipped, and the first open animates like every later one.
12
+ */
13
+ export function useOpenHeight(open: boolean) {
14
+ const ref = useRef<HTMLDivElement>(null);
15
+ const [height, setHeight] = useState<number | null>(null);
16
+
17
+ useIsoLayoutEffect(() => {
18
+ const el = ref.current;
19
+ if (!open || !el) return;
20
+ setHeight(el.scrollHeight);
21
+ if (typeof ResizeObserver === "undefined") return;
22
+ const observer = new ResizeObserver(() => setHeight(el.scrollHeight));
23
+ observer.observe(el);
24
+ return () => observer.disconnect();
25
+ }, [open]);
26
+
27
+ return { ref, maxHeight: open ? (height ?? 0) : 0 } as const;
28
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
2
  import { newTabProps } from "../lib/newTabProps";
3
+ import { useWithBase } from "../routing/BasePathProvider";
3
4
 
4
5
  export interface ButtonProps {
5
6
  variant?: "primary" | "dark" | "ghost";
@@ -39,6 +40,7 @@ export function Button({
39
40
  style,
40
41
  children,
41
42
  }: ButtonProps) {
43
+ const withBase = useWithBase();
42
44
  const classes = [
43
45
  "btn",
44
46
  `btn-${variant}`,
@@ -63,7 +65,7 @@ export function Button({
63
65
  if (href) {
64
66
  return (
65
67
  <a
66
- href={href}
68
+ href={withBase(href)}
67
69
  className={classes}
68
70
  style={style}
69
71
  aria-label={ariaLabel}
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { useWithBase } from "../routing/BasePathProvider";
2
3
 
3
4
  export interface ChipProps {
4
5
  /** Shows the small brand-colored dot before the label. */
@@ -12,6 +13,7 @@ export interface ChipProps {
12
13
 
13
14
  /** Small mono tag/badge, used for credentials, tags, and service areas. */
14
15
  export function Chip({ dot = false, onDark = false, href, children }: ChipProps) {
16
+ const withBase = useWithBase();
15
17
  const classes = ["chip", onDark && "on-dark"].filter(Boolean).join(" ");
16
18
  const content = (
17
19
  <>
@@ -21,7 +23,7 @@ export function Chip({ dot = false, onDark = false, href, children }: ChipProps)
21
23
  );
22
24
  if (href) {
23
25
  return (
24
- <a className={classes} href={href}>
26
+ <a className={classes} href={withBase(href)}>
25
27
  {content}
26
28
  </a>
27
29
  );
@@ -0,0 +1,48 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useWithBase } from "../routing/BasePathProvider";
3
+
4
+ export interface ImageFrameProps {
5
+ src?: string;
6
+ alt?: string;
7
+ /** CSS aspect-ratio for the frame, e.g. "4/5". */
8
+ aspectRatio?: string;
9
+ maxWidth?: string;
10
+ /** Placeholder label rendered when no src is provided yet. */
11
+ placeholder?: string;
12
+ /** Set on above-the-fold imagery; defaults to lazy loading. */
13
+ priority?: boolean;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ /** Rounded, shadowed media frame; renders a labeled placeholder block until an image exists. */
19
+ export function ImageFrame({
20
+ src,
21
+ alt = "",
22
+ aspectRatio,
23
+ maxWidth,
24
+ placeholder,
25
+ priority = false,
26
+ className,
27
+ style,
28
+ }: ImageFrameProps) {
29
+ const withBase = useWithBase();
30
+ const frameStyle: CSSProperties = { aspectRatio, maxWidth, ...style };
31
+ if (!src) {
32
+ return (
33
+ <div className={["ph", className].filter(Boolean).join(" ")} style={frameStyle}>
34
+ {placeholder ?? "Image"}
35
+ </div>
36
+ );
37
+ }
38
+ return (
39
+ <div className={["imgframe", className].filter(Boolean).join(" ")} style={frameStyle}>
40
+ <img
41
+ src={withBase(src)}
42
+ alt={alt}
43
+ loading={priority ? undefined : "lazy"}
44
+ fetchPriority={priority ? "high" : undefined}
45
+ />
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * One block of ported prose. A deliberately tiny vocabulary — headings,
3
+ * paragraphs, lists — because that is what survives extraction from a CMS
4
+ * intact, and because anything richer invites rewriting content that was
5
+ * meant to be carried across verbatim.
6
+ */
7
+ export interface ProseBlock {
8
+ type: "h" | "p" | "ul";
9
+ /** Present for "h" and "p". */
10
+ text?: string;
11
+ /** Present for "ul". */
12
+ items?: string[];
13
+ }
14
+
15
+ export interface ProseBlocksProps {
16
+ blocks: ProseBlock[];
17
+ /**
18
+ * Heading level for "h" blocks. Defaults to h2, since the page's h1
19
+ * belongs to whatever hero sits above this.
20
+ */
21
+ headingLevel?: "h2" | "h3";
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * Renders a document body from data rather than markup: legal pages, ported
27
+ * blog posts, anything whose words came from somewhere else and must not be
28
+ * quietly edited on the way in.
29
+ *
30
+ * Keys are derived from content plus position. Repeated text is common in
31
+ * ported prose ("Contact us" appears in three legal documents), so neither
32
+ * alone is stable.
33
+ */
34
+ export function ProseBlocks({ blocks, headingLevel = "h2", className }: ProseBlocksProps) {
35
+ const Heading = headingLevel;
36
+ return (
37
+ <div className={["prose-blocks", className].filter(Boolean).join(" ")}>
38
+ {blocks.map((block, i) => {
39
+ if (block.type === "h") {
40
+ return (
41
+ // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
42
+ <Heading className="pb-heading" key={`h-${i}-${block.text?.slice(0, 24)}`}>
43
+ {block.text}
44
+ </Heading>
45
+ );
46
+ }
47
+ if (block.type === "ul") {
48
+ return (
49
+ // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
50
+ <ul className="pb-list" key={`ul-${i}-${(block.items ?? [])[0]?.slice(0, 24)}`}>
51
+ {(block.items ?? []).map((item, j) => (
52
+ // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
53
+ <li key={`${j}-${item.slice(0, 40)}`}>{item}</li>
54
+ ))}
55
+ </ul>
56
+ );
57
+ }
58
+ return (
59
+ // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
60
+ <p className="pb-body" key={`p-${i}-${block.text?.slice(0, 24)}`}>
61
+ {block.text}
62
+ </p>
63
+ );
64
+ })}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface QuoteCardProps {
4
+ quote: ReactNode;
5
+ /** Attribution line, e.g. "Dr. Alex Rivers, PT, DPT". */
6
+ by: string;
7
+ center?: boolean;
8
+ className?: string;
9
+ }
10
+
11
+ /** Testimonial / pull-quote card with serif italic type. */
12
+ export function QuoteCard({ quote, by, center = false, className }: QuoteCardProps) {
13
+ const classes = ["quote-card", center && "center", className].filter(Boolean).join(" ");
14
+ return (
15
+ <figure className={classes}>
16
+ <blockquote>{quote}</blockquote>
17
+ <figcaption className="by">{by}</figcaption>
18
+ </figure>
19
+ );
20
+ }
@@ -0,0 +1,37 @@
1
+ import { Star } from "lucide-react";
2
+
3
+ export interface StarRowProps {
4
+ /** Star count, 1 to 5. Rounded and clamped into range. */
5
+ rating: number;
6
+ /** Glyph size in px. */
7
+ size?: number;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * A row of filled stars, rendered as a single labeled image so screen
13
+ * readers announce "4 out of 5 stars" instead of four decorative glyphs.
14
+ * Shared by the review components; render one only when the source
15
+ * actually shows a rating — never invent one.
16
+ */
17
+ export function StarRow({ rating, size = 15, className }: StarRowProps) {
18
+ const count = Math.max(1, Math.min(5, Math.round(rating)));
19
+ return (
20
+ <span
21
+ className={["star-row", className].filter(Boolean).join(" ")}
22
+ role="img"
23
+ aria-label={`${count} out of 5 stars`}
24
+ >
25
+ {Array.from({ length: count }, (_, i) => (
26
+ <Star
27
+ // biome-ignore lint/suspicious/noArrayIndexKey: static decorative row
28
+ key={i}
29
+ size={size}
30
+ strokeWidth={1.5}
31
+ fill="currentColor"
32
+ aria-hidden="true"
33
+ />
34
+ ))}
35
+ </span>
36
+ );
37
+ }
@@ -0,0 +1,24 @@
1
+ import { Check } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ export interface TickListProps {
5
+ items: ReactNode[];
6
+ className?: string;
7
+ }
8
+
9
+ /** Checkmark bullet list used in feature details and pricing cards. */
10
+ export function TickList({ items, className }: TickListProps) {
11
+ return (
12
+ <ul className={["ticks", className].filter(Boolean).join(" ")}>
13
+ {items.map((item, i) => (
14
+ // biome-ignore lint/suspicious/noArrayIndexKey: static content lists never reorder
15
+ <li key={i}>
16
+ <span className="tick" aria-hidden="true">
17
+ <Check size={12} strokeWidth={3} />
18
+ </span>
19
+ {item}
20
+ </li>
21
+ ))}
22
+ </ul>
23
+ );
24
+ }
@@ -1,5 +1,10 @@
1
1
  export { Button, type ButtonProps } from "./Button";
2
2
  export { Chip, type ChipProps } from "./Chip";
3
3
  export { Eyebrow, type EyebrowProps } from "./Eyebrow";
4
+ export { ImageFrame, type ImageFrameProps } from "./ImageFrame";
4
5
  export { Lead, type LeadProps } from "./Lead";
6
+ export { type ProseBlock, ProseBlocks, type ProseBlocksProps } from "./ProseBlocks";
7
+ export { QuoteCard, type QuoteCardProps } from "./QuoteCard";
5
8
  export { SectionHeading, type SectionHeadingProps } from "./SectionHeading";
9
+ export { StarRow, type StarRowProps } from "./StarRow";
10
+ export { TickList, type TickListProps } from "./TickList";
@@ -0,0 +1,47 @@
1
+ import { createContext, type ReactNode, useCallback, useContext } from "react";
2
+ import { joinBase, normalizeBasePath } from "./basePath";
3
+
4
+ /**
5
+ * The base path every link the package renders carries. The package imports
6
+ * no router and reads no build setting, so a site says its base once, here,
7
+ * and `Button`, `Chip`, and every section that builds a link or an image
8
+ * source read it through `useBasePath`. `SiteShell` mounts the provider from
9
+ * its `basePath` prop, so a site that renders its pages inside the shell
10
+ * never mounts it by hand.
11
+ *
12
+ * With no provider above it the base is "", the root, and every path renders
13
+ * as given.
14
+ */
15
+ const BasePathContext = createContext("");
16
+
17
+ export interface BasePathProviderProps {
18
+ /** The prefix the site is served under: "", "/", "proto", and "/proto/" are all read as written. */
19
+ base?: string;
20
+ children: ReactNode;
21
+ }
22
+
23
+ export function BasePathProvider({ base, children }: BasePathProviderProps) {
24
+ return (
25
+ <BasePathContext.Provider value={normalizeBasePath(base)}>{children}</BasePathContext.Provider>
26
+ );
27
+ }
28
+
29
+ /** The base path in force: "" at the root, otherwise "/proto" in normalized form. */
30
+ export function useBasePath(): string {
31
+ return useContext(BasePathContext);
32
+ }
33
+
34
+ /**
35
+ * A function that puts the base in force on a root absolute path. External
36
+ * URLs, protocol links, fragments, and paths that already carry the base pass
37
+ * through unchanged, and so does `undefined`, so an optional prop can be
38
+ * handed over as it is.
39
+ */
40
+ export function useWithBase(): <T extends string | undefined>(path: T) => T {
41
+ const base = useBasePath();
42
+ return useCallback(
43
+ <T extends string | undefined>(path: T): T =>
44
+ (path === undefined ? path : joinBase(base, path)) as T,
45
+ [base],
46
+ );
47
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The base path: the prefix a site is served under when it does not own the
3
+ * root of its host (a website build previewed at `/proto` on the business's
4
+ * artifact host). A site sets it once; `BasePathProvider` hands it to every
5
+ * component below, and `marketingConfig` turns it into Vite's `base` and the
6
+ * router's `basepath`.
7
+ *
8
+ * Normalized form: "" for the root, otherwise a leading slash and no trailing
9
+ * slash ("/proto"). These helpers are pure and take the base explicitly, so
10
+ * the Vite config, the build commands, and the tests use them as they are.
11
+ * Plain TypeScript with no imports, since the built Vite entry imports it.
12
+ */
13
+
14
+ const SEGMENT = /^(\/[A-Za-z0-9._~-]+)+$/;
15
+
16
+ /** Turns a configured value ("", "/", "proto", "/proto/") into "" or "/proto". Throws on anything else. */
17
+ export function normalizeBasePath(raw: string | undefined): string {
18
+ const trimmed = (raw ?? "").trim();
19
+ if (trimmed === "" || trimmed === "/") return "";
20
+ const base = `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
21
+ if (!SEGMENT.test(base)) {
22
+ throw new Error(
23
+ `basePath must be a path such as "/proto" (letters, digits, dot, dash, underscore, tilde): got ${JSON.stringify(raw)}`,
24
+ );
25
+ }
26
+ return base;
27
+ }
28
+
29
+ /** True for a path the base applies to: root absolute, not protocol relative. */
30
+ function isRootAbsolute(path: string): boolean {
31
+ return path.startsWith("/") && !path.startsWith("//");
32
+ }
33
+
34
+ /** True when the path already carries the base (so applying it again is a no op). */
35
+ export function hasBase(base: string, path: string): boolean {
36
+ if (!base) return true;
37
+ if (!path.startsWith(base)) return false;
38
+ const next = path.charAt(base.length);
39
+ return next === "" || next === "/" || next === "?" || next === "#";
40
+ }
41
+
42
+ /**
43
+ * Prefixes a root absolute path with the base. External URLs, protocol links
44
+ * (mailto:, tel:), fragments, relative paths, and paths that already carry the
45
+ * base come back unchanged, so it is safe on any href and idempotent.
46
+ */
47
+ export function joinBase(base: string, path: string): string {
48
+ if (!base || !isRootAbsolute(path) || hasBase(base, path)) return path;
49
+ return `${base}${path}`;
50
+ }
51
+
52
+ /** Removes the base from a path that carries it ("/proto/faqs" to "/faqs", "/proto" to "/"). */
53
+ export function stripBase(base: string, path: string): string {
54
+ if (!base || !isRootAbsolute(path) || !path.startsWith(base) || !hasBase(base, path)) return path;
55
+ const rest = path.slice(base.length);
56
+ return rest === "" || !rest.startsWith("/") ? `/${rest}` : rest;
57
+ }
@@ -29,3 +29,11 @@ export function staticNotFoundRouting<Page>(page: Page) {
29
29
  defaultPendingComponent: page,
30
30
  };
31
31
  }
32
+
33
+ export {
34
+ BasePathProvider,
35
+ type BasePathProviderProps,
36
+ useBasePath,
37
+ useWithBase,
38
+ } from "./BasePathProvider";
39
+ export { hasBase, joinBase, normalizeBasePath, stripBase } from "./basePath";