@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.
- package/README.md +428 -44
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Writeup.tsx +7 -7
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +472 -399
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /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 `.
|
|
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="
|
|
37
|
+
<div className="atelic-ui">
|
|
38
38
|
<SkipLink />
|
|
39
39
|
<SiteHeader
|
|
40
40
|
brand={{ name, href, logo, recipient }}
|
package/src/artifact/Gate.tsx
CHANGED
|
@@ -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/
|
|
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/
|
|
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;
|
package/src/artifact/Writeup.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
}
|
package/src/chrome/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
27
|
+
summary?: never;
|
|
28
|
+
defaultOpen?: never;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
}:
|
|
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 `.
|
|
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 `.
|
|
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.
|
package/src/components/index.ts
CHANGED
|
@@ -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 {
|
|
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
package/src/gate/middleware.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
* `
|
|
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 {
|
|
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 ??
|
|
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 (
|
|
76
|
-
return !
|
|
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
|
}
|