@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,32 @@
1
+ "use client";
2
+
3
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
4
+ import * as React from "react";
5
+
6
+ import { cn } from "../lib/utils";
7
+
8
+ const TooltipProvider = TooltipPrimitive.Provider;
9
+
10
+ const Tooltip = TooltipPrimitive.Root;
11
+
12
+ const TooltipTrigger = TooltipPrimitive.Trigger;
13
+
14
+ const TooltipContent = React.forwardRef<
15
+ React.ElementRef<typeof TooltipPrimitive.Content>,
16
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
17
+ >(({ className, sideOffset = 4, ...props }, ref) => (
18
+ <TooltipPrimitive.Portal>
19
+ <TooltipPrimitive.Content
20
+ ref={ref}
21
+ sideOffset={sideOffset}
22
+ className={cn(
23
+ "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ </TooltipPrimitive.Portal>
29
+ ));
30
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
31
+
32
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -19,7 +19,7 @@ export interface ArtifactShellProps {
19
19
  }
20
20
 
21
21
  /**
22
- * The wrapper every artifact page uses: the `.mkt` theming
22
+ * The wrapper every artifact page uses: the `.atelic-ui` theming
23
23
  * scope, the skip link, the header, the credit band, and the access gate's
24
24
  * watermark, composed from the chrome. The header is always the dark
25
25
  * variant with the sender's lockup and never a site nav or a CTA, because the
@@ -34,7 +34,7 @@ export function ArtifactShell({ site, currentPath, stops, children }: ArtifactSh
34
34
  const recipient = site.recipient.logo;
35
35
 
36
36
  return (
37
- <div className="mkt">
37
+ <div className="atelic-ui">
38
38
  <SkipLink />
39
39
  <SiteHeader
40
40
  brand={{ name, href, logo, recipient }}
@@ -2,7 +2,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
2
2
  import { Button } from "../primitives/Button";
3
3
 
4
4
  /**
5
- * PBKDF2 work factor and hash. Mirrored by scripts/build-gate-payload.mjs;
5
+ * PBKDF2 work factor and hash. Mirrored by scripts/seal-document.mjs;
6
6
  * the two must agree exactly or every sealed payload stops opening.
7
7
  */
8
8
  const PBKDF2_ITERATIONS = 200000;
@@ -40,7 +40,7 @@ export interface GatePayload {
40
40
  }
41
41
 
42
42
  export interface GateProps {
43
- /** Built by scripts/build-gate-payload.mjs. Carries no plaintext. */
43
+ /** Built by scripts/seal-document.mjs. Carries no plaintext. */
44
44
  payload: GatePayload;
45
45
  /** localStorage key holding the reader's email, so a return visit walks straight in. */
46
46
  storageKey?: string;
@@ -4,10 +4,10 @@ import {
4
4
  Callout,
5
5
  Checklist,
6
6
  type ChecklistItem,
7
- Collapsible,
8
7
  ColumnChart,
9
8
  type ColumnChartGroup,
10
9
  DataTable,
10
+ Disclosure,
11
11
  StackedBar,
12
12
  type StackedBarSegment,
13
13
  type StatItem,
@@ -34,7 +34,7 @@ export type { WriteupPerson } from "./SignOff";
34
34
  * type brought down, flowing across letter pages; nothing is clipped.
35
35
  *
36
36
  * Every block is a component from @atelic-action/ui (the checklist, the
37
- * sortable table, the charts, the stat row, the callout, the collapsible);
37
+ * sortable table, the charts, the stat row, the callout, the disclosure);
38
38
  * this file owns only the document: the overview, the tiers, the section
39
39
  * card, the markets table, and the close. Its skeleton is the practice's
40
40
  * FOOTPRINT.md, and what it says follows the pitch surface rules
@@ -158,7 +158,7 @@ export interface WriteupShots {
158
158
  shots: WriteupShot[];
159
159
  }
160
160
 
161
- /** Blocks that can sit inside a collapsible. */
161
+ /** Blocks that can sit inside a fold (a `more` block). */
162
162
  export type WriteupInnerBlock =
163
163
  | WriteupChecks
164
164
  | WriteupTable
@@ -170,8 +170,8 @@ export type WriteupInnerBlock =
170
170
  | WriteupShots;
171
171
 
172
172
  /**
173
- * A collapsible: the label says what is inside and the summary says what it
174
- * shows, so the detail needs no explaining once open.
173
+ * A fold, the native Disclosure: the label says what is inside and the
174
+ * summary says what it shows, so the detail needs no explaining once open.
175
175
  */
176
176
  export interface WriteupMore {
177
177
  kind: "more";
@@ -432,14 +432,14 @@ function Shots({ block }: { block: WriteupShots }) {
432
432
 
433
433
  function More({ block }: { block: WriteupMore }) {
434
434
  return (
435
- <Collapsible className="op-block op-more" label={block.label} summary={block.summary}>
435
+ <Disclosure className="op-block op-more" label={block.label} summary={block.summary}>
436
436
  <div className="op-more-body">
437
437
  {block.blocks.map((inner, i) => (
438
438
  // biome-ignore lint/suspicious/noArrayIndexKey: blocks are static content in a fixed order
439
439
  <TopicBlock block={inner} key={i} />
440
440
  ))}
441
441
  </div>
442
- </Collapsible>
442
+ </Disclosure>
443
443
  );
444
444
  }
445
445
 
@@ -0,0 +1,141 @@
1
+ import type { ReactNode } from "react";
2
+ import { Watermark } from "../components/Watermark";
3
+ import type { PageStop } from "../hooks/useScrollSpy";
4
+ import { formatHoursRange } from "../lib/hours";
5
+ import { BasePathProvider } from "../routing/BasePathProvider";
6
+ import { joinBase, normalizeBasePath, stripBase } from "../routing/basePath";
7
+ import type { NavItem, SiteConfig } from "../sections/siteConfig";
8
+ import { CreditBar } from "./CreditBar";
9
+ import { Footer } from "./Footer";
10
+ import { SiteHeader, type SiteHeaderVariant } from "./SiteHeader";
11
+ import { SkipLink } from "./SkipLink";
12
+ import { StickyCTABar } from "./StickyCTABar";
13
+
14
+ export interface SiteShellProps {
15
+ site: SiteConfig;
16
+ /**
17
+ * The path being read, from the site's own router, with or without the
18
+ * base path. The package imports no router, so a site's wrapper passes it.
19
+ */
20
+ currentPath: string;
21
+ /**
22
+ * The prefix the site is served under. Defaults to `site.basePath`; a site
23
+ * whose build sets the base another way (a rehearsal build under
24
+ * `VITE_BASE_PATH`) passes what the build used. The shell mounts
25
+ * `BasePathProvider` with it, so every section inside reads the same base.
26
+ */
27
+ basePath?: string;
28
+ /** The header's links. Defaults to `site.nav`; the footer lists the same ones. */
29
+ nav?: NavItem[];
30
+ /** Home style pages with a full bleed hero get a transparent header. */
31
+ heroNav?: boolean;
32
+ /**
33
+ * A page that closes on its own ask turns the mobile booking bar off,
34
+ * since that ask is the conversion surface there.
35
+ */
36
+ stickyCta?: boolean;
37
+ /**
38
+ * Such a page also drops the site footer: when a page ends on its own
39
+ * sign off, the NAP footer after it steps on the close.
40
+ */
41
+ footer?: boolean;
42
+ /** Presentation mode: the page's stops replace the nav links and the CTA. */
43
+ pageNav?: PageStop[];
44
+ /** Match the header to a dark page surface. */
45
+ navOnDark?: boolean;
46
+ /** Hide the header's primary CTA (e.g. a reveal, where the ask is the letter). The menu keeps it. */
47
+ hideNavCta?: boolean;
48
+ /**
49
+ * The developer credit band. Defaults on (client sites carry it); the
50
+ * practice's own site turns it off, since crediting yourself on your
51
+ * own page reads as noise.
52
+ */
53
+ credit?: boolean;
54
+ children: ReactNode;
55
+ }
56
+
57
+ /**
58
+ * The marketing page wrapper: the `.atelic-ui` scope around the skip link,
59
+ * the header, `<main>`, the sticky CTA bar, the footer with its credit band,
60
+ * and the watermark, all mapped from the site's config. Moved here from
61
+ * template-marketing in 0.19.0 (ATE-643); a site keeps a wrapper a few lines
62
+ * long that reads the path from its router and passes it in.
63
+ */
64
+ export function SiteShell({
65
+ site,
66
+ currentPath,
67
+ basePath,
68
+ nav,
69
+ heroNav = false,
70
+ stickyCta = true,
71
+ footer = true,
72
+ pageNav,
73
+ navOnDark = false,
74
+ hideNavCta = false,
75
+ credit = true,
76
+ children,
77
+ }: SiteShellProps) {
78
+ const base = normalizeBasePath(basePath ?? site.basePath);
79
+ const withBase = (path: string) => joinBase(base, path);
80
+ // Every href and src handed to the chrome carries the base path (a no op at
81
+ // the root). The current path is compared against those hrefs, so it is
82
+ // put in the same form whether or not the router reports it with the base.
83
+ const pathname = withBase(stripBase(base, currentPath));
84
+ const brand = {
85
+ name: site.name,
86
+ href: withBase("/"),
87
+ logo: { ...site.logo, src: site.logo.src === undefined ? undefined : withBase(site.logo.src) },
88
+ };
89
+ const links = (nav ?? site.nav).map((item) => ({ label: item.label, href: withBase(item.to) }));
90
+ const cta = { ...site.cta, href: withBase(site.cta.href) };
91
+ const based = <T extends { href: string }>(items: T[] | undefined) =>
92
+ items?.map((item) => ({ ...item, href: withBase(item.href) }));
93
+ // The header takes one variant. A hero page wins over a dark one: the bar
94
+ // sits transparent over the hero either way, and only its scrolled state
95
+ // differed.
96
+ const variant: SiteHeaderVariant = heroNav ? "transparent" : navOnDark ? "dark" : "solid";
97
+
98
+ return (
99
+ <BasePathProvider base={base}>
100
+ <div className="atelic-ui">
101
+ <SkipLink />
102
+ <SiteHeader
103
+ brand={brand}
104
+ links={links}
105
+ primaryCTA={cta}
106
+ variant={variant}
107
+ currentPath={pathname}
108
+ stops={pageNav}
109
+ hideCTA={hideNavCta}
110
+ contact={{ phone: site.phone, email: site.email, address: site.address }}
111
+ />
112
+ <main id="main">{children}</main>
113
+ {stickyCta && <StickyCTABar primaryCTA={cta} phone={site.phone} />}
114
+ {footer ? (
115
+ <Footer
116
+ brand={brand}
117
+ blurb={site.footerBlurb}
118
+ socialLinks={based(site.socialLinks)}
119
+ links={links}
120
+ extraLinks={based(site.footerLinks)}
121
+ contactHeading={site.footer?.contactHeading}
122
+ address={site.footer?.showAddress === false ? undefined : site.address}
123
+ phone={site.phone}
124
+ email={site.email}
125
+ hours={site.hours.map((row) => ({
126
+ label: row.labelShort,
127
+ range: formatHoursRange(row, { short: true }),
128
+ }))}
129
+ attribution={site.attribution}
130
+ note={site.footer?.note}
131
+ legalLinks={based(site.legalLinks)}
132
+ credit={credit}
133
+ />
134
+ ) : (
135
+ credit && <CreditBar />
136
+ )}
137
+ <Watermark />
138
+ </div>
139
+ </BasePathProvider>
140
+ );
141
+ }
@@ -1,4 +1,5 @@
1
1
  export type { PageStop } from "../hooks/useScrollSpy";
2
+ export type { NavItem, SiteConfig } from "../sections/siteConfig";
2
3
  export type {
3
4
  Brand,
4
5
  BrandLogo,
@@ -16,5 +17,6 @@ export { CreditBar } from "./CreditBar";
16
17
  export { Footer, type FooterProps } from "./Footer";
17
18
  export { SiteHeader, type SiteHeaderProps, type SiteHeaderVariant } from "./SiteHeader";
18
19
  export { SiteMenu, type SiteMenuProps } from "./SiteMenu";
20
+ export { SiteShell, type SiteShellProps } from "./SiteShell";
19
21
  export { SkipLink, type SkipLinkProps } from "./SkipLink";
20
22
  export { StickyCTABar, type StickyCTABarProps } from "./StickyCTABar";
@@ -1,9 +1,18 @@
1
1
  import { ChevronDown } from "lucide-react";
2
2
  import { type ReactNode, useId } from "react";
3
3
 
4
- export interface DisclosureProps {
4
+ interface DisclosureBaseProps {
5
5
  /** What the section is, and the toggle's accessible name. */
6
6
  label: ReactNode;
7
+ className?: string;
8
+ children: ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Held by the caller: an app's section. The caller keeps `open`, so it can
13
+ * remember which sections were folded, and a closed body is not rendered.
14
+ */
15
+ export interface DisclosureControlledProps extends DisclosureBaseProps {
7
16
  open: boolean;
8
17
  onToggle: () => void;
9
18
  /**
@@ -15,23 +24,80 @@ export interface DisclosureProps {
15
24
  headingLevel?: 2 | 3 | 4 | 5 | 6;
16
25
  /** The panel's id; minted when left out. */
17
26
  panelId?: string;
18
- className?: string;
19
- children: ReactNode;
27
+ summary?: never;
28
+ defaultOpen?: never;
20
29
  }
21
30
 
22
31
  /**
23
- * A section the page opens and closes: a header row that folds a body the
24
- * caller controls. Where `Collapsible` is a native details element that keeps
25
- * its own state and its detail in the page, this one is for an app. The
26
- * caller holds `open`, so it can remember which sections were folded, and a
27
- * closed body is not rendered, so a long list costs nothing until asked for.
32
+ * Left to the browser: a document's fold. A native details element that keeps
33
+ * its own state and its detail in the page, so it opens before any script
34
+ * loads, find in page opens it, and it prints open.
35
+ */
36
+ export interface DisclosureNativeProps extends DisclosureBaseProps {
37
+ /**
38
+ * What the detail shows, in a sentence, under the label, so a reader who
39
+ * never opens it still has the point and the detail needs no explaining
40
+ * once open.
41
+ */
42
+ summary?: ReactNode;
43
+ /** Open on first render; closed by default. */
44
+ defaultOpen?: boolean;
45
+ open?: never;
46
+ onToggle?: never;
47
+ meta?: never;
48
+ headingLevel?: never;
49
+ panelId?: never;
50
+ }
51
+
52
+ export type DisclosureProps = DisclosureControlledProps | DisclosureNativeProps;
53
+
54
+ /**
55
+ * A section the reader opens and closes, in one of two forms chosen by
56
+ * whether the caller passes `open` or `onToggle`.
57
+ *
58
+ * Controlled (`open` and `onToggle`), it is an app's card: a header row that
59
+ * folds a body the caller holds, and a closed body is not rendered, so a long
60
+ * list costs nothing until asked for. The label is the toggle, so its name
61
+ * reads "Produce, collapsed" and not "Produce 10 items"; `meta` is a sibling
62
+ * for that reason. The toggle's `::after` stretches over the whole header
63
+ * row, so a tap anywhere on it, meta and chevron included, folds the section.
28
64
  *
29
- * The label is the toggle, so its name reads "Produce, collapsed" and not
30
- * "Produce 10 items"; `meta` is a sibling for that reason. The toggle's
31
- * `::after` stretches over the whole header row, so a tap anywhere on it,
32
- * meta and chevron included, folds the section.
65
+ * Uncontrolled, it is a document's fold: a labeled line over a summary, as a
66
+ * native details element. It opens with a click, a tap, Enter, or Space, works
67
+ * before any script loads, a browser's find in page opens it, and it prints
68
+ * open. The writeup's "more" blocks are this form.
33
69
  */
34
- export function Disclosure({
70
+ export function Disclosure(props: DisclosureProps) {
71
+ if (props.open !== undefined || props.onToggle !== undefined)
72
+ return <ControlledDisclosure {...props} />;
73
+ return <NativeDisclosure {...props} />;
74
+ }
75
+
76
+ function NativeDisclosure({
77
+ label,
78
+ summary,
79
+ defaultOpen,
80
+ className,
81
+ children,
82
+ }: DisclosureNativeProps) {
83
+ return (
84
+ <details
85
+ className={["disclosure", "is-native", className].filter(Boolean).join(" ")}
86
+ open={defaultOpen}
87
+ >
88
+ <summary className="disclosure-head">
89
+ <span className="disclosure-text">
90
+ <span className="disclosure-label">{label}</span>
91
+ {summary && <span className="disclosure-summary">{summary}</span>}
92
+ </span>
93
+ <ChevronDown className="disclosure-chevron" size={18} aria-hidden="true" />
94
+ </summary>
95
+ <div className="disclosure-body">{children}</div>
96
+ </details>
97
+ );
98
+ }
99
+
100
+ function ControlledDisclosure({
35
101
  label,
36
102
  open,
37
103
  onToggle,
@@ -40,7 +106,7 @@ export function Disclosure({
40
106
  panelId,
41
107
  className,
42
108
  children,
43
- }: DisclosureProps) {
109
+ }: DisclosureControlledProps) {
44
110
  const minted = useId();
45
111
  const id = panelId ?? minted;
46
112
  const Root = headingLevel ? "section" : "div";
@@ -28,7 +28,7 @@ export interface GateLoginProps {
28
28
  * addresses. The response is the same either way, so this screen always
29
29
  * shows the same "check your inbox" confirmation.
30
30
  *
31
- * It renders the screen only: a site wraps it in its `.mkt` scope with no
31
+ * It renders the screen only: a site wraps it in its `.atelic-ui` scope with no
32
32
  * header, since a header would only point at pages the visitor cannot open
33
33
  * yet. The button wears the site's own `.btn` classes, as the chrome's do.
34
34
  */
@@ -8,7 +8,7 @@ import { readCookie } from "../lib/readCookie";
8
8
  * faint tiled overlay when the gate runs in "tiled" mode. Renders nothing
9
9
  * when there is no session, so it is inert on a public site and during
10
10
  * prerender. The email travels into any screenshot. Mount it inside the
11
- * site's `.mkt` (or `.atelic-ui`) wrapper, where its styles are scoped.
11
+ * site's `.atelic-ui` wrapper, where its styles are scoped.
12
12
  *
13
13
  * It deters and attributes; it does not enforce. The cookies it reads are
14
14
  * unsigned and it is drawn in the reader's own browser.
@@ -1,7 +1,6 @@
1
1
  export { readCookie } from "../lib/readCookie";
2
2
  export { Callout, type CalloutProps } from "./Callout";
3
3
  export { Checklist, type ChecklistItem, type ChecklistProps } from "./Checklist";
4
- export { Collapsible, type CollapsibleProps } from "./Collapsible";
5
4
  export {
6
5
  ColumnChart,
7
6
  type ColumnChartBar,
@@ -17,7 +16,12 @@ export {
17
16
  type DataTableValueCell,
18
17
  type SortDirection,
19
18
  } from "./DataTable";
20
- export { Disclosure, type DisclosureProps } from "./Disclosure";
19
+ export {
20
+ Disclosure,
21
+ type DisclosureControlledProps,
22
+ type DisclosureNativeProps,
23
+ type DisclosureProps,
24
+ } from "./Disclosure";
21
25
  export { GateLogin, type GateLoginProps } from "./GateLogin";
22
26
  export { NotFound, type NotFoundProps } from "./NotFound";
23
27
  export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
package/src/gate/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { safeNext } from "./next.js";
2
2
  export { signToken, type TokenClaims, type TokenPurpose, verifyToken } from "./tokens.js";
3
- export { needsSession } from "./wall.js";
3
+ export { createNeedsSession, needsSession, OPEN_PATHS, type WallOpenings } from "./wall.js";
@@ -16,17 +16,28 @@
16
16
  *
17
17
  * That matcher keeps only the two sign in functions away from the middleware,
18
18
  * so any other function a host adds is held like a page. The default holds
19
- * back everything `needsSession` in wall.ts does. A site
20
- * whose matcher already leaves its open paths out passes its own test to
21
- * `createMiddleware`.
19
+ * back everything `needsSession` in wall.ts does. A marketing site, whose
20
+ * other functions (the contact relay) are public by design, uses the simple
21
+ * matcher instead, everything but `api/` and the login page:
22
+ *
23
+ * export const config = { matcher: ["/((?!api/|login$|login/).*)"] };
24
+ *
25
+ * and leaves the deciding here: a request passes with no session only when
26
+ * the wall's open exact paths and open prefixes name it, so a page is held by
27
+ * default whatever its path looks like (`/blog/release-2.0` included), where
28
+ * a matcher that let file extensions through let it escape. A site with an
29
+ * open file of its own passes `open`; one whose matcher already leaves its
30
+ * open paths out may still pass its own `needsSession`, which wins over both.
22
31
  */
23
32
  import { isAllowed } from "./config.js";
24
33
  import { verifyToken } from "./tokens.js";
25
- import { needsSession as wallNeedsSession } from "./wall.js";
34
+ import { createNeedsSession, type WallOpenings } from "./wall.js";
26
35
 
27
36
  export interface MiddlewareOptions {
28
- /** Whether a path needs a session once the gate is armed. */
37
+ /** Whether a path needs a session once the gate is armed. Wins over `open`. */
29
38
  needsSession?: (pathname: string) => boolean;
39
+ /** Paths the site opens beyond the wall's own, added to its allowlist. */
40
+ open?: WallOpenings;
30
41
  }
31
42
 
32
43
  function readCookie(header: string, name: string): string | undefined {
@@ -40,7 +51,7 @@ function readCookie(header: string, name: string): string | undefined {
40
51
  }
41
52
 
42
53
  export function createMiddleware(options: MiddlewareOptions = {}) {
43
- const needsSession = options.needsSession ?? wallNeedsSession;
54
+ const needsSession = options.needsSession ?? createNeedsSession(options.open);
44
55
 
45
56
  return async function middleware(request: Request): Promise<Response | undefined> {
46
57
  const secret = process.env.GATE_SESSION_SECRET;
package/src/gate/wall.ts CHANGED
@@ -30,6 +30,13 @@
30
30
  * it stays open on a private host too: a prospect reads the prototype
31
31
  * without a sign in while the documents beside it stay behind the wall.
32
32
  *
33
+ * A marketing site's login page loads the same kinds of file (its hashed
34
+ * assets, its fonts, its favicon), so the same lists hold a website by
35
+ * default: any path they do not name, a page whose last segment carries a
36
+ * dot (`/blog/release-2.0`) included, needs a session. A site with an open
37
+ * file of its own names it through `createNeedsSession` (or the middleware's
38
+ * `open` option), which adds to these lists and never opens the held corner.
39
+ *
33
40
  * Pure and dependency free, so the middleware imports it and the tests call
34
41
  * it directly.
35
42
  */
@@ -49,6 +56,20 @@ const OPEN_PREFIXES = ["/api/auth/", "/assets/", "/brand/", "/fonts/", "/proto/"
49
56
  /** The one corner of an open prefix that stays behind the wall. */
50
57
  const HELD_PREFIXES = ["/assets/doc/"];
51
58
 
59
+ /** What the wall leaves open with no session, exact paths and prefixes, before any a site adds. */
60
+ export const OPEN_PATHS: {
61
+ readonly exact: readonly string[];
62
+ readonly prefixes: readonly string[];
63
+ } = { exact: [...OPEN_EXACT], prefixes: [...OPEN_PREFIXES] };
64
+
65
+ /** Paths a site opens beyond the wall's own, for a file its login page needs. */
66
+ export interface WallOpenings {
67
+ /** Paths opened as written, each starting with "/" (`/apple-touch-icon.png`). */
68
+ exact?: readonly string[];
69
+ /** Folders opened whole, each starting and ending with "/" and never "/" alone (`/icons/`). */
70
+ prefixes?: readonly string[];
71
+ }
72
+
52
73
  /**
53
74
  * Whether a request for this path needs a session when the wall is armed.
54
75
  * The path is read the way a file server would read it (decoded, repeated
@@ -62,6 +83,10 @@ const HELD_PREFIXES = ["/assets/doc/"];
62
83
  * like an open one names some other file.
63
84
  */
64
85
  export function needsSession(pathname: string): boolean {
86
+ return judge(pathname, OPEN_EXACT, OPEN_PREFIXES);
87
+ }
88
+
89
+ function judge(pathname: string, exact: ReadonlySet<string>, prefixes: readonly string[]): boolean {
65
90
  let path: string;
66
91
  try {
67
92
  path = decodeURIComponent(pathname);
@@ -72,6 +97,39 @@ export function needsSession(pathname: string): boolean {
72
97
  if (path.includes("..") || path.includes("\\") || path.includes("%")) return true;
73
98
  const folded = path.toLowerCase();
74
99
  if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix))) return true;
75
- if (OPEN_EXACT.has(path)) return false;
76
- return !OPEN_PREFIXES.some((prefix) => path.startsWith(prefix));
100
+ if (exact.has(path)) return false;
101
+ return !prefixes.some((prefix) => path.startsWith(prefix));
102
+ }
103
+
104
+ /**
105
+ * `needsSession` with a site's own open paths added to the wall's, read the
106
+ * same way. Throws on an entry that is not a plain path, or a prefix that
107
+ * would open the whole host, so a typo cannot quietly unlock a site.
108
+ */
109
+ export function createNeedsSession(open: WallOpenings = {}): (pathname: string) => boolean {
110
+ const extraExact = open.exact ?? [];
111
+ const extraPrefixes = open.prefixes ?? [];
112
+ for (const path of extraExact) {
113
+ if (!path.startsWith("/") || path.includes("..") || path.includes("%")) {
114
+ throw new Error(
115
+ `An open path must be a plain path starting with "/": ${JSON.stringify(path)}`,
116
+ );
117
+ }
118
+ }
119
+ for (const prefix of extraPrefixes) {
120
+ if (
121
+ prefix === "/" ||
122
+ !prefix.startsWith("/") ||
123
+ !prefix.endsWith("/") ||
124
+ prefix.includes("..")
125
+ ) {
126
+ throw new Error(
127
+ `An open prefix must be a folder such as "/icons/", never "/" alone: ${JSON.stringify(prefix)}`,
128
+ );
129
+ }
130
+ }
131
+ if (extraExact.length === 0 && extraPrefixes.length === 0) return needsSession;
132
+ const exact = new Set([...OPEN_EXACT, ...extraExact]);
133
+ const prefixes = [...OPEN_PREFIXES, ...extraPrefixes];
134
+ return (pathname) => judge(pathname, exact, prefixes);
77
135
  }